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:
1 parent
092d0e4b9e
commit
471353e3d7
190 files changed
+16640
-3484
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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 检查给定告警是否被静默规则匹配
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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_"
|
||||
}
|
||||
@@ -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}})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 自动化运维处理器
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
// 本周告警
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: "已恢复"})
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 模板
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user