feat: 监控告警体系统一重构 + 自动化任务调度引擎 + 监控大屏拆分

告警中心
- 新增统一告警(alert_unified)、监控告警规则(monitor_alert_rule)、指标目录(alert_metric_catalog)
- 通知策略与消息分发(alert_notify_policy/dispatch/message)、静默规则
- 告警引擎按阈值/SNMP 拆分(alert_engine_monitor/snmp)

监控采集
- SNMP 常量/模板与历史指标(snmp templates/constants、snmp_metrics_history、snmp_collect)
- 中间件监控 Redis 采集与种子(middleware_monitor_redis/seed)
- 站点探测 pkg(siteprobe) + 站点视图(site_view)

自动化任务
- cron 调度器(cronscheduler)、脚本运行器(scriptrunner)、系统任务(systask)及运行记录(cron_job_run、system_task)

审计与凭证
- 审计设置(audit_setting) + 历史清理(auditcleaner)

前端
- 监控页面(host/middleware/site/snmp)拆分为子模块并大屏化
- 告警中心多 Tab(规则/指标目录/通知策略/静默)、任务管理组件重构、Agent CLI Markdown 渲染
- 移除旧脚本卡片/详情/日志组件,统一为 ScriptTable/ScriptCodeEditor/TerminalOutput

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
LoganandClaude committed 2026-07-01 23:44:45 +08:00
1 parent 092d0e4b9e
commit 471353e3d7
190 files changed
+16640 -3484

No files matched your search

+70 -6
View File
@@ -335,18 +335,82 @@ func (h *AgentHandler) GetLatestReports(c *gin.Context) {
})
}
// 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?hours=1
// GET /api/v1/agent/history/:hostname?minutes=60 | ?hours=1 | ?start=&end=
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, until, err := resolveHostHistoryRange(c)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
since := time.Now().Add(-time.Duration(hours) * time.Hour)
var reports []model.AgentReport
h.db.Where("hostname = ? AND report_time >= ?", hostname, since).
h.db.Where("hostname = ? AND report_time >= ? AND report_time <= ?", hostname, since, until).
Order("report_time ASC").
Find(&reports)
+39 -3
View File
@@ -54,8 +54,9 @@ type wsEvent struct {
// wsControl 前端→后端控制消息 // Logan.Li
type wsControl struct {
Type string `json:"type"` // start/continue/confirm/deny/abort
Query string `json:"query,omitempty"` // start 时任务描述
Type string `json:"type"` // start/continue/resume/confirm/deny/abort
Query string `json:"query,omitempty"` // start/continue 时任务描述
SessionID uint `json:"session_id,omitempty"` // resume 时恢复的历史会话 ID
SafetyMode string `json:"safety_mode,omitempty"` // strict/balanced/permissive
ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时
}
@@ -159,6 +160,12 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
continue
}
ac.continueTask(sshCfg, ctrl.Query, ctrl.SafetyMode)
case "resume":
if ctrl.SessionID == 0 {
ac.write(wsEvent{Type: "error", Message: "缺少 session_id"})
continue
}
ac.resumeSession(ctrl.SessionID)
case "confirm":
ac.resolveConfirm(ctrl.ConfirmID, true)
case "deny":
@@ -211,6 +218,33 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, query, safetyMode st
ac.runTask(sshCfg, sess, query, safetyMode, llmCfg, false)
}
// resumeSession 从历史会话恢复上下文,供继续追问 // Logan.Li
func (ac *agentConn) resumeSession(sessionID uint) {
if ac.isRunning() {
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
return
}
var sess model.AgentCLISession
if err := ac.db.Where("user_id = ? AND server_id = ?", ac.userID, ac.server.ID).
First(&sess, sessionID).Error; err != nil {
ac.write(wsEvent{Type: "error", Message: "会话不存在或无权访问"})
return
}
if sess.Status == "running" {
ac.write(wsEvent{Type: "error", Message: "该会话仍在执行中,无法恢复"})
return
}
var msgs []model.AgentCLIMessage
ac.db.Where("session_id = ?", sessionID).Order("id ASC").Find(&msgs)
ac.session = &sess
ac.convHistory = buildConvHistoryFromMessages(msgs)
ac.setTotalTokens(sess.TotalTokens)
ac.write(wsEvent{
Type: "session", SessionID: sess.ID,
Message: fmt.Sprintf("已恢复历史会话「%s」,可继续追问或执行新指令", sess.Title),
})
}
// continueTask 在同一会话内追问(保留上下文) // Logan.Li
func (ac *agentConn) continueTask(sshCfg remoteexec.SSHConfig, query, safetyMode string) {
if ac.isRunning() {
@@ -262,6 +296,7 @@ func (ac *agentConn) runTask(sshCfg remoteexec.SSHConfig, sess *model.AgentCLISe
Core: coreCfg,
})
if err != nil {
ac.write(wsEvent{Type: "error", Message: "引擎初始化失败: " + err.Error()})
ac.finish(sess, "error", "引擎初始化失败: "+err.Error(), ac.lastRound(), 0)
return
}
@@ -444,7 +479,8 @@ func (ac *agentConn) lastRound() int {
func (ac *agentConn) finish(sess *model.AgentCLISession, status, errMsg string, rounds int, dur time.Duration) {
ac.setRunning(false)
tokens := ac.getTotalTokens()
if status == "done" || status == "aborted" || status == "error" {
// done/error 已由引擎 onEvent 推送;此处仅 aborted 需补推 WS(引擎初始化失败在 runTask 内单独推送) // Logan.Li
if status == "aborted" {
ac.write(wsEvent{Type: status, SessionID: sess.ID, Message: errMsg, Round: rounds, Tokens: tokens})
}
updates := map[string]interface{}{
+89
View File
@@ -0,0 +1,89 @@
// agent_cli_history.go - Agent CLI 历史会话恢复为 LLM 对话上下文 // Logan.Li
package controller
import (
"encoding/json"
"fmt"
"ezops/model"
"ezops/pkg/agentcli/llm"
)
// buildConvHistoryFromMessages 将 DB 消息重建为追问用的 prior 历史(不含 system) // Logan.Li
func buildConvHistoryFromMessages(msgs []model.AgentCLIMessage) []llm.Message {
var out []llm.Message
var pendingThought string
var pendingToolCalls []llm.ToolCall
var pendingToolCallIDs []string
flushAssistant := func() {
if pendingThought == "" && len(pendingToolCalls) == 0 {
return
}
out = append(out, llm.Message{
Role: "assistant",
Content: pendingThought,
ToolCalls: append([]llm.ToolCall(nil), pendingToolCalls...),
})
for _, tc := range pendingToolCalls {
pendingToolCallIDs = append(pendingToolCallIDs, tc.ID)
}
pendingThought = ""
pendingToolCalls = nil
}
for _, m := range msgs {
switch m.Type {
case "user":
flushAssistant()
out = append(out, llm.Message{Role: "user", Content: m.Content})
case "thought":
pendingThought = m.Content
case "tool_call":
args := validateToolArgsJSON(m.ToolArgs)
tcID := fmt.Sprintf("hist-%d", m.ID)
pendingToolCalls = append(pendingToolCalls, llm.ToolCall{
ID: tcID, Type: "function",
Function: llm.FunctionCall{Name: m.ToolName, Arguments: args},
})
case "tool_result":
if pendingThought != "" || len(pendingToolCalls) > 0 {
flushAssistant()
}
toolID := ""
if len(pendingToolCallIDs) > 0 {
toolID = pendingToolCallIDs[0]
pendingToolCallIDs = pendingToolCallIDs[1:]
}
content := m.Content
if content == "" {
content = m.ToolOutput
}
out = append(out, llm.Message{
Role: "tool", Name: m.ToolName, Content: content, ToolCallID: toolID,
})
case "done":
flushAssistant()
out = append(out, llm.Message{Role: "assistant", Content: m.Content})
case "error":
flushAssistant()
out = append(out, llm.Message{Role: "assistant", Content: "错误: " + m.Content})
default:
// 忽略 progress/session 等 // Logan.Li
}
}
flushAssistant()
return out
}
// validateToolArgsJSON 校验工具参数 JSON,非法则回退空对象 // Logan.Li
func validateToolArgsJSON(raw string) string {
if raw == "" {
return "{}"
}
var tmp map[string]interface{}
if json.Unmarshal([]byte(raw), &tmp) != nil {
return "{}"
}
return raw
}
+25 -31
View File
@@ -16,6 +16,7 @@ import (
"ezops/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 通知渠道 CRUD(AlertHandler 方法接收者,与 alert.go 同包共享) // Logan.Li
@@ -198,7 +199,7 @@ func (h *AlertHandler) TestNotifyChannel(c *gin.Context) {
switch channel.Type {
case "feishu":
err := sendFeishuTest(channel.Config)
err := sendFeishuTest(h.db, channel.Config)
if err != nil {
status = "error"
message = "飞书通知发送失败: " + err.Error()
@@ -226,8 +227,8 @@ func (h *AlertHandler) TestNotifyChannel(c *gin.Context) {
// ==================== 飞书 Webhook 发送逻辑 ====================
// sendFeishuTest 发送飞书测试通知
func sendFeishuTest(configJSON string) error {
// sendFeishuTest 发送飞书测试通知 // Logan.Li
func sendFeishuTest(db *gorm.DB, configJSON string) error {
var cfg model.FeishuWebhookConfig
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
return fmt.Errorf("解析配置失败: %w", err)
@@ -239,11 +240,11 @@ func sendFeishuTest(configJSON string) error {
title := "✅ AIOps 告警测试通知"
content := fmt.Sprintf("**测试时间**:%s\\n**来源**:AIOps 智能运维平台\\n**状态**:连通性测试成功 🎉", time.Now().Format("2006-01-02 15:04:05"))
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, "green")
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, "green", buildNotifyFooter(db))
}
// sendFeishuAlert 发送飞书告警通知
func sendFeishuAlert(configJSON, title, content, severity string) error {
// sendFeishuAlert 发送飞书告警通知 // Logan.Li
func sendFeishuAlert(db *gorm.DB, configJSON, title, content, severity string) error {
var cfg model.FeishuWebhookConfig
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
return fmt.Errorf("解析配置失败: %w", err)
@@ -267,11 +268,15 @@ func sendFeishuAlert(configJSON, title, content, severity string) error {
}
title = emoji + " " + title
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, color)
footer := defaultNotifyFooterTemplate
if db != nil {
footer = buildNotifyFooter(db)
}
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, color, footer)
}
// sendFeishuRecovery 发送飞书恢复通知
func sendFeishuRecovery(configJSON, title, content string) error {
// sendFeishuRecovery 发送飞书恢复通知 // Logan.Li
func sendFeishuRecovery(db *gorm.DB, configJSON, title, content string) error {
var cfg model.FeishuWebhookConfig
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
return fmt.Errorf("解析配置失败: %w", err)
@@ -281,11 +286,15 @@ func sendFeishuRecovery(configJSON, title, content string) error {
}
title = "✅ " + title
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, "green")
footer := buildNotifyFooter(db)
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, "green", footer)
}
// sendFeishuCard 发送飞书交互卡片消息
func sendFeishuCard(webhookURL, secret, title, content, color string) error {
// sendFeishuCard 发送飞书交互卡片消息 // Logan.Li
func sendFeishuCard(webhookURL, secret, title, content, color, footer string) error {
if footer == "" {
footer = fmt.Sprintf("%s | %s", time.Now().Format("2006-01-02 15:04:05"), defaultNotifyPlatformName)
}
// 解析 content 中的字段(格式: 多行 key: value)
lines := splitLines(content)
var fields []map[string]interface{}
@@ -339,7 +348,7 @@ func sendFeishuCard(webhookURL, secret, title, content, color string) error {
"elements": []map[string]interface{}{
{
"tag": "plain_text",
"content": fmt.Sprintf("⏰ %s | AIOps 智能运维平台", time.Now().Format("2006-01-02 15:04:05")),
"content": footer,
},
},
},
@@ -438,9 +447,9 @@ func (h *AlertHandler) NotifyAlert(ruleName, hostname, metric, severity, message
for _, ch := range channels {
switch ch.Type {
case "feishu":
title := buildAlertTitle(ruleName, hostname, metric, severity)
title := buildNotifyTitle(h.db, ruleName, hostname, metric, severity)
content := buildAlertContent(ruleName, hostname, metric, severity, message, value, threshold)
if err := sendFeishuAlert(ch.Config, title, content, severity); err != nil {
if err := sendFeishuAlert(h.db, ch.Config, title, content, severity); err != nil {
fmt.Printf("[AlertNotify] 飞书通知失败 (渠道:%s): %v\n", ch.Name, err)
}
}
@@ -458,26 +467,11 @@ func (h *AlertHandler) NotifyRecovery(ruleName, hostname, metric, message string
title := fmt.Sprintf("%s — 已恢复正常", ruleName)
content := fmt.Sprintf("**告警已恢复**\\n\\n主机:%s\\n指标:%s\\n详情:%s\\n恢复时间:%s",
hostname, metric, message, time.Now().Format("2006-01-02 15:04:05"))
if err := sendFeishuRecovery(ch.Config, title, content); err != nil {
if err := sendFeishuRecovery(h.db, ch.Config, title, content); err != nil {
fmt.Printf("[AlertNotify] 飞书恢复通知失败 (渠道:%s): %v\n", ch.Name, err)
}
}
}
}
func buildAlertTitle(ruleName, hostname, metric, severity string) string {
sevLabel := "严重告警"
if severity == "warning" {
sevLabel = "警告"
} else if severity == "info" {
sevLabel = "信息"
}
return fmt.Sprintf("[%s] %s — %s (%s)", sevLabel, ruleName, hostname, metric)
}
func buildAlertContent(ruleName, hostname, metric, severity, message string, value, threshold float64) string {
return fmt.Sprintf(
"**规则名称:** %s\\n**目标主机:** %s\\n**指标类型:** %s\\n**告警级别:** %s\\n**当前值:** %.2f\\n**阈值:** %.2f\\n**详细信息:** %s\\n**触发时间:** %s",
ruleName, hostname, metric, severity, value, threshold, message, time.Now().Format("2006-01-02 15:04:05"),
)
}
+34 -65
View File
@@ -64,6 +64,10 @@ func (e *AlertEngine) Check() {
e.checkResourceAlerts()
e.checkOfflineAlerts()
e.checkPortAlerts()
e.checkMiddlewareAlerts()
e.checkSNMPAlerts()
e.checkSiteAlerts()
e.autoResolveSiteSslAlerts()
e.checkLogAlertRules()
e.autoResolveAlerts()
e.checkLogAlertRecovery()
@@ -124,8 +128,15 @@ func (e *AlertEngine) GetEngineStatus() map[string]interface{} {
// 检查 CPU/内存/磁盘/进程/TCP连接/端口 阈值
func (e *AlertEngine) checkResourceAlerts() {
hostMetrics := []string{
"cpu", "cpu_iowait", "mem", "mem_available", "swap", "load1",
"disk", "disk_inode", "disk_io_util",
"tcp_time_wait", "tcp_close_wait", "tcp_established",
"net_rx_bps", "net_tx_bps", "net_rx_errors", "fd",
"service_process",
}
var rules []model.AgentRule
e.db.Where("enabled = ? AND metric IN ?", true, []string{"cpu", "mem", "disk", "service_process", "tcp_time_wait", "tcp_close_wait", "fd"}).Find(&rules)
e.db.Where("enabled = ? AND metric IN ?", true, hostMetrics).Find(&rules)
for _, rule := range rules {
// 获取目标主机最新上报
@@ -140,29 +151,7 @@ func (e *AlertEngine) checkResourceAlerts() {
}
var value float64
switch rule.Metric {
case "cpu":
value = report.CPUUsage
case "mem":
value = report.MemUsage
case "disk":
var disks []model.DiskMetric
json.Unmarshal([]byte(report.DiskInfo), &disks)
for _, d := range disks {
if d.UsagePercent > value {
value = d.UsagePercent
}
}
case "tcp_time_wait":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
value = float64(netStat.TCPTimeWait)
case "tcp_close_wait":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
value = float64(netStat.TCPCloseWait)
case "service_process":
// 进程不存在告警 — 检查 process_top_info 中是否包含指定进程名
if rule.Metric == "service_process" {
var processes []model.ProcessMetric
json.Unmarshal([]byte(report.ProcessTopInfo), &processes)
found := false
@@ -173,13 +162,15 @@ func (e *AlertEngine) checkResourceAlerts() {
}
}
if found {
continue // 进程存在,不触发
continue
}
value = 0
} else {
var ok bool
value, ok = extractHostMetricValue(report, rule.Metric)
if !ok {
continue
}
value = 0 // 进程不存在时触发
case "fd":
var fdStat model.FDMetric
json.Unmarshal([]byte(report.FDInfo), &fdStat)
value = fdStat.UsagePct
}
// 检查是否超阈值
@@ -305,7 +296,7 @@ func (e *AlertEngine) checkOfflineAlerts() {
e.db.Create(&alert)
// 发送离线告警通知
e.notifyAlertRule("主机离线", s.Hostname, "offline", "critical",
e.notifyAlertRule("host", "主机离线", s.Hostname, "offline", "critical",
alert.Message, offlineMinutes, 3)
// 记录离线上报时间用于恢复通知
@@ -378,7 +369,7 @@ func (e *AlertEngine) checkPortAlerts() {
e.db.Create(&alert)
// 发送端口Down告警通知
e.notifyAlertRule(rule.Name, report.Hostname, "port_down", "critical",
e.notifyAlertRule("host", rule.Name, report.Hostname, "port_down", "critical",
alert.Message, float64(rule.Port), float64(rule.Port))
now := time.Now()
@@ -440,7 +431,7 @@ func (e *AlertEngine) checkLogAlertRules() {
}
e.db.Create(&alert)
e.notifyAlertRule(rule.Name, alert.Target, "log", rule.Severity, alert.Message, float64(count), 0)
e.notifyAlertRule("host", rule.Name, alert.Target, "log", rule.Severity, alert.Message, float64(count), 0)
e.db.Model(&rule).Update("last_triggered", now)
}
@@ -610,7 +601,7 @@ func (e *AlertEngine) autoResolveAlerts() {
})
// 发送恢复通知
e.notifyRecovery(alert.RuleName, alert.Hostname, alert.Metric, alert.Message)
e.notifyRecovery("host", alert.RuleName, alert.Hostname, alert.Metric, alert.Message)
}
}
}
@@ -648,7 +639,7 @@ func (e *AlertEngine) checkLogAlertRecovery() {
"status": model.AlertEventResolved,
"resolved_at": now,
})
e.notifyRecovery(rule.Name, evt.Target, "log", evt.Message)
e.notifyRecovery("host", rule.Name, evt.Target, "log", evt.Message)
}
}
@@ -669,42 +660,20 @@ func (e *AlertEngine) compareOperator(op string, val, threshold float64) bool {
}
}
// notifyAlert 发送告警通知(通过 AgentRule 对应的 notify_channels)
// notifyAlert 发送主机监控告警通知 // Logan.Li
func (e *AlertEngine) notifyAlert(rule *model.AgentRule, hostname string, alert model.AgentAlert) {
e.notifyAlertRule(rule.Name, hostname, alert.Metric, alert.Severity,
e.notifyAlertRule("host", rule.Name, hostname, alert.Metric, alert.Severity,
alert.Message, alert.Value, alert.Threshold)
}
// notifyAlertRule 按规则发送告警通知到所有启用的飞书渠道
func (e *AlertEngine) notifyAlertRule(ruleName, hostname, metric, severity, message string, value, threshold float64) {
var channels []model.NotifyChannel
e.db.Where("enabled = ? AND type = ?", true, "feishu").Find(&channels)
for _, ch := range channels {
title := fmt.Sprintf("[%s] %s — %s (%s)", severity, ruleName, hostname, metric)
content := fmt.Sprintf(
"**规则名称:** %s\\n**目标主机:** %s\\n**指标类型:** %s\\n**告警级别:** %s\\n**当前值:** %.2f\\n**阈值:** %.2f\\n**详细信息:** %s\\n**触发时间:** %s",
ruleName, hostname, metric, severity, value, threshold, message, time.Now().Format("2006-01-02 15:04:05"),
)
if err := sendFeishuAlert(ch.Config, title, content, severity); err != nil {
fmt.Printf("[AlertEngine] 飞书通知失败 (渠道:%s): %v\n", ch.Name, err)
}
}
// notifyAlertRule 按告警集合策略发送通知(无策略时回退全局渠道)// Logan.Li
func (e *AlertEngine) notifyAlertRule(category, ruleName, hostname, metric, severity, message string, value, threshold float64) {
dispatchCategoryAlert(e.db, category, ruleName, hostname, metric, severity, message, value, threshold)
}
// notifyRecovery 发送恢复通知到所有启用的飞书渠道
func (e *AlertEngine) notifyRecovery(ruleName, hostname, metric, message string) {
var channels []model.NotifyChannel
e.db.Where("enabled = ? AND type = ?", true, "feishu").Find(&channels)
for _, ch := range channels {
title := fmt.Sprintf("%s — 已恢复正常", ruleName)
content := fmt.Sprintf("**告警已恢复**\\\\n\\\\n主机:%s\\\\n指标:%s\\\\n详情:%s\\\\n恢复时间:%s",
hostname, metric, message, time.Now().Format("2006-01-02 15:04:05"))
if err := sendFeishuRecovery(ch.Config, title, content); err != nil {
fmt.Printf("[AlertEngine] 飞书恢复通知失败 (渠道:%s): %v\n", ch.Name, err)
}
}
// notifyRecovery 按告警集合策略发送恢复通知 // Logan.Li
func (e *AlertEngine) notifyRecovery(category, ruleName, hostname, metric, message string) {
dispatchCategoryRecovery(e.db, category, ruleName, hostname, metric, message)
}
// isSilenced 检查给定告警是否被静默规则匹配
+329
View File
@@ -0,0 +1,329 @@
package controller
import (
"encoding/json"
"fmt"
"time"
"ezops/model"
"ezops/pkg/siteprobe"
)
// checkMiddlewareAlerts 中间件指标告警 // Logan.Li
func (e *AlertEngine) checkMiddlewareAlerts() {
var rules []model.MonitorAlertRule
e.db.Where("enabled = ? AND category = ?", true, model.MonitorAlertMiddleware).Find(&rules)
if len(rules) == 0 {
return
}
var targets []model.MonitorTarget
e.db.Where("type = ?", model.TargetTypeMiddleware).Find(&targets)
targetMap := make(map[uint]model.MonitorTarget)
for _, t := range targets {
targetMap[t.ID] = t
}
for _, rule := range rules {
checkTargets := targets
if rule.TargetID > 0 {
if t, ok := targetMap[rule.TargetID]; ok {
checkTargets = []model.MonitorTarget{t}
} else {
continue
}
}
for _, target := range checkTargets {
e.evalMiddlewareRule(rule, target)
}
}
}
func (e *AlertEngine) evalMiddlewareRule(rule model.MonitorAlertRule, target model.MonitorTarget) {
if rule.Metric == "middleware_offline" {
if target.Status == model.TargetStatusOffline || target.Status == model.TargetStatusUnknown {
e.fireMiddlewareAlert(rule, target, 1, 1, "中间件不可达或采集失败")
}
return
}
value, ok := e.getLatestMetricValue(target.ID, rule.Metric)
if !ok {
return
}
if !e.compareOperator(rule.Operator, value, rule.Threshold) {
return
}
msg := fmt.Sprintf("%s %s %.2f %s %.2f", target.Name, rule.Metric, value, rule.Operator, rule.Threshold)
e.fireMiddlewareAlert(rule, target, value, rule.Threshold, msg)
}
func (e *AlertEngine) getLatestMetricValue(targetID uint, metric string) (float64, bool) {
var hist model.MetricHistory
e.db.Where("target_id = ? AND metric_name = ?", targetID, metric).
Order("timestamp DESC").First(&hist)
if hist.ID == 0 {
return 0, false
}
return hist.Value, true
}
func (e *AlertEngine) fireMiddlewareAlert(rule model.MonitorAlertRule, target model.MonitorTarget, value, threshold float64, msg string) {
tenMinAgo := time.Now().Add(-10 * time.Minute)
var existing model.AgentAlert
e.db.Where("rule_name = ? AND hostname = ? AND resolved = ? AND created_at > ?",
rule.Name, target.Name, false, tenMinAgo).First(&existing)
if existing.ID > 0 {
return
}
if e.isSilenced(rule.Name, target.Name, rule.ID) {
return
}
sev := rule.Severity
if sev == "" {
sev = "warning"
}
alert := model.AgentAlert{
RuleName: rule.Name, Hostname: target.Name, Metric: rule.Metric,
Severity: sev, Message: msg, Value: value, Threshold: threshold,
}
e.db.Create(&alert)
e.notifyAlertRule("middleware", rule.Name, target.Name, rule.Metric, sev, msg, value, threshold)
now := time.Now()
e.db.Model(&rule).Update("last_triggered", now)
}
// checkSiteAlerts 站点拨测告警 // Logan.Li
func (e *AlertEngine) checkSiteAlerts() {
var rules []model.MonitorAlertRule
e.db.Where("enabled = ? AND category = ?", true, model.MonitorAlertSite).Find(&rules)
if len(rules) == 0 {
return
}
var tasks []model.SiteTask
e.db.Find(&tasks)
taskMap := make(map[uint]model.SiteTask)
for _, t := range tasks {
taskMap[t.ID] = t
}
for _, rule := range rules {
checkTasks := tasks
if rule.TargetID > 0 {
if t, ok := taskMap[rule.TargetID]; ok {
checkTasks = []model.SiteTask{t}
} else {
continue
}
}
for _, task := range checkTasks {
if task.Status != model.SiteTaskStatusRunning {
continue
}
e.evalSiteRule(rule, task)
}
}
}
func (e *AlertEngine) evalSiteRule(rule model.MonitorAlertRule, task model.SiteTask) {
var value float64
var triggered bool
var msg string
switch rule.Metric {
case "probe_fail":
triggered = task.LastResult == "fail" || task.LastResult == "timeout"
value = 1
if triggered {
msg = fmt.Sprintf("拨测任务 %s 失败: %s", task.Name, task.LastResult)
}
case "probe_timeout":
triggered = task.LastResult == "timeout"
value = 1
if triggered {
msg = fmt.Sprintf("拨测任务 %s 超时", task.Name)
}
case "response_time":
value = task.AvgResponse
triggered = e.compareOperator(rule.Operator, value, rule.Threshold)
if triggered {
msg = fmt.Sprintf("拨测 %s 延迟 %.0fms 超过阈值 %.0fms", task.Name, value, rule.Threshold)
}
case "availability":
value = task.SuccessRate
triggered = e.compareOperator(rule.Operator, value, rule.Threshold)
if triggered {
msg = fmt.Sprintf("拨测 %s 可用率 %.1f%% 低于阈值 %.1f%%", task.Name, value, rule.Threshold)
}
case "http_status_error":
triggered = task.LastResult == "fail"
value = 1
if triggered {
msg = fmt.Sprintf("拨测 %s HTTP 状态码异常", task.Name)
}
case "ssl_expiry_days":
if !siteprobe.IsSSLProbeTask(task) {
return
}
var result model.SiteResult
e.db.Where("task_id = ? AND ssl_cert_expiry IS NOT NULL", task.ID).
Order("checked_at DESC").First(&result)
if result.ID == 0 {
return
}
value = float64(result.SSLDaysRemaining)
triggered = e.compareOperator(rule.Operator, value, rule.Threshold)
if triggered {
expiryStr := ""
if result.SSLCertExpiry != nil {
expiryStr = result.SSLCertExpiry.Format("2006-01-02")
}
if value < 0 {
msg = fmt.Sprintf("SSL 拨测 %s 证书已过期", task.Name)
} else if expiryStr != "" {
msg = fmt.Sprintf("SSL 拨测 %s 证书剩余 %.0f 天(到期 %s)", task.Name, value, expiryStr)
} else {
msg = fmt.Sprintf("SSL 拨测 %s 证书剩余 %.0f 天", task.Name, value)
}
}
default:
return
}
if !triggered {
return
}
tenMinAgo := time.Now().Add(-10 * time.Minute)
var existing model.SiteAlert
e.db.Where("task_id = ? AND title = ? AND status = ? AND started_at > ?",
task.ID, rule.Name, model.SiteAlertStatusActive, tenMinAgo).First(&existing)
if existing.ID > 0 {
return
}
sev := model.SiteAlertWarning
if rule.Severity == "critical" || (rule.Metric == "ssl_expiry_days" && value <= 7) {
sev = model.SiteAlertCritical
}
now := time.Now()
siteAlert := model.SiteAlert{
TaskID: task.ID, Severity: sev, Status: model.SiteAlertStatusActive,
Title: rule.Name, Message: msg, TriggerType: rule.Metric,
Threshold: rule.Threshold, ResponseTime: task.AvgResponse,
StartedAt: now,
}
e.db.Create(&siteAlert)
e.notifyAlertRule("site", rule.Name, task.Name, rule.Metric, string(sev), msg, value, rule.Threshold)
nt := time.Now()
e.db.Model(&rule).Update("last_triggered", nt)
}
// extractHostMetricValue 从 Agent 上报提取扩展指标值 // Logan.Li
func extractHostMetricValue(report model.AgentReport, metric string) (float64, bool) {
switch metric {
case "cpu":
return report.CPUUsage, true
case "cpu_iowait":
return report.CPUIOwait, true
case "mem":
return report.MemUsage, true
case "mem_available":
return report.MemAvailable, true
case "load1":
return report.Load1, true
case "disk":
var disks []model.DiskMetric
json.Unmarshal([]byte(report.DiskInfo), &disks)
max := 0.0
for _, d := range disks {
if d.UsagePercent > max {
max = d.UsagePercent
}
}
return max, true
case "disk_inode":
var disks []model.DiskMetric
json.Unmarshal([]byte(report.DiskInfo), &disks)
max := 0.0
for _, d := range disks {
if d.InodeUsagePct > max {
max = d.InodeUsagePct
}
}
return max, true
case "disk_io_util":
var dio model.DiskIOStatModel
json.Unmarshal([]byte(report.DiskIOInfo), &dio)
return dio.Util, true
case "swap":
var swap model.SwapMetric
json.Unmarshal([]byte(report.SwapInfo), &swap)
return swap.UsagePercent, true
case "tcp_time_wait":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return float64(netStat.TCPTimeWait), true
case "tcp_close_wait":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return float64(netStat.TCPCloseWait), true
case "tcp_established":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return float64(netStat.TCPEstablished), true
case "net_rx_bps":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return netStat.NetRxBPS, true
case "net_tx_bps":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return netStat.NetTxBPS, true
case "net_rx_errors":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return float64(netStat.NetRxErrors), true
case "fd":
var fdStat model.FDMetric
json.Unmarshal([]byte(report.FDInfo), &fdStat)
return fdStat.UsagePct, true
default:
return 0, false
}
}
// autoResolveSiteSslAlerts 证书续期后自动恢复 SSL 过期告警 // Logan.Li
func (e *AlertEngine) autoResolveSiteSslAlerts() {
var alerts []model.SiteAlert
e.db.Where("status = ? AND trigger_type = ?", model.SiteAlertStatusActive, "ssl_expiry_days").Find(&alerts)
if len(alerts) == 0 {
return
}
now := time.Now()
for _, alert := range alerts {
var result model.SiteResult
e.db.Where("task_id = ? AND ssl_cert_expiry IS NOT NULL", alert.TaskID).
Order("checked_at DESC").First(&result)
if result.ID == 0 {
continue
}
if float64(result.SSLDaysRemaining) >= 30 {
e.db.Model(&alert).Updates(map[string]interface{}{
"status": model.SiteAlertStatusResolved, "resolved_at": now,
})
}
}
}
// isMiddlewareMetric 判断 AgentAlert 是否属于中间件 // Logan.Li
func isMiddlewareMetric(metric string) bool {
prefixes := []string{"redis_", "mysql_", "middleware_"}
for _, p := range prefixes {
if len(metric) >= len(p) && metric[:len(p)] == p {
return true
}
}
return false
}
+184
View File
@@ -0,0 +1,184 @@
package controller
import (
"fmt"
"time"
"ezops/model"
)
// SNMP 指标名映射(与采集模板一致)// Logan.Li
var snmpCPUMetricNames = []string{
"h3c_cpu_usage", "hw_cpu_usage", "cisco_cpu_5min", "ruijie_cpu_usage", "inspur_cpu_usage",
}
var snmpMemMetricNames = []string{
"h3c_mem_usage", "hw_mem_usage", "ruijie_mem_usage", "inspur_mem_usage",
}
var snmpTempMetricNames = []string{
"h3c_temperature", "hw_temperature", "ruijie_temperature", "dell_temperature",
"inspur_temperature", "cisco_sensor_value",
}
// checkSNMPAlerts SNMP 设备指标告警 // Logan.Li
func (e *AlertEngine) checkSNMPAlerts() {
var rules []model.MonitorAlertRule
e.db.Where("enabled = ? AND category = ?", true, model.MonitorAlertSNMP).Find(&rules)
if len(rules) == 0 {
return
}
var devices []model.SNMPDevice
e.db.Where("template_id > 0").Find(&devices)
deviceMap := make(map[uint]model.SNMPDevice, len(devices))
for _, d := range devices {
deviceMap[d.ID] = d
}
for _, rule := range rules {
checkDevices := devices
if rule.TargetID > 0 {
if d, ok := deviceMap[rule.TargetID]; ok {
checkDevices = []model.SNMPDevice{d}
} else {
continue
}
}
for _, device := range checkDevices {
e.evalSNMPRule(rule, device)
}
}
}
func (e *AlertEngine) evalSNMPRule(rule model.MonitorAlertRule, device model.SNMPDevice) {
var value float64
var triggered bool
var msg string
switch rule.Metric {
case "snmp_offline":
triggered = device.Status != model.SNMPDeviceOnline
value = 1
if triggered {
msg = fmt.Sprintf("SNMP 设备 %s (%s) 离线或采集异常", device.Name, device.IP)
}
case "snmp_cpu_usage":
value, triggered = e.evalSNMPGaugeRule(device.ID, snmpCPUMetricNames, rule)
if triggered {
msg = fmt.Sprintf("SNMP %s CPU %.1f%% %s %.1f%%", device.Name, value, rule.Operator, rule.Threshold)
}
case "snmp_mem_usage":
value, triggered = e.evalSNMPGaugeRule(device.ID, snmpMemMetricNames, rule)
if triggered {
msg = fmt.Sprintf("SNMP %s 内存 %.1f%% %s %.1f%%", device.Name, value, rule.Operator, rule.Threshold)
}
case "snmp_temperature":
value, triggered = e.evalSNMPGaugeRule(device.ID, snmpTempMetricNames, rule)
if triggered {
msg = fmt.Sprintf("SNMP %s 温度 %.1f°C %s %.1f°C", device.Name, value, rule.Operator, rule.Threshold)
}
case "snmp_if_in_bps":
value, triggered = e.evalSNMPRateRule(device.ID, "if_in_octets", rule)
if triggered {
msg = fmt.Sprintf("SNMP %s 入流量 %.0f bps %s %.0f bps", device.Name, value, rule.Operator, rule.Threshold)
}
case "snmp_if_out_bps":
value, triggered = e.evalSNMPRateRule(device.ID, "if_out_octets", rule)
if triggered {
msg = fmt.Sprintf("SNMP %s 出流量 %.0f bps %s %.0f bps", device.Name, value, rule.Operator, rule.Threshold)
}
default:
return
}
if !triggered {
return
}
e.fireSNMPAlert(rule, device, value, rule.Threshold, msg)
}
func (e *AlertEngine) evalSNMPGaugeRule(deviceID uint, names []string, rule model.MonitorAlertRule) (float64, bool) {
value, ok := e.getMaxSNMPMetric(deviceID, names)
if !ok {
return 0, false
}
return value, e.compareOperator(rule.Operator, value, rule.Threshold)
}
func (e *AlertEngine) evalSNMPRateRule(deviceID uint, counterName string, rule model.MonitorAlertRule) (float64, bool) {
value, ok := e.getSNMPCounterRate(deviceID, counterName)
if !ok {
return 0, false
}
return value, e.compareOperator(rule.Operator, value, rule.Threshold)
}
func (e *AlertEngine) getMaxSNMPMetric(deviceID uint, names []string) (float64, bool) {
var latestTS int64
e.db.Model(&model.SNMPMetric{}).Where("device_id = ?", deviceID).
Select("COALESCE(MAX(timestamp), 0)").Scan(&latestTS)
if latestTS == 0 {
return 0, false
}
var metrics []model.SNMPMetric
e.db.Where("device_id = ? AND timestamp = ? AND name IN ?", deviceID, latestTS, names).Find(&metrics)
if len(metrics) == 0 {
return 0, false
}
max := 0.0
for _, m := range metrics {
if m.Value > max {
max = m.Value
}
}
return max, true
}
// getSNMPCounterRate 取 counter 最近两次差分比特速率 // Logan.Li
func (e *AlertEngine) getSNMPCounterRate(deviceID uint, name string) (float64, bool) {
var metrics []model.SNMPMetric
e.db.Where("device_id = ? AND name = ?", deviceID, name).
Order("timestamp DESC").Limit(2).Find(&metrics)
if len(metrics) < 2 {
return 0, false
}
cur, prev := metrics[0], metrics[1]
dt := float64(cur.Timestamp - prev.Timestamp)
if dt <= 0 {
return 0, false
}
delta := cur.Value - prev.Value
if delta < 0 {
return 0, false
}
return (delta * 8) / dt, true
}
func (e *AlertEngine) fireSNMPAlert(rule model.MonitorAlertRule, device model.SNMPDevice, value, threshold float64, msg string) {
tenMinAgo := time.Now().Add(-10 * time.Minute)
var existing model.AgentAlert
e.db.Where("rule_name = ? AND hostname = ? AND resolved = ? AND created_at > ?",
rule.Name, device.Name, false, tenMinAgo).First(&existing)
if existing.ID > 0 {
return
}
if e.isSilenced(rule.Name, device.Name, rule.ID) {
return
}
sev := rule.Severity
if sev == "" {
sev = "warning"
}
alert := model.AgentAlert{
RuleName: rule.Name, Hostname: device.Name, Metric: rule.Metric,
Severity: sev, Message: msg, Value: value, Threshold: threshold,
}
e.db.Create(&alert)
e.notifyAlertRule("snmp", rule.Name, device.Name, rule.Metric, sev, msg, value, threshold)
now := time.Now()
e.db.Model(&rule).Update("last_triggered", now)
}
// isSNMPMetric 判断 AgentAlert 是否属于 SNMP // Logan.Li
func isSNMPMetric(metric string) bool {
return len(metric) >= 5 && metric[:5] == "snmp_"
}
+107
View File
@@ -0,0 +1,107 @@
package controller
import (
"net/http"
"ezops/model"
"github.com/gin-gonic/gin"
)
// 指标目录静态定义,供监控中心与告警中心共用 // Logan.Li
// MetricCatalogItem 可采集/可告警指标定义 // Logan.Li
type MetricCatalogItem struct {
Key string `json:"key"`
Label string `json:"label"`
Unit string `json:"unit"`
Type string `json:"type"` // gauge / counter / status
DefaultOp string `json:"default_op"`
DefaultWarn float64 `json:"default_warn"`
DefaultCrit float64 `json:"default_crit"`
Min float64 `json:"min"`
Max float64 `json:"max"`
CollectSource string `json:"collect_source"`
Description string `json:"description"`
Operators []string `json:"operators"`
}
// MetricCatalogCategory 指标分类 // Logan.Li
type MetricCatalogCategory struct {
ID string `json:"id"`
Label string `json:"label"`
Description string `json:"description"`
Metrics []MetricCatalogItem `json:"metrics"`
}
var metricCatalog = []MetricCatalogCategory{
{
ID: "host", Label: "主机监控", Description: "Agent 主动采集 /proc 指标,默认 10s 上报",
Metrics: []MetricCatalogItem{
{Key: "cpu", Label: "CPU 使用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 80, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "agent", Description: "整体 CPU 占用百分比", Operators: []string{">", ">=", "<", "<="}},
{Key: "cpu_iowait", Label: "CPU IO 等待", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 20, DefaultCrit: 40, Min: 0, Max: 100, CollectSource: "agent", Description: "iowait 占比过高表示磁盘瓶颈", Operators: []string{">", ">="}},
{Key: "mem", Label: "内存使用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 80, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "agent", Description: "物理内存占用百分比", Operators: []string{">", ">=", "<", "<="}},
{Key: "mem_available", Label: "可用内存", Unit: "GB", Type: "gauge", DefaultOp: "<", DefaultWarn: 1, DefaultCrit: 0.5, Min: 0, Max: 1024, CollectSource: "agent", Description: "剩余可用内存低于阈值告警", Operators: []string{"<", "<="}},
{Key: "swap", Label: "Swap 使用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 50, DefaultCrit: 80, Min: 0, Max: 100, CollectSource: "agent", Description: "Swap 使用过高影响性能", Operators: []string{">", ">="}},
{Key: "disk", Label: "磁盘使用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 85, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "agent", Description: "各挂载点最大使用率", Operators: []string{">", ">="}},
{Key: "disk_inode", Label: "Inode 使用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 80, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "agent", Description: "Inode 耗尽导致无法创建文件", Operators: []string{">", ">="}},
{Key: "disk_io_util", Label: "磁盘 IO 利用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 80, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "agent", Description: "磁盘繁忙程度", Operators: []string{">", ">="}},
{Key: "load1", Label: "1分钟负载", Unit: "", Type: "gauge", DefaultOp: ">", DefaultWarn: 4, DefaultCrit: 8, Min: 0, Max: 256, CollectSource: "agent", Description: "系统 1 分钟平均负载", Operators: []string{">", ">="}},
{Key: "net_rx_bps", Label: "入网带宽", Unit: "B/s", Type: "gauge", DefaultOp: ">", DefaultWarn: 104857600, DefaultCrit: 524288000, Min: 0, Max: 1e12, CollectSource: "agent", Description: "接收字节速率", Operators: []string{">", ">="}},
{Key: "net_tx_bps", Label: "出网带宽", Unit: "B/s", Type: "gauge", DefaultOp: ">", DefaultWarn: 104857600, DefaultCrit: 524288000, Min: 0, Max: 1e12, CollectSource: "agent", Description: "发送字节速率", Operators: []string{">", ">="}},
{Key: "net_rx_errors", Label: "入网错误", Unit: "次", Type: "counter", DefaultOp: ">", DefaultWarn: 10, DefaultCrit: 100, Min: 0, Max: 1e9, CollectSource: "agent", Description: "网卡接收错误累计", Operators: []string{">", ">="}},
{Key: "tcp_established", Label: "TCP 已建立连接", Unit: "个", Type: "gauge", DefaultOp: ">", DefaultWarn: 5000, DefaultCrit: 20000, Min: 0, Max: 1e6, CollectSource: "agent", Description: "ESTABLISHED 连接数", Operators: []string{">", ">="}},
{Key: "tcp_time_wait", Label: "TCP TIME_WAIT", Unit: "个", Type: "gauge", DefaultOp: ">", DefaultWarn: 15000, DefaultCrit: 30000, Min: 0, Max: 1e6, CollectSource: "agent", Description: "TIME_WAIT 过多可能端口耗尽", Operators: []string{">", ">="}},
{Key: "tcp_close_wait", Label: "TCP CLOSE_WAIT", Unit: "个", Type: "gauge", DefaultOp: ">", DefaultWarn: 200, DefaultCrit: 1000, Min: 0, Max: 1e6, CollectSource: "agent", Description: "CLOSE_WAIT 泄漏检测", Operators: []string{">", ">="}},
{Key: "fd", Label: "文件描述符使用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 80, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "agent", Description: "进程 FD 占用/system limit", Operators: []string{">", ">="}},
{Key: "offline", Label: "主机离线", Unit: "分钟", Type: "status", DefaultOp: ">", DefaultWarn: 3, DefaultCrit: 5, Min: 0, Max: 1440, CollectSource: "agent", Description: "超过 N 分钟无上报", Operators: []string{">", ">="}},
{Key: "port_down", Label: "端口不通", Unit: "", Type: "status", DefaultOp: "==", DefaultWarn: 0, DefaultCrit: 0, Min: 0, Max: 65535, CollectSource: "agent", Description: "监听端口消失", Operators: []string{"=="}},
{Key: "health_probe", Label: "健康探针失败", Unit: "", Type: "status", DefaultOp: "==", DefaultWarn: 1, DefaultCrit: 1, Min: 0, Max: 1, CollectSource: "agent", Description: "HTTP/TCP/Exec 探针失败", Operators: []string{"=="}},
},
},
{
ID: "middleware", Label: "中间件监控", Description: "平台直连 Redis/MySQL 采集,默认 30s",
Metrics: []MetricCatalogItem{
{Key: "redis_connected_clients", Label: "Redis 连接数", Unit: "个", Type: "gauge", DefaultOp: ">", DefaultWarn: 500, DefaultCrit: 2000, Min: 0, Max: 1e5, CollectSource: "middleware", Description: "当前客户端连接数", Operators: []string{">", ">="}},
{Key: "redis_used_memory", Label: "Redis 内存", Unit: "bytes", Type: "gauge", DefaultOp: ">", DefaultWarn: 536870912, DefaultCrit: 1073741824, Min: 0, Max: 1e12, CollectSource: "middleware", Description: "已用内存字节数", Operators: []string{">", ">="}},
{Key: "redis_hit_rate", Label: "Redis 命中率", Unit: "ratio", Type: "gauge", DefaultOp: "<", DefaultWarn: 0.9, DefaultCrit: 0.8, Min: 0, Max: 1, CollectSource: "middleware", Description: "keyspace 命中率 (0~1)", Operators: []string{"<", "<="}},
{Key: "redis_qps", Label: "Redis QPS", Unit: "ops/s", Type: "gauge", DefaultOp: ">", DefaultWarn: 10000, DefaultCrit: 50000, Min: 0, Max: 1e7, CollectSource: "middleware", Description: "每秒命令数", Operators: []string{">", ">="}},
{Key: "redis_blocked_clients", Label: "Redis 阻塞客户端", Unit: "个", Type: "gauge", DefaultOp: ">", DefaultWarn: 1, DefaultCrit: 10, Min: 0, Max: 1e4, CollectSource: "middleware", Description: "BLPOP 等阻塞连接", Operators: []string{">", ">="}},
{Key: "redis_evicted_keys", Label: "Redis 驱逐键", Unit: "个", Type: "counter", DefaultOp: ">", DefaultWarn: 100, DefaultCrit: 1000, Min: 0, Max: 1e12, CollectSource: "middleware", Description: "内存不足驱逐 key 数", Operators: []string{">", ">="}},
{Key: "mysql_connections", Label: "MySQL 连接数", Unit: "个", Type: "gauge", DefaultOp: ">", DefaultWarn: 200, DefaultCrit: 500, Min: 0, Max: 1e4, CollectSource: "middleware", Description: "Threads_connected", Operators: []string{">", ">="}},
{Key: "mysql_threads_running", Label: "MySQL 活跃线程", Unit: "个", Type: "gauge", DefaultOp: ">", DefaultWarn: 20, DefaultCrit: 50, Min: 0, Max: 1e4, CollectSource: "middleware", Description: "正在执行的线程", Operators: []string{">", ">="}},
{Key: "mysql_slow_queries", Label: "MySQL 慢查询", Unit: "次", Type: "counter", DefaultOp: ">", DefaultWarn: 10, DefaultCrit: 100, Min: 0, Max: 1e12, CollectSource: "middleware", Description: "慢查询累计数", Operators: []string{">", ">="}},
{Key: "mysql_buffer_hit_rate", Label: "MySQL 缓冲命中率", Unit: "ratio", Type: "gauge", DefaultOp: "<", DefaultWarn: 0.95, DefaultCrit: 0.9, Min: 0, Max: 1, CollectSource: "middleware", Description: "InnoDB buffer pool 命中率 (0~1)", Operators: []string{"<", "<="}},
{Key: "mysql_qps", Label: "MySQL QPS", Unit: "ops/s", Type: "gauge", DefaultOp: ">", DefaultWarn: 5000, DefaultCrit: 20000, Min: 0, Max: 1e7, CollectSource: "middleware", Description: "每秒查询数", Operators: []string{">", ">="}},
{Key: "middleware_offline", Label: "中间件离线", Unit: "", Type: "status", DefaultOp: "==", DefaultWarn: 1, DefaultCrit: 1, Min: 0, Max: 1, CollectSource: "middleware", Description: "采集失败或不可达", Operators: []string{"=="}},
},
},
{
ID: "snmp", Label: "SNMP 监控", Description: "网络设备 / BMC 采集,默认 5 分钟周期",
Metrics: []MetricCatalogItem{
{Key: "snmp_cpu_usage", Label: "CPU 使用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 80, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "snmp", Description: "各厂商 CPU 利用率最大值", Operators: []string{">", ">="}},
{Key: "snmp_mem_usage", Label: "内存使用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 85, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "snmp", Description: "各厂商内存利用率最大值", Operators: []string{">", ">="}},
{Key: "snmp_temperature", Label: "设备温度", Unit: "°C", Type: "gauge", DefaultOp: ">", DefaultWarn: 65, DefaultCrit: 80, Min: 0, Max: 120, CollectSource: "snmp", Description: "温度传感器读数最大值", Operators: []string{">", ">="}},
{Key: "snmp_if_in_bps", Label: "入方向流量", Unit: "bps", Type: "gauge", DefaultOp: ">", DefaultWarn: 1e9, DefaultCrit: 5e9, Min: 0, Max: 1e12, CollectSource: "snmp", Description: "接口入方向比特速率", Operators: []string{">", ">="}},
{Key: "snmp_if_out_bps", Label: "出方向流量", Unit: "bps", Type: "gauge", DefaultOp: ">", DefaultWarn: 1e9, DefaultCrit: 5e9, Min: 0, Max: 1e12, CollectSource: "snmp", Description: "接口出方向比特速率", Operators: []string{">", ">="}},
{Key: "snmp_offline", Label: "设备离线", Unit: "", Type: "status", DefaultOp: "==", DefaultWarn: 1, DefaultCrit: 1, Min: 0, Max: 1, CollectSource: "snmp", Description: "设备离线或采集异常", Operators: []string{"=="}},
},
},
{
ID: "site", Label: "站点拨测", Description: "HTTP/SSL/TCP/DNS/PING 主动拨测",
Metrics: []MetricCatalogItem{
{Key: "probe_fail", Label: "拨测失败", Unit: "", Type: "status", DefaultOp: "==", DefaultWarn: 1, DefaultCrit: 1, Min: 0, Max: 1, CollectSource: "site", Description: "连续拨测失败", Operators: []string{"=="}},
{Key: "probe_timeout", Label: "拨测超时", Unit: "", Type: "status", DefaultOp: "==", DefaultWarn: 1, DefaultCrit: 1, Min: 0, Max: 1, CollectSource: "site", Description: "请求超时", Operators: []string{"=="}},
{Key: "response_time", Label: "响应延迟", Unit: "ms", Type: "gauge", DefaultOp: ">", DefaultWarn: 500, DefaultCrit: 2000, Min: 0, Max: 60000, CollectSource: "site", Description: "拨测响应时间", Operators: []string{">", ">="}},
{Key: "availability", Label: "可用率", Unit: "%", Type: "gauge", DefaultOp: "<", DefaultWarn: 99, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "site", Description: "近 1h 成功率", Operators: []string{"<", "<="}},
{Key: "http_status_error", Label: "HTTP 状态码异常", Unit: "", Type: "status", DefaultOp: "==", DefaultWarn: 1, DefaultCrit: 1, Min: 0, Max: 1, CollectSource: "site", Description: "非期望状态码", Operators: []string{"=="}},
{Key: "ssl_expiry_days", Label: "SSL 证书剩余天数", Unit: "天", Type: "gauge", DefaultOp: "<", DefaultWarn: 30, DefaultCrit: 7, Min: -365, Max: 365, CollectSource: "site", Description: "SSL 协议拨测证书即将过期", Operators: []string{"<", "<="}},
},
},
}
// GetMetricCatalog 返回监控指标目录
// GET /api/v1/alerts/metric-catalog
func (h *AlertHandler) GetMetricCatalog(c *gin.Context) {
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"categories": metricCatalog}})
}
+181
View File
@@ -0,0 +1,181 @@
package controller
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"ezops/model"
"gorm.io/gorm"
)
// dispatchCategoryAlert 按告警集合策略发送告警通知 // Logan.Li
func dispatchCategoryAlert(db *gorm.DB, category string, ruleName, hostname, metric, severity, message string, value, threshold float64) {
var policy model.AlertNotifyPolicy
if err := db.Where("category = ? AND enabled = ?", category, true).First(&policy).Error; err != nil {
fallbackNotifyChannels(db, ruleName, hostname, metric, severity, message, value, threshold)
return
}
title := buildNotifyTitle(db, ruleName, hostname, metric, severity)
content := buildAlertContent(ruleName, hostname, metric, severity, message, value, threshold)
sent, _ := dispatchToPolicyContacts(db, &policy, title, content, severity, false)
if sent == 0 {
fallbackNotifyChannels(db, ruleName, hostname, metric, severity, message, value, threshold)
}
}
// dispatchCategoryRecovery 按告警集合策略发送恢复通知 // Logan.Li
func dispatchCategoryRecovery(db *gorm.DB, category, ruleName, hostname, metric, message string) {
var policy model.AlertNotifyPolicy
if err := db.Where("category = ? AND enabled = ?", category, true).First(&policy).Error; err != nil {
fallbackNotifyRecovery(db, ruleName, hostname, metric, message)
return
}
title := fmt.Sprintf("%s — 已恢复正常", ruleName)
content := fmt.Sprintf("**告警已恢复**\\n\\n目标:%s\\n指标:%s\\n详情:%s\\n恢复时间:%s",
hostname, metric, message, time.Now().Format("2006-01-02 15:04:05"))
sent, _ := dispatchToPolicyContacts(db, &policy, title, content, "info", true)
if sent == 0 {
fallbackNotifyRecovery(db, ruleName, hostname, metric, message)
}
}
// resolvePolicyContactIDs 展开策略中的联系组与直接联系人 // Logan.Li
func resolvePolicyContactIDs(db *gorm.DB, policy *model.AlertNotifyPolicy) []uint {
idSet := make(map[uint]struct{})
var groupIDs []uint
if policy.ContactGroupIDs != "" && policy.ContactGroupIDs != "null" {
_ = json.Unmarshal([]byte(policy.ContactGroupIDs), &groupIDs)
}
for _, gid := range groupIDs {
var g model.AlertContactGroup
if db.First(&g, gid).Error != nil {
continue
}
var cids []uint
if g.ContactIDs != "" && g.ContactIDs != "null" {
_ = json.Unmarshal([]byte(g.ContactIDs), &cids)
}
for _, cid := range cids {
idSet[cid] = struct{}{}
}
}
var directIDs []uint
if policy.ContactIDs != "" && policy.ContactIDs != "null" {
_ = json.Unmarshal([]byte(policy.ContactIDs), &directIDs)
}
for _, cid := range directIDs {
idSet[cid] = struct{}{}
}
ids := make([]uint, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
return ids
}
// dispatchToPolicyContacts 向策略解析出的联系人发送通知 // Logan.Li
func dispatchToPolicyContacts(db *gorm.DB, policy *model.AlertNotifyPolicy, title, content, severity string, isRecovery bool) (sent, skipped int) {
if policy == nil || !policy.Enabled {
return 0, 1
}
contactIDs := resolvePolicyContactIDs(db, policy)
if len(contactIDs) == 0 {
return 0, 1
}
for _, cid := range contactIDs {
var contact model.AlertContact
if db.First(&contact, cid).Error != nil || contact.WebhookURL == "" {
skipped++
continue
}
var err error
switch contact.ChannelType {
case "feishu", "":
if isRecovery {
err = sendFeishuRecovery(db, feishuConfigFromURL(contact.WebhookURL), title, content)
} else {
err = sendFeishuAlert(db, feishuConfigFromURL(contact.WebhookURL), title, content, severity)
}
case "dingtalk", "wechat", "webhook":
err = sendGenericWebhook(contact.WebhookURL, title, content)
default:
err = sendGenericWebhook(contact.WebhookURL, title, content)
}
if err != nil {
fmt.Printf("[AlertNotify] 通知失败 (联系人:%s): %v\n", contact.Name, err)
skipped++
} else {
sent++
}
}
return sent, skipped
}
// feishuConfigFromURL 联系人 webhook 转飞书配置 JSON // Logan.Li
func feishuConfigFromURL(url string) string {
b, _ := json.Marshal(model.FeishuWebhookConfig{URL: url})
return string(b)
}
// sendGenericWebhook 钉钉/企微/通用 Webhook 文本通知 // Logan.Li
func sendGenericWebhook(webhookURL, title, content string) error {
body := map[string]interface{}{
"msgtype": "text",
"text": map[string]string{
"content": title + "\n" + strings.ReplaceAll(content, "\\n", "\n"),
},
}
raw, _ := json.Marshal(body)
req, err := http.NewRequest("POST", webhookURL, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(b))
}
return nil
}
// fallbackNotifyChannels 无策略时回退到全局 NotifyChannel // Logan.Li
func fallbackNotifyChannels(db *gorm.DB, ruleName, hostname, metric, severity, message string, value, threshold float64) {
var channels []model.NotifyChannel
db.Where("enabled = ? AND type = ?", true, "feishu").Find(&channels)
title := buildNotifyTitle(db, ruleName, hostname, metric, severity)
content := buildAlertContent(ruleName, hostname, metric, severity, message, value, threshold)
for _, ch := range channels {
if err := sendFeishuAlert(db, ch.Config, title, content, severity); err != nil {
fmt.Printf("[AlertNotify] 飞书回退通知失败 (渠道:%s): %v\n", ch.Name, err)
}
}
}
func fallbackNotifyRecovery(db *gorm.DB, ruleName, hostname, metric, message string) {
var channels []model.NotifyChannel
db.Where("enabled = ? AND type = ?", true, "feishu").Find(&channels)
title := fmt.Sprintf("%s — 已恢复正常", ruleName)
content := fmt.Sprintf("**告警已恢复**\\n\\n主机:%s\\n指标:%s\\n详情:%s\\n恢复时间:%s",
hostname, metric, message, time.Now().Format("2006-01-02 15:04:05"))
for _, ch := range channels {
if err := sendFeishuRecovery(db, ch.Config, title, content); err != nil {
fmt.Printf("[AlertNotify] 飞书恢复回退失败 (渠道:%s): %v\n", ch.Name, err)
}
}
}
+137
View File
@@ -0,0 +1,137 @@
package controller
import (
"fmt"
"net/http"
"strings"
"time"
"ezops/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
defaultNotifyTitleTemplate = "[{{severity_label}}] {{rule_name}} — {{hostname}} ({{metric}})"
defaultNotifyFooterTemplate = "{{time}} | {{platform}}"
defaultNotifyPlatformName = "AIOps 智能运维平台"
)
// ensureNotifySettings 确保全局消息模版存在 // Logan.Li
func ensureNotifySettings(db *gorm.DB) model.AlertNotifySettings {
var s model.AlertNotifySettings
if err := db.First(&s).Error; err != nil {
s = model.AlertNotifySettings{
TitleTemplate: defaultNotifyTitleTemplate,
FooterTemplate: defaultNotifyFooterTemplate,
PlatformName: defaultNotifyPlatformName,
}
db.Create(&s)
}
if s.PlatformName == "" {
s.PlatformName = defaultNotifyPlatformName
}
return s
}
// GetNotifyMessageSettings GET /alerts/notify-message-settings // Logan.Li
func (h *AlertHandler) GetNotifyMessageSettings(c *gin.Context) {
s := ensureNotifySettings(h.db)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: s})
}
// SaveNotifyMessageSettings PUT /alerts/notify-message-settings // Logan.Li
func (h *AlertHandler) SaveNotifyMessageSettings(c *gin.Context) {
var req struct {
TitleTemplate string `json:"title_template"`
FooterTemplate string `json:"footer_template"`
PlatformName string `json:"platform_name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
return
}
s := ensureNotifySettings(h.db)
updates := map[string]interface{}{
"title_template": strings.TrimSpace(req.TitleTemplate),
"footer_template": strings.TrimSpace(req.FooterTemplate),
"platform_name": strings.TrimSpace(req.PlatformName),
}
if updates["platform_name"] == "" {
updates["platform_name"] = defaultNotifyPlatformName
}
h.db.Model(&s).Updates(updates)
h.db.First(&s, s.ID)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: s, Message: "消息模版已保存"})
}
// notifyTemplateVars 告警通知模版变量 // Logan.Li
func notifyTemplateVars(ruleName, hostname, metric, severity, message string, value, threshold float64, platform string) map[string]string {
sevLabel := "严重告警"
switch severity {
case "warning":
sevLabel = "警告"
case "info":
sevLabel = "信息"
}
if platform == "" {
platform = defaultNotifyPlatformName
}
return map[string]string{
"time": time.Now().Format("2006-01-02 15:04:05"),
"platform": platform,
"rule_name": ruleName,
"hostname": hostname,
"metric": metric,
"severity": severity,
"severity_label": sevLabel,
"message": message,
"value": fmt.Sprintf("%.2f", value),
"threshold": fmt.Sprintf("%.2f", threshold),
}
}
// renderNotifyTemplate 简单变量替换 {{key}} // Logan.Li
func renderNotifyTemplate(tpl string, vars map[string]string) string {
out := tpl
for k, v := range vars {
out = strings.ReplaceAll(out, "{{"+k+"}}", v)
}
return out
}
// buildNotifyTitle 按全局模版生成通知标题 // Logan.Li
func buildNotifyTitle(db *gorm.DB, ruleName, hostname, metric, severity string) string {
s := ensureNotifySettings(db)
tpl := s.TitleTemplate
if tpl == "" {
tpl = defaultNotifyTitleTemplate
}
return renderNotifyTemplate(tpl, notifyTemplateVars(ruleName, hostname, metric, severity, "", 0, 0, s.PlatformName))
}
// buildNotifyFooter 按全局模版生成卡片底部 // Logan.Li
func buildNotifyFooter(db *gorm.DB) string {
s := ensureNotifySettings(db)
tpl := s.FooterTemplate
if tpl == "" {
tpl = defaultNotifyFooterTemplate
}
vars := map[string]string{
"time": time.Now().Format("2006-01-02 15:04:05"),
"platform": s.PlatformName,
}
if vars["platform"] == "" {
vars["platform"] = defaultNotifyPlatformName
}
return renderNotifyTemplate(tpl, vars)
}
// buildAlertContent 生成告警正文(字段列表,供飞书卡片解析) // Logan.Li
func buildAlertContent(ruleName, hostname, metric, severity, message string, value, threshold float64) string {
return fmt.Sprintf(
"**规则名称:** %s\\n**目标主机:** %s\\n**指标类型:** %s\\n**告警级别:** %s\\n**当前值:** %.2f\\n**阈值:** %.2f\\n**详细信息:** %s\\n**触发时间:** %s",
ruleName, hostname, metric, severity, value, threshold, message, time.Now().Format("2006-01-02 15:04:05"),
)
}
+129
View File
@@ -0,0 +1,129 @@
package controller
import (
"encoding/json"
"net/http"
"strings"
"ezops/model"
"github.com/gin-gonic/gin"
)
// categoryLabels 告警集合中文名 // Logan.Li
var categoryLabels = map[model.AlertNotifyCategory]string{
model.AlertNotifyHost: "主机监控",
model.AlertNotifyMiddleware: "中间件监控",
model.AlertNotifySite: "站点拨测",
model.AlertNotifySNMP: "SNMP 监控",
}
// ensureNotifyPolicies 确保四类默认策略存在 // Logan.Li
func (h *AlertHandler) ensureNotifyPolicies() {
for _, cat := range model.AllAlertNotifyCategories {
var p model.AlertNotifyPolicy
h.db.Where("category = ?", cat).First(&p)
if p.ID == 0 {
h.db.Create(&model.AlertNotifyPolicy{
Category: cat,
Label: categoryLabels[cat],
Enabled: false,
})
}
}
}
// ListNotifyPolicies GET /alerts/notify-policies
func (h *AlertHandler) ListNotifyPolicies(c *gin.Context) {
h.ensureNotifyPolicies()
var policies []model.AlertNotifyPolicy
h.db.Order("id ASC").Find(&policies)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": policies, "total": len(policies)}})
}
// UpsertNotifyPolicy PUT /alerts/notify-policies/:category
func (h *AlertHandler) UpsertNotifyPolicy(c *gin.Context) {
cat := model.AlertNotifyCategory(strings.TrimSpace(c.Param("category")))
if !isValidNotifyCategory(cat) {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的告警集合类型"})
return
}
var req struct {
Enabled *bool `json:"enabled"`
ContactGroupIDs []uint `json:"contact_group_ids"`
ContactIDs []uint `json:"contact_ids"`
NotifyMode string `json:"notify_mode"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
return
}
h.ensureNotifyPolicies()
var policy model.AlertNotifyPolicy
h.db.Where("category = ?", cat).First(&policy)
updates := map[string]interface{}{}
if req.Enabled != nil {
updates["enabled"] = *req.Enabled
}
if req.ContactGroupIDs != nil {
b, _ := json.Marshal(req.ContactGroupIDs)
updates["contact_group_ids"] = string(b)
}
if req.ContactIDs != nil {
b, _ := json.Marshal(req.ContactIDs)
updates["contact_ids"] = string(b)
}
if req.NotifyMode != "" {
updates["notify_mode"] = req.NotifyMode
}
if req.Description != "" || c.Request.ContentLength > 0 {
updates["description"] = req.Description
}
if len(updates) > 0 {
h.db.Model(&policy).Updates(updates)
}
h.db.Where("category = ?", cat).First(&policy)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: policy})
}
// TestNotifyPolicy POST /alerts/notify-policies/:category/test
func (h *AlertHandler) TestNotifyPolicy(c *gin.Context) {
cat := model.AlertNotifyCategory(strings.TrimSpace(c.Param("category")))
if !isValidNotifyCategory(cat) {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的告警集合类型"})
return
}
h.ensureNotifyPolicies()
var policy model.AlertNotifyPolicy
h.db.Where("category = ?", cat).First(&policy)
label := categoryLabels[cat]
title := buildNotifyTitle(h.db, "通知策略测试", label, "test", "info")
content := buildAlertContent("通知策略测试", label, "test", "info",
"这是一条测试通知,用于验证该告警集合的通知对象配置是否正确。", 0, 0)
sent, skipped := dispatchToPolicyContacts(h.db, &policy, title, content, "info", true)
if sent == 0 {
msg := "未发送任何通知"
if skipped > 0 {
msg += "(策略未启用或未配置联系人)"
}
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: msg})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"sent": sent, "skipped": skipped}, Message: "测试通知已发送"})
}
func isValidNotifyCategory(cat model.AlertNotifyCategory) bool {
for _, c := range model.AllAlertNotifyCategories {
if c == cat {
return true
}
}
return false
}
+215
View File
@@ -0,0 +1,215 @@
package controller
import (
"fmt"
"net/http"
"strconv"
"time"
"ezops/model"
"github.com/gin-gonic/gin"
)
// UnifiedAlertItem 统一告警事件视图 // Logan.Li
type UnifiedAlertItem struct {
ID string `json:"id"`
SourceType string `json:"source_type"`
RuleName string `json:"rule_name"`
Title string `json:"title"`
Message string `json:"message"`
Severity string `json:"severity"`
Status string `json:"status"`
Target string `json:"target"`
Metric string `json:"metric"`
Value float64 `json:"value"`
Threshold float64 `json:"threshold"`
StartedAt string `json:"started_at"`
ResolvedAt *string `json:"resolved_at,omitempty"`
}
// ListUnifiedAlerts 统一告警列表(主机 + 中间件 + 站点 + 日志)
// GET /api/v1/alerts/unified
func (h *AlertHandler) ListUnifiedAlerts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if pageSize > 100 {
pageSize = 100
}
statusFilter := c.Query("status")
severityFilter := c.Query("severity")
sourceFilter := c.Query("source_type")
items := make([]UnifiedAlertItem, 0)
// 1. Agent 主机/中间件/SNMP 告警(中间件与 SNMP 复用 AgentAlert 存储)
if sourceFilter == "" || sourceFilter == "host" || sourceFilter == "middleware" || sourceFilter == "snmp" {
var alerts []model.AgentAlert
q := h.db.Model(&model.AgentAlert{}).Order("created_at DESC").Limit(200)
if statusFilter == "firing" || statusFilter == "" {
if statusFilter == "firing" {
q = q.Where("resolved = ?", false)
}
} else if statusFilter == "resolved" {
q = q.Where("resolved = ?", true)
}
if severityFilter != "" {
q = q.Where("severity = ?", severityFilter)
}
q.Find(&alerts)
for _, a := range alerts {
st := "firing"
if a.Resolved {
st = "resolved"
}
srcType := "host"
if isSNMPMetric(a.Metric) {
srcType = "snmp"
} else if isMiddlewareMetric(a.Metric) {
srcType = "middleware"
}
if sourceFilter == "host" && srcType != "host" {
continue
}
if sourceFilter == "middleware" && srcType != "middleware" {
continue
}
if sourceFilter == "snmp" && srcType != "snmp" {
continue
}
var resolved *string
if a.ResolvedAt != nil {
s := a.ResolvedAt.Format(time.RFC3339)
resolved = &s
}
items = append(items, UnifiedAlertItem{
ID: "agent-" + strconv.Itoa(int(a.ID)), SourceType: srcType,
RuleName: a.RuleName, Title: a.RuleName, Message: a.Message,
Severity: a.Severity, Status: st, Target: a.Hostname,
Metric: a.Metric, Value: a.Value, Threshold: a.Threshold,
StartedAt: a.CreatedAt.Format(time.RFC3339), ResolvedAt: resolved,
})
}
}
// 2. 站点拨测告警
if sourceFilter == "" || sourceFilter == "site" {
var alerts []model.SiteAlert
q := h.db.Model(&model.SiteAlert{}).Preload("Task").Order("started_at DESC").Limit(200)
if statusFilter == "firing" {
q = q.Where("status = ?", model.SiteAlertStatusActive)
} else if statusFilter == "resolved" {
q = q.Where("status = ?", model.SiteAlertStatusResolved)
}
if severityFilter != "" {
q = q.Where("severity = ?", severityFilter)
}
q.Find(&alerts)
for _, a := range alerts {
st := "firing"
if a.Status == model.SiteAlertStatusResolved {
st = "resolved"
}
target := fmt.Sprintf("task-%d", a.TaskID)
if a.Task != nil {
target = a.Task.Name
}
var resolved *string
if a.ResolvedAt != nil {
s := a.ResolvedAt.Format(time.RFC3339)
resolved = &s
}
items = append(items, UnifiedAlertItem{
ID: "site-" + strconv.Itoa(int(a.ID)), SourceType: "site",
RuleName: a.Title, Title: a.Title, Message: a.Message,
Severity: string(a.Severity), Status: st, Target: target,
Metric: a.TriggerType, Value: a.ResponseTime, Threshold: a.Threshold,
StartedAt: a.StartedAt.Format(time.RFC3339), ResolvedAt: resolved,
})
}
}
// 3. 日志/通用 AlertEvent
if sourceFilter == "" || sourceFilter == "log" {
var events []model.AlertEvent
q := h.db.Model(&model.AlertEvent{}).Order("started_at DESC").Limit(200)
if statusFilter != "" && statusFilter != "all" {
q = q.Where("status = ?", statusFilter)
}
if severityFilter != "" {
q = q.Where("severity = ?", severityFilter)
}
q.Find(&events)
for _, e := range events {
var resolved *string
if e.ResolvedAt != nil {
s := e.ResolvedAt.Format(time.RFC3339)
resolved = &s
}
items = append(items, UnifiedAlertItem{
ID: "event-" + strconv.Itoa(int(e.ID)), SourceType: "log",
RuleName: e.RuleName, Title: e.RuleName, Message: e.Message,
Severity: e.Severity, Status: e.Status, Target: e.Target,
Metric: "log", Value: e.Value, Threshold: e.Threshold,
StartedAt: e.StartedAt.Format(time.RFC3339), ResolvedAt: resolved,
})
}
}
// 按时间排序
sortUnifiedAlerts(items)
total := len(items)
start := (page - 1) * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
pageItems := items[start:end]
// 统计
stats := gin.H{
"firing": countStatus(items, "firing"),
"critical": countSeverityStatus(items, "critical", "firing"),
"warning": countSeverityStatus(items, "warning", "firing"),
"resolved": countStatus(items, "resolved"),
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Data: gin.H{"list": pageItems, "total": total, "stats": stats},
})
}
func sortUnifiedAlerts(items []UnifiedAlertItem) {
for i := 0; i < len(items); i++ {
for j := i + 1; j < len(items); j++ {
if items[j].StartedAt > items[i].StartedAt {
items[i], items[j] = items[j], items[i]
}
}
}
}
func countStatus(items []UnifiedAlertItem, status string) int {
n := 0
for _, it := range items {
if it.Status == status {
n++
}
}
return n
}
func countSeverityStatus(items []UnifiedAlertItem, sev, status string) int {
n := 0
for _, it := range items {
if it.Severity == sev && it.Status == status {
n++
}
}
return n
}
+121
View File
@@ -0,0 +1,121 @@
package controller
import (
"fmt"
"net/http"
"time"
"ezops/model"
"ezops/pkg/auditcleaner"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AuditSettingsHandler 审计策略配置 // Logan.Li
type AuditSettingsHandler struct {
db *gorm.DB
}
func NewAuditSettingsHandler(db *gorm.DB) *AuditSettingsHandler {
return &AuditSettingsHandler{db: db}
}
func (h *AuditSettingsHandler) getOrCreate() (model.AuditSettings, error) {
var s model.AuditSettings
err := h.db.First(&s).Error
if err == nil {
return s, nil
}
if err != gorm.ErrRecordNotFound {
return s, err
}
s = model.AuditSettings{RetentionDays: 180, RecordAll: true}
return s, h.db.Create(&s).Error
}
// GetAuditSettings 获取审计策略 // Logan.Li
// GET /api/v1/admin/audit-settings
func (h *AuditSettingsHandler) GetAuditSettings(c *gin.Context) {
s, err := h.getOrCreate()
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取配置失败"})
return
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
Data: gin.H{
"retention_days": s.RetentionDays,
"record_all": s.RecordAll,
"last_cleanup_at": s.LastCleanupAt,
"last_cleanup_summary": s.LastCleanupSummary,
"allowed_retention_days": model.AllowedAuditRetentionDays,
},
})
}
// ApplyAuditSettings 应用审计策略并立即清理超期日志 // Logan.Li
// PUT /api/v1/admin/audit-settings
func (h *AuditSettingsHandler) ApplyAuditSettings(c *gin.Context) {
var req struct {
RetentionDays int `json:"retention_days" binding:"required"`
RecordAll bool `json:"record_all"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误"})
return
}
if !model.IsValidRetentionDays(req.RetentionDays) {
c.JSON(http.StatusBadRequest, model.Response{
Code: 400,
Message: "保留天数仅支持 7 / 15 / 30 / 90 / 180 / 365",
})
return
}
s, err := h.getOrCreate()
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取配置失败"})
return
}
s.RetentionDays = req.RetentionDays
s.RecordAll = req.RecordAll
cleanResult, cleanErr := auditcleaner.Run(h.db, req.RetentionDays)
now := time.Now()
s.LastCleanupAt = &now
s.LastCleanupSummary = cleanResult.SummaryJSON()
if err := h.db.Save(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存配置失败"})
return
}
msg := fmt.Sprintf("已应用保留 %d 天策略", req.RetentionDays)
if cleanErr != nil {
c.JSON(http.StatusInternalServerError, model.Response{
Code: 500,
Message: "配置已保存,但立即清理失败: " + cleanErr.Error(),
})
return
}
if cleanResult.Total > 0 {
msg += fmt.Sprintf(",已清理 %d 条超期记录", cleanResult.Total)
} else {
msg += ",暂无超期记录需清理"
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: msg,
Data: gin.H{
"retention_days": s.RetentionDays,
"record_all": s.RecordAll,
"last_cleanup_at": s.LastCleanupAt,
"last_cleanup_summary": s.LastCleanupSummary,
"cleanup": cleanResult,
},
})
}
+5 -44
View File
@@ -1,14 +1,13 @@
package controller
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"os/exec"
"time"
"ezops/pkg/scriptrunner"
"gorm.io/gorm"
)
@@ -21,48 +20,10 @@ func randomCode(prefix string) string {
return fmt.Sprintf("%s-%d-%s", prefix, time.Now().Unix(), hex.EncodeToString(b))
}
// runLocal executes a script or shell command locally on the AIOps host and
// returns the combined stdout+stderr output, the process exit code (0 = success)
// and the wall-clock duration in milliseconds. A timeout prevents runaway jobs.
// runLocal 委托 scriptrunner 在本机执行脚本(含运行时预检) // Logan.Li
func runLocal(scriptType, content string, timeoutSec int) (output string, exitCode int, durationMs int) {
start := time.Now()
timeout := time.Duration(timeoutSec) * time.Second
if timeout <= 0 {
timeout = 300 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var cmd *exec.Cmd
switch scriptType {
case "python":
cmd = exec.CommandContext(ctx, "python3", "-c", content)
default: // shell / command / "" → run via bash
cmd = exec.CommandContext(ctx, "bash", "-c", content)
}
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
durationMs = int(time.Since(start).Milliseconds())
output = out.String()
if ctx.Err() == context.DeadlineExceeded {
output += fmt.Sprintf("\n[超时] 执行超过 %d 秒被终止", timeoutSec)
return output, -1, durationMs
}
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
exitCode = exitError.ExitCode()
} else {
exitCode = -1
output += "\n" + err.Error()
}
return output, exitCode, durationMs
}
return output, 0, durationMs
output, exitCode, durationMs, _ = scriptrunner.Run(scriptType, content, timeoutSec)
return output, exitCode, durationMs
}
// AutomationHandler 自动化运维处理器
+4 -104
View File
@@ -1,13 +1,10 @@
package controller
import (
"fmt"
"net/http"
"strconv"
"time"
"ezops/model"
"ezops/pkg/remoteexec"
"github.com/gin-gonic/gin"
)
@@ -294,113 +291,16 @@ func (h *AutomationHandler) ToggleCronJob(c *gin.Context) {
// @Router /cron-jobs/{id}/execute [post]
func (h *AutomationHandler) ExecuteCronJob(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var job model.CronJob
if err := h.db.First(&job, id).Error; err != nil {
execution, err := RunCronJobByID(h.db, uint(id))
if err != nil {
c.JSON(http.StatusNotFound, model.Response{
Code: 404,
Message: "定时任务不存在",
Message: err.Error(),
})
return
}
// Resolve the content to execute. For type=script, load the linked Script.
content := job.Content
scriptType := job.Type // shell | python | command | script
if scriptType == "command" {
scriptType = "shell" // one-liner commands run via bash
}
if job.Type == "script" && job.ScriptID != nil {
var script model.Script
if err := h.db.First(&script, *job.ScriptID).Error; err == nil {
content = script.Content
scriptType = script.Type
h.db.Model(&script).UpdateColumn("run_count", script.RunCount+1)
}
}
now := time.Now()
execution := model.TaskExecution{
TaskType: "cron",
TaskID: uint(id),
TaskName: job.Name,
Status: "running",
TargetType: job.TargetType,
Targets: job.Targets,
StartedAt: &now,
}
if err := h.db.Create(&execution).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{
Code: 500,
Message: "创建执行记录失败",
})
return
}
var output string
var exitCode int
var duration int
status := "success"
// 根据 TargetType 决定执行方式 // Logan.Li
if job.TargetType != "" && job.TargetType != "local" && job.Targets != "" {
// 远程目标执行 — 通过 SSH 并发执行
remoteResults, remoteErr := ExecuteCronOnTargets(h.db, job, scriptType, content, job.Timeout)
duration = int(time.Since(now).Milliseconds())
if remoteErr != nil {
output = fmt.Sprintf("远程执行调度失败: %v", remoteErr)
exitCode = -1
status = "failed"
} else {
// 格式化远程执行结果
output = remoteexec.FormatExecResults(remoteResults)
// 任意一台失败则整体标记为 failed
for _, r := range remoteResults {
if !r.Success {
status = "failed"
exitCode = -1
break
}
}
}
} else {
// 本地执行
output, exitCode, duration = runLocal(scriptType, content, job.Timeout)
if exitCode != 0 {
status = "failed"
}
}
finishedAt := time.Now()
h.db.Model(&execution).Updates(map[string]interface{}{
"status": status,
"output": output,
"duration": duration,
"finished_at": finishedAt,
})
// Update cron job statistics.
statsUpdates := map[string]interface{}{
"last_run_at": now,
"last_status": status,
"run_count": job.RunCount + 1,
}
if status == "success" {
statsUpdates["success_count"] = job.SuccessCount + 1
} else {
statsUpdates["fail_count"] = job.FailCount + 1
}
h.db.Model(&job).Updates(statsUpdates)
execution.Output = output
execution.Status = status
execution.Duration = duration
execution.FinishedAt = &finishedAt
msg := "执行成功"
if status == "failed" {
if execution.Status == "failed" {
msg = "执行失败"
}
c.JSON(http.StatusOK, model.Response{
+26 -1
View File
@@ -5,11 +5,23 @@ import (
"strconv"
"time"
"ezops/middleware"
"ezops/model"
"ezops/pkg/scriptrunner"
"github.com/gin-gonic/gin"
)
// GetScriptRuntime 获取本机脚本运行时能力(Shell/Python/PowerShell) // Logan.Li
func (h *AutomationHandler) GetScriptRuntime(c *gin.Context) {
rt := scriptrunner.DetectRuntime()
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
Data: rt,
})
}
// ListScripts 获取脚本列表
// @Summary 获取脚本列表
// @Description 获取所有脚本列表
@@ -104,6 +116,10 @@ func (h *AutomationHandler) CreateScript(c *gin.Context) {
if script.Timeout == 0 {
script.Timeout = 300
}
if script.Code == "" {
script.Code = randomCode("sc")
}
script.CreatedBy = middleware.GetUserID(c)
if err := h.db.Create(&script).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{
@@ -283,7 +299,16 @@ func (h *AutomationHandler) ExecuteScript(c *gin.Context) {
return
}
// Execute locally on the AIOps host.
rt := scriptrunner.DetectRuntime()
if ok, reason := scriptrunner.CanRun(rt, script.Type); !ok {
c.JSON(http.StatusBadRequest, model.Response{
Code: 400,
Message: reason,
})
return
}
// 在 AIOps 本机执行(embed 单二进制 / Docker 容器内)
output, exitCode, duration := runLocal(script.Type, script.Content, script.Timeout)
finishedAt := time.Now()
status := "success"
+116
View File
@@ -0,0 +1,116 @@
// 定时任务执行引擎 — 供 HTTP 手动触发与后台 Cron 调度共用 // Logan.Li
package controller
import (
"fmt"
"time"
"ezops/model"
"ezops/pkg/remoteexec"
"ezops/pkg/scriptrunner"
"gorm.io/gorm"
)
// RunCronJobByID 执行指定定时任务并写入执行记录 // Logan.Li
func RunCronJobByID(db *gorm.DB, id uint) (*model.TaskExecution, error) {
var job model.CronJob
if err := db.First(&job, id).Error; err != nil {
return nil, fmt.Errorf("定时任务不存在")
}
return runCronJob(db, job)
}
func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) {
content := job.Content
scriptType := job.Type
if scriptType == "command" {
scriptType = "shell"
}
if job.Type == "script" && job.ScriptID != nil {
var script model.Script
if err := db.First(&script, *job.ScriptID).Error; err == nil {
content = script.Content
scriptType = script.Type
db.Model(&script).UpdateColumn("run_count", script.RunCount+1)
}
}
now := time.Now()
execution := model.TaskExecution{
TaskType: "cron",
TaskID: job.ID,
TaskName: job.Name,
Status: "running",
TargetType: job.TargetType,
Targets: job.Targets,
StartedAt: &now,
}
if err := db.Create(&execution).Error; err != nil {
return nil, fmt.Errorf("创建执行记录失败")
}
var output string
var exitCode int
var duration int
status := "success"
if job.TargetType != "" && job.TargetType != "local" && job.Targets != "" {
remoteResults, remoteErr := ExecuteCronOnTargets(db, job, scriptType, content, job.Timeout)
duration = int(time.Since(now).Milliseconds())
if remoteErr != nil {
output = fmt.Sprintf("远程执行调度失败: %v", remoteErr)
exitCode = -1
status = "failed"
} else {
output = remoteexec.FormatExecResults(remoteResults)
for _, r := range remoteResults {
if !r.Success {
status = "failed"
exitCode = -1
break
}
}
}
} else {
rt := scriptrunner.DetectRuntime()
if ok, reason := scriptrunner.CanRun(rt, scriptType); !ok {
output = reason + "\n"
exitCode = -1
duration = int(time.Since(now).Milliseconds())
status = "failed"
} else {
output, exitCode, duration = runLocal(scriptType, content, job.Timeout)
}
if exitCode != 0 {
status = "failed"
}
}
finishedAt := time.Now()
db.Model(&execution).Updates(map[string]interface{}{
"status": status,
"output": output,
"duration": duration,
"finished_at": finishedAt,
})
statsUpdates := map[string]interface{}{
"last_run_at": now,
"last_status": status,
"run_count": job.RunCount + 1,
}
if status == "success" {
statsUpdates["success_count"] = job.SuccessCount + 1
} else {
statsUpdates["fail_count"] = job.FailCount + 1
}
db.Model(&job).Updates(statsUpdates)
execution.Output = output
execution.Status = status
execution.Duration = duration
execution.FinishedAt = &finishedAt
_ = exitCode
return &execution, nil
}
+29 -2
View File
@@ -97,16 +97,44 @@ func (h *DashboardHandler) GetStats(c *gin.Context) {
h.db.Model(&model.KubernetesCluster{}).Where("status = ?", "warning").Count(&k8sStats.Warning)
h.db.Model(&model.KubernetesCluster{}).Where("status = ?", "error").Count(&k8sStats.Error)
var totalNodes, totalPods, totalServices int64
var totalNodes, totalPods, totalServices, totalIngresses, totalDeployments int64
h.db.Model(&model.KubernetesNode{}).Count(&totalNodes)
h.db.Model(&model.KubernetesPod{}).Count(&totalPods)
h.db.Model(&model.KubernetesService{}).Count(&totalServices)
h.db.Model(&model.KubernetesIngress{}).Count(&totalIngresses)
h.db.Model(&model.KubernetesDeployment{}).Count(&totalDeployments)
k8sStats.TotalNodes = totalNodes
k8sStats.TotalPods = totalPods
k8sStats.TotalServices = totalServices
k8sStats.TotalIngresses = totalIngresses
k8sStats.TotalDeployments = totalDeployments
stats.K8sStats = k8sStats
today := time.Now().Format("2006-01-02")
// 自动化运维统计 // Logan.Li
var automationStats model.AutomationStats
h.db.Model(&model.CronJob{}).Count(&automationStats.CronJobCount)
h.db.Model(&model.Script{}).Count(&automationStats.ScriptCount)
h.db.Model(&model.TaskExecution{}).Where("DATE(created_at) = ?", today).Count(&automationStats.TodayExecutions)
var successExec, totalExec int64
h.db.Model(&model.TaskExecution{}).Where("status = ?", "success").Count(&successExec)
h.db.Model(&model.TaskExecution{}).Count(&totalExec)
if totalExec > 0 {
automationStats.SuccessRate = math.Round(float64(successExec)/float64(totalExec)*10000) / 100
}
stats.AutomationStats = automationStats
// 运维概览:拨测 / 中间件 / SNMP // Logan.Li
var opsStats model.OpsOverviewStats
h.db.Model(&model.Domain{}).Count(&opsStats.DomainCount)
h.db.Model(&model.SiteTask{}).Count(&opsStats.SiteTaskCount)
h.db.Model(&model.MonitorTarget{}).Where("description IN ?", []string{"redis", "mysql"}).Count(&opsStats.MiddlewareCount)
h.db.Model(&model.SNMPDevice{}).Count(&opsStats.SnmpDeviceCount)
h.db.Model(&model.MonitorTarget{}).Count(&opsStats.MonitorTargetCount)
stats.OpsStats = opsStats
// 告警统计
var alertStats model.AlertStats
h.db.Model(&model.AlertEvent{}).Count(&alertStats.Total)
@@ -117,7 +145,6 @@ func (h *DashboardHandler) GetStats(c *gin.Context) {
h.db.Model(&model.AlertEvent{}).Where("severity = ?", "info").Count(&alertStats.Info)
// 今日告警
today := time.Now().Format("2006-01-02")
h.db.Model(&model.AlertEvent{}).Where("DATE(created_at) = ?", today).Count(&alertStats.TodayCount)
// 本周告警
+4
View File
@@ -24,6 +24,9 @@ func (h *KubernetesHandler) ListClusters(c *gin.Context) {
var clusters []model.KubernetesCluster
h.db.Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&clusters)
// 列表页资源计数与详情页对齐(实时拉取 Ingress 等)// Logan.Li
h.enrichClustersForList(clusters)
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
@@ -495,6 +498,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
"ingress_count": result.Ingresses,
"last_sync_at": &now,
})
h.updateClusterUsageAfterSync(cluster.ID)
msg := fmt.Sprintf("同步完成: %d nodes, %d pods, %d deployments, %d services, %d ingresses",
result.Nodes, result.Pods, result.Deployments, result.Services, result.Ingresses)
+109
View File
@@ -0,0 +1,109 @@
// K8s 集群列表统计增强 — 实时拉取资源数量,修复 Ingress 等与详情页不一致 // Logan.Li
package controller
import (
"log"
"ezops/model"
)
// enrichClustersForList 列表返回前刷新资源计数与节点均值使用率 // Logan.Li
func (h *KubernetesHandler) enrichClustersForList(clusters []model.KubernetesCluster) {
for i := range clusters {
c := &clusters[i]
h.fillClusterCountsFromDB(c)
h.refreshClusterCountsLive(c)
h.fillClusterUsageFromNodes(c)
// 异步落库,避免列表阻塞过久
snap := *c
go func(cluster model.KubernetesCluster) {
if err := h.db.Model(&model.KubernetesCluster{}).Where("id = ?", cluster.ID).Updates(map[string]interface{}{
"node_count": cluster.NodeCount,
"pod_count": cluster.PodCount,
"service_count": cluster.ServiceCount,
"deployment_count": cluster.DeploymentCount,
"ingress_count": cluster.IngressCount,
"cpu_usage": cluster.CPUUsage,
"memory_usage": cluster.MemoryUsage,
}).Error; err != nil {
log.Printf("[K8s] enrich cluster %d stats persist error: %v", cluster.ID, err)
}
}(snap)
}
}
func (h *KubernetesHandler) fillClusterCountsFromDB(c *model.KubernetesCluster) {
var nodes, pods, svcs, deps, ings int64
h.db.Model(&model.KubernetesNode{}).Where("cluster_id = ?", c.ID).Count(&nodes)
h.db.Model(&model.KubernetesPod{}).Where("cluster_id = ?", c.ID).Count(&pods)
h.db.Model(&model.KubernetesService{}).Where("cluster_id = ?", c.ID).Count(&svcs)
h.db.Model(&model.KubernetesDeployment{}).Where("cluster_id = ?", c.ID).Count(&deps)
h.db.Model(&model.KubernetesIngress{}).Where("cluster_id = ?", c.ID).Count(&ings)
if nodes > 0 {
c.NodeCount = int(nodes)
}
if pods > 0 {
c.PodCount = int(pods)
}
if svcs > 0 {
c.ServiceCount = int(svcs)
}
if deps > 0 {
c.DeploymentCount = int(deps)
}
if ings > 0 {
c.IngressCount = int(ings)
}
}
func (h *KubernetesHandler) refreshClusterCountsLive(c *model.KubernetesCluster) {
client, err := buildK8sClient(c)
if err != nil {
return
}
if nodes, err := client.ListNodes(); err == nil {
c.NodeCount = len(nodes.Items)
}
if pods, err := client.ListPods(); err == nil {
c.PodCount = len(pods.Items)
}
if deps, err := client.ListDeployments(); err == nil {
c.DeploymentCount = len(deps.Items)
}
if svcs, err := client.ListServices(); err == nil {
c.ServiceCount = len(svcs.Items)
}
if ings, err := client.ListIngresses(); err == nil {
c.IngressCount = len(ings.Items)
} else {
log.Printf("[K8s] list ingresses for cluster %s error: %v", c.Code, err)
}
}
func (h *KubernetesHandler) fillClusterUsageFromNodes(c *model.KubernetesCluster) {
var avgCPU, avgMem float64
row := h.db.Model(&model.KubernetesNode{}).Where("cluster_id = ?", c.ID).
Select("COALESCE(AVG(cpu_usage), 0), COALESCE(AVG(memory_usage), 0)").Row()
if row != nil {
_ = row.Scan(&avgCPU, &avgMem)
}
if avgCPU > 0 {
c.CPUUsage = avgCPU
}
if avgMem > 0 {
c.MemoryUsage = avgMem
}
}
// updateClusterUsageAfterSync 同步后根据节点指标写回集群 CPU/内存均值 // Logan.Li
func (h *KubernetesHandler) updateClusterUsageAfterSync(clusterID uint) {
var cluster model.KubernetesCluster
cluster.ID = clusterID
h.fillClusterUsageFromNodes(&cluster)
if cluster.CPUUsage > 0 || cluster.MemoryUsage > 0 {
h.db.Model(&model.KubernetesCluster{}).Where("id = ?", clusterID).Updates(map[string]interface{}{
"cpu_usage": cluster.CPUUsage,
"memory_usage": cluster.MemoryUsage,
})
}
}
+17 -2
View File
@@ -147,17 +147,32 @@ func resolveEndpoint(provider, endpoint string) string {
return "https://api.anthropic.com"
case "glm-coding": // 智谱 GLM 编码套餐 Anthropic 协议端点(模型 GLM-5.2) // Logan.Li
return "https://open.bigmodel.cn/api/anthropic"
case "minimax": // MiniMax 按量 OpenAI 兼容端点 // Logan.Li
return "https://api.minimaxi.com/v1"
case "minimax-coding": // MiniMax 编码套餐 Anthropic 协议端点 // Logan.Li
return "https://api.minimaxi.com/anthropic"
case "openai": // OpenAI 官方端点 // Logan.Li
return "https://api.openai.com/v1"
case "siliconflow": // 硅基流动 OpenAI 兼容端点 // Logan.Li
return "https://api.siliconflow.cn/v1"
case "ollama":
return "http://localhost:11434/v1"
case "vllm": // vLLM OpenAI 兼容端点 // Logan.Li
return "http://localhost:8000/v1"
default: // openai / custom
return "https://api.openai.com/v1"
}
}
// isLocalLLMProvider 本地/自建模型,API Key 可选 // Logan.Li
func isLocalLLMProvider(provider string) bool {
return provider == "ollama" || provider == "vllm" || provider == "custom"
}
// resolveProtocol 根据提供商推断接入协议(决定走 OpenAI 还是 Anthropic 客户端) // Logan.Li
// 编码套餐(智谱 GLM 编码 / Anthropic 官方)走 Anthropic Messages 协议;其余走 OpenAI 兼容协议。
// 编码套餐(智谱 / MiniMax)与 Anthropic 官方走 Messages 协议;其余走 OpenAI 兼容协议。 // Logan.Li
func resolveProtocol(provider string) string {
if provider == "anthropic" || provider == "glm-coding" {
if provider == "anthropic" || provider == "glm-coding" || provider == "minimax-coding" {
return "anthropic"
}
return "openai"
+61 -10
View File
@@ -176,16 +176,67 @@ func writeSSEError(c *gin.Context, code int, message string) {
c.Writer.Flush()
}
// TestLLMConnection 测试 LLM 连接(询问模型身份并返回回答) // Logan.Li
// llmConfigFromTestRequest 将测试请求转为运行时配置(脱敏 Key 回退已保存值) // Logan.Li
func (h *LLMHandler) llmConfigFromTestRequest(req model.SaveLLMConfigRequest) (model.LLMConfig, string) {
cfg := model.LLMConfig{
Provider: req.Provider,
Model: req.Model,
APIKey: req.APIKey,
Endpoint: req.Endpoint,
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
ReasoningEnabled: req.ReasoningEnabled,
}
if cfg.Temperature == 0 {
cfg.Temperature = 0.7
}
if cfg.MaxTokens == 0 {
cfg.MaxTokens = 8192
}
if strings.Contains(req.APIKey, "****") {
var saved model.LLMConfig
if err := h.db.First(&saved).Error; err != nil {
if !isLocalLLMProvider(req.Provider) {
return cfg, "请填写 API Key"
}
} else {
cfg.APIKey = saved.APIKey
}
}
if cfg.Provider == "" {
return cfg, "请选择提供商"
}
if cfg.Model == "" {
return cfg, "请填写模型名"
}
if cfg.APIKey == "" && !isLocalLLMProvider(cfg.Provider) {
return cfg, "请填写 API Key"
}
return cfg, ""
}
// TestLLMConnection 测试 LLM 连接(支持草稿配置,保存前即可验证) // Logan.Li
// POST /api/v1/aiops/llm-config/test
func (h *LLMHandler) TestLLMConnection(c *gin.Context) {
var req model.SaveLLMConfigRequest
_ = c.ShouldBindJSON(&req) // 空 body 时回退已保存配置 // Logan.Li
var cfg model.LLMConfig
if err := h.db.First(&cfg).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先保存配置"})
return
var errMsg string
if req.Provider != "" {
cfg, errMsg = h.llmConfigFromTestRequest(req)
} else {
if err := h.db.First(&cfg).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先填写配置并测试连接"})
return
}
if cfg.APIKey == "" && !isLocalLLMProvider(cfg.Provider) {
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请填写 API Key"})
return
}
}
if !cfg.Enabled {
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先启用 LLM 服务"})
if errMsg != "" {
c.JSON(http.StatusOK, model.Response{Code: 400, Message: errMsg})
return
}
resp, err := buildLLMClientFromConfig(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{
@@ -196,10 +247,10 @@ func (h *LLMHandler) TestLLMConnection(c *gin.Context) {
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "连接成功", Data: gin.H{
"answer": resp.Message.Content,
"model": resp.Model,
"tokens_used": resp.Usage.TotalTokens,
"provider": cfg.Provider,
"answer": resp.Message.Content,
"model": resp.Model,
"tokens_used": resp.Usage.TotalTokens,
"provider": cfg.Provider,
"reasoning_enabled": cfg.ReasoningEnabled,
}})
}
+111 -96
View File
@@ -13,10 +13,10 @@ import (
"time"
"ezops/model"
"ezops/pkg/systask"
_ "github.com/go-sql-driver/mysql"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
@@ -27,9 +27,29 @@ import (
// MiddlewareConfig 存储在 MonitorTarget.Config JSON 字段中
type MiddlewareConfig struct {
Password string `json:"password,omitempty"`
Username string `json:"username,omitempty"` // MySQL 专用
Database string `json:"database,omitempty"` // MySQL 专用
Password string `json:"password,omitempty"`
Username string `json:"username,omitempty"` // MySQL 专用
Database string `json:"database,omitempty"` // MySQL 专用
RedisMode string `json:"redis_mode,omitempty"` // Redis: standalone / cluster
}
// parseMiddlewareConfig 解析中间件配置并补全默认值 // Logan.Li
func parseMiddlewareConfig(configJSON string) MiddlewareConfig {
var cfg MiddlewareConfig
if configJSON != "" {
json.Unmarshal([]byte(configJSON), &cfg)
}
if cfg.RedisMode == "" {
cfg.RedisMode = "standalone"
}
return cfg
}
// middlewareConfigPublic 返回给前端的脱敏配置 // Logan.Li
type middlewareConfigPublic struct {
Username string `json:"username,omitempty"`
Database string `json:"database,omitempty"`
RedisMode string `json:"redis_mode,omitempty"`
}
// MiddlewareMonitorHandler 中间件监控 handler
@@ -60,11 +80,11 @@ func (h *MiddlewareMonitorHandler) StartCollector(intervalSec int) {
defer ticker.Stop()
log.Printf("[MiddlewareMonitor] 采集引擎启动,间隔 %ds", intervalSec)
// 首次立即采集一次
h.collectAll()
h.collectAllWithRecord()
for {
select {
case <-ticker.C:
h.collectAll()
h.collectAllWithRecord()
case <-h.stopCh:
log.Printf("[MiddlewareMonitor] 采集引擎停止")
return
@@ -87,14 +107,15 @@ func (h *MiddlewareMonitorHandler) StopCollector() {
// POST /api/v1/middleware-monitors
func (h *MiddlewareMonitorHandler) CreateMiddlewareTarget(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"` // redis / mysql
IP string `json:"ip" binding:"required"`
Port int `json:"port" binding:"required"`
Password string `json:"password"`
Username string `json:"username"` // MySQL
Database string `json:"database"` // MySQL
Interval int `json:"interval"` // 采集间隔,默认 30s
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"` // redis / mysql
IP string `json:"ip" binding:"required"`
Port int `json:"port" binding:"required"`
Password string `json:"password"`
Username string `json:"username"` // MySQL
Database string `json:"database"` // MySQL
RedisMode string `json:"redis_mode"` // Redis: standalone / cluster
Interval int `json:"interval"` // 采集间隔,默认 30s
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
@@ -119,11 +140,21 @@ func (h *MiddlewareMonitorHandler) CreateMiddlewareTarget(c *gin.Context) {
req.Interval = 30
}
redisMode := strings.ToLower(req.RedisMode)
if mwType == "redis" {
if redisMode != "cluster" {
redisMode = "standalone"
}
} else {
redisMode = ""
}
// 构建 Config JSON
cfg := MiddlewareConfig{
Password: req.Password,
Username: req.Username,
Database: req.Database,
Password: req.Password,
Username: req.Username,
Database: req.Database,
RedisMode: redisMode,
}
cfgJSON, _ := json.Marshal(cfg)
@@ -139,7 +170,8 @@ func (h *MiddlewareMonitorHandler) CreateMiddlewareTarget(c *gin.Context) {
Description: mwType, // 复用 Description 字段存储中间件子类型
}
if err := h.db.Create(&target).Error; err != nil {
// host_group_id 默认 0 会触发外键约束,创建时显式忽略 // Logan.Li
if err := h.db.Omit("HostGroupID").Create(&target).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建失败: " + err.Error()})
return
}
@@ -174,11 +206,12 @@ func (h *MiddlewareMonitorHandler) ListMiddlewareTargets(c *gin.Context) {
targets = []model.MonitorTarget{}
}
// 附带最新指标
// 附带最新指标(Config 脱敏,不返回密码)
type TargetWithMetrics struct {
model.MonitorTarget
SubType string `json:"sub_type"`
LatestMetrics map[string]float64 `json:"latest_metrics"`
SubType string `json:"sub_type"`
LatestMetrics map[string]float64 `json:"latest_metrics"`
ConfigPublic middlewareConfigPublic `json:"config_public"`
}
result := make([]TargetWithMetrics, len(targets))
now := time.Now().Unix()
@@ -193,10 +226,18 @@ func (h *MiddlewareMonitorHandler) ListMiddlewareTargets(c *gin.Context) {
metrics[mh.MetricName] = mh.Value
}
}
cfg := parseMiddlewareConfig(t.Config)
// 列表响应隐藏原始 config 字段,避免泄露密码 // Logan.Li
t.Config = ""
result[i] = TargetWithMetrics{
MonitorTarget: t,
SubType: t.Description,
LatestMetrics: metrics,
ConfigPublic: middlewareConfigPublic{
Username: cfg.Username,
Database: cfg.Database,
RedisMode: cfg.RedisMode,
},
}
}
@@ -237,13 +278,14 @@ func (h *MiddlewareMonitorHandler) UpdateMiddlewareTarget(c *gin.Context) {
}
var req struct {
Name *string `json:"name"`
IP *string `json:"ip"`
Port *int `json:"port"`
Password *string `json:"password"`
Username *string `json:"username"`
Database *string `json:"database"`
Interval *int `json:"interval"`
Name *string `json:"name"`
IP *string `json:"ip"`
Port *int `json:"port"`
Password *string `json:"password"`
Username *string `json:"username"`
Database *string `json:"database"`
RedisMode *string `json:"redis_mode"`
Interval *int `json:"interval"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
@@ -264,12 +306,9 @@ func (h *MiddlewareMonitorHandler) UpdateMiddlewareTarget(c *gin.Context) {
updates["interval"] = *req.Interval
}
// 如果有密码/用户名变更,更新 Config JSON
if req.Password != nil || req.Username != nil || req.Database != nil {
var cfg MiddlewareConfig
if target.Config != "" {
json.Unmarshal([]byte(target.Config), &cfg)
}
// 如果有连接配置变更,更新 Config JSON
if req.Password != nil || req.Username != nil || req.Database != nil || req.RedisMode != nil {
cfg := parseMiddlewareConfig(target.Config)
if req.Password != nil {
cfg.Password = *req.Password
}
@@ -279,6 +318,14 @@ func (h *MiddlewareMonitorHandler) UpdateMiddlewareTarget(c *gin.Context) {
if req.Database != nil {
cfg.Database = *req.Database
}
if req.RedisMode != nil {
mode := strings.ToLower(*req.RedisMode)
if mode == "cluster" {
cfg.RedisMode = "cluster"
} else {
cfg.RedisMode = "standalone"
}
}
cfgJSON, _ := json.Marshal(cfg)
updates["config"] = string(cfgJSON)
}
@@ -363,6 +410,14 @@ func (h *MiddlewareMonitorHandler) GetMiddlewareMetrics(c *gin.Context) {
startTime := now - 3600 // 默认 1h
endTime := now
if h := c.Query("hours"); h != "" {
if hours, err := strconv.Atoi(h); err == nil && hours > 0 {
if hours > 168 {
hours = 168
}
startTime = now - int64(hours)*3600
}
}
if s := c.Query("start"); s != "" {
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
startTime = v
@@ -393,6 +448,14 @@ func (h *MiddlewareMonitorHandler) GetMiddlewareMetrics(c *gin.Context) {
// 指标采集引擎
// ============================================
// collectAllWithRecord 采集并记录系统任务状态 // Logan.Li
func (h *MiddlewareMonitorHandler) collectAllWithRecord() int {
start := time.Now()
count := h.collectAll()
systask.Record("middleware_monitor", "success", fmt.Sprintf("采集 %d 个目标", count), time.Since(start))
return count
}
// collectAll 采集所有启用的中间件目标
func (h *MiddlewareMonitorHandler) collectAll() int {
var targets []model.MonitorTarget
@@ -453,11 +516,7 @@ func (h *MiddlewareMonitorHandler) collectTarget(target model.MonitorTarget) (ma
}
}
// 解析配置
var cfg MiddlewareConfig
if target.Config != "" {
json.Unmarshal([]byte(target.Config), &cfg)
}
cfg := parseMiddlewareConfig(target.Config)
addr := fmt.Sprintf("%s:%d", target.IP, target.Port)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -465,7 +524,7 @@ func (h *MiddlewareMonitorHandler) collectTarget(target model.MonitorTarget) (ma
switch mwType {
case "redis":
return h.collectRedis(ctx, addr, cfg.Password)
return h.collectRedis(ctx, addr, cfg.Password, cfg.RedisMode)
case "mysql":
return h.collectMySQL(ctx, addr, cfg)
default:
@@ -473,62 +532,6 @@ func (h *MiddlewareMonitorHandler) collectTarget(target model.MonitorTarget) (ma
}
}
// collectRedis 采集 Redis 指标
func (h *MiddlewareMonitorHandler) collectRedis(ctx context.Context, addr, password string) (map[string]float64, error) {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: 0,
})
defer rdb.Close()
// PING 检测连接
if err := rdb.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("Redis 连接失败: %v", err)
}
result := make(map[string]float64)
// INFO 命令获取详细信息
info, err := rdb.Info(ctx).Result()
if err == nil {
infoMap := parseRedisInfo(info)
result["redis_connected_clients"] = parseFloat(infoMap["connected_clients"])
result["redis_used_memory"] = parseFloat(infoMap["used_memory"])
result["redis_used_memory_peak"] = parseFloat(infoMap["used_memory_peak"])
result["redis_total_commands_processed"] = parseFloat(infoMap["total_commands_processed"])
result["redis_uptime_in_seconds"] = parseFloat(infoMap["uptime_in_seconds"])
result["redis_keyspace_hits"] = parseFloat(infoMap["keyspace_hits"])
result["redis_keyspace_misses"] = parseFloat(infoMap["keyspace_misses"])
result["redis_evicted_keys"] = parseFloat(infoMap["evicted_keys"])
result["redis_blocked_clients"] = parseFloat(infoMap["blocked_clients"])
// 计算命中率
hits := result["redis_keyspace_hits"]
misses := result["redis_keyspace_misses"]
if hits+misses > 0 {
result["redis_hit_rate"] = hits / (hits + misses)
} else {
result["redis_hit_rate"] = 0
}
// 计算 QPS
uptime := result["redis_uptime_in_seconds"]
cmds := result["redis_total_commands_processed"]
if uptime > 0 {
result["redis_qps"] = cmds / uptime
}
}
// DBSIZE
dbSize, err := rdb.DBSize(ctx).Result()
if err == nil {
result["redis_db_size"] = float64(dbSize)
}
return result, nil
}
// collectMySQL 采集 MySQL 指标
func (h *MiddlewareMonitorHandler) collectMySQL(ctx context.Context, addr string, cfg MiddlewareConfig) (map[string]float64, error) {
dbName := cfg.Database
@@ -597,6 +600,18 @@ func (h *MiddlewareMonitorHandler) collectMySQL(ctx context.Context, addr string
result["mysql_buffer_hit_rate"] = 0
}
// SHOW GLOBAL VARIABLES — 连接上限等 // Logan.Li
varRows, err := db.QueryContext(ctx, "SHOW GLOBAL VARIABLES LIKE 'max_connections'")
if err == nil {
defer varRows.Close()
for varRows.Next() {
var name, val string
if varRows.Scan(&name, &val) == nil && name == "max_connections" {
result["mysql_max_connections"] = parseFloat(val)
}
}
}
return result, nil
}
@@ -0,0 +1,101 @@
package controller
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
)
// collectRedis 采集 Redis 指标,支持单机与集群模式 // Logan.Li
func (h *MiddlewareMonitorHandler) collectRedis(ctx context.Context, addr, password, mode string) (map[string]float64, error) {
if mode == "" {
mode = "standalone"
}
var (
info string
dbSize int64
clusterInfo string
pingErr error
)
switch mode {
case "cluster":
rdb := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{addr},
Password: password,
})
defer rdb.Close()
pingErr = rdb.Ping(ctx).Err()
if pingErr == nil {
info, _ = rdb.Info(ctx).Result()
dbSize, _ = rdb.DBSize(ctx).Result()
clusterInfo, _ = rdb.ClusterInfo(ctx).Result()
}
default:
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: 0,
})
defer rdb.Close()
pingErr = rdb.Ping(ctx).Err()
if pingErr == nil {
info, _ = rdb.Info(ctx).Result()
dbSize, _ = rdb.DBSize(ctx).Result()
}
}
if pingErr != nil {
return nil, fmt.Errorf("Redis 连接失败: %v", pingErr)
}
return buildRedisMetrics(info, dbSize, clusterInfo, mode), nil
}
// buildRedisMetrics 从 INFO 与 DBSIZE 构建指标 map // Logan.Li
func buildRedisMetrics(info string, dbSize int64, clusterInfo, mode string) map[string]float64 {
result := make(map[string]float64)
if info != "" {
infoMap := parseRedisInfo(info)
result["redis_connected_clients"] = parseFloat(infoMap["connected_clients"])
result["redis_used_memory"] = parseFloat(infoMap["used_memory"])
result["redis_used_memory_peak"] = parseFloat(infoMap["used_memory_peak"])
result["redis_total_commands_processed"] = parseFloat(infoMap["total_commands_processed"])
result["redis_uptime_in_seconds"] = parseFloat(infoMap["uptime_in_seconds"])
result["redis_keyspace_hits"] = parseFloat(infoMap["keyspace_hits"])
result["redis_keyspace_misses"] = parseFloat(infoMap["keyspace_misses"])
result["redis_evicted_keys"] = parseFloat(infoMap["evicted_keys"])
result["redis_blocked_clients"] = parseFloat(infoMap["blocked_clients"])
result["redis_maxmemory"] = parseFloat(infoMap["maxmemory"])
result["redis_mem_fragmentation_ratio"] = parseFloat(infoMap["mem_fragmentation_ratio"])
hits := result["redis_keyspace_hits"]
misses := result["redis_keyspace_misses"]
if hits+misses > 0 {
result["redis_hit_rate"] = hits / (hits + misses)
}
uptime := result["redis_uptime_in_seconds"]
cmds := result["redis_total_commands_processed"]
if uptime > 0 {
result["redis_qps"] = cmds / uptime
}
}
if dbSize >= 0 {
result["redis_db_size"] = float64(dbSize)
}
if mode == "cluster" && clusterInfo != "" {
ci := parseRedisInfo(clusterInfo)
result["redis_cluster_known_nodes"] = parseFloat(ci["cluster_known_nodes"])
result["redis_cluster_size"] = parseFloat(ci["cluster_size"])
}
return result
}
@@ -0,0 +1,120 @@
package controller
import (
"encoding/json"
"log"
"net/http"
"os"
"ezops/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// middlewareSeedResult 种子创建结果 // Logan.Li
type middlewareSeedResult struct {
Created int `json:"created"`
Skipped []string `json:"skipped"`
Failed []string `json:"failed"`
Total int64 `json:"total"`
Message string `json:"message"`
Success bool `json:"success"`
}
// SeedDefaultMiddlewareTargets 首次启动时创建平台默认 Redis/MySQL 监控目标 // Logan.Li
func SeedDefaultMiddlewareTargets(db *gorm.DB) {
seedDefaultMiddlewareTargets(db)
}
// SeedDefaultMiddlewareTargetsAPI 手动触发添加默认监控目标 // Logan.Li
// POST /api/v1/middleware-monitors/seed-defaults
func (h *MiddlewareMonitorHandler) SeedDefaultMiddlewareTargetsAPI(c *gin.Context) {
result := seedDefaultMiddlewareTargets(h.db)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: result})
}
// seedDefaultMiddlewareTargets 内部实现,返回创建结果 // Logan.Li
func seedDefaultMiddlewareTargets(db *gorm.DB) middlewareSeedResult {
result := middlewareSeedResult{
Skipped: []string{},
Failed: []string{},
}
mysqlUser := os.Getenv("DATABASE_USER")
if mysqlUser == "" {
mysqlUser = "root"
}
mysqlPassword := os.Getenv("DATABASE_PASSWORD")
if mysqlPassword == "" {
mysqlPassword = os.Getenv("MYSQL_ROOT_PASSWORD")
}
mysqlDB := os.Getenv("DATABASE_DBNAME")
if mysqlDB == "" {
mysqlDB = "aiops"
}
redisCfg, _ := json.Marshal(MiddlewareConfig{RedisMode: "standalone"})
mysqlCfg, _ := json.Marshal(MiddlewareConfig{
Username: mysqlUser,
Password: mysqlPassword,
Database: mysqlDB,
})
defaults := []model.MonitorTarget{
{
Name: "aiops-redis",
Type: model.TargetTypeMiddleware,
Status: model.TargetStatusUnknown,
IP: "aiops-redis",
Port: 6379,
CollectMode: "api",
Config: string(redisCfg),
Interval: 30,
Description: "redis",
},
{
Name: "aiops-mysql",
Type: model.TargetTypeMiddleware,
Status: model.TargetStatusUnknown,
IP: "aiops-mysql",
Port: 3306,
CollectMode: "api",
Config: string(mysqlCfg),
Interval: 30,
Description: "mysql",
},
}
for _, target := range defaults {
var exists int64
db.Model(&model.MonitorTarget{}).
Where("type = ? AND name = ?", model.TargetTypeMiddleware, target.Name).
Count(&exists)
if exists > 0 {
result.Skipped = append(result.Skipped, target.Name)
continue
}
// host_group_id 默认 0 会触发外键约束,创建时显式忽略 // Logan.Li
if err := db.Omit("HostGroupID").Create(&target).Error; err != nil {
log.Printf("[MiddlewareMonitor] 种子数据创建失败 %s: %v", target.Name, err)
result.Failed = append(result.Failed, target.Name)
continue
}
result.Created++
log.Printf("[MiddlewareMonitor] 已创建默认监控目标: %s (%s:%d)", target.Name, target.IP, target.Port)
}
db.Model(&model.MonitorTarget{}).Where("type = ?", model.TargetTypeMiddleware).Count(&result.Total)
result.Success = result.Created > 0 || (len(result.Skipped) > 0 && len(result.Failed) == 0)
switch {
case result.Created > 0:
result.Message = "已添加平台默认监控目标"
case len(result.Failed) > 0:
result.Message = "部分默认目标创建失败,请查看服务日志"
case len(result.Skipped) > 0:
result.Message = "默认监控目标已存在,请在下方列表查看"
default:
result.Message = "未创建任何默认目标"
}
return result
}
+285
View File
@@ -0,0 +1,285 @@
package controller
import (
"net/http"
"strconv"
"strings"
"ezops/model"
"time"
"github.com/gin-gonic/gin"
)
// ListMonitorAlertRules 列出中间件/站点告警规则
// GET /api/v1/alerts/monitor-rules?category=middleware|site
func (h *AlertHandler) ListMonitorAlertRules(c *gin.Context) {
q := h.db.Model(&model.MonitorAlertRule{})
if cat := c.Query("category"); cat != "" {
q = q.Where("category = ?", cat)
}
var rules []model.MonitorAlertRule
var total int64
q.Count(&total)
q.Order("id DESC").Find(&rules)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": rules, "total": total}})
}
// CreateMonitorAlertRule 创建中间件/站点告警规则
// POST /api/v1/alerts/monitor-rules
func (h *AlertHandler) CreateMonitorAlertRule(c *gin.Context) {
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
rule := parseMonitorAlertRule(raw)
if rule.Name == "" || rule.Metric == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "名称和指标不能为空"})
return
}
if rule.Category != model.MonitorAlertMiddleware && rule.Category != model.MonitorAlertSite && rule.Category != model.MonitorAlertSNMP {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "category 须为 middleware、snmp 或 site"})
return
}
if err := h.db.Create(&rule).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error()})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: rule, Message: "创建成功"})
}
// UpdateMonitorAlertRule 更新规则
// PUT /api/v1/alerts/monitor-rules/:id
func (h *AlertHandler) UpdateMonitorAlertRule(c *gin.Context) {
id := c.Param("id")
var rule model.MonitorAlertRule
if err := h.db.First(&rule, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "规则不存在"})
return
}
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
updates := buildMonitorRuleUpdates(raw)
if len(updates) > 0 {
h.db.Model(&rule).Updates(updates)
h.db.First(&rule, id)
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: rule, Message: "更新成功"})
}
// DeleteMonitorAlertRule 删除规则
// DELETE /api/v1/alerts/monitor-rules/:id
func (h *AlertHandler) DeleteMonitorAlertRule(c *gin.Context) {
id := c.Param("id")
if err := h.db.Delete(&model.MonitorAlertRule{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error()})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "删除成功"})
}
// ListUnifiedAlertRules 统一规则列表(主机 AgentRule + 中间件/站点 MonitorAlertRule)
// GET /api/v1/alerts/unified-rules?category=host|middleware|site|all
func (h *AlertHandler) ListUnifiedAlertRules(c *gin.Context) {
cat := c.DefaultQuery("category", "all")
list := make([]gin.H, 0)
if cat == "all" || cat == "host" {
var agentRules []model.AgentRule
h.db.Order("id DESC").Find(&agentRules)
for _, r := range agentRules {
list = append(list, gin.H{
"id": r.ID, "category": "host", "name": r.Name,
"target": r.Hostname, "metric": r.Metric, "operator": r.Operator,
"threshold": r.Threshold, "port": r.Port, "enabled": r.Enabled,
"severity": "warning", "description": r.Hostname + " " + r.Metric,
"rule_type": "agent",
})
}
}
if cat == "all" || cat == "middleware" || cat == "site" || cat == "snmp" {
q := h.db.Model(&model.MonitorAlertRule{})
if cat != "all" {
q = q.Where("category = ?", cat)
}
var rules []model.MonitorAlertRule
q.Order("id DESC").Find(&rules)
for _, r := range rules {
list = append(list, gin.H{
"id": r.ID, "category": r.Category, "name": r.Name,
"target_id": r.TargetID, "metric": r.Metric, "operator": r.Operator,
"threshold": r.Threshold, "enabled": r.Enabled, "severity": r.Severity,
"description": r.Description, "rule_type": "monitor",
})
}
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": list, "total": len(list)}})
}
func parseMonitorAlertRule(raw map[string]interface{}) model.MonitorAlertRule {
getStr := func(keys ...string) string {
for _, k := range keys {
if v, ok := raw[k].(string); ok {
return strings.TrimSpace(v)
}
}
return ""
}
getFloat := func(def float64, keys ...string) float64 {
for _, k := range keys {
if v, ok := raw[k].(float64); ok {
return v
}
}
return def
}
getUint := func(keys ...string) uint {
for _, k := range keys {
switch v := raw[k].(type) {
case float64:
return uint(v)
case int:
return uint(v)
}
}
return 0
}
rule := model.MonitorAlertRule{
Category: model.MonitorAlertCategory(getStr("category")),
Name: getStr("name"),
Metric: getStr("metric"),
Operator: getStr("operator", "op"),
Threshold: getFloat(0, "threshold"),
Severity: getStr("severity"),
Description: getStr("description"),
TargetID: getUint("target_id"),
Enabled: true,
}
if rule.Operator == "" {
rule.Operator = ">"
}
if rule.Severity == "" {
rule.Severity = "warning"
}
if v, ok := raw["enabled"].(bool); ok {
rule.Enabled = v
}
return rule
}
func buildMonitorRuleUpdates(raw map[string]interface{}) map[string]interface{} {
updates := map[string]interface{}{}
if v, ok := raw["name"].(string); ok && strings.TrimSpace(v) != "" {
updates["name"] = strings.TrimSpace(v)
}
if v, ok := raw["metric"].(string); ok && v != "" {
updates["metric"] = v
}
if v, ok := raw["operator"].(string); ok && v != "" {
updates["operator"] = v
}
if v, ok := raw["threshold"].(float64); ok {
updates["threshold"] = v
}
if v, ok := raw["severity"].(string); ok && v != "" {
updates["severity"] = v
}
if v, ok := raw["description"].(string); ok {
updates["description"] = v
}
if v, ok := raw["enabled"].(bool); ok {
updates["enabled"] = v
}
if v, ok := raw["target_id"].(float64); ok {
updates["target_id"] = uint(v)
}
return updates
}
// QuickSetupMonitorRules 一键创建中间件/站点默认告警规则
// POST /api/v1/alerts/monitor-quick-setup
func (h *AlertHandler) QuickSetupMonitorRules(c *gin.Context) {
var req struct {
Category string `json:"category"`
}
c.ShouldBindJSON(&req)
cat := req.Category
if cat == "" {
cat = "all"
}
created := 0
if cat == "all" || cat == "middleware" {
defaults := []model.MonitorAlertRule{
{Category: model.MonitorAlertMiddleware, Name: "Redis 连接数过高", Metric: "redis_connected_clients", Operator: ">", Threshold: 500, Severity: "warning", Enabled: true, Description: "Redis 连接数 > 500"},
{Category: model.MonitorAlertMiddleware, Name: "Redis 命中率过低", Metric: "redis_hit_rate", Operator: "<", Threshold: 0.9, Severity: "warning", Enabled: true, Description: "命中率 < 90%"},
{Category: model.MonitorAlertMiddleware, Name: "MySQL 连接数过高", Metric: "mysql_connections", Operator: ">", Threshold: 200, Severity: "warning", Enabled: true, Description: "连接数 > 200"},
{Category: model.MonitorAlertMiddleware, Name: "MySQL 慢查询过多", Metric: "mysql_slow_queries", Operator: ">", Threshold: 10, Severity: "warning", Enabled: true, Description: "慢查询 > 10"},
}
for _, r := range defaults {
var exist model.MonitorAlertRule
h.db.Where("name = ? AND category = ?", r.Name, r.Category).First(&exist)
if exist.ID == 0 {
h.db.Create(&r)
created++
}
}
}
if cat == "all" || cat == "site" {
defaults := []model.MonitorAlertRule{
{Category: model.MonitorAlertSite, Name: "拨测失败", Metric: "probe_fail", Operator: "==", Threshold: 1, Severity: "critical", Enabled: true, Description: "拨测连续失败"},
{Category: model.MonitorAlertSite, Name: "响应延迟过高", Metric: "response_time", Operator: ">", Threshold: 500, Severity: "warning", Enabled: true, Description: "延迟 > 500ms"},
{Category: model.MonitorAlertSite, Name: "可用率过低", Metric: "availability", Operator: "<", Threshold: 99, Severity: "warning", Enabled: true, Description: "1h 可用率 < 99%"},
{Category: model.MonitorAlertSite, Name: "SSL 证书即将过期", Metric: "ssl_expiry_days", Operator: "<", Threshold: 30, Severity: "warning", Enabled: true, Description: "SSL 拨测证书剩余 < 30 天"},
}
for _, r := range defaults {
var exist model.MonitorAlertRule
h.db.Where("name = ? AND category = ?", r.Name, r.Category).First(&exist)
if exist.ID == 0 {
h.db.Create(&r)
created++
}
}
}
if cat == "all" || cat == "snmp" {
defaults := []model.MonitorAlertRule{
{Category: model.MonitorAlertSNMP, Name: "SNMP CPU 过高", Metric: "snmp_cpu_usage", Operator: ">", Threshold: 80, Severity: "warning", Enabled: true, Description: "CPU > 80%"},
{Category: model.MonitorAlertSNMP, Name: "SNMP 内存过高", Metric: "snmp_mem_usage", Operator: ">", Threshold: 85, Severity: "warning", Enabled: true, Description: "内存 > 85%"},
{Category: model.MonitorAlertSNMP, Name: "SNMP 温度过高", Metric: "snmp_temperature", Operator: ">", Threshold: 65, Severity: "warning", Enabled: true, Description: "温度 > 65°C"},
{Category: model.MonitorAlertSNMP, Name: "SNMP 设备离线", Metric: "snmp_offline", Operator: "==", Threshold: 1, Severity: "critical", Enabled: true, Description: "设备离线或采集失败"},
}
for _, r := range defaults {
var exist model.MonitorAlertRule
h.db.Where("name = ? AND category = ?", r.Name, r.Category).First(&exist)
if exist.ID == 0 {
h.db.Create(&r)
created++
}
}
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"created": created}, Message: "默认规则已创建"})
}
// ResolveUnifiedAlert 统一告警恢复
// POST /api/v1/alerts/unified/:source/:id/resolve
func (h *AlertHandler) ResolveUnifiedAlert(c *gin.Context) {
source := c.Param("source")
id, _ := strconv.Atoi(c.Param("id"))
now := time.Now()
switch source {
case "agent":
h.db.Model(&model.AgentAlert{}).Where("id = ?", id).Updates(map[string]interface{}{"resolved": true, "resolved_at": now})
case "site":
h.db.Model(&model.SiteAlert{}).Where("id = ?", id).Updates(map[string]interface{}{"status": model.SiteAlertStatusResolved, "resolved_at": now})
case "event":
h.db.Model(&model.AlertEvent{}).Where("id = ?", id).Updates(map[string]interface{}{"status": model.AlertEventResolved, "resolved_at": now})
default:
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "未知告警来源"})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已恢复"})
}
+2 -2
View File
@@ -101,7 +101,7 @@ func (h *AlertContactHandlerV2) ListContacts(c *gin.Context) {
list, total, err := h.svc.List(keyword, page, pageSize)
if err != nil {
utils.InternalError(c, "查询失败")
utils.InternalError(c, "查询失败: "+err.Error())
return
}
utils.ListResponse(c, list, total)
@@ -125,7 +125,7 @@ func (h *AlertContactHandlerV2) CreateContact(c *gin.Context) {
}
contact, err := h.svc.Create(&req)
if err != nil {
utils.InternalError(c, "创建失败")
utils.InternalError(c, "创建失败: "+err.Error())
return
}
utils.OK(c, contact)
+166 -64
View File
@@ -1,9 +1,13 @@
package controller
import (
"ezops/model"
"net/http"
"strconv"
"strings"
"time"
"ezops/model"
"ezops/pkg/siteprobe"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -11,60 +15,79 @@ import (
// SiteHandler 站点拨测管理 handler // Logan.Li
type SiteHandler struct {
db *gorm.DB
db *gorm.DB
worker *siteprobe.Worker
}
// NewSiteHandler 创建站点拨测 handler
func NewSiteHandler(db *gorm.DB) *SiteHandler {
return &SiteHandler{db: db}
func NewSiteHandler(db *gorm.DB, worker *siteprobe.Worker) *SiteHandler {
return &SiteHandler{db: db, worker: worker}
}
// ========== Site Task CRUD ==========
// ListTasks 列出拨测任务
// GET /api/v1/site/tasks?page=1&page_size=20&type=&status=
// GET /api/v1/site/tasks
func (h *SiteHandler) ListTasks(c *gin.Context) {
var tasks []model.SiteTask
q := h.db.Model(&model.SiteTask{})
if t := c.Query("type"); t != "" {
q = q.Where("type = ?", t)
}
if s := c.Query("status"); s != "" {
q = q.Where("status = ?", s)
q = q.Where("task_type = ?", strings.ToLower(t))
}
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"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100"))
var total int64
q.Count(&total)
q.Offset((page - 1) * pageSize).Limit(pageSize).Order("name ASC").Find(&tasks)
q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&tasks)
if tasks == nil {
tasks = []model.SiteTask{}
views := make([]SiteTaskView, 0, len(tasks))
for _, task := range tasks {
if task.TaskType == "" {
inferred := model.SiteTaskType(siteprobe.InferTaskTypePublic(task))
h.db.Model(&task).Update("task_type", inferred)
task.TaskType = inferred
}
views = append(views, toSiteTaskView(task))
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": tasks, "total": total}})
enrichSiteTaskViewsSSL(h.db, views)
enrichSiteTaskViewsAlert(h.db, views)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": views, "total": total}})
}
// CreateTask 创建拨测任务
// POST /api/v1/site/tasks
func (h *SiteHandler) CreateTask(c *gin.Context) {
var task model.SiteTask
if err := c.ShouldBindJSON(&task); err != nil {
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
task := parseCreateTaskRequest(raw)
if task.Name == "" || task.URL == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "名称和地址不能为空"})
return
}
if task.Frequency < 10 {
task.Frequency = 60
}
if task.Timeout <= 0 {
task.Timeout = 10
}
if err := h.db.Create(&task).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error()})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: task, Message: "创建成功"})
if h.worker != nil {
go h.worker.RunTaskNow(task.ID)
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: toSiteTaskView(task), Message: "创建成功"})
}
// UpdateTask 更新拨测任务
// UpdateTask 更新拨测任务(启用/停用或配置变更)
// PUT /api/v1/site/tasks/:id
func (h *SiteHandler) UpdateTask(c *gin.Context) {
id := c.Param("id")
@@ -73,19 +96,72 @@ func (h *SiteHandler) UpdateTask(c *gin.Context) {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "任务不存在"})
return
}
if err := c.ShouldBindJSON(&task); err != nil {
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
h.db.Save(&task)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: task, Message: "更新成功"})
updates := map[string]interface{}{}
if v, ok := raw["name"].(string); ok && strings.TrimSpace(v) != "" {
updates["name"] = strings.TrimSpace(v)
}
if v, ok := raw["url"].(string); ok && strings.TrimSpace(v) != "" {
updates["url"] = strings.TrimSpace(v)
}
if v, ok := raw["type"].(string); ok && v != "" {
t := strings.ToLower(v)
if t == "https" {
t = "http"
}
updates["task_type"] = t
}
if v, ok := raw["frequency_sec"].(float64); ok {
updates["frequency"] = int(v)
}
if v, ok := raw["timeout_sec"].(float64); ok {
updates["timeout"] = int(v)
}
if v, ok := raw["expected_status_code"].(float64); ok {
updates["expected_status"] = int(v)
}
if v, ok := raw["enabled"].(bool); ok {
if v {
updates["status"] = model.SiteTaskStatusRunning
} else {
updates["status"] = model.SiteTaskStatusPaused
}
}
if len(updates) > 0 {
h.db.Model(&task).Updates(updates)
h.db.First(&task, id)
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: toSiteTaskView(task), Message: "更新成功"})
}
// deleteSiteTaskCascade 先删子表再删任务,避免外键约束 // Logan.Li
func (h *SiteHandler) deleteSiteTaskCascade(taskID uint) error {
return h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("task_id = ?", taskID).Delete(&model.SiteResult{}).Error; err != nil {
return err
}
if err := tx.Where("task_id = ?", taskID).Delete(&model.SiteAlert{}).Error; err != nil {
return err
}
tx.Where("category = ? AND target_id = ?", model.MonitorAlertSite, taskID).Delete(&model.MonitorAlertRule{})
return tx.Delete(&model.SiteTask{}, taskID).Error
})
}
// DeleteTask 删除拨测任务
// DELETE /api/v1/site/tasks/:id
func (h *SiteHandler) DeleteTask(c *gin.Context) {
id := c.Param("id")
if err := h.db.Delete(&model.SiteTask{}, id).Error; err != nil {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效 ID"})
return
}
if err := h.deleteSiteTaskCascade(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error()})
return
}
@@ -93,74 +169,100 @@ func (h *SiteHandler) DeleteTask(c *gin.Context) {
}
// BatchDeleteTasks 批量删除
// DELETE /api/v1/site/tasks/batch-delete
func (h *SiteHandler) BatchDeleteTasks(c *gin.Context) {
var req model.IDsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
h.db.Where("id IN ?", req.IDs).Delete(&model.SiteTask{})
for _, tid := range req.IDs {
if err := h.deleteSiteTaskCascade(uint(tid)); err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error()})
return
}
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "批量删除成功"})
}
// ========== Site Results ==========
// ListResults 查询拨测结果
// GET /api/v1/site/results?task_id=N&from=&to=&limit=100
// GET /api/v1/site/results?task_id=N&limit=100
func (h *SiteHandler) ListResults(c *gin.Context) {
var results []model.SiteResult
q := h.db.Model(&model.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 h := c.Query("hours"); h != "" {
if hours, err := strconv.Atoi(h); err == nil && hours > 0 {
if hours > 168 {
hours = 168
}
since := time.Now().Add(-time.Duration(hours) * time.Hour)
q = q.Where("checked_at >= ?", since)
}
}
if to := c.Query("to"); to != "" {
q = q.Where("created_at <= ?", to)
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "500"))
if limit <= 0 {
limit = 500
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
q.Order("created_at DESC").Limit(limit).Find(&results)
if limit > 2000 {
limit = 2000
}
q.Order("checked_at DESC").Limit(limit).Find(&results)
if results == nil {
results = []model.SiteResult{}
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: results})
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": results, "total": len(results)}})
}
// ========== Site Statistics ==========
// ProbeNow 立即触发拨测
// POST /api/v1/site/tasks/:id/probe
func (h *SiteHandler) ProbeNow(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效 ID"})
return
}
if h.worker == nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "拨测引擎未启动"})
return
}
if err := h.worker.RunTaskNow(uint(id)); err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "任务不存在"})
return
}
var task model.SiteTask
h.db.First(&task, id)
view := toSiteTaskView(task)
enrichSiteTaskViewsSSL(h.db, []SiteTaskView{view})
enrichSiteTaskViewsAlert(h.db, []SiteTaskView{view})
c.JSON(http.StatusOK, model.Response{Code: 200, Data: view, Message: "拨测完成"})
}
// GetSiteStats 获取拨测概览统计
// GET /api/v1/site/stats
func (h *SiteHandler) GetSiteStats(c *gin.Context) {
var total, normal, abnormal int64
var total, enabled, upCount, downCount int64
h.db.Model(&model.SiteTask{}).Count(&total)
h.db.Model(&model.SiteTask{}).Where("status = ?", "active").Count(&normal)
h.db.Model(&model.SiteTask{}).Where("status = ?", "error").Count(&abnormal)
h.db.Model(&model.SiteTask{}).Where("status = ?", model.SiteTaskStatusRunning).Count(&enabled)
h.db.Model(&model.SiteTask{}).Where("last_result = ?", "success").Count(&upCount)
h.db.Model(&model.SiteTask{}).Where("last_result IN ?", []string{"fail", "timeout"}).Count(&downCount)
// Calculate availability rate from recent results
var availRate float64
if total > 0 {
var successCount int64
h.db.Model(&model.SiteResult{}).
Where("created_at > datetime('now', '-1 hour') AND status = 'success'").
Count(&successCount)
var totalRecent int64
h.db.Model(&model.SiteResult{}).
Where("created_at > datetime('now', '-1 hour')").
Count(&totalRecent)
if totalRecent > 0 {
availRate = float64(successCount) / float64(totalRecent) * 100
}
var availability float64
since := time.Now().Add(-24 * time.Hour)
var successRecent, totalRecent int64
h.db.Model(&model.SiteResult{}).Where("checked_at >= ? AND status = ?", since, model.SiteResultSuccess).Count(&successRecent)
h.db.Model(&model.SiteResult{}).Where("checked_at >= ?", since).Count(&totalRecent)
if totalRecent > 0 {
availability = float64(successRecent) / float64(totalRecent)
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
"total": total,
"normal": normal,
"abnormal": abnormal,
"avail_rate": availRate,
"total_tasks": total,
"enabled_tasks": enabled,
"up_count": upCount,
"down_count": downCount,
"availability": availability,
"probe_count": totalRecent,
}})
}
}
+211
View File
@@ -0,0 +1,211 @@
package controller
import (
"strings"
"time"
"ezops/model"
"ezops/pkg/siteprobe"
"gorm.io/gorm"
)
// SiteTaskView 前端拨测任务视图(字段与前端对齐)// Logan.Li
type SiteTaskView struct {
ID uint `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Type string `json:"type"`
FrequencySec int `json:"frequency_sec"`
TimeoutSec int `json:"timeout_sec"`
ExpectedStatusCode int `json:"expected_status_code"`
Enabled bool `json:"enabled"`
LastStatus string `json:"last_status,omitempty"`
LastResponseTimeMs float64 `json:"last_response_time_ms,omitempty"`
LastCheckedAt *string `json:"last_checked_at,omitempty"`
SuccessRate float64 `json:"success_rate"`
AlertStatus string `json:"alert_status"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
SSLDaysRemaining *int `json:"ssl_days_remaining,omitempty"`
SSLCertExpiry *string `json:"ssl_cert_expiry,omitempty"`
}
func toSiteTaskView(task model.SiteTask) SiteTaskView {
taskType := normalizeTaskType(task)
enabled := task.Status == model.SiteTaskStatusRunning
lastStatus := "unknown"
switch task.LastResult {
case "success":
lastStatus = "up"
case "fail", "timeout":
lastStatus = "down"
}
var lastChecked *string
if task.LastCheckAt != nil {
s := task.LastCheckAt.Format("2006-01-02T15:04:05Z07:00")
lastChecked = &s
}
return SiteTaskView{
ID: task.ID,
Name: task.Name,
URL: task.URL,
Type: taskType,
FrequencySec: task.Frequency,
TimeoutSec: task.Timeout,
ExpectedStatusCode: task.ExpectedStatus,
Enabled: enabled,
LastStatus: lastStatus,
LastResponseTimeMs: task.AvgResponse,
LastCheckedAt: lastChecked,
SuccessRate: task.SuccessRate,
AlertStatus: "normal",
Status: string(task.Status),
CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}
// enrichSiteTaskViewsAlert 批量附加活跃告警状态 // Logan.Li
func enrichSiteTaskViewsAlert(db *gorm.DB, views []SiteTaskView) {
if len(views) == 0 {
return
}
ids := make([]uint, 0, len(views))
for _, v := range views {
ids = append(ids, v.ID)
}
type row struct {
TaskID uint
}
var rows []row
db.Model(&model.SiteAlert{}).
Select("DISTINCT task_id").
Where("task_id IN ? AND status = ?", ids, model.SiteAlertStatusActive).
Find(&rows)
alerting := make(map[uint]bool, len(rows))
for _, r := range rows {
alerting[r.TaskID] = true
}
for i := range views {
if alerting[views[i].ID] {
views[i].AlertStatus = "alerting"
}
}
}
func normalizeTaskType(task model.SiteTask) string {
t := strings.ToLower(string(task.TaskType))
if t == "https" {
return "HTTP"
}
if t != "" {
return strings.ToUpper(t)
}
return strings.ToUpper(siteprobe.InferTaskTypePublic(task))
}
type siteSSLInfo struct {
DaysRemaining int
CertExpiry *time.Time
}
// enrichSiteTaskViewsSSL 批量附加 SSL 拨测任务的证书到期信息 // Logan.Li
func enrichSiteTaskViewsSSL(db *gorm.DB, views []SiteTaskView) {
if len(views) == 0 {
return
}
ids := make([]uint, 0, len(views))
for _, v := range views {
if strings.ToUpper(v.Type) != "SSL" {
continue
}
ids = append(ids, v.ID)
}
if len(ids) == 0 {
return
}
type row struct {
TaskID uint
SSLDaysRemaining int
SSLCertExpiry *time.Time
}
var rows []row
db.Model(&model.SiteResult{}).
Select("task_id, ssl_days_remaining, ssl_cert_expiry").
Where("task_id IN ? AND ssl_cert_expiry IS NOT NULL", ids).
Order("checked_at DESC").
Find(&rows)
seen := map[uint]bool{}
sslMap := make(map[uint]siteSSLInfo, len(rows))
for _, r := range rows {
if seen[r.TaskID] {
continue
}
seen[r.TaskID] = true
sslMap[r.TaskID] = siteSSLInfo{DaysRemaining: r.SSLDaysRemaining, CertExpiry: r.SSLCertExpiry}
}
for i := range views {
if ssl, ok := sslMap[views[i].ID]; ok {
d := ssl.DaysRemaining
views[i].SSLDaysRemaining = &d
if ssl.CertExpiry != nil {
s := ssl.CertExpiry.Format("2006-01-02T15:04:05Z07:00")
views[i].SSLCertExpiry = &s
}
}
}
}
func parseCreateTaskRequest(raw map[string]interface{}) model.SiteTask {
getStr := func(keys ...string) string {
for _, k := range keys {
if v, ok := raw[k]; ok {
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
}
}
return ""
}
getInt := func(def int, keys ...string) int {
for _, k := range keys {
if v, ok := raw[k]; ok {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
}
}
}
return def
}
taskType := strings.ToLower(getStr("type", "task_type"))
switch taskType {
case "https":
taskType = "http"
case "":
taskType = "http"
}
task := model.SiteTask{
Name: getStr("name"),
URL: getStr("url"),
TaskType: model.SiteTaskType(taskType),
Method: model.SiteTaskMethodGET,
Timeout: getInt(10, "timeout_sec", "timeout"),
Frequency: getInt(60, "frequency_sec", "frequency"),
ExpectedStatus: getInt(200, "expected_status_code", "expected_status"),
FollowRedirect: true,
VerifySSL: true,
Status: model.SiteTaskStatusRunning,
}
if v, ok := raw["enabled"].(bool); ok && !v {
task.Status = model.SiteTaskStatusPaused
}
if v, ok := raw["dns_resolver"].(string); ok {
task.DNSResolver = strings.TrimSpace(v)
}
return task
}
+44 -8
View File
@@ -4,19 +4,22 @@ import (
"ezops/model"
"net/http"
"strconv"
"sync"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// SNMPHandler SNMP 设备管理 handler // Logan.Li
// SNMPHandler SNMP 设备管理 + 采集 handler // Logan.Li
type SNMPHandler struct {
db *gorm.DB
db *gorm.DB
stopCh chan struct{}
wg sync.WaitGroup
}
// NewSNMPHandler 创建 SNMP handler
func NewSNMPHandler(db *gorm.DB) *SNMPHandler {
return &SNMPHandler{db: db}
return &SNMPHandler{db: db, stopCh: make(chan struct{})}
}
// ========== SNMP Device CRUD ==========
@@ -46,11 +49,42 @@ func (h *SNMPHandler) ListDevices(c *gin.Context) {
// CreateDevice 创建 SNMP 设备
// POST /api/v1/snmp/devices
func (h *SNMPHandler) CreateDevice(c *gin.Context) {
var device model.SNMPDevice
if err := c.ShouldBindJSON(&device); err != nil {
var req struct {
Name string `json:"name" binding:"required"`
IP string `json:"ip" binding:"required"`
Port int `json:"port"`
SnmpVersion string `json:"snmp_version"`
Version string `json:"version"`
Community string `json:"community"`
TemplateID uint `json:"template_id"`
DeviceType string `json:"device_type"`
Vendor string `json:"vendor"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
ver := req.SnmpVersion
if ver == "" {
ver = req.Version
}
if ver == "" {
ver = "v2c"
}
device := model.SNMPDevice{
Name: req.Name, IP: req.IP, Port: req.Port,
Version: model.SNMPVersion(ver), Community: req.Community,
TemplateID: req.TemplateID, DeviceType: req.DeviceType,
Vendor: req.Vendor, Description: req.Description,
Status: model.SNMPDeviceOffline,
}
if device.Port == 0 {
device.Port = 161
}
if device.Community == "" {
device.Community = "public"
}
if err := h.db.Create(&device).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error()})
return
@@ -100,15 +134,17 @@ func (h *SNMPHandler) BatchDeleteDevices(c *gin.Context) {
// ========== SNMP Template CRUD ==========
// ListTemplates 列出 OID 模板
// ListTemplates 列出 OID 模板(含 OID 条目)// Logan.Li
// GET /api/v1/snmp/templates
func (h *SNMPHandler) ListTemplates(c *gin.Context) {
var templates []model.SNMPTemplate
h.db.Order("name ASC").Find(&templates)
h.db.Preload("OIDs", func(db *gorm.DB) *gorm.DB {
return db.Order("sort_order ASC")
}).Order("name ASC").Find(&templates)
if templates == nil {
templates = []model.SNMPTemplate{}
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: templates})
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": templates, "total": len(templates)}})
}
// CreateTemplate 创建 OID 模板
+201
View File
@@ -0,0 +1,201 @@
package controller
import (
"context"
"fmt"
"log"
"net/http"
"strconv"
"time"
"ezops/model"
snmppkg "ezops/pkg/snmp"
"github.com/gin-gonic/gin"
"github.com/gosnmp/gosnmp"
)
// collectDevice 采集单台 SNMP 设备并落库 // Logan.Li
func (h *SNMPHandler) collectDevice(device *model.SNMPDevice) error {
if device.TemplateID == 0 {
return fmt.Errorf("未绑定 OID 模板")
}
var oids []model.SNMPTemplateOID
h.db.Where("template_id = ?", device.TemplateID).Order("sort_order ASC").Find(&oids)
if len(oids) == 0 {
return fmt.Errorf("模板无 OID 定义")
}
col, err := h.newCollector(device)
if err != nil {
return err
}
defs := make([]snmppkg.OIDDefinition, 0, len(oids))
for _, o := range oids {
defs = append(defs, snmppkg.OIDDefinition{
Name: o.Name, OID: o.OID, Unit: o.Unit,
DataType: o.DataType, IsTable: o.Conversion == "table",
})
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(device.Timeout)*time.Second)
defer cancel()
result := col.Collect(ctx, defs)
now := time.Now()
ts := now.Unix()
updates := map[string]interface{}{
"last_collect_at": now,
}
if result.Success {
updates["status"] = model.SNMPDeviceOnline
updates["last_error"] = ""
for _, m := range result.Metrics {
h.db.Create(&model.SNMPMetric{
DeviceID: device.ID, Name: m.Name, OID: m.OID, Index: m.Index,
Value: m.Value, RawValue: m.RawValue, Unit: m.Unit,
DataType: m.DataType, Timestamp: ts,
})
}
} else {
updates["status"] = model.SNMPDeviceError
errMsg := result.Error
if errMsg == "" {
errMsg = "采集无数据"
}
updates["last_error"] = errMsg
}
h.db.Model(device).Updates(updates)
if !result.Success {
return fmt.Errorf("%s", updates["last_error"])
}
return nil
}
func (h *SNMPHandler) newCollector(device *model.SNMPDevice) (*snmppkg.Collector, error) {
timeout := device.Timeout
if timeout <= 0 {
timeout = 10
}
retries := device.Retries
if retries <= 0 {
retries = 2
}
port := uint16(device.Port)
if port == 0 {
port = 161
}
cfg := &snmppkg.CollectorConfig{
IP: device.IP, Port: port, Community: device.Community,
Timeout: time.Duration(timeout) * time.Second, Retries: retries,
MaxRepetitions: uint8(device.MaxRepetitions),
}
switch device.Version {
case model.SNMPVersionV1:
cfg.Version = gosnmp.Version1
case model.SNMPVersionV3:
cfg.Version = gosnmp.Version3
default:
cfg.Version = gosnmp.Version2c
}
return snmppkg.NewCollector(cfg)
}
// deviceCollectInterval 设备采集间隔,不低于全局最低值 // Logan.Li
func deviceCollectInterval(device *model.SNMPDevice) int {
interval := device.CollectInterval
if interval <= 0 {
interval = snmppkg.DefaultCollectIntervalSec
}
if interval < snmppkg.MinCollectIntervalSec {
interval = snmppkg.MinCollectIntervalSec
}
return interval
}
// shouldCollectDevice 判断是否到达该设备的采集周期 // Logan.Li
func shouldCollectDevice(device *model.SNMPDevice, now time.Time) bool {
if device.LastCollectAt == nil {
return true
}
interval := time.Duration(deviceCollectInterval(device)) * time.Second
return now.Sub(*device.LastCollectAt) >= interval
}
// StartCollector 启动 SNMP 后台采集 // Logan.Li
func (h *SNMPHandler) StartCollector(intervalSec int) {
if intervalSec < snmppkg.MinCollectIntervalSec {
intervalSec = snmppkg.DefaultCollectIntervalSec
}
h.wg.Add(1)
go func() {
defer h.wg.Done()
ticker := time.NewTicker(time.Duration(intervalSec) * time.Second)
defer ticker.Stop()
log.Printf("[SNMP] 采集引擎启动,间隔 %ds", intervalSec)
h.collectAll()
for {
select {
case <-ticker.C:
h.collectAll()
case <-h.stopCh:
log.Printf("[SNMP] 采集引擎停止")
return
}
}
}()
}
func (h *SNMPHandler) collectAll() {
var devices []model.SNMPDevice
h.db.Where("template_id > 0").Find(&devices)
now := time.Now()
for i := range devices {
if !shouldCollectDevice(&devices[i], now) {
continue
}
if err := h.collectDevice(&devices[i]); err != nil {
log.Printf("[SNMP] 采集失败 %s(%s): %v", devices[i].Name, devices[i].IP, err)
}
}
}
// CollectDeviceNow POST /snmp/devices/:id/collect
func (h *SNMPHandler) CollectDeviceNow(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var device model.SNMPDevice
if err := h.db.First(&device, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "设备不存在"})
return
}
if err := h.collectDevice(&device); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
h.db.First(&device, id)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: device, Message: "采集成功"})
}
// ListDeviceMetrics GET /snmp/devices/:id/metrics(仅返回最近一次采集批次) // Logan.Li
func (h *SNMPHandler) ListDeviceMetrics(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var latestTS int64
h.db.Model(&model.SNMPMetric{}).Where("device_id = ?", id).
Select("COALESCE(MAX(timestamp), 0)").Scan(&latestTS)
var metrics []model.SNMPMetric
if latestTS > 0 {
h.db.Where("device_id = ? AND timestamp = ?", id, latestTS).
Order("name ASC, `index` ASC").Limit(500).Find(&metrics)
}
if metrics == nil {
metrics = []model.SNMPMetric{}
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": metrics, "total": len(metrics)}})
}
// StopCollector 停止采集引擎 // Logan.Li
func (h *SNMPHandler) StopCollector() {
close(h.stopCh)
h.wg.Wait()
}
+273
View File
@@ -0,0 +1,273 @@
package controller
import (
"net/http"
"strconv"
"time"
"ezops/model"
snmppkg "ezops/pkg/snmp"
"github.com/gin-gonic/gin"
)
// snmpChartableNames 适合折线图展示的标量指标 // Logan.Li
var snmpChartableNames = []string{
"h3c_cpu_usage", "hw_cpu_usage", "cisco_cpu_5min", "ruijie_cpu_usage", "inspur_cpu_usage",
"h3c_mem_usage", "hw_mem_usage", "ruijie_mem_usage", "inspur_mem_usage",
"h3c_temperature", "hw_temperature", "ruijie_temperature", "dell_temperature", "inspur_temperature",
"h3c_fan_speed", "hw_fan_speed", "ruijie_fan_speed", "dell_fan_speed", "inspur_fan_speed",
"cisco_sensor_value", "ent_sensor_value",
"if_in_octets", "if_out_octets",
}
// snmpCounterMetrics 累计型 counter,历史查询需差分换算速率 // Logan.Li
var snmpCounterMetrics = map[string]bool{
"if_in_octets": true,
"if_out_octets": true,
}
type snmpMetricSeries struct {
Name string `json:"name"`
Index string `json:"index,omitempty"`
Unit string `json:"unit,omitempty"`
Label string `json:"label"`
LatestValue float64 `json:"latest_value"`
}
type snmpHistoryPoint struct {
Timestamp int64 `json:"timestamp"`
Value float64 `json:"value"`
Unit string `json:"unit,omitempty"`
}
type snmpOverviewItem struct {
DeviceID uint `json:"device_id"`
Name string `json:"name"`
IP string `json:"ip"`
Status string `json:"status"`
CPU float64 `json:"cpu,omitempty"`
Mem float64 `json:"mem,omitempty"`
Temp float64 `json:"temp,omitempty"`
}
// ListMetricSeries GET /snmp/devices/:id/metrics/series — 可绘图指标序列 // Logan.Li
func (h *SNMPHandler) ListMetricSeries(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var device model.SNMPDevice
if err := h.db.First(&device, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "设备不存在"})
return
}
var latestTS int64
h.db.Model(&model.SNMPMetric{}).Where("device_id = ?", id).
Select("COALESCE(MAX(timestamp), 0)").Scan(&latestTS)
if latestTS == 0 {
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": []snmpMetricSeries{}, "total": 0}})
return
}
type row struct {
Name string
Index string
Unit string
LatestValue float64
}
var rows []row
h.db.Model(&model.SNMPMetric{}).
Select("name, `index`, unit, MAX(value) as latest_value").
Where("device_id = ? AND timestamp = ? AND name IN ?", id, latestTS, snmpChartableNames).
Group("name, `index`, unit").
Order("name ASC, `index` ASC").
Scan(&rows)
series := make([]snmpMetricSeries, 0, len(rows))
for _, r := range rows {
label := snmpMetricLabel(r.Name, r.Index)
series = append(series, snmpMetricSeries{
Name: r.Name, Index: r.Index, Unit: r.Unit,
Label: label, LatestValue: r.LatestValue,
})
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": series, "total": len(series)}})
}
// QueryMetricHistory GET /snmp/devices/:id/metrics/history — 指标时序 // Logan.Li
func (h *SNMPHandler) QueryMetricHistory(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
metricName := c.Query("metric_name")
if metricName == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "metric_name 不能为空"})
return
}
index := c.Query("index")
hours, _ := strconv.Atoi(c.DefaultQuery("hours", "168"))
if hours <= 0 {
hours = 168
}
if hours > snmppkg.MetricsRetentionDays*24 {
hours = snmppkg.MetricsRetentionDays * 24
}
fromTS := time.Now().Add(-time.Duration(hours) * time.Hour).Unix()
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "500"))
if limit <= 0 || limit > 2000 {
limit = 500
}
q := h.db.Model(&model.SNMPMetric{}).
Where("device_id = ? AND name = ? AND timestamp >= ?", id, metricName, fromTS)
if index != "" {
q = q.Where("`index` = ?", index)
}
var points []snmpHistoryPoint
if index != "" {
var metrics []model.SNMPMetric
q.Order("timestamp ASC").Limit(limit).Find(&metrics)
points = make([]snmpHistoryPoint, 0, len(metrics))
for _, m := range metrics {
points = append(points, snmpHistoryPoint{
Timestamp: m.Timestamp, Value: m.Value, Unit: m.Unit,
})
}
} else {
type aggRow struct {
Timestamp int64
Value float64
Unit string
}
var rows []aggRow
h.db.Model(&model.SNMPMetric{}).
Select("timestamp, AVG(value) as value, MAX(unit) as unit").
Where("device_id = ? AND name = ? AND timestamp >= ?", id, metricName, fromTS).
Group("timestamp").
Order("timestamp ASC").
Limit(limit).
Scan(&rows)
points = make([]snmpHistoryPoint, 0, len(rows))
for _, r := range rows {
points = append(points, snmpHistoryPoint{
Timestamp: r.Timestamp, Value: r.Value, Unit: r.Unit,
})
}
}
if points == nil {
points = []snmpHistoryPoint{}
}
// counter 指标差分换算为 bps(bits per second)// Logan.Li
rateUnit := ""
if snmpCounterMetrics[metricName] && len(points) > 1 {
points = snmpCounterToRate(points)
rateUnit = "bps"
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
"list": points, "total": len(points),
"metric_name": metricName, "index": index, "hours": hours,
"rate_unit": rateUnit,
}})
}
// snmpCounterToRate 将累计字节 counter 差分为比特速率 // Logan.Li
func snmpCounterToRate(points []snmpHistoryPoint) []snmpHistoryPoint {
if len(points) < 2 {
return points
}
out := make([]snmpHistoryPoint, 0, len(points)-1)
for i := 1; i < len(points); i++ {
prev, cur := points[i-1], points[i]
dt := float64(cur.Timestamp - prev.Timestamp)
if dt <= 0 {
continue
}
delta := cur.Value - prev.Value
if delta < 0 {
// counter 回绕,跳过该点 // Logan.Li
continue
}
bps := (delta * 8) / dt
out = append(out, snmpHistoryPoint{
Timestamp: cur.Timestamp,
Value: bps,
Unit: "bps",
})
}
if len(out) == 0 {
return []snmpHistoryPoint{}
}
return out
}
// GetOverviewMetrics GET /snmp/overview/metrics — 各设备最新关键指标(图表概览) // Logan.Li
func (h *SNMPHandler) GetOverviewMetrics(c *gin.Context) {
var devices []model.SNMPDevice
h.db.Where("template_id > 0").Order("name ASC").Find(&devices)
cpuNames := map[string]bool{
"h3c_cpu_usage": true, "hw_cpu_usage": true, "cisco_cpu_5min": true,
"ruijie_cpu_usage": true, "inspur_cpu_usage": true,
}
memNames := map[string]bool{
"h3c_mem_usage": true, "hw_mem_usage": true, "ruijie_mem_usage": true, "inspur_mem_usage": true,
}
tempNames := map[string]bool{
"h3c_temperature": true, "hw_temperature": true, "ruijie_temperature": true,
"dell_temperature": true, "inspur_temperature": true, "cisco_sensor_value": true,
}
items := make([]snmpOverviewItem, 0, len(devices))
for _, d := range devices {
item := snmpOverviewItem{
DeviceID: d.ID, Name: d.Name, IP: d.IP, Status: string(d.Status),
}
var latestTS int64
h.db.Model(&model.SNMPMetric{}).Where("device_id = ?", d.ID).
Select("COALESCE(MAX(timestamp), 0)").Scan(&latestTS)
if latestTS == 0 {
items = append(items, item)
continue
}
var metrics []model.SNMPMetric
h.db.Where("device_id = ? AND timestamp = ? AND name IN ?", d.ID, latestTS, snmpChartableNames).
Find(&metrics)
for _, m := range metrics {
switch {
case cpuNames[m.Name] && m.Value > item.CPU:
item.CPU = m.Value
case memNames[m.Name] && m.Value > item.Mem:
item.Mem = m.Value
case tempNames[m.Name] && m.Value > item.Temp:
item.Temp = m.Value
}
}
items = append(items, item)
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": items, "total": len(items)}})
}
func snmpMetricLabel(name, index string) string {
labels := map[string]string{
"h3c_cpu_usage": "H3C CPU", "hw_cpu_usage": "华为 CPU", "cisco_cpu_5min": "Cisco CPU",
"ruijie_cpu_usage": "锐捷 CPU", "inspur_cpu_usage": "浪潮 CPU",
"h3c_mem_usage": "H3C 内存", "hw_mem_usage": "华为 内存",
"ruijie_mem_usage": "锐捷 内存", "inspur_mem_usage": "浪潮 内存",
"h3c_temperature": "H3C 温度", "hw_temperature": "华为 温度",
"ruijie_temperature": "锐捷 温度", "dell_temperature": "Dell 温度",
"inspur_temperature": "浪潮 温度", "cisco_sensor_value": "Cisco 传感器",
"h3c_fan_speed": "H3C 风扇", "hw_fan_speed": "华为 风扇",
"ruijie_fan_speed": "锐捷 风扇", "dell_fan_speed": "Dell 风扇", "inspur_fan_speed": "浪潮 风扇",
"ent_sensor_value": "实体传感器",
"if_in_octets": "入方向流量", "if_out_octets": "出方向流量",
}
label := labels[name]
if label == "" {
label = name
}
if index != "" {
label += " #" + index
}
return label
}
+53
View File
@@ -0,0 +1,53 @@
package controller
import (
"net/http"
"ezops/model"
"ezops/pkg/systask"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// SystemTaskHandler 内置系统任务查询 // Logan.Li
type SystemTaskHandler struct {
db *gorm.DB
dbDriver string
alertEngine *AlertEngine
}
func NewSystemTaskHandler(db *gorm.DB, driver string, alertEngine *AlertEngine) *SystemTaskHandler {
return &SystemTaskHandler{db: db, dbDriver: driver, alertEngine: alertEngine}
}
// ListSystemTasks 获取系统内置任务列表 // Logan.Li
// GET /api/v1/system-tasks
func (h *SystemTaskHandler) ListSystemTasks(c *gin.Context) {
extras := map[string]any{}
if h.alertEngine != nil {
extras["alert_engine"] = h.alertEngine.GetEngineStatus()
}
list := systask.List(h.db, extras)
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
Data: model.ListResponse{List: list, Total: int64(len(list))},
})
}
// RunSystemTask 手动执行系统任务 // Logan.Li
// POST /api/v1/system-tasks/:code/run
func (h *SystemTaskHandler) RunSystemTask(c *gin.Context) {
code := c.Param("code")
item, err := systask.RunManual(h.db, h.dbDriver, code)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "执行完成",
Data: item,
})
}
+50
View File
@@ -249,6 +249,56 @@ func (h *UserHandler) DeleteUser(c *gin.Context) {
})
}
// BatchDeleteUsers 批量删除用户 // Logan.Li
// POST /api/v1/admin/users/batch-delete
func (h *UserHandler) BatchDeleteUsers(c *gin.Context) {
var req model.IDsRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供要删除的用户 ID 列表"})
return
}
currentUID, _ := c.Get("userID")
currentID, _ := currentUID.(uint)
deleted := 0
skipped := 0
var skippedNames []string
for _, id := range req.IDs {
if uint(id) == currentID {
skipped++
skippedNames = append(skippedNames, "当前登录用户")
continue
}
var user model.User
if err := h.db.First(&user, id).Error; err != nil {
skipped++
continue
}
if user.IsAdmin {
skipped++
skippedNames = append(skippedNames, user.Username)
continue
}
if err := h.db.Delete(&user).Error; err != nil {
skipped++
continue
}
deleted++
}
msg := fmt.Sprintf("已删除 %d 个用户", deleted)
if skipped > 0 {
msg += fmt.Sprintf(",跳过 %d 个", skipped)
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: msg,
Data: gin.H{"deleted": deleted, "skipped": skipped, "skipped_names": skippedNames},
})
}
// BatchImportUsers 批量导入用户
// POST /api/v1/admin/users/batch-import
func (h *UserHandler) BatchImportUsers(c *gin.Context) {