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

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

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

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

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

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

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

No files matched your search

+70 -6
View File
@@ -335,18 +335,82 @@ func (h *AgentHandler) GetLatestReports(c *gin.Context) {
})
}
// parseFlexibleTime 解析前端传入的时间(ISO / datetime-local) // Logan.Li
func parseFlexibleTime(s string) (time.Time, error) {
s = strings.TrimSpace(s)
layouts := []string{
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02 15:04:05",
"2006-01-02T15:04",
"2006-01-02 15:04",
}
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("无法解析时间: %s", s)
}
// resolveHostHistoryRange 解析历史查询时间范围(minutes / hours / start+end) // Logan.Li
func resolveHostHistoryRange(c *gin.Context) (since, until time.Time, err error) {
until = time.Now()
if startStr := strings.TrimSpace(c.Query("start")); startStr != "" {
endStr := strings.TrimSpace(c.Query("end"))
if endStr == "" {
return since, until, fmt.Errorf("自定义范围缺少 end 参数")
}
since, err = parseFlexibleTime(startStr)
if err != nil {
return since, until, err
}
until, err = parseFlexibleTime(endStr)
if err != nil {
return since, until, err
}
if until.Before(since) {
since, until = until, since
}
return since, until, nil
}
if minStr := strings.TrimSpace(c.Query("minutes")); minStr != "" {
mins, _ := strconv.Atoi(minStr)
if mins <= 0 {
mins = 60
}
if mins > 43200 {
mins = 43200
}
since = until.Add(-time.Duration(mins) * time.Minute)
return since, until, nil
}
hours := 1
if v := strings.TrimSpace(c.Query("hours")); v != "" {
hours, _ = strconv.Atoi(v)
if hours <= 0 {
hours = 1
}
if hours > 720 {
hours = 720
}
}
since = until.Add(-time.Duration(hours) * time.Hour)
return since, until, nil
}
// GetHostHistory 获取指定主机的历史指标
// GET /api/v1/agent/history/:hostname?hours=1
// GET /api/v1/agent/history/:hostname?minutes=60 | ?hours=1 | ?start=&end=
func (h *AgentHandler) GetHostHistory(c *gin.Context) {
hostname := c.Param("hostname")
hours := 1
if v := c.Query("hours"); v != "" {
fmt.Sscanf(v, "%d", &hours)
since, until, err := resolveHostHistoryRange(c)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
since := time.Now().Add(-time.Duration(hours) * time.Hour)
var reports []model.AgentReport
h.db.Where("hostname = ? AND report_time >= ?", hostname, since).
h.db.Where("hostname = ? AND report_time >= ? AND report_time <= ?", hostname, since, until).
Order("report_time ASC").
Find(&reports)
+39 -3
View File
@@ -54,8 +54,9 @@ type wsEvent struct {
// wsControl 前端→后端控制消息 // Logan.Li
type wsControl struct {
Type string `json:"type"` // start/continue/confirm/deny/abort
Query string `json:"query,omitempty"` // start 时任务描述
Type string `json:"type"` // start/continue/resume/confirm/deny/abort
Query string `json:"query,omitempty"` // start/continue 时任务描述
SessionID uint `json:"session_id,omitempty"` // resume 时恢复的历史会话 ID
SafetyMode string `json:"safety_mode,omitempty"` // strict/balanced/permissive
ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时
}
@@ -159,6 +160,12 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
continue
}
ac.continueTask(sshCfg, ctrl.Query, ctrl.SafetyMode)
case "resume":
if ctrl.SessionID == 0 {
ac.write(wsEvent{Type: "error", Message: "缺少 session_id"})
continue
}
ac.resumeSession(ctrl.SessionID)
case "confirm":
ac.resolveConfirm(ctrl.ConfirmID, true)
case "deny":
@@ -211,6 +218,33 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, query, safetyMode st
ac.runTask(sshCfg, sess, query, safetyMode, llmCfg, false)
}
// resumeSession 从历史会话恢复上下文,供继续追问 // Logan.Li
func (ac *agentConn) resumeSession(sessionID uint) {
if ac.isRunning() {
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
return
}
var sess model.AgentCLISession
if err := ac.db.Where("user_id = ? AND server_id = ?", ac.userID, ac.server.ID).
First(&sess, sessionID).Error; err != nil {
ac.write(wsEvent{Type: "error", Message: "会话不存在或无权访问"})
return
}
if sess.Status == "running" {
ac.write(wsEvent{Type: "error", Message: "该会话仍在执行中,无法恢复"})
return
}
var msgs []model.AgentCLIMessage
ac.db.Where("session_id = ?", sessionID).Order("id ASC").Find(&msgs)
ac.session = &sess
ac.convHistory = buildConvHistoryFromMessages(msgs)
ac.setTotalTokens(sess.TotalTokens)
ac.write(wsEvent{
Type: "session", SessionID: sess.ID,
Message: fmt.Sprintf("已恢复历史会话「%s」,可继续追问或执行新指令", sess.Title),
})
}
// continueTask 在同一会话内追问(保留上下文) // Logan.Li
func (ac *agentConn) continueTask(sshCfg remoteexec.SSHConfig, query, safetyMode string) {
if ac.isRunning() {
@@ -262,6 +296,7 @@ func (ac *agentConn) runTask(sshCfg remoteexec.SSHConfig, sess *model.AgentCLISe
Core: coreCfg,
})
if err != nil {
ac.write(wsEvent{Type: "error", Message: "引擎初始化失败: " + err.Error()})
ac.finish(sess, "error", "引擎初始化失败: "+err.Error(), ac.lastRound(), 0)
return
}
@@ -444,7 +479,8 @@ func (ac *agentConn) lastRound() int {
func (ac *agentConn) finish(sess *model.AgentCLISession, status, errMsg string, rounds int, dur time.Duration) {
ac.setRunning(false)
tokens := ac.getTotalTokens()
if status == "done" || status == "aborted" || status == "error" {
// done/error 已由引擎 onEvent 推送;此处仅 aborted 需补推 WS(引擎初始化失败在 runTask 内单独推送) // Logan.Li
if status == "aborted" {
ac.write(wsEvent{Type: status, SessionID: sess.ID, Message: errMsg, Round: rounds, Tokens: tokens})
}
updates := map[string]interface{}{
+89
View File
@@ -0,0 +1,89 @@
// agent_cli_history.go - Agent CLI 历史会话恢复为 LLM 对话上下文 // Logan.Li
package controller
import (
"encoding/json"
"fmt"
"ezops/model"
"ezops/pkg/agentcli/llm"
)
// buildConvHistoryFromMessages 将 DB 消息重建为追问用的 prior 历史(不含 system) // Logan.Li
func buildConvHistoryFromMessages(msgs []model.AgentCLIMessage) []llm.Message {
var out []llm.Message
var pendingThought string
var pendingToolCalls []llm.ToolCall
var pendingToolCallIDs []string
flushAssistant := func() {
if pendingThought == "" && len(pendingToolCalls) == 0 {
return
}
out = append(out, llm.Message{
Role: "assistant",
Content: pendingThought,
ToolCalls: append([]llm.ToolCall(nil), pendingToolCalls...),
})
for _, tc := range pendingToolCalls {
pendingToolCallIDs = append(pendingToolCallIDs, tc.ID)
}
pendingThought = ""
pendingToolCalls = nil
}
for _, m := range msgs {
switch m.Type {
case "user":
flushAssistant()
out = append(out, llm.Message{Role: "user", Content: m.Content})
case "thought":
pendingThought = m.Content
case "tool_call":
args := validateToolArgsJSON(m.ToolArgs)
tcID := fmt.Sprintf("hist-%d", m.ID)
pendingToolCalls = append(pendingToolCalls, llm.ToolCall{
ID: tcID, Type: "function",
Function: llm.FunctionCall{Name: m.ToolName, Arguments: args},
})
case "tool_result":
if pendingThought != "" || len(pendingToolCalls) > 0 {
flushAssistant()
}
toolID := ""
if len(pendingToolCallIDs) > 0 {
toolID = pendingToolCallIDs[0]
pendingToolCallIDs = pendingToolCallIDs[1:]
}
content := m.Content
if content == "" {
content = m.ToolOutput
}
out = append(out, llm.Message{
Role: "tool", Name: m.ToolName, Content: content, ToolCallID: toolID,
})
case "done":
flushAssistant()
out = append(out, llm.Message{Role: "assistant", Content: m.Content})
case "error":
flushAssistant()
out = append(out, llm.Message{Role: "assistant", Content: "错误: " + m.Content})
default:
// 忽略 progress/session 等 // Logan.Li
}
}
flushAssistant()
return out
}
// validateToolArgsJSON 校验工具参数 JSON,非法则回退空对象 // Logan.Li
func validateToolArgsJSON(raw string) string {
if raw == "" {
return "{}"
}
var tmp map[string]interface{}
if json.Unmarshal([]byte(raw), &tmp) != nil {
return "{}"
}
return raw
}
+25 -31
View File
@@ -16,6 +16,7 @@ import (
"ezops/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 通知渠道 CRUD(AlertHandler 方法接收者,与 alert.go 同包共享) // Logan.Li
@@ -198,7 +199,7 @@ func (h *AlertHandler) TestNotifyChannel(c *gin.Context) {
switch channel.Type {
case "feishu":
err := sendFeishuTest(channel.Config)
err := sendFeishuTest(h.db, channel.Config)
if err != nil {
status = "error"
message = "飞书通知发送失败: " + err.Error()
@@ -226,8 +227,8 @@ func (h *AlertHandler) TestNotifyChannel(c *gin.Context) {
// ==================== 飞书 Webhook 发送逻辑 ====================
// sendFeishuTest 发送飞书测试通知
func sendFeishuTest(configJSON string) error {
// sendFeishuTest 发送飞书测试通知 // Logan.Li
func sendFeishuTest(db *gorm.DB, configJSON string) error {
var cfg model.FeishuWebhookConfig
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
return fmt.Errorf("解析配置失败: %w", err)
@@ -239,11 +240,11 @@ func sendFeishuTest(configJSON string) error {
title := "✅ AIOps 告警测试通知"
content := fmt.Sprintf("**测试时间**:%s\\n**来源**:AIOps 智能运维平台\\n**状态**:连通性测试成功 🎉", time.Now().Format("2006-01-02 15:04:05"))
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, "green")
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, "green", buildNotifyFooter(db))
}
// sendFeishuAlert 发送飞书告警通知
func sendFeishuAlert(configJSON, title, content, severity string) error {
// sendFeishuAlert 发送飞书告警通知 // Logan.Li
func sendFeishuAlert(db *gorm.DB, configJSON, title, content, severity string) error {
var cfg model.FeishuWebhookConfig
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
return fmt.Errorf("解析配置失败: %w", err)
@@ -267,11 +268,15 @@ func sendFeishuAlert(configJSON, title, content, severity string) error {
}
title = emoji + " " + title
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, color)
footer := defaultNotifyFooterTemplate
if db != nil {
footer = buildNotifyFooter(db)
}
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, color, footer)
}
// sendFeishuRecovery 发送飞书恢复通知
func sendFeishuRecovery(configJSON, title, content string) error {
// sendFeishuRecovery 发送飞书恢复通知 // Logan.Li
func sendFeishuRecovery(db *gorm.DB, configJSON, title, content string) error {
var cfg model.FeishuWebhookConfig
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
return fmt.Errorf("解析配置失败: %w", err)
@@ -281,11 +286,15 @@ func sendFeishuRecovery(configJSON, title, content string) error {
}
title = "✅ " + title
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, "green")
footer := buildNotifyFooter(db)
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, "green", footer)
}
// sendFeishuCard 发送飞书交互卡片消息
func sendFeishuCard(webhookURL, secret, title, content, color string) error {
// sendFeishuCard 发送飞书交互卡片消息 // Logan.Li
func sendFeishuCard(webhookURL, secret, title, content, color, footer string) error {
if footer == "" {
footer = fmt.Sprintf("%s | %s", time.Now().Format("2006-01-02 15:04:05"), defaultNotifyPlatformName)
}
// 解析 content 中的字段(格式: 多行 key: value)
lines := splitLines(content)
var fields []map[string]interface{}
@@ -339,7 +348,7 @@ func sendFeishuCard(webhookURL, secret, title, content, color string) error {
"elements": []map[string]interface{}{
{
"tag": "plain_text",
"content": fmt.Sprintf("⏰ %s | AIOps 智能运维平台", time.Now().Format("2006-01-02 15:04:05")),
"content": footer,
},
},
},
@@ -438,9 +447,9 @@ func (h *AlertHandler) NotifyAlert(ruleName, hostname, metric, severity, message
for _, ch := range channels {
switch ch.Type {
case "feishu":
title := buildAlertTitle(ruleName, hostname, metric, severity)
title := buildNotifyTitle(h.db, ruleName, hostname, metric, severity)
content := buildAlertContent(ruleName, hostname, metric, severity, message, value, threshold)
if err := sendFeishuAlert(ch.Config, title, content, severity); err != nil {
if err := sendFeishuAlert(h.db, ch.Config, title, content, severity); err != nil {
fmt.Printf("[AlertNotify] 飞书通知失败 (渠道:%s): %v\n", ch.Name, err)
}
}
@@ -458,26 +467,11 @@ func (h *AlertHandler) NotifyRecovery(ruleName, hostname, metric, message string
title := fmt.Sprintf("%s — 已恢复正常", ruleName)
content := fmt.Sprintf("**告警已恢复**\\n\\n主机:%s\\n指标:%s\\n详情:%s\\n恢复时间:%s",
hostname, metric, message, time.Now().Format("2006-01-02 15:04:05"))
if err := sendFeishuRecovery(ch.Config, title, content); err != nil {
if err := sendFeishuRecovery(h.db, ch.Config, title, content); err != nil {
fmt.Printf("[AlertNotify] 飞书恢复通知失败 (渠道:%s): %v\n", ch.Name, err)
}
}
}
}
func buildAlertTitle(ruleName, hostname, metric, severity string) string {
sevLabel := "严重告警"
if severity == "warning" {
sevLabel = "警告"
} else if severity == "info" {
sevLabel = "信息"
}
return fmt.Sprintf("[%s] %s — %s (%s)", sevLabel, ruleName, hostname, metric)
}
func buildAlertContent(ruleName, hostname, metric, severity, message string, value, threshold float64) string {
return fmt.Sprintf(
"**规则名称:** %s\\n**目标主机:** %s\\n**指标类型:** %s\\n**告警级别:** %s\\n**当前值:** %.2f\\n**阈值:** %.2f\\n**详细信息:** %s\\n**触发时间:** %s",
ruleName, hostname, metric, severity, value, threshold, message, time.Now().Format("2006-01-02 15:04:05"),
)
}
+34 -65
View File
@@ -64,6 +64,10 @@ func (e *AlertEngine) Check() {
e.checkResourceAlerts()
e.checkOfflineAlerts()
e.checkPortAlerts()
e.checkMiddlewareAlerts()
e.checkSNMPAlerts()
e.checkSiteAlerts()
e.autoResolveSiteSslAlerts()
e.checkLogAlertRules()
e.autoResolveAlerts()
e.checkLogAlertRecovery()
@@ -124,8 +128,15 @@ func (e *AlertEngine) GetEngineStatus() map[string]interface{} {
// 检查 CPU/内存/磁盘/进程/TCP连接/端口 阈值
func (e *AlertEngine) checkResourceAlerts() {
hostMetrics := []string{
"cpu", "cpu_iowait", "mem", "mem_available", "swap", "load1",
"disk", "disk_inode", "disk_io_util",
"tcp_time_wait", "tcp_close_wait", "tcp_established",
"net_rx_bps", "net_tx_bps", "net_rx_errors", "fd",
"service_process",
}
var rules []model.AgentRule
e.db.Where("enabled = ? AND metric IN ?", true, []string{"cpu", "mem", "disk", "service_process", "tcp_time_wait", "tcp_close_wait", "fd"}).Find(&rules)
e.db.Where("enabled = ? AND metric IN ?", true, hostMetrics).Find(&rules)
for _, rule := range rules {
// 获取目标主机最新上报
@@ -140,29 +151,7 @@ func (e *AlertEngine) checkResourceAlerts() {
}
var value float64
switch rule.Metric {
case "cpu":
value = report.CPUUsage
case "mem":
value = report.MemUsage
case "disk":
var disks []model.DiskMetric
json.Unmarshal([]byte(report.DiskInfo), &disks)
for _, d := range disks {
if d.UsagePercent > value {
value = d.UsagePercent
}
}
case "tcp_time_wait":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
value = float64(netStat.TCPTimeWait)
case "tcp_close_wait":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
value = float64(netStat.TCPCloseWait)
case "service_process":
// 进程不存在告警 — 检查 process_top_info 中是否包含指定进程名
if rule.Metric == "service_process" {
var processes []model.ProcessMetric
json.Unmarshal([]byte(report.ProcessTopInfo), &processes)
found := false
@@ -173,13 +162,15 @@ func (e *AlertEngine) checkResourceAlerts() {
}
}
if found {
continue // 进程存在,不触发
continue
}
value = 0
} else {
var ok bool
value, ok = extractHostMetricValue(report, rule.Metric)
if !ok {
continue
}
value = 0 // 进程不存在时触发
case "fd":
var fdStat model.FDMetric
json.Unmarshal([]byte(report.FDInfo), &fdStat)
value = fdStat.UsagePct
}
// 检查是否超阈值
@@ -305,7 +296,7 @@ func (e *AlertEngine) checkOfflineAlerts() {
e.db.Create(&alert)
// 发送离线告警通知
e.notifyAlertRule("主机离线", s.Hostname, "offline", "critical",
e.notifyAlertRule("host", "主机离线", s.Hostname, "offline", "critical",
alert.Message, offlineMinutes, 3)
// 记录离线上报时间用于恢复通知
@@ -378,7 +369,7 @@ func (e *AlertEngine) checkPortAlerts() {
e.db.Create(&alert)
// 发送端口Down告警通知
e.notifyAlertRule(rule.Name, report.Hostname, "port_down", "critical",
e.notifyAlertRule("host", rule.Name, report.Hostname, "port_down", "critical",
alert.Message, float64(rule.Port), float64(rule.Port))
now := time.Now()
@@ -440,7 +431,7 @@ func (e *AlertEngine) checkLogAlertRules() {
}
e.db.Create(&alert)
e.notifyAlertRule(rule.Name, alert.Target, "log", rule.Severity, alert.Message, float64(count), 0)
e.notifyAlertRule("host", rule.Name, alert.Target, "log", rule.Severity, alert.Message, float64(count), 0)
e.db.Model(&rule).Update("last_triggered", now)
}
@@ -610,7 +601,7 @@ func (e *AlertEngine) autoResolveAlerts() {
})
// 发送恢复通知
e.notifyRecovery(alert.RuleName, alert.Hostname, alert.Metric, alert.Message)
e.notifyRecovery("host", alert.RuleName, alert.Hostname, alert.Metric, alert.Message)
}
}
}
@@ -648,7 +639,7 @@ func (e *AlertEngine) checkLogAlertRecovery() {
"status": model.AlertEventResolved,
"resolved_at": now,
})
e.notifyRecovery(rule.Name, evt.Target, "log", evt.Message)
e.notifyRecovery("host", rule.Name, evt.Target, "log", evt.Message)
}
}
@@ -669,42 +660,20 @@ func (e *AlertEngine) compareOperator(op string, val, threshold float64) bool {
}
}
// notifyAlert 发送告警通知(通过 AgentRule 对应的 notify_channels)
// notifyAlert 发送主机监控告警通知 // Logan.Li
func (e *AlertEngine) notifyAlert(rule *model.AgentRule, hostname string, alert model.AgentAlert) {
e.notifyAlertRule(rule.Name, hostname, alert.Metric, alert.Severity,
e.notifyAlertRule("host", rule.Name, hostname, alert.Metric, alert.Severity,
alert.Message, alert.Value, alert.Threshold)
}
// notifyAlertRule 按规则发送告警通知到所有启用的飞书渠道
func (e *AlertEngine) notifyAlertRule(ruleName, hostname, metric, severity, message string, value, threshold float64) {
var channels []model.NotifyChannel
e.db.Where("enabled = ? AND type = ?", true, "feishu").Find(&channels)
for _, ch := range channels {
title := fmt.Sprintf("[%s] %s — %s (%s)", severity, ruleName, hostname, metric)
content := fmt.Sprintf(
"**规则名称:** %s\\n**目标主机:** %s\\n**指标类型:** %s\\n**告警级别:** %s\\n**当前值:** %.2f\\n**阈值:** %.2f\\n**详细信息:** %s\\n**触发时间:** %s",
ruleName, hostname, metric, severity, value, threshold, message, time.Now().Format("2006-01-02 15:04:05"),
)
if err := sendFeishuAlert(ch.Config, title, content, severity); err != nil {
fmt.Printf("[AlertEngine] 飞书通知失败 (渠道:%s): %v\n", ch.Name, err)
}
}
// notifyAlertRule 按告警集合策略发送通知(无策略时回退全局渠道)// Logan.Li
func (e *AlertEngine) notifyAlertRule(category, ruleName, hostname, metric, severity, message string, value, threshold float64) {
dispatchCategoryAlert(e.db, category, ruleName, hostname, metric, severity, message, value, threshold)
}
// notifyRecovery 发送恢复通知到所有启用的飞书渠道
func (e *AlertEngine) notifyRecovery(ruleName, hostname, metric, message string) {
var channels []model.NotifyChannel
e.db.Where("enabled = ? AND type = ?", true, "feishu").Find(&channels)
for _, ch := range channels {
title := fmt.Sprintf("%s — 已恢复正常", ruleName)
content := fmt.Sprintf("**告警已恢复**\\\\n\\\\n主机:%s\\\\n指标:%s\\\\n详情:%s\\\\n恢复时间:%s",
hostname, metric, message, time.Now().Format("2006-01-02 15:04:05"))
if err := sendFeishuRecovery(ch.Config, title, content); err != nil {
fmt.Printf("[AlertEngine] 飞书恢复通知失败 (渠道:%s): %v\n", ch.Name, err)
}
}
// notifyRecovery 按告警集合策略发送恢复通知 // Logan.Li
func (e *AlertEngine) notifyRecovery(category, ruleName, hostname, metric, message string) {
dispatchCategoryRecovery(e.db, category, ruleName, hostname, metric, message)
}
// isSilenced 检查给定告警是否被静默规则匹配
+329
View File
@@ -0,0 +1,329 @@
package controller
import (
"encoding/json"
"fmt"
"time"
"ezops/model"
"ezops/pkg/siteprobe"
)
// checkMiddlewareAlerts 中间件指标告警 // Logan.Li
func (e *AlertEngine) checkMiddlewareAlerts() {
var rules []model.MonitorAlertRule
e.db.Where("enabled = ? AND category = ?", true, model.MonitorAlertMiddleware).Find(&rules)
if len(rules) == 0 {
return
}
var targets []model.MonitorTarget
e.db.Where("type = ?", model.TargetTypeMiddleware).Find(&targets)
targetMap := make(map[uint]model.MonitorTarget)
for _, t := range targets {
targetMap[t.ID] = t
}
for _, rule := range rules {
checkTargets := targets
if rule.TargetID > 0 {
if t, ok := targetMap[rule.TargetID]; ok {
checkTargets = []model.MonitorTarget{t}
} else {
continue
}
}
for _, target := range checkTargets {
e.evalMiddlewareRule(rule, target)
}
}
}
func (e *AlertEngine) evalMiddlewareRule(rule model.MonitorAlertRule, target model.MonitorTarget) {
if rule.Metric == "middleware_offline" {
if target.Status == model.TargetStatusOffline || target.Status == model.TargetStatusUnknown {
e.fireMiddlewareAlert(rule, target, 1, 1, "中间件不可达或采集失败")
}
return
}
value, ok := e.getLatestMetricValue(target.ID, rule.Metric)
if !ok {
return
}
if !e.compareOperator(rule.Operator, value, rule.Threshold) {
return
}
msg := fmt.Sprintf("%s %s %.2f %s %.2f", target.Name, rule.Metric, value, rule.Operator, rule.Threshold)
e.fireMiddlewareAlert(rule, target, value, rule.Threshold, msg)
}
func (e *AlertEngine) getLatestMetricValue(targetID uint, metric string) (float64, bool) {
var hist model.MetricHistory
e.db.Where("target_id = ? AND metric_name = ?", targetID, metric).
Order("timestamp DESC").First(&hist)
if hist.ID == 0 {
return 0, false
}
return hist.Value, true
}
func (e *AlertEngine) fireMiddlewareAlert(rule model.MonitorAlertRule, target model.MonitorTarget, value, threshold float64, msg string) {
tenMinAgo := time.Now().Add(-10 * time.Minute)
var existing model.AgentAlert
e.db.Where("rule_name = ? AND hostname = ? AND resolved = ? AND created_at > ?",
rule.Name, target.Name, false, tenMinAgo).First(&existing)
if existing.ID > 0 {
return
}
if e.isSilenced(rule.Name, target.Name, rule.ID) {
return
}
sev := rule.Severity
if sev == "" {
sev = "warning"
}
alert := model.AgentAlert{
RuleName: rule.Name, Hostname: target.Name, Metric: rule.Metric,
Severity: sev, Message: msg, Value: value, Threshold: threshold,
}
e.db.Create(&alert)
e.notifyAlertRule("middleware", rule.Name, target.Name, rule.Metric, sev, msg, value, threshold)
now := time.Now()
e.db.Model(&rule).Update("last_triggered", now)
}
// checkSiteAlerts 站点拨测告警 // Logan.Li
func (e *AlertEngine) checkSiteAlerts() {
var rules []model.MonitorAlertRule
e.db.Where("enabled = ? AND category = ?", true, model.MonitorAlertSite).Find(&rules)
if len(rules) == 0 {
return
}
var tasks []model.SiteTask
e.db.Find(&tasks)
taskMap := make(map[uint]model.SiteTask)
for _, t := range tasks {
taskMap[t.ID] = t
}
for _, rule := range rules {
checkTasks := tasks
if rule.TargetID > 0 {
if t, ok := taskMap[rule.TargetID]; ok {
checkTasks = []model.SiteTask{t}
} else {
continue
}
}
for _, task := range checkTasks {
if task.Status != model.SiteTaskStatusRunning {
continue
}
e.evalSiteRule(rule, task)
}
}
}
func (e *AlertEngine) evalSiteRule(rule model.MonitorAlertRule, task model.SiteTask) {
var value float64
var triggered bool
var msg string
switch rule.Metric {
case "probe_fail":
triggered = task.LastResult == "fail" || task.LastResult == "timeout"
value = 1
if triggered {
msg = fmt.Sprintf("拨测任务 %s 失败: %s", task.Name, task.LastResult)
}
case "probe_timeout":
triggered = task.LastResult == "timeout"
value = 1
if triggered {
msg = fmt.Sprintf("拨测任务 %s 超时", task.Name)
}
case "response_time":
value = task.AvgResponse
triggered = e.compareOperator(rule.Operator, value, rule.Threshold)
if triggered {
msg = fmt.Sprintf("拨测 %s 延迟 %.0fms 超过阈值 %.0fms", task.Name, value, rule.Threshold)
}
case "availability":
value = task.SuccessRate
triggered = e.compareOperator(rule.Operator, value, rule.Threshold)
if triggered {
msg = fmt.Sprintf("拨测 %s 可用率 %.1f%% 低于阈值 %.1f%%", task.Name, value, rule.Threshold)
}
case "http_status_error":
triggered = task.LastResult == "fail"
value = 1
if triggered {
msg = fmt.Sprintf("拨测 %s HTTP 状态码异常", task.Name)
}
case "ssl_expiry_days":
if !siteprobe.IsSSLProbeTask(task) {
return
}
var result model.SiteResult
e.db.Where("task_id = ? AND ssl_cert_expiry IS NOT NULL", task.ID).
Order("checked_at DESC").First(&result)
if result.ID == 0 {
return
}
value = float64(result.SSLDaysRemaining)
triggered = e.compareOperator(rule.Operator, value, rule.Threshold)
if triggered {
expiryStr := ""
if result.SSLCertExpiry != nil {
expiryStr = result.SSLCertExpiry.Format("2006-01-02")
}
if value < 0 {
msg = fmt.Sprintf("SSL 拨测 %s 证书已过期", task.Name)
} else if expiryStr != "" {
msg = fmt.Sprintf("SSL 拨测 %s 证书剩余 %.0f 天(到期 %s)", task.Name, value, expiryStr)
} else {
msg = fmt.Sprintf("SSL 拨测 %s 证书剩余 %.0f 天", task.Name, value)
}
}
default:
return
}
if !triggered {
return
}
tenMinAgo := time.Now().Add(-10 * time.Minute)
var existing model.SiteAlert
e.db.Where("task_id = ? AND title = ? AND status = ? AND started_at > ?",
task.ID, rule.Name, model.SiteAlertStatusActive, tenMinAgo).First(&existing)
if existing.ID > 0 {
return
}
sev := model.SiteAlertWarning
if rule.Severity == "critical" || (rule.Metric == "ssl_expiry_days" && value <= 7) {
sev = model.SiteAlertCritical
}
now := time.Now()
siteAlert := model.SiteAlert{
TaskID: task.ID, Severity: sev, Status: model.SiteAlertStatusActive,
Title: rule.Name, Message: msg, TriggerType: rule.Metric,
Threshold: rule.Threshold, ResponseTime: task.AvgResponse,
StartedAt: now,
}
e.db.Create(&siteAlert)
e.notifyAlertRule("site", rule.Name, task.Name, rule.Metric, string(sev), msg, value, rule.Threshold)
nt := time.Now()
e.db.Model(&rule).Update("last_triggered", nt)
}
// extractHostMetricValue 从 Agent 上报提取扩展指标值 // Logan.Li
func extractHostMetricValue(report model.AgentReport, metric string) (float64, bool) {
switch metric {
case "cpu":
return report.CPUUsage, true
case "cpu_iowait":
return report.CPUIOwait, true
case "mem":
return report.MemUsage, true
case "mem_available":
return report.MemAvailable, true
case "load1":
return report.Load1, true
case "disk":
var disks []model.DiskMetric
json.Unmarshal([]byte(report.DiskInfo), &disks)
max := 0.0
for _, d := range disks {
if d.UsagePercent > max {
max = d.UsagePercent
}
}
return max, true
case "disk_inode":
var disks []model.DiskMetric
json.Unmarshal([]byte(report.DiskInfo), &disks)
max := 0.0
for _, d := range disks {
if d.InodeUsagePct > max {
max = d.InodeUsagePct
}
}
return max, true
case "disk_io_util":
var dio model.DiskIOStatModel
json.Unmarshal([]byte(report.DiskIOInfo), &dio)
return dio.Util, true
case "swap":
var swap model.SwapMetric
json.Unmarshal([]byte(report.SwapInfo), &swap)
return swap.UsagePercent, true
case "tcp_time_wait":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return float64(netStat.TCPTimeWait), true
case "tcp_close_wait":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return float64(netStat.TCPCloseWait), true
case "tcp_established":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return float64(netStat.TCPEstablished), true
case "net_rx_bps":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return netStat.NetRxBPS, true
case "net_tx_bps":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return netStat.NetTxBPS, true
case "net_rx_errors":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return float64(netStat.NetRxErrors), true
case "fd":
var fdStat model.FDMetric
json.Unmarshal([]byte(report.FDInfo), &fdStat)
return fdStat.UsagePct, true
default:
return 0, false
}
}
// autoResolveSiteSslAlerts 证书续期后自动恢复 SSL 过期告警 // Logan.Li
func (e *AlertEngine) autoResolveSiteSslAlerts() {
var alerts []model.SiteAlert
e.db.Where("status = ? AND trigger_type = ?", model.SiteAlertStatusActive, "ssl_expiry_days").Find(&alerts)
if len(alerts) == 0 {
return
}
now := time.Now()
for _, alert := range alerts {
var result model.SiteResult
e.db.Where("task_id = ? AND ssl_cert_expiry IS NOT NULL", alert.TaskID).
Order("checked_at DESC").First(&result)
if result.ID == 0 {
continue
}
if float64(result.SSLDaysRemaining) >= 30 {
e.db.Model(&alert).Updates(map[string]interface{}{
"status": model.SiteAlertStatusResolved, "resolved_at": now,
})
}
}
}
// isMiddlewareMetric 判断 AgentAlert 是否属于中间件 // Logan.Li
func isMiddlewareMetric(metric string) bool {
prefixes := []string{"redis_", "mysql_", "middleware_"}
for _, p := range prefixes {
if len(metric) >= len(p) && metric[:len(p)] == p {
return true
}
}
return false
}
+184
View File
@@ -0,0 +1,184 @@
package controller
import (
"fmt"
"time"
"ezops/model"
)
// SNMP 指标名映射(与采集模板一致)// Logan.Li
var snmpCPUMetricNames = []string{
"h3c_cpu_usage", "hw_cpu_usage", "cisco_cpu_5min", "ruijie_cpu_usage", "inspur_cpu_usage",
}
var snmpMemMetricNames = []string{
"h3c_mem_usage", "hw_mem_usage", "ruijie_mem_usage", "inspur_mem_usage",
}
var snmpTempMetricNames = []string{
"h3c_temperature", "hw_temperature", "ruijie_temperature", "dell_temperature",
"inspur_temperature", "cisco_sensor_value",
}
// checkSNMPAlerts SNMP 设备指标告警 // Logan.Li
func (e *AlertEngine) checkSNMPAlerts() {
var rules []model.MonitorAlertRule
e.db.Where("enabled = ? AND category = ?", true, model.MonitorAlertSNMP).Find(&rules)
if len(rules) == 0 {
return
}
var devices []model.SNMPDevice
e.db.Where("template_id > 0").Find(&devices)
deviceMap := make(map[uint]model.SNMPDevice, len(devices))
for _, d := range devices {
deviceMap[d.ID] = d
}
for _, rule := range rules {
checkDevices := devices
if rule.TargetID > 0 {
if d, ok := deviceMap[rule.TargetID]; ok {
checkDevices = []model.SNMPDevice{d}
} else {
continue
}
}
for _, device := range checkDevices {
e.evalSNMPRule(rule, device)
}
}
}
func (e *AlertEngine) evalSNMPRule(rule model.MonitorAlertRule, device model.SNMPDevice) {
var value float64
var triggered bool
var msg string
switch rule.Metric {
case "snmp_offline":
triggered = device.Status != model.SNMPDeviceOnline
value = 1
if triggered {
msg = fmt.Sprintf("SNMP 设备 %s (%s) 离线或采集异常", device.Name, device.IP)
}
case "snmp_cpu_usage":
value, triggered = e.evalSNMPGaugeRule(device.ID, snmpCPUMetricNames, rule)
if triggered {
msg = fmt.Sprintf("SNMP %s CPU %.1f%% %s %.1f%%", device.Name, value, rule.Operator, rule.Threshold)
}
case "snmp_mem_usage":
value, triggered = e.evalSNMPGaugeRule(device.ID, snmpMemMetricNames, rule)
if triggered {
msg = fmt.Sprintf("SNMP %s 内存 %.1f%% %s %.1f%%", device.Name, value, rule.Operator, rule.Threshold)
}
case "snmp_temperature":
value, triggered = e.evalSNMPGaugeRule(device.ID, snmpTempMetricNames, rule)
if triggered {
msg = fmt.Sprintf("SNMP %s 温度 %.1f°C %s %.1f°C", device.Name, value, rule.Operator, rule.Threshold)
}
case "snmp_if_in_bps":
value, triggered = e.evalSNMPRateRule(device.ID, "if_in_octets", rule)
if triggered {
msg = fmt.Sprintf("SNMP %s 入流量 %.0f bps %s %.0f bps", device.Name, value, rule.Operator, rule.Threshold)
}
case "snmp_if_out_bps":
value, triggered = e.evalSNMPRateRule(device.ID, "if_out_octets", rule)
if triggered {
msg = fmt.Sprintf("SNMP %s 出流量 %.0f bps %s %.0f bps", device.Name, value, rule.Operator, rule.Threshold)
}
default:
return
}
if !triggered {
return
}
e.fireSNMPAlert(rule, device, value, rule.Threshold, msg)
}
func (e *AlertEngine) evalSNMPGaugeRule(deviceID uint, names []string, rule model.MonitorAlertRule) (float64, bool) {
value, ok := e.getMaxSNMPMetric(deviceID, names)
if !ok {
return 0, false
}
return value, e.compareOperator(rule.Operator, value, rule.Threshold)
}
func (e *AlertEngine) evalSNMPRateRule(deviceID uint, counterName string, rule model.MonitorAlertRule) (float64, bool) {
value, ok := e.getSNMPCounterRate(deviceID, counterName)
if !ok {
return 0, false
}
return value, e.compareOperator(rule.Operator, value, rule.Threshold)
}
func (e *AlertEngine) getMaxSNMPMetric(deviceID uint, names []string) (float64, bool) {
var latestTS int64
e.db.Model(&model.SNMPMetric{}).Where("device_id = ?", deviceID).
Select("COALESCE(MAX(timestamp), 0)").Scan(&latestTS)
if latestTS == 0 {
return 0, false
}
var metrics []model.SNMPMetric
e.db.Where("device_id = ? AND timestamp = ? AND name IN ?", deviceID, latestTS, names).Find(&metrics)
if len(metrics) == 0 {
return 0, false
}
max := 0.0
for _, m := range metrics {
if m.Value > max {
max = m.Value
}
}
return max, true
}
// getSNMPCounterRate 取 counter 最近两次差分比特速率 // Logan.Li
func (e *AlertEngine) getSNMPCounterRate(deviceID uint, name string) (float64, bool) {
var metrics []model.SNMPMetric
e.db.Where("device_id = ? AND name = ?", deviceID, name).
Order("timestamp DESC").Limit(2).Find(&metrics)
if len(metrics) < 2 {
return 0, false
}
cur, prev := metrics[0], metrics[1]
dt := float64(cur.Timestamp - prev.Timestamp)
if dt <= 0 {
return 0, false
}
delta := cur.Value - prev.Value
if delta < 0 {
return 0, false
}
return (delta * 8) / dt, true
}
func (e *AlertEngine) fireSNMPAlert(rule model.MonitorAlertRule, device model.SNMPDevice, value, threshold float64, msg string) {
tenMinAgo := time.Now().Add(-10 * time.Minute)
var existing model.AgentAlert
e.db.Where("rule_name = ? AND hostname = ? AND resolved = ? AND created_at > ?",
rule.Name, device.Name, false, tenMinAgo).First(&existing)
if existing.ID > 0 {
return
}
if e.isSilenced(rule.Name, device.Name, rule.ID) {
return
}
sev := rule.Severity
if sev == "" {
sev = "warning"
}
alert := model.AgentAlert{
RuleName: rule.Name, Hostname: device.Name, Metric: rule.Metric,
Severity: sev, Message: msg, Value: value, Threshold: threshold,
}
e.db.Create(&alert)
e.notifyAlertRule("snmp", rule.Name, device.Name, rule.Metric, sev, msg, value, threshold)
now := time.Now()
e.db.Model(&rule).Update("last_triggered", now)
}
// isSNMPMetric 判断 AgentAlert 是否属于 SNMP // Logan.Li
func isSNMPMetric(metric string) bool {
return len(metric) >= 5 && metric[:5] == "snmp_"
}
+107
View File
@@ -0,0 +1,107 @@
package controller
import (
"net/http"
"ezops/model"
"github.com/gin-gonic/gin"
)
// 指标目录静态定义,供监控中心与告警中心共用 // Logan.Li
// MetricCatalogItem 可采集/可告警指标定义 // Logan.Li
type MetricCatalogItem struct {
Key string `json:"key"`
Label string `json:"label"`
Unit string `json:"unit"`
Type string `json:"type"` // gauge / counter / status
DefaultOp string `json:"default_op"`
DefaultWarn float64 `json:"default_warn"`
DefaultCrit float64 `json:"default_crit"`
Min float64 `json:"min"`
Max float64 `json:"max"`
CollectSource string `json:"collect_source"`
Description string `json:"description"`
Operators []string `json:"operators"`
}
// MetricCatalogCategory 指标分类 // Logan.Li
type MetricCatalogCategory struct {
ID string `json:"id"`
Label string `json:"label"`
Description string `json:"description"`
Metrics []MetricCatalogItem `json:"metrics"`
}
var metricCatalog = []MetricCatalogCategory{
{
ID: "host", Label: "主机监控", Description: "Agent 主动采集 /proc 指标,默认 10s 上报",
Metrics: []MetricCatalogItem{
{Key: "cpu", Label: "CPU 使用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 80, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "agent", Description: "整体 CPU 占用百分比", Operators: []string{">", ">=", "<", "<="}},
{Key: "cpu_iowait", Label: "CPU IO 等待", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 20, DefaultCrit: 40, Min: 0, Max: 100, CollectSource: "agent", Description: "iowait 占比过高表示磁盘瓶颈", Operators: []string{">", ">="}},
{Key: "mem", Label: "内存使用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 80, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "agent", Description: "物理内存占用百分比", Operators: []string{">", ">=", "<", "<="}},
{Key: "mem_available", Label: "可用内存", Unit: "GB", Type: "gauge", DefaultOp: "<", DefaultWarn: 1, DefaultCrit: 0.5, Min: 0, Max: 1024, CollectSource: "agent", Description: "剩余可用内存低于阈值告警", Operators: []string{"<", "<="}},
{Key: "swap", Label: "Swap 使用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 50, DefaultCrit: 80, Min: 0, Max: 100, CollectSource: "agent", Description: "Swap 使用过高影响性能", Operators: []string{">", ">="}},
{Key: "disk", Label: "磁盘使用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 85, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "agent", Description: "各挂载点最大使用率", Operators: []string{">", ">="}},
{Key: "disk_inode", Label: "Inode 使用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 80, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "agent", Description: "Inode 耗尽导致无法创建文件", Operators: []string{">", ">="}},
{Key: "disk_io_util", Label: "磁盘 IO 利用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 80, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "agent", Description: "磁盘繁忙程度", Operators: []string{">", ">="}},
{Key: "load1", Label: "1分钟负载", Unit: "", Type: "gauge", DefaultOp: ">", DefaultWarn: 4, DefaultCrit: 8, Min: 0, Max: 256, CollectSource: "agent", Description: "系统 1 分钟平均负载", Operators: []string{">", ">="}},
{Key: "net_rx_bps", Label: "入网带宽", Unit: "B/s", Type: "gauge", DefaultOp: ">", DefaultWarn: 104857600, DefaultCrit: 524288000, Min: 0, Max: 1e12, CollectSource: "agent", Description: "接收字节速率", Operators: []string{">", ">="}},
{Key: "net_tx_bps", Label: "出网带宽", Unit: "B/s", Type: "gauge", DefaultOp: ">", DefaultWarn: 104857600, DefaultCrit: 524288000, Min: 0, Max: 1e12, CollectSource: "agent", Description: "发送字节速率", Operators: []string{">", ">="}},
{Key: "net_rx_errors", Label: "入网错误", Unit: "次", Type: "counter", DefaultOp: ">", DefaultWarn: 10, DefaultCrit: 100, Min: 0, Max: 1e9, CollectSource: "agent", Description: "网卡接收错误累计", Operators: []string{">", ">="}},
{Key: "tcp_established", Label: "TCP 已建立连接", Unit: "个", Type: "gauge", DefaultOp: ">", DefaultWarn: 5000, DefaultCrit: 20000, Min: 0, Max: 1e6, CollectSource: "agent", Description: "ESTABLISHED 连接数", Operators: []string{">", ">="}},
{Key: "tcp_time_wait", Label: "TCP TIME_WAIT", Unit: "个", Type: "gauge", DefaultOp: ">", DefaultWarn: 15000, DefaultCrit: 30000, Min: 0, Max: 1e6, CollectSource: "agent", Description: "TIME_WAIT 过多可能端口耗尽", Operators: []string{">", ">="}},
{Key: "tcp_close_wait", Label: "TCP CLOSE_WAIT", Unit: "个", Type: "gauge", DefaultOp: ">", DefaultWarn: 200, DefaultCrit: 1000, Min: 0, Max: 1e6, CollectSource: "agent", Description: "CLOSE_WAIT 泄漏检测", Operators: []string{">", ">="}},
{Key: "fd", Label: "文件描述符使用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 80, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "agent", Description: "进程 FD 占用/system limit", Operators: []string{">", ">="}},
{Key: "offline", Label: "主机离线", Unit: "分钟", Type: "status", DefaultOp: ">", DefaultWarn: 3, DefaultCrit: 5, Min: 0, Max: 1440, CollectSource: "agent", Description: "超过 N 分钟无上报", Operators: []string{">", ">="}},
{Key: "port_down", Label: "端口不通", Unit: "", Type: "status", DefaultOp: "==", DefaultWarn: 0, DefaultCrit: 0, Min: 0, Max: 65535, CollectSource: "agent", Description: "监听端口消失", Operators: []string{"=="}},
{Key: "health_probe", Label: "健康探针失败", Unit: "", Type: "status", DefaultOp: "==", DefaultWarn: 1, DefaultCrit: 1, Min: 0, Max: 1, CollectSource: "agent", Description: "HTTP/TCP/Exec 探针失败", Operators: []string{"=="}},
},
},
{
ID: "middleware", Label: "中间件监控", Description: "平台直连 Redis/MySQL 采集,默认 30s",
Metrics: []MetricCatalogItem{
{Key: "redis_connected_clients", Label: "Redis 连接数", Unit: "个", Type: "gauge", DefaultOp: ">", DefaultWarn: 500, DefaultCrit: 2000, Min: 0, Max: 1e5, CollectSource: "middleware", Description: "当前客户端连接数", Operators: []string{">", ">="}},
{Key: "redis_used_memory", Label: "Redis 内存", Unit: "bytes", Type: "gauge", DefaultOp: ">", DefaultWarn: 536870912, DefaultCrit: 1073741824, Min: 0, Max: 1e12, CollectSource: "middleware", Description: "已用内存字节数", Operators: []string{">", ">="}},
{Key: "redis_hit_rate", Label: "Redis 命中率", Unit: "ratio", Type: "gauge", DefaultOp: "<", DefaultWarn: 0.9, DefaultCrit: 0.8, Min: 0, Max: 1, CollectSource: "middleware", Description: "keyspace 命中率 (0~1)", Operators: []string{"<", "<="}},
{Key: "redis_qps", Label: "Redis QPS", Unit: "ops/s", Type: "gauge", DefaultOp: ">", DefaultWarn: 10000, DefaultCrit: 50000, Min: 0, Max: 1e7, CollectSource: "middleware", Description: "每秒命令数", Operators: []string{">", ">="}},
{Key: "redis_blocked_clients", Label: "Redis 阻塞客户端", Unit: "个", Type: "gauge", DefaultOp: ">", DefaultWarn: 1, DefaultCrit: 10, Min: 0, Max: 1e4, CollectSource: "middleware", Description: "BLPOP 等阻塞连接", Operators: []string{">", ">="}},
{Key: "redis_evicted_keys", Label: "Redis 驱逐键", Unit: "个", Type: "counter", DefaultOp: ">", DefaultWarn: 100, DefaultCrit: 1000, Min: 0, Max: 1e12, CollectSource: "middleware", Description: "内存不足驱逐 key 数", Operators: []string{">", ">="}},
{Key: "mysql_connections", Label: "MySQL 连接数", Unit: "个", Type: "gauge", DefaultOp: ">", DefaultWarn: 200, DefaultCrit: 500, Min: 0, Max: 1e4, CollectSource: "middleware", Description: "Threads_connected", Operators: []string{">", ">="}},
{Key: "mysql_threads_running", Label: "MySQL 活跃线程", Unit: "个", Type: "gauge", DefaultOp: ">", DefaultWarn: 20, DefaultCrit: 50, Min: 0, Max: 1e4, CollectSource: "middleware", Description: "正在执行的线程", Operators: []string{">", ">="}},
{Key: "mysql_slow_queries", Label: "MySQL 慢查询", Unit: "次", Type: "counter", DefaultOp: ">", DefaultWarn: 10, DefaultCrit: 100, Min: 0, Max: 1e12, CollectSource: "middleware", Description: "慢查询累计数", Operators: []string{">", ">="}},
{Key: "mysql_buffer_hit_rate", Label: "MySQL 缓冲命中率", Unit: "ratio", Type: "gauge", DefaultOp: "<", DefaultWarn: 0.95, DefaultCrit: 0.9, Min: 0, Max: 1, CollectSource: "middleware", Description: "InnoDB buffer pool 命中率 (0~1)", Operators: []string{"<", "<="}},
{Key: "mysql_qps", Label: "MySQL QPS", Unit: "ops/s", Type: "gauge", DefaultOp: ">", DefaultWarn: 5000, DefaultCrit: 20000, Min: 0, Max: 1e7, CollectSource: "middleware", Description: "每秒查询数", Operators: []string{">", ">="}},
{Key: "middleware_offline", Label: "中间件离线", Unit: "", Type: "status", DefaultOp: "==", DefaultWarn: 1, DefaultCrit: 1, Min: 0, Max: 1, CollectSource: "middleware", Description: "采集失败或不可达", Operators: []string{"=="}},
},
},
{
ID: "snmp", Label: "SNMP 监控", Description: "网络设备 / BMC 采集,默认 5 分钟周期",
Metrics: []MetricCatalogItem{
{Key: "snmp_cpu_usage", Label: "CPU 使用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 80, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "snmp", Description: "各厂商 CPU 利用率最大值", Operators: []string{">", ">="}},
{Key: "snmp_mem_usage", Label: "内存使用率", Unit: "%", Type: "gauge", DefaultOp: ">", DefaultWarn: 85, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "snmp", Description: "各厂商内存利用率最大值", Operators: []string{">", ">="}},
{Key: "snmp_temperature", Label: "设备温度", Unit: "°C", Type: "gauge", DefaultOp: ">", DefaultWarn: 65, DefaultCrit: 80, Min: 0, Max: 120, CollectSource: "snmp", Description: "温度传感器读数最大值", Operators: []string{">", ">="}},
{Key: "snmp_if_in_bps", Label: "入方向流量", Unit: "bps", Type: "gauge", DefaultOp: ">", DefaultWarn: 1e9, DefaultCrit: 5e9, Min: 0, Max: 1e12, CollectSource: "snmp", Description: "接口入方向比特速率", Operators: []string{">", ">="}},
{Key: "snmp_if_out_bps", Label: "出方向流量", Unit: "bps", Type: "gauge", DefaultOp: ">", DefaultWarn: 1e9, DefaultCrit: 5e9, Min: 0, Max: 1e12, CollectSource: "snmp", Description: "接口出方向比特速率", Operators: []string{">", ">="}},
{Key: "snmp_offline", Label: "设备离线", Unit: "", Type: "status", DefaultOp: "==", DefaultWarn: 1, DefaultCrit: 1, Min: 0, Max: 1, CollectSource: "snmp", Description: "设备离线或采集异常", Operators: []string{"=="}},
},
},
{
ID: "site", Label: "站点拨测", Description: "HTTP/SSL/TCP/DNS/PING 主动拨测",
Metrics: []MetricCatalogItem{
{Key: "probe_fail", Label: "拨测失败", Unit: "", Type: "status", DefaultOp: "==", DefaultWarn: 1, DefaultCrit: 1, Min: 0, Max: 1, CollectSource: "site", Description: "连续拨测失败", Operators: []string{"=="}},
{Key: "probe_timeout", Label: "拨测超时", Unit: "", Type: "status", DefaultOp: "==", DefaultWarn: 1, DefaultCrit: 1, Min: 0, Max: 1, CollectSource: "site", Description: "请求超时", Operators: []string{"=="}},
{Key: "response_time", Label: "响应延迟", Unit: "ms", Type: "gauge", DefaultOp: ">", DefaultWarn: 500, DefaultCrit: 2000, Min: 0, Max: 60000, CollectSource: "site", Description: "拨测响应时间", Operators: []string{">", ">="}},
{Key: "availability", Label: "可用率", Unit: "%", Type: "gauge", DefaultOp: "<", DefaultWarn: 99, DefaultCrit: 95, Min: 0, Max: 100, CollectSource: "site", Description: "近 1h 成功率", Operators: []string{"<", "<="}},
{Key: "http_status_error", Label: "HTTP 状态码异常", Unit: "", Type: "status", DefaultOp: "==", DefaultWarn: 1, DefaultCrit: 1, Min: 0, Max: 1, CollectSource: "site", Description: "非期望状态码", Operators: []string{"=="}},
{Key: "ssl_expiry_days", Label: "SSL 证书剩余天数", Unit: "天", Type: "gauge", DefaultOp: "<", DefaultWarn: 30, DefaultCrit: 7, Min: -365, Max: 365, CollectSource: "site", Description: "SSL 协议拨测证书即将过期", Operators: []string{"<", "<="}},
},
},
}
// GetMetricCatalog 返回监控指标目录
// GET /api/v1/alerts/metric-catalog
func (h *AlertHandler) GetMetricCatalog(c *gin.Context) {
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"categories": metricCatalog}})
}
+181
View File
@@ -0,0 +1,181 @@
package controller
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"ezops/model"
"gorm.io/gorm"
)
// dispatchCategoryAlert 按告警集合策略发送告警通知 // Logan.Li
func dispatchCategoryAlert(db *gorm.DB, category string, ruleName, hostname, metric, severity, message string, value, threshold float64) {
var policy model.AlertNotifyPolicy
if err := db.Where("category = ? AND enabled = ?", category, true).First(&policy).Error; err != nil {
fallbackNotifyChannels(db, ruleName, hostname, metric, severity, message, value, threshold)
return
}
title := buildNotifyTitle(db, ruleName, hostname, metric, severity)
content := buildAlertContent(ruleName, hostname, metric, severity, message, value, threshold)
sent, _ := dispatchToPolicyContacts(db, &policy, title, content, severity, false)
if sent == 0 {
fallbackNotifyChannels(db, ruleName, hostname, metric, severity, message, value, threshold)
}
}
// dispatchCategoryRecovery 按告警集合策略发送恢复通知 // Logan.Li
func dispatchCategoryRecovery(db *gorm.DB, category, ruleName, hostname, metric, message string) {
var policy model.AlertNotifyPolicy
if err := db.Where("category = ? AND enabled = ?", category, true).First(&policy).Error; err != nil {
fallbackNotifyRecovery(db, ruleName, hostname, metric, message)
return
}
title := fmt.Sprintf("%s — 已恢复正常", ruleName)
content := fmt.Sprintf("**告警已恢复**\\n\\n目标:%s\\n指标:%s\\n详情:%s\\n恢复时间:%s",
hostname, metric, message, time.Now().Format("2006-01-02 15:04:05"))
sent, _ := dispatchToPolicyContacts(db, &policy, title, content, "info", true)
if sent == 0 {
fallbackNotifyRecovery(db, ruleName, hostname, metric, message)
}
}
// resolvePolicyContactIDs 展开策略中的联系组与直接联系人 // Logan.Li
func resolvePolicyContactIDs(db *gorm.DB, policy *model.AlertNotifyPolicy) []uint {
idSet := make(map[uint]struct{})
var groupIDs []uint
if policy.ContactGroupIDs != "" && policy.ContactGroupIDs != "null" {
_ = json.Unmarshal([]byte(policy.ContactGroupIDs), &groupIDs)
}
for _, gid := range groupIDs {
var g model.AlertContactGroup
if db.First(&g, gid).Error != nil {
continue
}
var cids []uint
if g.ContactIDs != "" && g.ContactIDs != "null" {
_ = json.Unmarshal([]byte(g.ContactIDs), &cids)
}
for _, cid := range cids {
idSet[cid] = struct{}{}
}
}
var directIDs []uint
if policy.ContactIDs != "" && policy.ContactIDs != "null" {
_ = json.Unmarshal([]byte(policy.ContactIDs), &directIDs)
}
for _, cid := range directIDs {
idSet[cid] = struct{}{}
}
ids := make([]uint, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
return ids
}
// dispatchToPolicyContacts 向策略解析出的联系人发送通知 // Logan.Li
func dispatchToPolicyContacts(db *gorm.DB, policy *model.AlertNotifyPolicy, title, content, severity string, isRecovery bool) (sent, skipped int) {
if policy == nil || !policy.Enabled {
return 0, 1
}
contactIDs := resolvePolicyContactIDs(db, policy)
if len(contactIDs) == 0 {
return 0, 1
}
for _, cid := range contactIDs {
var contact model.AlertContact
if db.First(&contact, cid).Error != nil || contact.WebhookURL == "" {
skipped++
continue
}
var err error
switch contact.ChannelType {
case "feishu", "":
if isRecovery {
err = sendFeishuRecovery(db, feishuConfigFromURL(contact.WebhookURL), title, content)
} else {
err = sendFeishuAlert(db, feishuConfigFromURL(contact.WebhookURL), title, content, severity)
}
case "dingtalk", "wechat", "webhook":
err = sendGenericWebhook(contact.WebhookURL, title, content)
default:
err = sendGenericWebhook(contact.WebhookURL, title, content)
}
if err != nil {
fmt.Printf("[AlertNotify] 通知失败 (联系人:%s): %v\n", contact.Name, err)
skipped++
} else {
sent++
}
}
return sent, skipped
}
// feishuConfigFromURL 联系人 webhook 转飞书配置 JSON // Logan.Li
func feishuConfigFromURL(url string) string {
b, _ := json.Marshal(model.FeishuWebhookConfig{URL: url})
return string(b)
}
// sendGenericWebhook 钉钉/企微/通用 Webhook 文本通知 // Logan.Li
func sendGenericWebhook(webhookURL, title, content string) error {
body := map[string]interface{}{
"msgtype": "text",
"text": map[string]string{
"content": title + "\n" + strings.ReplaceAll(content, "\\n", "\n"),
},
}
raw, _ := json.Marshal(body)
req, err := http.NewRequest("POST", webhookURL, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(b))
}
return nil
}
// fallbackNotifyChannels 无策略时回退到全局 NotifyChannel // Logan.Li
func fallbackNotifyChannels(db *gorm.DB, ruleName, hostname, metric, severity, message string, value, threshold float64) {
var channels []model.NotifyChannel
db.Where("enabled = ? AND type = ?", true, "feishu").Find(&channels)
title := buildNotifyTitle(db, ruleName, hostname, metric, severity)
content := buildAlertContent(ruleName, hostname, metric, severity, message, value, threshold)
for _, ch := range channels {
if err := sendFeishuAlert(db, ch.Config, title, content, severity); err != nil {
fmt.Printf("[AlertNotify] 飞书回退通知失败 (渠道:%s): %v\n", ch.Name, err)
}
}
}
func fallbackNotifyRecovery(db *gorm.DB, ruleName, hostname, metric, message string) {
var channels []model.NotifyChannel
db.Where("enabled = ? AND type = ?", true, "feishu").Find(&channels)
title := fmt.Sprintf("%s — 已恢复正常", ruleName)
content := fmt.Sprintf("**告警已恢复**\\n\\n主机:%s\\n指标:%s\\n详情:%s\\n恢复时间:%s",
hostname, metric, message, time.Now().Format("2006-01-02 15:04:05"))
for _, ch := range channels {
if err := sendFeishuRecovery(db, ch.Config, title, content); err != nil {
fmt.Printf("[AlertNotify] 飞书恢复回退失败 (渠道:%s): %v\n", ch.Name, err)
}
}
}
+137
View File
@@ -0,0 +1,137 @@
package controller
import (
"fmt"
"net/http"
"strings"
"time"
"ezops/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
defaultNotifyTitleTemplate = "[{{severity_label}}] {{rule_name}} — {{hostname}} ({{metric}})"
defaultNotifyFooterTemplate = "{{time}} | {{platform}}"
defaultNotifyPlatformName = "AIOps 智能运维平台"
)
// ensureNotifySettings 确保全局消息模版存在 // Logan.Li
func ensureNotifySettings(db *gorm.DB) model.AlertNotifySettings {
var s model.AlertNotifySettings
if err := db.First(&s).Error; err != nil {
s = model.AlertNotifySettings{
TitleTemplate: defaultNotifyTitleTemplate,
FooterTemplate: defaultNotifyFooterTemplate,
PlatformName: defaultNotifyPlatformName,
}
db.Create(&s)
}
if s.PlatformName == "" {
s.PlatformName = defaultNotifyPlatformName
}
return s
}
// GetNotifyMessageSettings GET /alerts/notify-message-settings // Logan.Li
func (h *AlertHandler) GetNotifyMessageSettings(c *gin.Context) {
s := ensureNotifySettings(h.db)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: s})
}
// SaveNotifyMessageSettings PUT /alerts/notify-message-settings // Logan.Li
func (h *AlertHandler) SaveNotifyMessageSettings(c *gin.Context) {
var req struct {
TitleTemplate string `json:"title_template"`
FooterTemplate string `json:"footer_template"`
PlatformName string `json:"platform_name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
return
}
s := ensureNotifySettings(h.db)
updates := map[string]interface{}{
"title_template": strings.TrimSpace(req.TitleTemplate),
"footer_template": strings.TrimSpace(req.FooterTemplate),
"platform_name": strings.TrimSpace(req.PlatformName),
}
if updates["platform_name"] == "" {
updates["platform_name"] = defaultNotifyPlatformName
}
h.db.Model(&s).Updates(updates)
h.db.First(&s, s.ID)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: s, Message: "消息模版已保存"})
}
// notifyTemplateVars 告警通知模版变量 // Logan.Li
func notifyTemplateVars(ruleName, hostname, metric, severity, message string, value, threshold float64, platform string) map[string]string {
sevLabel := "严重告警"
switch severity {
case "warning":
sevLabel = "警告"
case "info":
sevLabel = "信息"
}
if platform == "" {
platform = defaultNotifyPlatformName
}
return map[string]string{
"time": time.Now().Format("2006-01-02 15:04:05"),
"platform": platform,
"rule_name": ruleName,
"hostname": hostname,
"metric": metric,
"severity": severity,
"severity_label": sevLabel,
"message": message,
"value": fmt.Sprintf("%.2f", value),
"threshold": fmt.Sprintf("%.2f", threshold),
}
}
// renderNotifyTemplate 简单变量替换 {{key}} // Logan.Li
func renderNotifyTemplate(tpl string, vars map[string]string) string {
out := tpl
for k, v := range vars {
out = strings.ReplaceAll(out, "{{"+k+"}}", v)
}
return out
}
// buildNotifyTitle 按全局模版生成通知标题 // Logan.Li
func buildNotifyTitle(db *gorm.DB, ruleName, hostname, metric, severity string) string {
s := ensureNotifySettings(db)
tpl := s.TitleTemplate
if tpl == "" {
tpl = defaultNotifyTitleTemplate
}
return renderNotifyTemplate(tpl, notifyTemplateVars(ruleName, hostname, metric, severity, "", 0, 0, s.PlatformName))
}
// buildNotifyFooter 按全局模版生成卡片底部 // Logan.Li
func buildNotifyFooter(db *gorm.DB) string {
s := ensureNotifySettings(db)
tpl := s.FooterTemplate
if tpl == "" {
tpl = defaultNotifyFooterTemplate
}
vars := map[string]string{
"time": time.Now().Format("2006-01-02 15:04:05"),
"platform": s.PlatformName,
}
if vars["platform"] == "" {
vars["platform"] = defaultNotifyPlatformName
}
return renderNotifyTemplate(tpl, vars)
}
// buildAlertContent 生成告警正文(字段列表,供飞书卡片解析) // Logan.Li
func buildAlertContent(ruleName, hostname, metric, severity, message string, value, threshold float64) string {
return fmt.Sprintf(
"**规则名称:** %s\\n**目标主机:** %s\\n**指标类型:** %s\\n**告警级别:** %s\\n**当前值:** %.2f\\n**阈值:** %.2f\\n**详细信息:** %s\\n**触发时间:** %s",
ruleName, hostname, metric, severity, value, threshold, message, time.Now().Format("2006-01-02 15:04:05"),
)
}
+129
View File
@@ -0,0 +1,129 @@
package controller
import (
"encoding/json"
"net/http"
"strings"
"ezops/model"
"github.com/gin-gonic/gin"
)
// categoryLabels 告警集合中文名 // Logan.Li
var categoryLabels = map[model.AlertNotifyCategory]string{
model.AlertNotifyHost: "主机监控",
model.AlertNotifyMiddleware: "中间件监控",
model.AlertNotifySite: "站点拨测",
model.AlertNotifySNMP: "SNMP 监控",
}
// ensureNotifyPolicies 确保四类默认策略存在 // Logan.Li
func (h *AlertHandler) ensureNotifyPolicies() {
for _, cat := range model.AllAlertNotifyCategories {
var p model.AlertNotifyPolicy
h.db.Where("category = ?", cat).First(&p)
if p.ID == 0 {
h.db.Create(&model.AlertNotifyPolicy{
Category: cat,
Label: categoryLabels[cat],
Enabled: false,
})
}
}
}
// ListNotifyPolicies GET /alerts/notify-policies
func (h *AlertHandler) ListNotifyPolicies(c *gin.Context) {
h.ensureNotifyPolicies()
var policies []model.AlertNotifyPolicy
h.db.Order("id ASC").Find(&policies)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": policies, "total": len(policies)}})
}
// UpsertNotifyPolicy PUT /alerts/notify-policies/:category
func (h *AlertHandler) UpsertNotifyPolicy(c *gin.Context) {
cat := model.AlertNotifyCategory(strings.TrimSpace(c.Param("category")))
if !isValidNotifyCategory(cat) {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的告警集合类型"})
return
}
var req struct {
Enabled *bool `json:"enabled"`
ContactGroupIDs []uint `json:"contact_group_ids"`
ContactIDs []uint `json:"contact_ids"`
NotifyMode string `json:"notify_mode"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
return
}
h.ensureNotifyPolicies()
var policy model.AlertNotifyPolicy
h.db.Where("category = ?", cat).First(&policy)
updates := map[string]interface{}{}
if req.Enabled != nil {
updates["enabled"] = *req.Enabled
}
if req.ContactGroupIDs != nil {
b, _ := json.Marshal(req.ContactGroupIDs)
updates["contact_group_ids"] = string(b)
}
if req.ContactIDs != nil {
b, _ := json.Marshal(req.ContactIDs)
updates["contact_ids"] = string(b)
}
if req.NotifyMode != "" {
updates["notify_mode"] = req.NotifyMode
}
if req.Description != "" || c.Request.ContentLength > 0 {
updates["description"] = req.Description
}
if len(updates) > 0 {
h.db.Model(&policy).Updates(updates)
}
h.db.Where("category = ?", cat).First(&policy)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: policy})
}
// TestNotifyPolicy POST /alerts/notify-policies/:category/test
func (h *AlertHandler) TestNotifyPolicy(c *gin.Context) {
cat := model.AlertNotifyCategory(strings.TrimSpace(c.Param("category")))
if !isValidNotifyCategory(cat) {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的告警集合类型"})
return
}
h.ensureNotifyPolicies()
var policy model.AlertNotifyPolicy
h.db.Where("category = ?", cat).First(&policy)
label := categoryLabels[cat]
title := buildNotifyTitle(h.db, "通知策略测试", label, "test", "info")
content := buildAlertContent("通知策略测试", label, "test", "info",
"这是一条测试通知,用于验证该告警集合的通知对象配置是否正确。", 0, 0)
sent, skipped := dispatchToPolicyContacts(h.db, &policy, title, content, "info", true)
if sent == 0 {
msg := "未发送任何通知"
if skipped > 0 {
msg += "(策略未启用或未配置联系人)"
}
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: msg})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"sent": sent, "skipped": skipped}, Message: "测试通知已发送"})
}
func isValidNotifyCategory(cat model.AlertNotifyCategory) bool {
for _, c := range model.AllAlertNotifyCategories {
if c == cat {
return true
}
}
return false
}
+215
View File
@@ -0,0 +1,215 @@
package controller
import (
"fmt"
"net/http"
"strconv"
"time"
"ezops/model"
"github.com/gin-gonic/gin"
)
// UnifiedAlertItem 统一告警事件视图 // Logan.Li
type UnifiedAlertItem struct {
ID string `json:"id"`
SourceType string `json:"source_type"`
RuleName string `json:"rule_name"`
Title string `json:"title"`
Message string `json:"message"`
Severity string `json:"severity"`
Status string `json:"status"`
Target string `json:"target"`
Metric string `json:"metric"`
Value float64 `json:"value"`
Threshold float64 `json:"threshold"`
StartedAt string `json:"started_at"`
ResolvedAt *string `json:"resolved_at,omitempty"`
}
// ListUnifiedAlerts 统一告警列表(主机 + 中间件 + 站点 + 日志)
// GET /api/v1/alerts/unified
func (h *AlertHandler) ListUnifiedAlerts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if pageSize > 100 {
pageSize = 100
}
statusFilter := c.Query("status")
severityFilter := c.Query("severity")
sourceFilter := c.Query("source_type")
items := make([]UnifiedAlertItem, 0)
// 1. Agent 主机/中间件/SNMP 告警(中间件与 SNMP 复用 AgentAlert 存储)
if sourceFilter == "" || sourceFilter == "host" || sourceFilter == "middleware" || sourceFilter == "snmp" {
var alerts []model.AgentAlert
q := h.db.Model(&model.AgentAlert{}).Order("created_at DESC").Limit(200)
if statusFilter == "firing" || statusFilter == "" {
if statusFilter == "firing" {
q = q.Where("resolved = ?", false)
}
} else if statusFilter == "resolved" {
q = q.Where("resolved = ?", true)
}
if severityFilter != "" {
q = q.Where("severity = ?", severityFilter)
}
q.Find(&alerts)
for _, a := range alerts {
st := "firing"
if a.Resolved {
st = "resolved"
}
srcType := "host"
if isSNMPMetric(a.Metric) {
srcType = "snmp"
} else if isMiddlewareMetric(a.Metric) {
srcType = "middleware"
}
if sourceFilter == "host" && srcType != "host" {
continue
}
if sourceFilter == "middleware" && srcType != "middleware" {
continue
}
if sourceFilter == "snmp" && srcType != "snmp" {
continue
}
var resolved *string
if a.ResolvedAt != nil {
s := a.ResolvedAt.Format(time.RFC3339)
resolved = &s
}
items = append(items, UnifiedAlertItem{
ID: "agent-" + strconv.Itoa(int(a.ID)), SourceType: srcType,
RuleName: a.RuleName, Title: a.RuleName, Message: a.Message,
Severity: a.Severity, Status: st, Target: a.Hostname,
Metric: a.Metric, Value: a.Value, Threshold: a.Threshold,
StartedAt: a.CreatedAt.Format(time.RFC3339), ResolvedAt: resolved,
})
}
}
// 2. 站点拨测告警
if sourceFilter == "" || sourceFilter == "site" {
var alerts []model.SiteAlert
q := h.db.Model(&model.SiteAlert{}).Preload("Task").Order("started_at DESC").Limit(200)
if statusFilter == "firing" {
q = q.Where("status = ?", model.SiteAlertStatusActive)
} else if statusFilter == "resolved" {
q = q.Where("status = ?", model.SiteAlertStatusResolved)
}
if severityFilter != "" {
q = q.Where("severity = ?", severityFilter)
}
q.Find(&alerts)
for _, a := range alerts {
st := "firing"
if a.Status == model.SiteAlertStatusResolved {
st = "resolved"
}
target := fmt.Sprintf("task-%d", a.TaskID)
if a.Task != nil {
target = a.Task.Name
}
var resolved *string
if a.ResolvedAt != nil {
s := a.ResolvedAt.Format(time.RFC3339)
resolved = &s
}
items = append(items, UnifiedAlertItem{
ID: "site-" + strconv.Itoa(int(a.ID)), SourceType: "site",
RuleName: a.Title, Title: a.Title, Message: a.Message,
Severity: string(a.Severity), Status: st, Target: target,
Metric: a.TriggerType, Value: a.ResponseTime, Threshold: a.Threshold,
StartedAt: a.StartedAt.Format(time.RFC3339), ResolvedAt: resolved,
})
}
}
// 3. 日志/通用 AlertEvent
if sourceFilter == "" || sourceFilter == "log" {
var events []model.AlertEvent
q := h.db.Model(&model.AlertEvent{}).Order("started_at DESC").Limit(200)
if statusFilter != "" && statusFilter != "all" {
q = q.Where("status = ?", statusFilter)
}
if severityFilter != "" {
q = q.Where("severity = ?", severityFilter)
}
q.Find(&events)
for _, e := range events {
var resolved *string
if e.ResolvedAt != nil {
s := e.ResolvedAt.Format(time.RFC3339)
resolved = &s
}
items = append(items, UnifiedAlertItem{
ID: "event-" + strconv.Itoa(int(e.ID)), SourceType: "log",
RuleName: e.RuleName, Title: e.RuleName, Message: e.Message,
Severity: e.Severity, Status: e.Status, Target: e.Target,
Metric: "log", Value: e.Value, Threshold: e.Threshold,
StartedAt: e.StartedAt.Format(time.RFC3339), ResolvedAt: resolved,
})
}
}
// 按时间排序
sortUnifiedAlerts(items)
total := len(items)
start := (page - 1) * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
pageItems := items[start:end]
// 统计
stats := gin.H{
"firing": countStatus(items, "firing"),
"critical": countSeverityStatus(items, "critical", "firing"),
"warning": countSeverityStatus(items, "warning", "firing"),
"resolved": countStatus(items, "resolved"),
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Data: gin.H{"list": pageItems, "total": total, "stats": stats},
})
}
func sortUnifiedAlerts(items []UnifiedAlertItem) {
for i := 0; i < len(items); i++ {
for j := i + 1; j < len(items); j++ {
if items[j].StartedAt > items[i].StartedAt {
items[i], items[j] = items[j], items[i]
}
}
}
}
func countStatus(items []UnifiedAlertItem, status string) int {
n := 0
for _, it := range items {
if it.Status == status {
n++
}
}
return n
}
func countSeverityStatus(items []UnifiedAlertItem, sev, status string) int {
n := 0
for _, it := range items {
if it.Severity == sev && it.Status == status {
n++
}
}
return n
}
+121
View File
@@ -0,0 +1,121 @@
package controller
import (
"fmt"
"net/http"
"time"
"ezops/model"
"ezops/pkg/auditcleaner"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AuditSettingsHandler 审计策略配置 // Logan.Li
type AuditSettingsHandler struct {
db *gorm.DB
}
func NewAuditSettingsHandler(db *gorm.DB) *AuditSettingsHandler {
return &AuditSettingsHandler{db: db}
}
func (h *AuditSettingsHandler) getOrCreate() (model.AuditSettings, error) {
var s model.AuditSettings
err := h.db.First(&s).Error
if err == nil {
return s, nil
}
if err != gorm.ErrRecordNotFound {
return s, err
}
s = model.AuditSettings{RetentionDays: 180, RecordAll: true}
return s, h.db.Create(&s).Error
}
// GetAuditSettings 获取审计策略 // Logan.Li
// GET /api/v1/admin/audit-settings
func (h *AuditSettingsHandler) GetAuditSettings(c *gin.Context) {
s, err := h.getOrCreate()
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取配置失败"})
return
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
Data: gin.H{
"retention_days": s.RetentionDays,
"record_all": s.RecordAll,
"last_cleanup_at": s.LastCleanupAt,
"last_cleanup_summary": s.LastCleanupSummary,
"allowed_retention_days": model.AllowedAuditRetentionDays,
},
})
}
// ApplyAuditSettings 应用审计策略并立即清理超期日志 // Logan.Li
// PUT /api/v1/admin/audit-settings
func (h *AuditSettingsHandler) ApplyAuditSettings(c *gin.Context) {
var req struct {
RetentionDays int `json:"retention_days" binding:"required"`
RecordAll bool `json:"record_all"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误"})
return
}
if !model.IsValidRetentionDays(req.RetentionDays) {
c.JSON(http.StatusBadRequest, model.Response{
Code: 400,
Message: "保留天数仅支持 7 / 15 / 30 / 90 / 180 / 365",
})
return
}
s, err := h.getOrCreate()
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取配置失败"})
return
}
s.RetentionDays = req.RetentionDays
s.RecordAll = req.RecordAll
cleanResult, cleanErr := auditcleaner.Run(h.db, req.RetentionDays)
now := time.Now()
s.LastCleanupAt = &now
s.LastCleanupSummary = cleanResult.SummaryJSON()
if err := h.db.Save(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存配置失败"})
return
}
msg := fmt.Sprintf("已应用保留 %d 天策略", req.RetentionDays)
if cleanErr != nil {
c.JSON(http.StatusInternalServerError, model.Response{
Code: 500,
Message: "配置已保存,但立即清理失败: " + cleanErr.Error(),
})
return
}
if cleanResult.Total > 0 {
msg += fmt.Sprintf(",已清理 %d 条超期记录", cleanResult.Total)
} else {
msg += ",暂无超期记录需清理"
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: msg,
Data: gin.H{
"retention_days": s.RetentionDays,
"record_all": s.RecordAll,
"last_cleanup_at": s.LastCleanupAt,
"last_cleanup_summary": s.LastCleanupSummary,
"cleanup": cleanResult,
},
})
}
+5 -44
View File
@@ -1,14 +1,13 @@
package controller
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"os/exec"
"time"
"ezops/pkg/scriptrunner"
"gorm.io/gorm"
)
@@ -21,48 +20,10 @@ func randomCode(prefix string) string {
return fmt.Sprintf("%s-%d-%s", prefix, time.Now().Unix(), hex.EncodeToString(b))
}
// runLocal executes a script or shell command locally on the AIOps host and
// returns the combined stdout+stderr output, the process exit code (0 = success)
// and the wall-clock duration in milliseconds. A timeout prevents runaway jobs.
// runLocal 委托 scriptrunner 在本机执行脚本(含运行时预检) // Logan.Li
func runLocal(scriptType, content string, timeoutSec int) (output string, exitCode int, durationMs int) {
start := time.Now()
timeout := time.Duration(timeoutSec) * time.Second
if timeout <= 0 {
timeout = 300 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var cmd *exec.Cmd
switch scriptType {
case "python":
cmd = exec.CommandContext(ctx, "python3", "-c", content)
default: // shell / command / "" → run via bash
cmd = exec.CommandContext(ctx, "bash", "-c", content)
}
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
durationMs = int(time.Since(start).Milliseconds())
output = out.String()
if ctx.Err() == context.DeadlineExceeded {
output += fmt.Sprintf("\n[超时] 执行超过 %d 秒被终止", timeoutSec)
return output, -1, durationMs
}
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
exitCode = exitError.ExitCode()
} else {
exitCode = -1
output += "\n" + err.Error()
}
return output, exitCode, durationMs
}
return output, 0, durationMs
output, exitCode, durationMs, _ = scriptrunner.Run(scriptType, content, timeoutSec)
return output, exitCode, durationMs
}
// AutomationHandler 自动化运维处理器
+4 -104
View File
@@ -1,13 +1,10 @@
package controller
import (
"fmt"
"net/http"
"strconv"
"time"
"ezops/model"
"ezops/pkg/remoteexec"
"github.com/gin-gonic/gin"
)
@@ -294,113 +291,16 @@ func (h *AutomationHandler) ToggleCronJob(c *gin.Context) {
// @Router /cron-jobs/{id}/execute [post]
func (h *AutomationHandler) ExecuteCronJob(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var job model.CronJob
if err := h.db.First(&job, id).Error; err != nil {
execution, err := RunCronJobByID(h.db, uint(id))
if err != nil {
c.JSON(http.StatusNotFound, model.Response{
Code: 404,
Message: "定时任务不存在",
Message: err.Error(),
})
return
}
// Resolve the content to execute. For type=script, load the linked Script.
content := job.Content
scriptType := job.Type // shell | python | command | script
if scriptType == "command" {
scriptType = "shell" // one-liner commands run via bash
}
if job.Type == "script" && job.ScriptID != nil {
var script model.Script
if err := h.db.First(&script, *job.ScriptID).Error; err == nil {
content = script.Content
scriptType = script.Type
h.db.Model(&script).UpdateColumn("run_count", script.RunCount+1)
}
}
now := time.Now()
execution := model.TaskExecution{
TaskType: "cron",
TaskID: uint(id),
TaskName: job.Name,
Status: "running",
TargetType: job.TargetType,
Targets: job.Targets,
StartedAt: &now,
}
if err := h.db.Create(&execution).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{
Code: 500,
Message: "创建执行记录失败",
})
return
}
var output string
var exitCode int
var duration int
status := "success"
// 根据 TargetType 决定执行方式 // Logan.Li
if job.TargetType != "" && job.TargetType != "local" && job.Targets != "" {
// 远程目标执行 — 通过 SSH 并发执行
remoteResults, remoteErr := ExecuteCronOnTargets(h.db, job, scriptType, content, job.Timeout)
duration = int(time.Since(now).Milliseconds())
if remoteErr != nil {
output = fmt.Sprintf("远程执行调度失败: %v", remoteErr)
exitCode = -1
status = "failed"
} else {
// 格式化远程执行结果
output = remoteexec.FormatExecResults(remoteResults)
// 任意一台失败则整体标记为 failed
for _, r := range remoteResults {
if !r.Success {
status = "failed"
exitCode = -1
break
}
}
}
} else {
// 本地执行
output, exitCode, duration = runLocal(scriptType, content, job.Timeout)
if exitCode != 0 {
status = "failed"
}
}
finishedAt := time.Now()
h.db.Model(&execution).Updates(map[string]interface{}{
"status": status,
"output": output,
"duration": duration,
"finished_at": finishedAt,
})
// Update cron job statistics.
statsUpdates := map[string]interface{}{
"last_run_at": now,
"last_status": status,
"run_count": job.RunCount + 1,
}
if status == "success" {
statsUpdates["success_count"] = job.SuccessCount + 1
} else {
statsUpdates["fail_count"] = job.FailCount + 1
}
h.db.Model(&job).Updates(statsUpdates)
execution.Output = output
execution.Status = status
execution.Duration = duration
execution.FinishedAt = &finishedAt
msg := "执行成功"
if status == "failed" {
if execution.Status == "failed" {
msg = "执行失败"
}
c.JSON(http.StatusOK, model.Response{
+26 -1
View File
@@ -5,11 +5,23 @@ import (
"strconv"
"time"
"ezops/middleware"
"ezops/model"
"ezops/pkg/scriptrunner"
"github.com/gin-gonic/gin"
)
// GetScriptRuntime 获取本机脚本运行时能力(Shell/Python/PowerShell) // Logan.Li
func (h *AutomationHandler) GetScriptRuntime(c *gin.Context) {
rt := scriptrunner.DetectRuntime()
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
Data: rt,
})
}
// ListScripts 获取脚本列表
// @Summary 获取脚本列表
// @Description 获取所有脚本列表
@@ -104,6 +116,10 @@ func (h *AutomationHandler) CreateScript(c *gin.Context) {
if script.Timeout == 0 {
script.Timeout = 300
}
if script.Code == "" {
script.Code = randomCode("sc")
}
script.CreatedBy = middleware.GetUserID(c)
if err := h.db.Create(&script).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{
@@ -283,7 +299,16 @@ func (h *AutomationHandler) ExecuteScript(c *gin.Context) {
return
}
// Execute locally on the AIOps host.
rt := scriptrunner.DetectRuntime()
if ok, reason := scriptrunner.CanRun(rt, script.Type); !ok {
c.JSON(http.StatusBadRequest, model.Response{
Code: 400,
Message: reason,
})
return
}
// 在 AIOps 本机执行(embed 单二进制 / Docker 容器内)
output, exitCode, duration := runLocal(script.Type, script.Content, script.Timeout)
finishedAt := time.Now()
status := "success"
+116
View File
@@ -0,0 +1,116 @@
// 定时任务执行引擎 — 供 HTTP 手动触发与后台 Cron 调度共用 // Logan.Li
package controller
import (
"fmt"
"time"
"ezops/model"
"ezops/pkg/remoteexec"
"ezops/pkg/scriptrunner"
"gorm.io/gorm"
)
// RunCronJobByID 执行指定定时任务并写入执行记录 // Logan.Li
func RunCronJobByID(db *gorm.DB, id uint) (*model.TaskExecution, error) {
var job model.CronJob
if err := db.First(&job, id).Error; err != nil {
return nil, fmt.Errorf("定时任务不存在")
}
return runCronJob(db, job)
}
func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) {
content := job.Content
scriptType := job.Type
if scriptType == "command" {
scriptType = "shell"
}
if job.Type == "script" && job.ScriptID != nil {
var script model.Script
if err := db.First(&script, *job.ScriptID).Error; err == nil {
content = script.Content
scriptType = script.Type
db.Model(&script).UpdateColumn("run_count", script.RunCount+1)
}
}
now := time.Now()
execution := model.TaskExecution{
TaskType: "cron",
TaskID: job.ID,
TaskName: job.Name,
Status: "running",
TargetType: job.TargetType,
Targets: job.Targets,
StartedAt: &now,
}
if err := db.Create(&execution).Error; err != nil {
return nil, fmt.Errorf("创建执行记录失败")
}
var output string
var exitCode int
var duration int
status := "success"
if job.TargetType != "" && job.TargetType != "local" && job.Targets != "" {
remoteResults, remoteErr := ExecuteCronOnTargets(db, job, scriptType, content, job.Timeout)
duration = int(time.Since(now).Milliseconds())
if remoteErr != nil {
output = fmt.Sprintf("远程执行调度失败: %v", remoteErr)
exitCode = -1
status = "failed"
} else {
output = remoteexec.FormatExecResults(remoteResults)
for _, r := range remoteResults {
if !r.Success {
status = "failed"
exitCode = -1
break
}
}
}
} else {
rt := scriptrunner.DetectRuntime()
if ok, reason := scriptrunner.CanRun(rt, scriptType); !ok {
output = reason + "\n"
exitCode = -1
duration = int(time.Since(now).Milliseconds())
status = "failed"
} else {
output, exitCode, duration = runLocal(scriptType, content, job.Timeout)
}
if exitCode != 0 {
status = "failed"
}
}
finishedAt := time.Now()
db.Model(&execution).Updates(map[string]interface{}{
"status": status,
"output": output,
"duration": duration,
"finished_at": finishedAt,
})
statsUpdates := map[string]interface{}{
"last_run_at": now,
"last_status": status,
"run_count": job.RunCount + 1,
}
if status == "success" {
statsUpdates["success_count"] = job.SuccessCount + 1
} else {
statsUpdates["fail_count"] = job.FailCount + 1
}
db.Model(&job).Updates(statsUpdates)
execution.Output = output
execution.Status = status
execution.Duration = duration
execution.FinishedAt = &finishedAt
_ = exitCode
return &execution, nil
}
+29 -2
View File
@@ -97,16 +97,44 @@ func (h *DashboardHandler) GetStats(c *gin.Context) {
h.db.Model(&model.KubernetesCluster{}).Where("status = ?", "warning").Count(&k8sStats.Warning)
h.db.Model(&model.KubernetesCluster{}).Where("status = ?", "error").Count(&k8sStats.Error)
var totalNodes, totalPods, totalServices int64
var totalNodes, totalPods, totalServices, totalIngresses, totalDeployments int64
h.db.Model(&model.KubernetesNode{}).Count(&totalNodes)
h.db.Model(&model.KubernetesPod{}).Count(&totalPods)
h.db.Model(&model.KubernetesService{}).Count(&totalServices)
h.db.Model(&model.KubernetesIngress{}).Count(&totalIngresses)
h.db.Model(&model.KubernetesDeployment{}).Count(&totalDeployments)
k8sStats.TotalNodes = totalNodes
k8sStats.TotalPods = totalPods
k8sStats.TotalServices = totalServices
k8sStats.TotalIngresses = totalIngresses
k8sStats.TotalDeployments = totalDeployments
stats.K8sStats = k8sStats
today := time.Now().Format("2006-01-02")
// 自动化运维统计 // Logan.Li
var automationStats model.AutomationStats
h.db.Model(&model.CronJob{}).Count(&automationStats.CronJobCount)
h.db.Model(&model.Script{}).Count(&automationStats.ScriptCount)
h.db.Model(&model.TaskExecution{}).Where("DATE(created_at) = ?", today).Count(&automationStats.TodayExecutions)
var successExec, totalExec int64
h.db.Model(&model.TaskExecution{}).Where("status = ?", "success").Count(&successExec)
h.db.Model(&model.TaskExecution{}).Count(&totalExec)
if totalExec > 0 {
automationStats.SuccessRate = math.Round(float64(successExec)/float64(totalExec)*10000) / 100
}
stats.AutomationStats = automationStats
// 运维概览:拨测 / 中间件 / SNMP // Logan.Li
var opsStats model.OpsOverviewStats
h.db.Model(&model.Domain{}).Count(&opsStats.DomainCount)
h.db.Model(&model.SiteTask{}).Count(&opsStats.SiteTaskCount)
h.db.Model(&model.MonitorTarget{}).Where("description IN ?", []string{"redis", "mysql"}).Count(&opsStats.MiddlewareCount)
h.db.Model(&model.SNMPDevice{}).Count(&opsStats.SnmpDeviceCount)
h.db.Model(&model.MonitorTarget{}).Count(&opsStats.MonitorTargetCount)
stats.OpsStats = opsStats
// 告警统计
var alertStats model.AlertStats
h.db.Model(&model.AlertEvent{}).Count(&alertStats.Total)
@@ -117,7 +145,6 @@ func (h *DashboardHandler) GetStats(c *gin.Context) {
h.db.Model(&model.AlertEvent{}).Where("severity = ?", "info").Count(&alertStats.Info)
// 今日告警
today := time.Now().Format("2006-01-02")
h.db.Model(&model.AlertEvent{}).Where("DATE(created_at) = ?", today).Count(&alertStats.TodayCount)
// 本周告警
+4
View File
@@ -24,6 +24,9 @@ func (h *KubernetesHandler) ListClusters(c *gin.Context) {
var clusters []model.KubernetesCluster
h.db.Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&clusters)
// 列表页资源计数与详情页对齐(实时拉取 Ingress 等)// Logan.Li
h.enrichClustersForList(clusters)
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
@@ -495,6 +498,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
"ingress_count": result.Ingresses,
"last_sync_at": &now,
})
h.updateClusterUsageAfterSync(cluster.ID)
msg := fmt.Sprintf("同步完成: %d nodes, %d pods, %d deployments, %d services, %d ingresses",
result.Nodes, result.Pods, result.Deployments, result.Services, result.Ingresses)
+109
View File
@@ -0,0 +1,109 @@
// K8s 集群列表统计增强 — 实时拉取资源数量,修复 Ingress 等与详情页不一致 // Logan.Li
package controller
import (
"log"
"ezops/model"
)
// enrichClustersForList 列表返回前刷新资源计数与节点均值使用率 // Logan.Li
func (h *KubernetesHandler) enrichClustersForList(clusters []model.KubernetesCluster) {
for i := range clusters {
c := &clusters[i]
h.fillClusterCountsFromDB(c)
h.refreshClusterCountsLive(c)
h.fillClusterUsageFromNodes(c)
// 异步落库,避免列表阻塞过久
snap := *c
go func(cluster model.KubernetesCluster) {
if err := h.db.Model(&model.KubernetesCluster{}).Where("id = ?", cluster.ID).Updates(map[string]interface{}{
"node_count": cluster.NodeCount,
"pod_count": cluster.PodCount,
"service_count": cluster.ServiceCount,
"deployment_count": cluster.DeploymentCount,
"ingress_count": cluster.IngressCount,
"cpu_usage": cluster.CPUUsage,
"memory_usage": cluster.MemoryUsage,
}).Error; err != nil {
log.Printf("[K8s] enrich cluster %d stats persist error: %v", cluster.ID, err)
}
}(snap)
}
}
func (h *KubernetesHandler) fillClusterCountsFromDB(c *model.KubernetesCluster) {
var nodes, pods, svcs, deps, ings int64
h.db.Model(&model.KubernetesNode{}).Where("cluster_id = ?", c.ID).Count(&nodes)
h.db.Model(&model.KubernetesPod{}).Where("cluster_id = ?", c.ID).Count(&pods)
h.db.Model(&model.KubernetesService{}).Where("cluster_id = ?", c.ID).Count(&svcs)
h.db.Model(&model.KubernetesDeployment{}).Where("cluster_id = ?", c.ID).Count(&deps)
h.db.Model(&model.KubernetesIngress{}).Where("cluster_id = ?", c.ID).Count(&ings)
if nodes > 0 {
c.NodeCount = int(nodes)
}
if pods > 0 {
c.PodCount = int(pods)
}
if svcs > 0 {
c.ServiceCount = int(svcs)
}
if deps > 0 {
c.DeploymentCount = int(deps)
}
if ings > 0 {
c.IngressCount = int(ings)
}
}
func (h *KubernetesHandler) refreshClusterCountsLive(c *model.KubernetesCluster) {
client, err := buildK8sClient(c)
if err != nil {
return
}
if nodes, err := client.ListNodes(); err == nil {
c.NodeCount = len(nodes.Items)
}
if pods, err := client.ListPods(); err == nil {
c.PodCount = len(pods.Items)
}
if deps, err := client.ListDeployments(); err == nil {
c.DeploymentCount = len(deps.Items)
}
if svcs, err := client.ListServices(); err == nil {
c.ServiceCount = len(svcs.Items)
}
if ings, err := client.ListIngresses(); err == nil {
c.IngressCount = len(ings.Items)
} else {
log.Printf("[K8s] list ingresses for cluster %s error: %v", c.Code, err)
}
}
func (h *KubernetesHandler) fillClusterUsageFromNodes(c *model.KubernetesCluster) {
var avgCPU, avgMem float64
row := h.db.Model(&model.KubernetesNode{}).Where("cluster_id = ?", c.ID).
Select("COALESCE(AVG(cpu_usage), 0), COALESCE(AVG(memory_usage), 0)").Row()
if row != nil {
_ = row.Scan(&avgCPU, &avgMem)
}
if avgCPU > 0 {
c.CPUUsage = avgCPU
}
if avgMem > 0 {
c.MemoryUsage = avgMem
}
}
// updateClusterUsageAfterSync 同步后根据节点指标写回集群 CPU/内存均值 // Logan.Li
func (h *KubernetesHandler) updateClusterUsageAfterSync(clusterID uint) {
var cluster model.KubernetesCluster
cluster.ID = clusterID
h.fillClusterUsageFromNodes(&cluster)
if cluster.CPUUsage > 0 || cluster.MemoryUsage > 0 {
h.db.Model(&model.KubernetesCluster{}).Where("id = ?", clusterID).Updates(map[string]interface{}{
"cpu_usage": cluster.CPUUsage,
"memory_usage": cluster.MemoryUsage,
})
}
}
+17 -2
View File
@@ -147,17 +147,32 @@ func resolveEndpoint(provider, endpoint string) string {
return "https://api.anthropic.com"
case "glm-coding": // 智谱 GLM 编码套餐 Anthropic 协议端点(模型 GLM-5.2) // Logan.Li
return "https://open.bigmodel.cn/api/anthropic"
case "minimax": // MiniMax 按量 OpenAI 兼容端点 // Logan.Li
return "https://api.minimaxi.com/v1"
case "minimax-coding": // MiniMax 编码套餐 Anthropic 协议端点 // Logan.Li
return "https://api.minimaxi.com/anthropic"
case "openai": // OpenAI 官方端点 // Logan.Li
return "https://api.openai.com/v1"
case "siliconflow": // 硅基流动 OpenAI 兼容端点 // Logan.Li
return "https://api.siliconflow.cn/v1"
case "ollama":
return "http://localhost:11434/v1"
case "vllm": // vLLM OpenAI 兼容端点 // Logan.Li
return "http://localhost:8000/v1"
default: // openai / custom
return "https://api.openai.com/v1"
}
}
// isLocalLLMProvider 本地/自建模型,API Key 可选 // Logan.Li
func isLocalLLMProvider(provider string) bool {
return provider == "ollama" || provider == "vllm" || provider == "custom"
}
// resolveProtocol 根据提供商推断接入协议(决定走 OpenAI 还是 Anthropic 客户端) // Logan.Li
// 编码套餐(智谱 GLM 编码 / Anthropic 官方)走 Anthropic Messages 协议;其余走 OpenAI 兼容协议。
// 编码套餐(智谱 / MiniMax)与 Anthropic 官方走 Messages 协议;其余走 OpenAI 兼容协议。 // Logan.Li
func resolveProtocol(provider string) string {
if provider == "anthropic" || provider == "glm-coding" {
if provider == "anthropic" || provider == "glm-coding" || provider == "minimax-coding" {
return "anthropic"
}
return "openai"
+61 -10
View File
@@ -176,16 +176,67 @@ func writeSSEError(c *gin.Context, code int, message string) {
c.Writer.Flush()
}
// TestLLMConnection 测试 LLM 连接(询问模型身份并返回回答) // Logan.Li
// llmConfigFromTestRequest 将测试请求转为运行时配置(脱敏 Key 回退已保存值) // Logan.Li
func (h *LLMHandler) llmConfigFromTestRequest(req model.SaveLLMConfigRequest) (model.LLMConfig, string) {
cfg := model.LLMConfig{
Provider: req.Provider,
Model: req.Model,
APIKey: req.APIKey,
Endpoint: req.Endpoint,
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
ReasoningEnabled: req.ReasoningEnabled,
}
if cfg.Temperature == 0 {
cfg.Temperature = 0.7
}
if cfg.MaxTokens == 0 {
cfg.MaxTokens = 8192
}
if strings.Contains(req.APIKey, "****") {
var saved model.LLMConfig
if err := h.db.First(&saved).Error; err != nil {
if !isLocalLLMProvider(req.Provider) {
return cfg, "请填写 API Key"
}
} else {
cfg.APIKey = saved.APIKey
}
}
if cfg.Provider == "" {
return cfg, "请选择提供商"
}
if cfg.Model == "" {
return cfg, "请填写模型名"
}
if cfg.APIKey == "" && !isLocalLLMProvider(cfg.Provider) {
return cfg, "请填写 API Key"
}
return cfg, ""
}
// TestLLMConnection 测试 LLM 连接(支持草稿配置,保存前即可验证) // Logan.Li
// POST /api/v1/aiops/llm-config/test
func (h *LLMHandler) TestLLMConnection(c *gin.Context) {
var req model.SaveLLMConfigRequest
_ = c.ShouldBindJSON(&req) // 空 body 时回退已保存配置 // Logan.Li
var cfg model.LLMConfig
if err := h.db.First(&cfg).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先保存配置"})
return
var errMsg string
if req.Provider != "" {
cfg, errMsg = h.llmConfigFromTestRequest(req)
} else {
if err := h.db.First(&cfg).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先填写配置并测试连接"})
return
}
if cfg.APIKey == "" && !isLocalLLMProvider(cfg.Provider) {
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请填写 API Key"})
return
}
}
if !cfg.Enabled {
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先启用 LLM 服务"})
if errMsg != "" {
c.JSON(http.StatusOK, model.Response{Code: 400, Message: errMsg})
return
}
resp, err := buildLLMClientFromConfig(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{
@@ -196,10 +247,10 @@ func (h *LLMHandler) TestLLMConnection(c *gin.Context) {
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "连接成功", Data: gin.H{
"answer": resp.Message.Content,
"model": resp.Model,
"tokens_used": resp.Usage.TotalTokens,
"provider": cfg.Provider,
"answer": resp.Message.Content,
"model": resp.Model,
"tokens_used": resp.Usage.TotalTokens,
"provider": cfg.Provider,
"reasoning_enabled": cfg.ReasoningEnabled,
}})
}
+111 -96
View File
@@ -13,10 +13,10 @@ import (
"time"
"ezops/model"
"ezops/pkg/systask"
_ "github.com/go-sql-driver/mysql"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
@@ -27,9 +27,29 @@ import (
// MiddlewareConfig 存储在 MonitorTarget.Config JSON 字段中
type MiddlewareConfig struct {
Password string `json:"password,omitempty"`
Username string `json:"username,omitempty"` // MySQL 专用
Database string `json:"database,omitempty"` // MySQL 专用
Password string `json:"password,omitempty"`
Username string `json:"username,omitempty"` // MySQL 专用
Database string `json:"database,omitempty"` // MySQL 专用
RedisMode string `json:"redis_mode,omitempty"` // Redis: standalone / cluster
}
// parseMiddlewareConfig 解析中间件配置并补全默认值 // Logan.Li
func parseMiddlewareConfig(configJSON string) MiddlewareConfig {
var cfg MiddlewareConfig
if configJSON != "" {
json.Unmarshal([]byte(configJSON), &cfg)
}
if cfg.RedisMode == "" {
cfg.RedisMode = "standalone"
}
return cfg
}
// middlewareConfigPublic 返回给前端的脱敏配置 // Logan.Li
type middlewareConfigPublic struct {
Username string `json:"username,omitempty"`
Database string `json:"database,omitempty"`
RedisMode string `json:"redis_mode,omitempty"`
}
// MiddlewareMonitorHandler 中间件监控 handler
@@ -60,11 +80,11 @@ func (h *MiddlewareMonitorHandler) StartCollector(intervalSec int) {
defer ticker.Stop()
log.Printf("[MiddlewareMonitor] 采集引擎启动,间隔 %ds", intervalSec)
// 首次立即采集一次
h.collectAll()
h.collectAllWithRecord()
for {
select {
case <-ticker.C:
h.collectAll()
h.collectAllWithRecord()
case <-h.stopCh:
log.Printf("[MiddlewareMonitor] 采集引擎停止")
return
@@ -87,14 +107,15 @@ func (h *MiddlewareMonitorHandler) StopCollector() {
// POST /api/v1/middleware-monitors
func (h *MiddlewareMonitorHandler) CreateMiddlewareTarget(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"` // redis / mysql
IP string `json:"ip" binding:"required"`
Port int `json:"port" binding:"required"`
Password string `json:"password"`
Username string `json:"username"` // MySQL
Database string `json:"database"` // MySQL
Interval int `json:"interval"` // 采集间隔,默认 30s
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"` // redis / mysql
IP string `json:"ip" binding:"required"`
Port int `json:"port" binding:"required"`
Password string `json:"password"`
Username string `json:"username"` // MySQL
Database string `json:"database"` // MySQL
RedisMode string `json:"redis_mode"` // Redis: standalone / cluster
Interval int `json:"interval"` // 采集间隔,默认 30s
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
@@ -119,11 +140,21 @@ func (h *MiddlewareMonitorHandler) CreateMiddlewareTarget(c *gin.Context) {
req.Interval = 30
}
redisMode := strings.ToLower(req.RedisMode)
if mwType == "redis" {
if redisMode != "cluster" {
redisMode = "standalone"
}
} else {
redisMode = ""
}
// 构建 Config JSON
cfg := MiddlewareConfig{
Password: req.Password,
Username: req.Username,
Database: req.Database,
Password: req.Password,
Username: req.Username,
Database: req.Database,
RedisMode: redisMode,
}
cfgJSON, _ := json.Marshal(cfg)
@@ -139,7 +170,8 @@ func (h *MiddlewareMonitorHandler) CreateMiddlewareTarget(c *gin.Context) {
Description: mwType, // 复用 Description 字段存储中间件子类型
}
if err := h.db.Create(&target).Error; err != nil {
// host_group_id 默认 0 会触发外键约束,创建时显式忽略 // Logan.Li
if err := h.db.Omit("HostGroupID").Create(&target).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建失败: " + err.Error()})
return
}
@@ -174,11 +206,12 @@ func (h *MiddlewareMonitorHandler) ListMiddlewareTargets(c *gin.Context) {
targets = []model.MonitorTarget{}
}
// 附带最新指标
// 附带最新指标(Config 脱敏,不返回密码)
type TargetWithMetrics struct {
model.MonitorTarget
SubType string `json:"sub_type"`
LatestMetrics map[string]float64 `json:"latest_metrics"`
SubType string `json:"sub_type"`
LatestMetrics map[string]float64 `json:"latest_metrics"`
ConfigPublic middlewareConfigPublic `json:"config_public"`
}
result := make([]TargetWithMetrics, len(targets))
now := time.Now().Unix()
@@ -193,10 +226,18 @@ func (h *MiddlewareMonitorHandler) ListMiddlewareTargets(c *gin.Context) {
metrics[mh.MetricName] = mh.Value
}
}
cfg := parseMiddlewareConfig(t.Config)
// 列表响应隐藏原始 config 字段,避免泄露密码 // Logan.Li
t.Config = ""
result[i] = TargetWithMetrics{
MonitorTarget: t,
SubType: t.Description,
LatestMetrics: metrics,
ConfigPublic: middlewareConfigPublic{
Username: cfg.Username,
Database: cfg.Database,
RedisMode: cfg.RedisMode,
},
}
}
@@ -237,13 +278,14 @@ func (h *MiddlewareMonitorHandler) UpdateMiddlewareTarget(c *gin.Context) {
}
var req struct {
Name *string `json:"name"`
IP *string `json:"ip"`
Port *int `json:"port"`
Password *string `json:"password"`
Username *string `json:"username"`
Database *string `json:"database"`
Interval *int `json:"interval"`
Name *string `json:"name"`
IP *string `json:"ip"`
Port *int `json:"port"`
Password *string `json:"password"`
Username *string `json:"username"`
Database *string `json:"database"`
RedisMode *string `json:"redis_mode"`
Interval *int `json:"interval"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
@@ -264,12 +306,9 @@ func (h *MiddlewareMonitorHandler) UpdateMiddlewareTarget(c *gin.Context) {
updates["interval"] = *req.Interval
}
// 如果有密码/用户名变更,更新 Config JSON
if req.Password != nil || req.Username != nil || req.Database != nil {
var cfg MiddlewareConfig
if target.Config != "" {
json.Unmarshal([]byte(target.Config), &cfg)
}
// 如果有连接配置变更,更新 Config JSON
if req.Password != nil || req.Username != nil || req.Database != nil || req.RedisMode != nil {
cfg := parseMiddlewareConfig(target.Config)
if req.Password != nil {
cfg.Password = *req.Password
}
@@ -279,6 +318,14 @@ func (h *MiddlewareMonitorHandler) UpdateMiddlewareTarget(c *gin.Context) {
if req.Database != nil {
cfg.Database = *req.Database
}
if req.RedisMode != nil {
mode := strings.ToLower(*req.RedisMode)
if mode == "cluster" {
cfg.RedisMode = "cluster"
} else {
cfg.RedisMode = "standalone"
}
}
cfgJSON, _ := json.Marshal(cfg)
updates["config"] = string(cfgJSON)
}
@@ -363,6 +410,14 @@ func (h *MiddlewareMonitorHandler) GetMiddlewareMetrics(c *gin.Context) {
startTime := now - 3600 // 默认 1h
endTime := now
if h := c.Query("hours"); h != "" {
if hours, err := strconv.Atoi(h); err == nil && hours > 0 {
if hours > 168 {
hours = 168
}
startTime = now - int64(hours)*3600
}
}
if s := c.Query("start"); s != "" {
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
startTime = v
@@ -393,6 +448,14 @@ func (h *MiddlewareMonitorHandler) GetMiddlewareMetrics(c *gin.Context) {
// 指标采集引擎
// ============================================
// collectAllWithRecord 采集并记录系统任务状态 // Logan.Li
func (h *MiddlewareMonitorHandler) collectAllWithRecord() int {
start := time.Now()
count := h.collectAll()
systask.Record("middleware_monitor", "success", fmt.Sprintf("采集 %d 个目标", count), time.Since(start))
return count
}
// collectAll 采集所有启用的中间件目标
func (h *MiddlewareMonitorHandler) collectAll() int {
var targets []model.MonitorTarget
@@ -453,11 +516,7 @@ func (h *MiddlewareMonitorHandler) collectTarget(target model.MonitorTarget) (ma
}
}
// 解析配置
var cfg MiddlewareConfig
if target.Config != "" {
json.Unmarshal([]byte(target.Config), &cfg)
}
cfg := parseMiddlewareConfig(target.Config)
addr := fmt.Sprintf("%s:%d", target.IP, target.Port)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -465,7 +524,7 @@ func (h *MiddlewareMonitorHandler) collectTarget(target model.MonitorTarget) (ma
switch mwType {
case "redis":
return h.collectRedis(ctx, addr, cfg.Password)
return h.collectRedis(ctx, addr, cfg.Password, cfg.RedisMode)
case "mysql":
return h.collectMySQL(ctx, addr, cfg)
default:
@@ -473,62 +532,6 @@ func (h *MiddlewareMonitorHandler) collectTarget(target model.MonitorTarget) (ma
}
}
// collectRedis 采集 Redis 指标
func (h *MiddlewareMonitorHandler) collectRedis(ctx context.Context, addr, password string) (map[string]float64, error) {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: 0,
})
defer rdb.Close()
// PING 检测连接
if err := rdb.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("Redis 连接失败: %v", err)
}
result := make(map[string]float64)
// INFO 命令获取详细信息
info, err := rdb.Info(ctx).Result()
if err == nil {
infoMap := parseRedisInfo(info)
result["redis_connected_clients"] = parseFloat(infoMap["connected_clients"])
result["redis_used_memory"] = parseFloat(infoMap["used_memory"])
result["redis_used_memory_peak"] = parseFloat(infoMap["used_memory_peak"])
result["redis_total_commands_processed"] = parseFloat(infoMap["total_commands_processed"])
result["redis_uptime_in_seconds"] = parseFloat(infoMap["uptime_in_seconds"])
result["redis_keyspace_hits"] = parseFloat(infoMap["keyspace_hits"])
result["redis_keyspace_misses"] = parseFloat(infoMap["keyspace_misses"])
result["redis_evicted_keys"] = parseFloat(infoMap["evicted_keys"])
result["redis_blocked_clients"] = parseFloat(infoMap["blocked_clients"])
// 计算命中率
hits := result["redis_keyspace_hits"]
misses := result["redis_keyspace_misses"]
if hits+misses > 0 {
result["redis_hit_rate"] = hits / (hits + misses)
} else {
result["redis_hit_rate"] = 0
}
// 计算 QPS
uptime := result["redis_uptime_in_seconds"]
cmds := result["redis_total_commands_processed"]
if uptime > 0 {
result["redis_qps"] = cmds / uptime
}
}
// DBSIZE
dbSize, err := rdb.DBSize(ctx).Result()
if err == nil {
result["redis_db_size"] = float64(dbSize)
}
return result, nil
}
// collectMySQL 采集 MySQL 指标
func (h *MiddlewareMonitorHandler) collectMySQL(ctx context.Context, addr string, cfg MiddlewareConfig) (map[string]float64, error) {
dbName := cfg.Database
@@ -597,6 +600,18 @@ func (h *MiddlewareMonitorHandler) collectMySQL(ctx context.Context, addr string
result["mysql_buffer_hit_rate"] = 0
}
// SHOW GLOBAL VARIABLES — 连接上限等 // Logan.Li
varRows, err := db.QueryContext(ctx, "SHOW GLOBAL VARIABLES LIKE 'max_connections'")
if err == nil {
defer varRows.Close()
for varRows.Next() {
var name, val string
if varRows.Scan(&name, &val) == nil && name == "max_connections" {
result["mysql_max_connections"] = parseFloat(val)
}
}
}
return result, nil
}
@@ -0,0 +1,101 @@
package controller
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
)
// collectRedis 采集 Redis 指标,支持单机与集群模式 // Logan.Li
func (h *MiddlewareMonitorHandler) collectRedis(ctx context.Context, addr, password, mode string) (map[string]float64, error) {
if mode == "" {
mode = "standalone"
}
var (
info string
dbSize int64
clusterInfo string
pingErr error
)
switch mode {
case "cluster":
rdb := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{addr},
Password: password,
})
defer rdb.Close()
pingErr = rdb.Ping(ctx).Err()
if pingErr == nil {
info, _ = rdb.Info(ctx).Result()
dbSize, _ = rdb.DBSize(ctx).Result()
clusterInfo, _ = rdb.ClusterInfo(ctx).Result()
}
default:
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: 0,
})
defer rdb.Close()
pingErr = rdb.Ping(ctx).Err()
if pingErr == nil {
info, _ = rdb.Info(ctx).Result()
dbSize, _ = rdb.DBSize(ctx).Result()
}
}
if pingErr != nil {
return nil, fmt.Errorf("Redis 连接失败: %v", pingErr)
}
return buildRedisMetrics(info, dbSize, clusterInfo, mode), nil
}
// buildRedisMetrics 从 INFO 与 DBSIZE 构建指标 map // Logan.Li
func buildRedisMetrics(info string, dbSize int64, clusterInfo, mode string) map[string]float64 {
result := make(map[string]float64)
if info != "" {
infoMap := parseRedisInfo(info)
result["redis_connected_clients"] = parseFloat(infoMap["connected_clients"])
result["redis_used_memory"] = parseFloat(infoMap["used_memory"])
result["redis_used_memory_peak"] = parseFloat(infoMap["used_memory_peak"])
result["redis_total_commands_processed"] = parseFloat(infoMap["total_commands_processed"])
result["redis_uptime_in_seconds"] = parseFloat(infoMap["uptime_in_seconds"])
result["redis_keyspace_hits"] = parseFloat(infoMap["keyspace_hits"])
result["redis_keyspace_misses"] = parseFloat(infoMap["keyspace_misses"])
result["redis_evicted_keys"] = parseFloat(infoMap["evicted_keys"])
result["redis_blocked_clients"] = parseFloat(infoMap["blocked_clients"])
result["redis_maxmemory"] = parseFloat(infoMap["maxmemory"])
result["redis_mem_fragmentation_ratio"] = parseFloat(infoMap["mem_fragmentation_ratio"])
hits := result["redis_keyspace_hits"]
misses := result["redis_keyspace_misses"]
if hits+misses > 0 {
result["redis_hit_rate"] = hits / (hits + misses)
}
uptime := result["redis_uptime_in_seconds"]
cmds := result["redis_total_commands_processed"]
if uptime > 0 {
result["redis_qps"] = cmds / uptime
}
}
if dbSize >= 0 {
result["redis_db_size"] = float64(dbSize)
}
if mode == "cluster" && clusterInfo != "" {
ci := parseRedisInfo(clusterInfo)
result["redis_cluster_known_nodes"] = parseFloat(ci["cluster_known_nodes"])
result["redis_cluster_size"] = parseFloat(ci["cluster_size"])
}
return result
}
@@ -0,0 +1,120 @@
package controller
import (
"encoding/json"
"log"
"net/http"
"os"
"ezops/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// middlewareSeedResult 种子创建结果 // Logan.Li
type middlewareSeedResult struct {
Created int `json:"created"`
Skipped []string `json:"skipped"`
Failed []string `json:"failed"`
Total int64 `json:"total"`
Message string `json:"message"`
Success bool `json:"success"`
}
// SeedDefaultMiddlewareTargets 首次启动时创建平台默认 Redis/MySQL 监控目标 // Logan.Li
func SeedDefaultMiddlewareTargets(db *gorm.DB) {
seedDefaultMiddlewareTargets(db)
}
// SeedDefaultMiddlewareTargetsAPI 手动触发添加默认监控目标 // Logan.Li
// POST /api/v1/middleware-monitors/seed-defaults
func (h *MiddlewareMonitorHandler) SeedDefaultMiddlewareTargetsAPI(c *gin.Context) {
result := seedDefaultMiddlewareTargets(h.db)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: result})
}
// seedDefaultMiddlewareTargets 内部实现,返回创建结果 // Logan.Li
func seedDefaultMiddlewareTargets(db *gorm.DB) middlewareSeedResult {
result := middlewareSeedResult{
Skipped: []string{},
Failed: []string{},
}
mysqlUser := os.Getenv("DATABASE_USER")
if mysqlUser == "" {
mysqlUser = "root"
}
mysqlPassword := os.Getenv("DATABASE_PASSWORD")
if mysqlPassword == "" {
mysqlPassword = os.Getenv("MYSQL_ROOT_PASSWORD")
}
mysqlDB := os.Getenv("DATABASE_DBNAME")
if mysqlDB == "" {
mysqlDB = "aiops"
}
redisCfg, _ := json.Marshal(MiddlewareConfig{RedisMode: "standalone"})
mysqlCfg, _ := json.Marshal(MiddlewareConfig{
Username: mysqlUser,
Password: mysqlPassword,
Database: mysqlDB,
})
defaults := []model.MonitorTarget{
{
Name: "aiops-redis",
Type: model.TargetTypeMiddleware,
Status: model.TargetStatusUnknown,
IP: "aiops-redis",
Port: 6379,
CollectMode: "api",
Config: string(redisCfg),
Interval: 30,
Description: "redis",
},
{
Name: "aiops-mysql",
Type: model.TargetTypeMiddleware,
Status: model.TargetStatusUnknown,
IP: "aiops-mysql",
Port: 3306,
CollectMode: "api",
Config: string(mysqlCfg),
Interval: 30,
Description: "mysql",
},
}
for _, target := range defaults {
var exists int64
db.Model(&model.MonitorTarget{}).
Where("type = ? AND name = ?", model.TargetTypeMiddleware, target.Name).
Count(&exists)
if exists > 0 {
result.Skipped = append(result.Skipped, target.Name)
continue
}
// host_group_id 默认 0 会触发外键约束,创建时显式忽略 // Logan.Li
if err := db.Omit("HostGroupID").Create(&target).Error; err != nil {
log.Printf("[MiddlewareMonitor] 种子数据创建失败 %s: %v", target.Name, err)
result.Failed = append(result.Failed, target.Name)
continue
}
result.Created++
log.Printf("[MiddlewareMonitor] 已创建默认监控目标: %s (%s:%d)", target.Name, target.IP, target.Port)
}
db.Model(&model.MonitorTarget{}).Where("type = ?", model.TargetTypeMiddleware).Count(&result.Total)
result.Success = result.Created > 0 || (len(result.Skipped) > 0 && len(result.Failed) == 0)
switch {
case result.Created > 0:
result.Message = "已添加平台默认监控目标"
case len(result.Failed) > 0:
result.Message = "部分默认目标创建失败,请查看服务日志"
case len(result.Skipped) > 0:
result.Message = "默认监控目标已存在,请在下方列表查看"
default:
result.Message = "未创建任何默认目标"
}
return result
}
+285
View File
@@ -0,0 +1,285 @@
package controller
import (
"net/http"
"strconv"
"strings"
"ezops/model"
"time"
"github.com/gin-gonic/gin"
)
// ListMonitorAlertRules 列出中间件/站点告警规则
// GET /api/v1/alerts/monitor-rules?category=middleware|site
func (h *AlertHandler) ListMonitorAlertRules(c *gin.Context) {
q := h.db.Model(&model.MonitorAlertRule{})
if cat := c.Query("category"); cat != "" {
q = q.Where("category = ?", cat)
}
var rules []model.MonitorAlertRule
var total int64
q.Count(&total)
q.Order("id DESC").Find(&rules)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": rules, "total": total}})
}
// CreateMonitorAlertRule 创建中间件/站点告警规则
// POST /api/v1/alerts/monitor-rules
func (h *AlertHandler) CreateMonitorAlertRule(c *gin.Context) {
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
rule := parseMonitorAlertRule(raw)
if rule.Name == "" || rule.Metric == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "名称和指标不能为空"})
return
}
if rule.Category != model.MonitorAlertMiddleware && rule.Category != model.MonitorAlertSite && rule.Category != model.MonitorAlertSNMP {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "category 须为 middleware、snmp 或 site"})
return
}
if err := h.db.Create(&rule).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error()})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: rule, Message: "创建成功"})
}
// UpdateMonitorAlertRule 更新规则
// PUT /api/v1/alerts/monitor-rules/:id
func (h *AlertHandler) UpdateMonitorAlertRule(c *gin.Context) {
id := c.Param("id")
var rule model.MonitorAlertRule
if err := h.db.First(&rule, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "规则不存在"})
return
}
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
updates := buildMonitorRuleUpdates(raw)
if len(updates) > 0 {
h.db.Model(&rule).Updates(updates)
h.db.First(&rule, id)
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: rule, Message: "更新成功"})
}
// DeleteMonitorAlertRule 删除规则
// DELETE /api/v1/alerts/monitor-rules/:id
func (h *AlertHandler) DeleteMonitorAlertRule(c *gin.Context) {
id := c.Param("id")
if err := h.db.Delete(&model.MonitorAlertRule{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error()})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "删除成功"})
}
// ListUnifiedAlertRules 统一规则列表(主机 AgentRule + 中间件/站点 MonitorAlertRule)
// GET /api/v1/alerts/unified-rules?category=host|middleware|site|all
func (h *AlertHandler) ListUnifiedAlertRules(c *gin.Context) {
cat := c.DefaultQuery("category", "all")
list := make([]gin.H, 0)
if cat == "all" || cat == "host" {
var agentRules []model.AgentRule
h.db.Order("id DESC").Find(&agentRules)
for _, r := range agentRules {
list = append(list, gin.H{
"id": r.ID, "category": "host", "name": r.Name,
"target": r.Hostname, "metric": r.Metric, "operator": r.Operator,
"threshold": r.Threshold, "port": r.Port, "enabled": r.Enabled,
"severity": "warning", "description": r.Hostname + " " + r.Metric,
"rule_type": "agent",
})
}
}
if cat == "all" || cat == "middleware" || cat == "site" || cat == "snmp" {
q := h.db.Model(&model.MonitorAlertRule{})
if cat != "all" {
q = q.Where("category = ?", cat)
}
var rules []model.MonitorAlertRule
q.Order("id DESC").Find(&rules)
for _, r := range rules {
list = append(list, gin.H{
"id": r.ID, "category": r.Category, "name": r.Name,
"target_id": r.TargetID, "metric": r.Metric, "operator": r.Operator,
"threshold": r.Threshold, "enabled": r.Enabled, "severity": r.Severity,
"description": r.Description, "rule_type": "monitor",
})
}
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": list, "total": len(list)}})
}
func parseMonitorAlertRule(raw map[string]interface{}) model.MonitorAlertRule {
getStr := func(keys ...string) string {
for _, k := range keys {
if v, ok := raw[k].(string); ok {
return strings.TrimSpace(v)
}
}
return ""
}
getFloat := func(def float64, keys ...string) float64 {
for _, k := range keys {
if v, ok := raw[k].(float64); ok {
return v
}
}
return def
}
getUint := func(keys ...string) uint {
for _, k := range keys {
switch v := raw[k].(type) {
case float64:
return uint(v)
case int:
return uint(v)
}
}
return 0
}
rule := model.MonitorAlertRule{
Category: model.MonitorAlertCategory(getStr("category")),
Name: getStr("name"),
Metric: getStr("metric"),
Operator: getStr("operator", "op"),
Threshold: getFloat(0, "threshold"),
Severity: getStr("severity"),
Description: getStr("description"),
TargetID: getUint("target_id"),
Enabled: true,
}
if rule.Operator == "" {
rule.Operator = ">"
}
if rule.Severity == "" {
rule.Severity = "warning"
}
if v, ok := raw["enabled"].(bool); ok {
rule.Enabled = v
}
return rule
}
func buildMonitorRuleUpdates(raw map[string]interface{}) map[string]interface{} {
updates := map[string]interface{}{}
if v, ok := raw["name"].(string); ok && strings.TrimSpace(v) != "" {
updates["name"] = strings.TrimSpace(v)
}
if v, ok := raw["metric"].(string); ok && v != "" {
updates["metric"] = v
}
if v, ok := raw["operator"].(string); ok && v != "" {
updates["operator"] = v
}
if v, ok := raw["threshold"].(float64); ok {
updates["threshold"] = v
}
if v, ok := raw["severity"].(string); ok && v != "" {
updates["severity"] = v
}
if v, ok := raw["description"].(string); ok {
updates["description"] = v
}
if v, ok := raw["enabled"].(bool); ok {
updates["enabled"] = v
}
if v, ok := raw["target_id"].(float64); ok {
updates["target_id"] = uint(v)
}
return updates
}
// QuickSetupMonitorRules 一键创建中间件/站点默认告警规则
// POST /api/v1/alerts/monitor-quick-setup
func (h *AlertHandler) QuickSetupMonitorRules(c *gin.Context) {
var req struct {
Category string `json:"category"`
}
c.ShouldBindJSON(&req)
cat := req.Category
if cat == "" {
cat = "all"
}
created := 0
if cat == "all" || cat == "middleware" {
defaults := []model.MonitorAlertRule{
{Category: model.MonitorAlertMiddleware, Name: "Redis 连接数过高", Metric: "redis_connected_clients", Operator: ">", Threshold: 500, Severity: "warning", Enabled: true, Description: "Redis 连接数 > 500"},
{Category: model.MonitorAlertMiddleware, Name: "Redis 命中率过低", Metric: "redis_hit_rate", Operator: "<", Threshold: 0.9, Severity: "warning", Enabled: true, Description: "命中率 < 90%"},
{Category: model.MonitorAlertMiddleware, Name: "MySQL 连接数过高", Metric: "mysql_connections", Operator: ">", Threshold: 200, Severity: "warning", Enabled: true, Description: "连接数 > 200"},
{Category: model.MonitorAlertMiddleware, Name: "MySQL 慢查询过多", Metric: "mysql_slow_queries", Operator: ">", Threshold: 10, Severity: "warning", Enabled: true, Description: "慢查询 > 10"},
}
for _, r := range defaults {
var exist model.MonitorAlertRule
h.db.Where("name = ? AND category = ?", r.Name, r.Category).First(&exist)
if exist.ID == 0 {
h.db.Create(&r)
created++
}
}
}
if cat == "all" || cat == "site" {
defaults := []model.MonitorAlertRule{
{Category: model.MonitorAlertSite, Name: "拨测失败", Metric: "probe_fail", Operator: "==", Threshold: 1, Severity: "critical", Enabled: true, Description: "拨测连续失败"},
{Category: model.MonitorAlertSite, Name: "响应延迟过高", Metric: "response_time", Operator: ">", Threshold: 500, Severity: "warning", Enabled: true, Description: "延迟 > 500ms"},
{Category: model.MonitorAlertSite, Name: "可用率过低", Metric: "availability", Operator: "<", Threshold: 99, Severity: "warning", Enabled: true, Description: "1h 可用率 < 99%"},
{Category: model.MonitorAlertSite, Name: "SSL 证书即将过期", Metric: "ssl_expiry_days", Operator: "<", Threshold: 30, Severity: "warning", Enabled: true, Description: "SSL 拨测证书剩余 < 30 天"},
}
for _, r := range defaults {
var exist model.MonitorAlertRule
h.db.Where("name = ? AND category = ?", r.Name, r.Category).First(&exist)
if exist.ID == 0 {
h.db.Create(&r)
created++
}
}
}
if cat == "all" || cat == "snmp" {
defaults := []model.MonitorAlertRule{
{Category: model.MonitorAlertSNMP, Name: "SNMP CPU 过高", Metric: "snmp_cpu_usage", Operator: ">", Threshold: 80, Severity: "warning", Enabled: true, Description: "CPU > 80%"},
{Category: model.MonitorAlertSNMP, Name: "SNMP 内存过高", Metric: "snmp_mem_usage", Operator: ">", Threshold: 85, Severity: "warning", Enabled: true, Description: "内存 > 85%"},
{Category: model.MonitorAlertSNMP, Name: "SNMP 温度过高", Metric: "snmp_temperature", Operator: ">", Threshold: 65, Severity: "warning", Enabled: true, Description: "温度 > 65°C"},
{Category: model.MonitorAlertSNMP, Name: "SNMP 设备离线", Metric: "snmp_offline", Operator: "==", Threshold: 1, Severity: "critical", Enabled: true, Description: "设备离线或采集失败"},
}
for _, r := range defaults {
var exist model.MonitorAlertRule
h.db.Where("name = ? AND category = ?", r.Name, r.Category).First(&exist)
if exist.ID == 0 {
h.db.Create(&r)
created++
}
}
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"created": created}, Message: "默认规则已创建"})
}
// ResolveUnifiedAlert 统一告警恢复
// POST /api/v1/alerts/unified/:source/:id/resolve
func (h *AlertHandler) ResolveUnifiedAlert(c *gin.Context) {
source := c.Param("source")
id, _ := strconv.Atoi(c.Param("id"))
now := time.Now()
switch source {
case "agent":
h.db.Model(&model.AgentAlert{}).Where("id = ?", id).Updates(map[string]interface{}{"resolved": true, "resolved_at": now})
case "site":
h.db.Model(&model.SiteAlert{}).Where("id = ?", id).Updates(map[string]interface{}{"status": model.SiteAlertStatusResolved, "resolved_at": now})
case "event":
h.db.Model(&model.AlertEvent{}).Where("id = ?", id).Updates(map[string]interface{}{"status": model.AlertEventResolved, "resolved_at": now})
default:
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "未知告警来源"})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已恢复"})
}
+2 -2
View File
@@ -101,7 +101,7 @@ func (h *AlertContactHandlerV2) ListContacts(c *gin.Context) {
list, total, err := h.svc.List(keyword, page, pageSize)
if err != nil {
utils.InternalError(c, "查询失败")
utils.InternalError(c, "查询失败: "+err.Error())
return
}
utils.ListResponse(c, list, total)
@@ -125,7 +125,7 @@ func (h *AlertContactHandlerV2) CreateContact(c *gin.Context) {
}
contact, err := h.svc.Create(&req)
if err != nil {
utils.InternalError(c, "创建失败")
utils.InternalError(c, "创建失败: "+err.Error())
return
}
utils.OK(c, contact)
+166 -64
View File
@@ -1,9 +1,13 @@
package controller
import (
"ezops/model"
"net/http"
"strconv"
"strings"
"time"
"ezops/model"
"ezops/pkg/siteprobe"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -11,60 +15,79 @@ import (
// SiteHandler 站点拨测管理 handler // Logan.Li
type SiteHandler struct {
db *gorm.DB
db *gorm.DB
worker *siteprobe.Worker
}
// NewSiteHandler 创建站点拨测 handler
func NewSiteHandler(db *gorm.DB) *SiteHandler {
return &SiteHandler{db: db}
func NewSiteHandler(db *gorm.DB, worker *siteprobe.Worker) *SiteHandler {
return &SiteHandler{db: db, worker: worker}
}
// ========== Site Task CRUD ==========
// ListTasks 列出拨测任务
// GET /api/v1/site/tasks?page=1&page_size=20&type=&status=
// GET /api/v1/site/tasks
func (h *SiteHandler) ListTasks(c *gin.Context) {
var tasks []model.SiteTask
q := h.db.Model(&model.SiteTask{})
if t := c.Query("type"); t != "" {
q = q.Where("type = ?", t)
}
if s := c.Query("status"); s != "" {
q = q.Where("status = ?", s)
q = q.Where("task_type = ?", strings.ToLower(t))
}
if kw := c.Query("keyword"); kw != "" {
q = q.Where("name LIKE ? OR url LIKE ?", "%"+kw+"%", "%"+kw+"%")
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100"))
var total int64
q.Count(&total)
q.Offset((page - 1) * pageSize).Limit(pageSize).Order("name ASC").Find(&tasks)
q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&tasks)
if tasks == nil {
tasks = []model.SiteTask{}
views := make([]SiteTaskView, 0, len(tasks))
for _, task := range tasks {
if task.TaskType == "" {
inferred := model.SiteTaskType(siteprobe.InferTaskTypePublic(task))
h.db.Model(&task).Update("task_type", inferred)
task.TaskType = inferred
}
views = append(views, toSiteTaskView(task))
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": tasks, "total": total}})
enrichSiteTaskViewsSSL(h.db, views)
enrichSiteTaskViewsAlert(h.db, views)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": views, "total": total}})
}
// CreateTask 创建拨测任务
// POST /api/v1/site/tasks
func (h *SiteHandler) CreateTask(c *gin.Context) {
var task model.SiteTask
if err := c.ShouldBindJSON(&task); err != nil {
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
task := parseCreateTaskRequest(raw)
if task.Name == "" || task.URL == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "名称和地址不能为空"})
return
}
if task.Frequency < 10 {
task.Frequency = 60
}
if task.Timeout <= 0 {
task.Timeout = 10
}
if err := h.db.Create(&task).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error()})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: task, Message: "创建成功"})
if h.worker != nil {
go h.worker.RunTaskNow(task.ID)
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: toSiteTaskView(task), Message: "创建成功"})
}
// UpdateTask 更新拨测任务
// UpdateTask 更新拨测任务(启用/停用或配置变更)
// PUT /api/v1/site/tasks/:id
func (h *SiteHandler) UpdateTask(c *gin.Context) {
id := c.Param("id")
@@ -73,19 +96,72 @@ func (h *SiteHandler) UpdateTask(c *gin.Context) {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "任务不存在"})
return
}
if err := c.ShouldBindJSON(&task); err != nil {
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
h.db.Save(&task)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: task, Message: "更新成功"})
updates := map[string]interface{}{}
if v, ok := raw["name"].(string); ok && strings.TrimSpace(v) != "" {
updates["name"] = strings.TrimSpace(v)
}
if v, ok := raw["url"].(string); ok && strings.TrimSpace(v) != "" {
updates["url"] = strings.TrimSpace(v)
}
if v, ok := raw["type"].(string); ok && v != "" {
t := strings.ToLower(v)
if t == "https" {
t = "http"
}
updates["task_type"] = t
}
if v, ok := raw["frequency_sec"].(float64); ok {
updates["frequency"] = int(v)
}
if v, ok := raw["timeout_sec"].(float64); ok {
updates["timeout"] = int(v)
}
if v, ok := raw["expected_status_code"].(float64); ok {
updates["expected_status"] = int(v)
}
if v, ok := raw["enabled"].(bool); ok {
if v {
updates["status"] = model.SiteTaskStatusRunning
} else {
updates["status"] = model.SiteTaskStatusPaused
}
}
if len(updates) > 0 {
h.db.Model(&task).Updates(updates)
h.db.First(&task, id)
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: toSiteTaskView(task), Message: "更新成功"})
}
// deleteSiteTaskCascade 先删子表再删任务,避免外键约束 // Logan.Li
func (h *SiteHandler) deleteSiteTaskCascade(taskID uint) error {
return h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("task_id = ?", taskID).Delete(&model.SiteResult{}).Error; err != nil {
return err
}
if err := tx.Where("task_id = ?", taskID).Delete(&model.SiteAlert{}).Error; err != nil {
return err
}
tx.Where("category = ? AND target_id = ?", model.MonitorAlertSite, taskID).Delete(&model.MonitorAlertRule{})
return tx.Delete(&model.SiteTask{}, taskID).Error
})
}
// DeleteTask 删除拨测任务
// DELETE /api/v1/site/tasks/:id
func (h *SiteHandler) DeleteTask(c *gin.Context) {
id := c.Param("id")
if err := h.db.Delete(&model.SiteTask{}, id).Error; err != nil {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效 ID"})
return
}
if err := h.deleteSiteTaskCascade(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error()})
return
}
@@ -93,74 +169,100 @@ func (h *SiteHandler) DeleteTask(c *gin.Context) {
}
// BatchDeleteTasks 批量删除
// DELETE /api/v1/site/tasks/batch-delete
func (h *SiteHandler) BatchDeleteTasks(c *gin.Context) {
var req model.IDsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
h.db.Where("id IN ?", req.IDs).Delete(&model.SiteTask{})
for _, tid := range req.IDs {
if err := h.deleteSiteTaskCascade(uint(tid)); err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error()})
return
}
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "批量删除成功"})
}
// ========== Site Results ==========
// ListResults 查询拨测结果
// GET /api/v1/site/results?task_id=N&from=&to=&limit=100
// GET /api/v1/site/results?task_id=N&limit=100
func (h *SiteHandler) ListResults(c *gin.Context) {
var results []model.SiteResult
q := h.db.Model(&model.SiteResult{})
if tid := c.Query("task_id"); tid != "" {
q = q.Where("task_id = ?", tid)
}
if from := c.Query("from"); from != "" {
q = q.Where("created_at >= ?", from)
if h := c.Query("hours"); h != "" {
if hours, err := strconv.Atoi(h); err == nil && hours > 0 {
if hours > 168 {
hours = 168
}
since := time.Now().Add(-time.Duration(hours) * time.Hour)
q = q.Where("checked_at >= ?", since)
}
}
if to := c.Query("to"); to != "" {
q = q.Where("created_at <= ?", to)
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "500"))
if limit <= 0 {
limit = 500
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
q.Order("created_at DESC").Limit(limit).Find(&results)
if limit > 2000 {
limit = 2000
}
q.Order("checked_at DESC").Limit(limit).Find(&results)
if results == nil {
results = []model.SiteResult{}
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: results})
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": results, "total": len(results)}})
}
// ========== Site Statistics ==========
// ProbeNow 立即触发拨测
// POST /api/v1/site/tasks/:id/probe
func (h *SiteHandler) ProbeNow(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效 ID"})
return
}
if h.worker == nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "拨测引擎未启动"})
return
}
if err := h.worker.RunTaskNow(uint(id)); err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "任务不存在"})
return
}
var task model.SiteTask
h.db.First(&task, id)
view := toSiteTaskView(task)
enrichSiteTaskViewsSSL(h.db, []SiteTaskView{view})
enrichSiteTaskViewsAlert(h.db, []SiteTaskView{view})
c.JSON(http.StatusOK, model.Response{Code: 200, Data: view, Message: "拨测完成"})
}
// GetSiteStats 获取拨测概览统计
// GET /api/v1/site/stats
func (h *SiteHandler) GetSiteStats(c *gin.Context) {
var total, normal, abnormal int64
var total, enabled, upCount, downCount int64
h.db.Model(&model.SiteTask{}).Count(&total)
h.db.Model(&model.SiteTask{}).Where("status = ?", "active").Count(&normal)
h.db.Model(&model.SiteTask{}).Where("status = ?", "error").Count(&abnormal)
h.db.Model(&model.SiteTask{}).Where("status = ?", model.SiteTaskStatusRunning).Count(&enabled)
h.db.Model(&model.SiteTask{}).Where("last_result = ?", "success").Count(&upCount)
h.db.Model(&model.SiteTask{}).Where("last_result IN ?", []string{"fail", "timeout"}).Count(&downCount)
// Calculate availability rate from recent results
var availRate float64
if total > 0 {
var successCount int64
h.db.Model(&model.SiteResult{}).
Where("created_at > datetime('now', '-1 hour') AND status = 'success'").
Count(&successCount)
var totalRecent int64
h.db.Model(&model.SiteResult{}).
Where("created_at > datetime('now', '-1 hour')").
Count(&totalRecent)
if totalRecent > 0 {
availRate = float64(successCount) / float64(totalRecent) * 100
}
var availability float64
since := time.Now().Add(-24 * time.Hour)
var successRecent, totalRecent int64
h.db.Model(&model.SiteResult{}).Where("checked_at >= ? AND status = ?", since, model.SiteResultSuccess).Count(&successRecent)
h.db.Model(&model.SiteResult{}).Where("checked_at >= ?", since).Count(&totalRecent)
if totalRecent > 0 {
availability = float64(successRecent) / float64(totalRecent)
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
"total": total,
"normal": normal,
"abnormal": abnormal,
"avail_rate": availRate,
"total_tasks": total,
"enabled_tasks": enabled,
"up_count": upCount,
"down_count": downCount,
"availability": availability,
"probe_count": totalRecent,
}})
}
}
+211
View File
@@ -0,0 +1,211 @@
package controller
import (
"strings"
"time"
"ezops/model"
"ezops/pkg/siteprobe"
"gorm.io/gorm"
)
// SiteTaskView 前端拨测任务视图(字段与前端对齐)// Logan.Li
type SiteTaskView struct {
ID uint `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Type string `json:"type"`
FrequencySec int `json:"frequency_sec"`
TimeoutSec int `json:"timeout_sec"`
ExpectedStatusCode int `json:"expected_status_code"`
Enabled bool `json:"enabled"`
LastStatus string `json:"last_status,omitempty"`
LastResponseTimeMs float64 `json:"last_response_time_ms,omitempty"`
LastCheckedAt *string `json:"last_checked_at,omitempty"`
SuccessRate float64 `json:"success_rate"`
AlertStatus string `json:"alert_status"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
SSLDaysRemaining *int `json:"ssl_days_remaining,omitempty"`
SSLCertExpiry *string `json:"ssl_cert_expiry,omitempty"`
}
func toSiteTaskView(task model.SiteTask) SiteTaskView {
taskType := normalizeTaskType(task)
enabled := task.Status == model.SiteTaskStatusRunning
lastStatus := "unknown"
switch task.LastResult {
case "success":
lastStatus = "up"
case "fail", "timeout":
lastStatus = "down"
}
var lastChecked *string
if task.LastCheckAt != nil {
s := task.LastCheckAt.Format("2006-01-02T15:04:05Z07:00")
lastChecked = &s
}
return SiteTaskView{
ID: task.ID,
Name: task.Name,
URL: task.URL,
Type: taskType,
FrequencySec: task.Frequency,
TimeoutSec: task.Timeout,
ExpectedStatusCode: task.ExpectedStatus,
Enabled: enabled,
LastStatus: lastStatus,
LastResponseTimeMs: task.AvgResponse,
LastCheckedAt: lastChecked,
SuccessRate: task.SuccessRate,
AlertStatus: "normal",
Status: string(task.Status),
CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}
// enrichSiteTaskViewsAlert 批量附加活跃告警状态 // Logan.Li
func enrichSiteTaskViewsAlert(db *gorm.DB, views []SiteTaskView) {
if len(views) == 0 {
return
}
ids := make([]uint, 0, len(views))
for _, v := range views {
ids = append(ids, v.ID)
}
type row struct {
TaskID uint
}
var rows []row
db.Model(&model.SiteAlert{}).
Select("DISTINCT task_id").
Where("task_id IN ? AND status = ?", ids, model.SiteAlertStatusActive).
Find(&rows)
alerting := make(map[uint]bool, len(rows))
for _, r := range rows {
alerting[r.TaskID] = true
}
for i := range views {
if alerting[views[i].ID] {
views[i].AlertStatus = "alerting"
}
}
}
func normalizeTaskType(task model.SiteTask) string {
t := strings.ToLower(string(task.TaskType))
if t == "https" {
return "HTTP"
}
if t != "" {
return strings.ToUpper(t)
}
return strings.ToUpper(siteprobe.InferTaskTypePublic(task))
}
type siteSSLInfo struct {
DaysRemaining int
CertExpiry *time.Time
}
// enrichSiteTaskViewsSSL 批量附加 SSL 拨测任务的证书到期信息 // Logan.Li
func enrichSiteTaskViewsSSL(db *gorm.DB, views []SiteTaskView) {
if len(views) == 0 {
return
}
ids := make([]uint, 0, len(views))
for _, v := range views {
if strings.ToUpper(v.Type) != "SSL" {
continue
}
ids = append(ids, v.ID)
}
if len(ids) == 0 {
return
}
type row struct {
TaskID uint
SSLDaysRemaining int
SSLCertExpiry *time.Time
}
var rows []row
db.Model(&model.SiteResult{}).
Select("task_id, ssl_days_remaining, ssl_cert_expiry").
Where("task_id IN ? AND ssl_cert_expiry IS NOT NULL", ids).
Order("checked_at DESC").
Find(&rows)
seen := map[uint]bool{}
sslMap := make(map[uint]siteSSLInfo, len(rows))
for _, r := range rows {
if seen[r.TaskID] {
continue
}
seen[r.TaskID] = true
sslMap[r.TaskID] = siteSSLInfo{DaysRemaining: r.SSLDaysRemaining, CertExpiry: r.SSLCertExpiry}
}
for i := range views {
if ssl, ok := sslMap[views[i].ID]; ok {
d := ssl.DaysRemaining
views[i].SSLDaysRemaining = &d
if ssl.CertExpiry != nil {
s := ssl.CertExpiry.Format("2006-01-02T15:04:05Z07:00")
views[i].SSLCertExpiry = &s
}
}
}
}
func parseCreateTaskRequest(raw map[string]interface{}) model.SiteTask {
getStr := func(keys ...string) string {
for _, k := range keys {
if v, ok := raw[k]; ok {
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
}
}
return ""
}
getInt := func(def int, keys ...string) int {
for _, k := range keys {
if v, ok := raw[k]; ok {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
}
}
}
return def
}
taskType := strings.ToLower(getStr("type", "task_type"))
switch taskType {
case "https":
taskType = "http"
case "":
taskType = "http"
}
task := model.SiteTask{
Name: getStr("name"),
URL: getStr("url"),
TaskType: model.SiteTaskType(taskType),
Method: model.SiteTaskMethodGET,
Timeout: getInt(10, "timeout_sec", "timeout"),
Frequency: getInt(60, "frequency_sec", "frequency"),
ExpectedStatus: getInt(200, "expected_status_code", "expected_status"),
FollowRedirect: true,
VerifySSL: true,
Status: model.SiteTaskStatusRunning,
}
if v, ok := raw["enabled"].(bool); ok && !v {
task.Status = model.SiteTaskStatusPaused
}
if v, ok := raw["dns_resolver"].(string); ok {
task.DNSResolver = strings.TrimSpace(v)
}
return task
}
+44 -8
View File
@@ -4,19 +4,22 @@ import (
"ezops/model"
"net/http"
"strconv"
"sync"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// SNMPHandler SNMP 设备管理 handler // Logan.Li
// SNMPHandler SNMP 设备管理 + 采集 handler // Logan.Li
type SNMPHandler struct {
db *gorm.DB
db *gorm.DB
stopCh chan struct{}
wg sync.WaitGroup
}
// NewSNMPHandler 创建 SNMP handler
func NewSNMPHandler(db *gorm.DB) *SNMPHandler {
return &SNMPHandler{db: db}
return &SNMPHandler{db: db, stopCh: make(chan struct{})}
}
// ========== SNMP Device CRUD ==========
@@ -46,11 +49,42 @@ func (h *SNMPHandler) ListDevices(c *gin.Context) {
// CreateDevice 创建 SNMP 设备
// POST /api/v1/snmp/devices
func (h *SNMPHandler) CreateDevice(c *gin.Context) {
var device model.SNMPDevice
if err := c.ShouldBindJSON(&device); err != nil {
var req struct {
Name string `json:"name" binding:"required"`
IP string `json:"ip" binding:"required"`
Port int `json:"port"`
SnmpVersion string `json:"snmp_version"`
Version string `json:"version"`
Community string `json:"community"`
TemplateID uint `json:"template_id"`
DeviceType string `json:"device_type"`
Vendor string `json:"vendor"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
ver := req.SnmpVersion
if ver == "" {
ver = req.Version
}
if ver == "" {
ver = "v2c"
}
device := model.SNMPDevice{
Name: req.Name, IP: req.IP, Port: req.Port,
Version: model.SNMPVersion(ver), Community: req.Community,
TemplateID: req.TemplateID, DeviceType: req.DeviceType,
Vendor: req.Vendor, Description: req.Description,
Status: model.SNMPDeviceOffline,
}
if device.Port == 0 {
device.Port = 161
}
if device.Community == "" {
device.Community = "public"
}
if err := h.db.Create(&device).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error()})
return
@@ -100,15 +134,17 @@ func (h *SNMPHandler) BatchDeleteDevices(c *gin.Context) {
// ========== SNMP Template CRUD ==========
// ListTemplates 列出 OID 模板
// ListTemplates 列出 OID 模板(含 OID 条目)// Logan.Li
// GET /api/v1/snmp/templates
func (h *SNMPHandler) ListTemplates(c *gin.Context) {
var templates []model.SNMPTemplate
h.db.Order("name ASC").Find(&templates)
h.db.Preload("OIDs", func(db *gorm.DB) *gorm.DB {
return db.Order("sort_order ASC")
}).Order("name ASC").Find(&templates)
if templates == nil {
templates = []model.SNMPTemplate{}
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: templates})
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": templates, "total": len(templates)}})
}
// CreateTemplate 创建 OID 模板
+201
View File
@@ -0,0 +1,201 @@
package controller
import (
"context"
"fmt"
"log"
"net/http"
"strconv"
"time"
"ezops/model"
snmppkg "ezops/pkg/snmp"
"github.com/gin-gonic/gin"
"github.com/gosnmp/gosnmp"
)
// collectDevice 采集单台 SNMP 设备并落库 // Logan.Li
func (h *SNMPHandler) collectDevice(device *model.SNMPDevice) error {
if device.TemplateID == 0 {
return fmt.Errorf("未绑定 OID 模板")
}
var oids []model.SNMPTemplateOID
h.db.Where("template_id = ?", device.TemplateID).Order("sort_order ASC").Find(&oids)
if len(oids) == 0 {
return fmt.Errorf("模板无 OID 定义")
}
col, err := h.newCollector(device)
if err != nil {
return err
}
defs := make([]snmppkg.OIDDefinition, 0, len(oids))
for _, o := range oids {
defs = append(defs, snmppkg.OIDDefinition{
Name: o.Name, OID: o.OID, Unit: o.Unit,
DataType: o.DataType, IsTable: o.Conversion == "table",
})
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(device.Timeout)*time.Second)
defer cancel()
result := col.Collect(ctx, defs)
now := time.Now()
ts := now.Unix()
updates := map[string]interface{}{
"last_collect_at": now,
}
if result.Success {
updates["status"] = model.SNMPDeviceOnline
updates["last_error"] = ""
for _, m := range result.Metrics {
h.db.Create(&model.SNMPMetric{
DeviceID: device.ID, Name: m.Name, OID: m.OID, Index: m.Index,
Value: m.Value, RawValue: m.RawValue, Unit: m.Unit,
DataType: m.DataType, Timestamp: ts,
})
}
} else {
updates["status"] = model.SNMPDeviceError
errMsg := result.Error
if errMsg == "" {
errMsg = "采集无数据"
}
updates["last_error"] = errMsg
}
h.db.Model(device).Updates(updates)
if !result.Success {
return fmt.Errorf("%s", updates["last_error"])
}
return nil
}
func (h *SNMPHandler) newCollector(device *model.SNMPDevice) (*snmppkg.Collector, error) {
timeout := device.Timeout
if timeout <= 0 {
timeout = 10
}
retries := device.Retries
if retries <= 0 {
retries = 2
}
port := uint16(device.Port)
if port == 0 {
port = 161
}
cfg := &snmppkg.CollectorConfig{
IP: device.IP, Port: port, Community: device.Community,
Timeout: time.Duration(timeout) * time.Second, Retries: retries,
MaxRepetitions: uint8(device.MaxRepetitions),
}
switch device.Version {
case model.SNMPVersionV1:
cfg.Version = gosnmp.Version1
case model.SNMPVersionV3:
cfg.Version = gosnmp.Version3
default:
cfg.Version = gosnmp.Version2c
}
return snmppkg.NewCollector(cfg)
}
// deviceCollectInterval 设备采集间隔,不低于全局最低值 // Logan.Li
func deviceCollectInterval(device *model.SNMPDevice) int {
interval := device.CollectInterval
if interval <= 0 {
interval = snmppkg.DefaultCollectIntervalSec
}
if interval < snmppkg.MinCollectIntervalSec {
interval = snmppkg.MinCollectIntervalSec
}
return interval
}
// shouldCollectDevice 判断是否到达该设备的采集周期 // Logan.Li
func shouldCollectDevice(device *model.SNMPDevice, now time.Time) bool {
if device.LastCollectAt == nil {
return true
}
interval := time.Duration(deviceCollectInterval(device)) * time.Second
return now.Sub(*device.LastCollectAt) >= interval
}
// StartCollector 启动 SNMP 后台采集 // Logan.Li
func (h *SNMPHandler) StartCollector(intervalSec int) {
if intervalSec < snmppkg.MinCollectIntervalSec {
intervalSec = snmppkg.DefaultCollectIntervalSec
}
h.wg.Add(1)
go func() {
defer h.wg.Done()
ticker := time.NewTicker(time.Duration(intervalSec) * time.Second)
defer ticker.Stop()
log.Printf("[SNMP] 采集引擎启动,间隔 %ds", intervalSec)
h.collectAll()
for {
select {
case <-ticker.C:
h.collectAll()
case <-h.stopCh:
log.Printf("[SNMP] 采集引擎停止")
return
}
}
}()
}
func (h *SNMPHandler) collectAll() {
var devices []model.SNMPDevice
h.db.Where("template_id > 0").Find(&devices)
now := time.Now()
for i := range devices {
if !shouldCollectDevice(&devices[i], now) {
continue
}
if err := h.collectDevice(&devices[i]); err != nil {
log.Printf("[SNMP] 采集失败 %s(%s): %v", devices[i].Name, devices[i].IP, err)
}
}
}
// CollectDeviceNow POST /snmp/devices/:id/collect
func (h *SNMPHandler) CollectDeviceNow(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var device model.SNMPDevice
if err := h.db.First(&device, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "设备不存在"})
return
}
if err := h.collectDevice(&device); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
h.db.First(&device, id)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: device, Message: "采集成功"})
}
// ListDeviceMetrics GET /snmp/devices/:id/metrics(仅返回最近一次采集批次) // Logan.Li
func (h *SNMPHandler) ListDeviceMetrics(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var latestTS int64
h.db.Model(&model.SNMPMetric{}).Where("device_id = ?", id).
Select("COALESCE(MAX(timestamp), 0)").Scan(&latestTS)
var metrics []model.SNMPMetric
if latestTS > 0 {
h.db.Where("device_id = ? AND timestamp = ?", id, latestTS).
Order("name ASC, `index` ASC").Limit(500).Find(&metrics)
}
if metrics == nil {
metrics = []model.SNMPMetric{}
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": metrics, "total": len(metrics)}})
}
// StopCollector 停止采集引擎 // Logan.Li
func (h *SNMPHandler) StopCollector() {
close(h.stopCh)
h.wg.Wait()
}
+273
View File
@@ -0,0 +1,273 @@
package controller
import (
"net/http"
"strconv"
"time"
"ezops/model"
snmppkg "ezops/pkg/snmp"
"github.com/gin-gonic/gin"
)
// snmpChartableNames 适合折线图展示的标量指标 // Logan.Li
var snmpChartableNames = []string{
"h3c_cpu_usage", "hw_cpu_usage", "cisco_cpu_5min", "ruijie_cpu_usage", "inspur_cpu_usage",
"h3c_mem_usage", "hw_mem_usage", "ruijie_mem_usage", "inspur_mem_usage",
"h3c_temperature", "hw_temperature", "ruijie_temperature", "dell_temperature", "inspur_temperature",
"h3c_fan_speed", "hw_fan_speed", "ruijie_fan_speed", "dell_fan_speed", "inspur_fan_speed",
"cisco_sensor_value", "ent_sensor_value",
"if_in_octets", "if_out_octets",
}
// snmpCounterMetrics 累计型 counter,历史查询需差分换算速率 // Logan.Li
var snmpCounterMetrics = map[string]bool{
"if_in_octets": true,
"if_out_octets": true,
}
type snmpMetricSeries struct {
Name string `json:"name"`
Index string `json:"index,omitempty"`
Unit string `json:"unit,omitempty"`
Label string `json:"label"`
LatestValue float64 `json:"latest_value"`
}
type snmpHistoryPoint struct {
Timestamp int64 `json:"timestamp"`
Value float64 `json:"value"`
Unit string `json:"unit,omitempty"`
}
type snmpOverviewItem struct {
DeviceID uint `json:"device_id"`
Name string `json:"name"`
IP string `json:"ip"`
Status string `json:"status"`
CPU float64 `json:"cpu,omitempty"`
Mem float64 `json:"mem,omitempty"`
Temp float64 `json:"temp,omitempty"`
}
// ListMetricSeries GET /snmp/devices/:id/metrics/series — 可绘图指标序列 // Logan.Li
func (h *SNMPHandler) ListMetricSeries(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var device model.SNMPDevice
if err := h.db.First(&device, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "设备不存在"})
return
}
var latestTS int64
h.db.Model(&model.SNMPMetric{}).Where("device_id = ?", id).
Select("COALESCE(MAX(timestamp), 0)").Scan(&latestTS)
if latestTS == 0 {
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": []snmpMetricSeries{}, "total": 0}})
return
}
type row struct {
Name string
Index string
Unit string
LatestValue float64
}
var rows []row
h.db.Model(&model.SNMPMetric{}).
Select("name, `index`, unit, MAX(value) as latest_value").
Where("device_id = ? AND timestamp = ? AND name IN ?", id, latestTS, snmpChartableNames).
Group("name, `index`, unit").
Order("name ASC, `index` ASC").
Scan(&rows)
series := make([]snmpMetricSeries, 0, len(rows))
for _, r := range rows {
label := snmpMetricLabel(r.Name, r.Index)
series = append(series, snmpMetricSeries{
Name: r.Name, Index: r.Index, Unit: r.Unit,
Label: label, LatestValue: r.LatestValue,
})
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": series, "total": len(series)}})
}
// QueryMetricHistory GET /snmp/devices/:id/metrics/history — 指标时序 // Logan.Li
func (h *SNMPHandler) QueryMetricHistory(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
metricName := c.Query("metric_name")
if metricName == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "metric_name 不能为空"})
return
}
index := c.Query("index")
hours, _ := strconv.Atoi(c.DefaultQuery("hours", "168"))
if hours <= 0 {
hours = 168
}
if hours > snmppkg.MetricsRetentionDays*24 {
hours = snmppkg.MetricsRetentionDays * 24
}
fromTS := time.Now().Add(-time.Duration(hours) * time.Hour).Unix()
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "500"))
if limit <= 0 || limit > 2000 {
limit = 500
}
q := h.db.Model(&model.SNMPMetric{}).
Where("device_id = ? AND name = ? AND timestamp >= ?", id, metricName, fromTS)
if index != "" {
q = q.Where("`index` = ?", index)
}
var points []snmpHistoryPoint
if index != "" {
var metrics []model.SNMPMetric
q.Order("timestamp ASC").Limit(limit).Find(&metrics)
points = make([]snmpHistoryPoint, 0, len(metrics))
for _, m := range metrics {
points = append(points, snmpHistoryPoint{
Timestamp: m.Timestamp, Value: m.Value, Unit: m.Unit,
})
}
} else {
type aggRow struct {
Timestamp int64
Value float64
Unit string
}
var rows []aggRow
h.db.Model(&model.SNMPMetric{}).
Select("timestamp, AVG(value) as value, MAX(unit) as unit").
Where("device_id = ? AND name = ? AND timestamp >= ?", id, metricName, fromTS).
Group("timestamp").
Order("timestamp ASC").
Limit(limit).
Scan(&rows)
points = make([]snmpHistoryPoint, 0, len(rows))
for _, r := range rows {
points = append(points, snmpHistoryPoint{
Timestamp: r.Timestamp, Value: r.Value, Unit: r.Unit,
})
}
}
if points == nil {
points = []snmpHistoryPoint{}
}
// counter 指标差分换算为 bps(bits per second)// Logan.Li
rateUnit := ""
if snmpCounterMetrics[metricName] && len(points) > 1 {
points = snmpCounterToRate(points)
rateUnit = "bps"
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
"list": points, "total": len(points),
"metric_name": metricName, "index": index, "hours": hours,
"rate_unit": rateUnit,
}})
}
// snmpCounterToRate 将累计字节 counter 差分为比特速率 // Logan.Li
func snmpCounterToRate(points []snmpHistoryPoint) []snmpHistoryPoint {
if len(points) < 2 {
return points
}
out := make([]snmpHistoryPoint, 0, len(points)-1)
for i := 1; i < len(points); i++ {
prev, cur := points[i-1], points[i]
dt := float64(cur.Timestamp - prev.Timestamp)
if dt <= 0 {
continue
}
delta := cur.Value - prev.Value
if delta < 0 {
// counter 回绕,跳过该点 // Logan.Li
continue
}
bps := (delta * 8) / dt
out = append(out, snmpHistoryPoint{
Timestamp: cur.Timestamp,
Value: bps,
Unit: "bps",
})
}
if len(out) == 0 {
return []snmpHistoryPoint{}
}
return out
}
// GetOverviewMetrics GET /snmp/overview/metrics — 各设备最新关键指标(图表概览) // Logan.Li
func (h *SNMPHandler) GetOverviewMetrics(c *gin.Context) {
var devices []model.SNMPDevice
h.db.Where("template_id > 0").Order("name ASC").Find(&devices)
cpuNames := map[string]bool{
"h3c_cpu_usage": true, "hw_cpu_usage": true, "cisco_cpu_5min": true,
"ruijie_cpu_usage": true, "inspur_cpu_usage": true,
}
memNames := map[string]bool{
"h3c_mem_usage": true, "hw_mem_usage": true, "ruijie_mem_usage": true, "inspur_mem_usage": true,
}
tempNames := map[string]bool{
"h3c_temperature": true, "hw_temperature": true, "ruijie_temperature": true,
"dell_temperature": true, "inspur_temperature": true, "cisco_sensor_value": true,
}
items := make([]snmpOverviewItem, 0, len(devices))
for _, d := range devices {
item := snmpOverviewItem{
DeviceID: d.ID, Name: d.Name, IP: d.IP, Status: string(d.Status),
}
var latestTS int64
h.db.Model(&model.SNMPMetric{}).Where("device_id = ?", d.ID).
Select("COALESCE(MAX(timestamp), 0)").Scan(&latestTS)
if latestTS == 0 {
items = append(items, item)
continue
}
var metrics []model.SNMPMetric
h.db.Where("device_id = ? AND timestamp = ? AND name IN ?", d.ID, latestTS, snmpChartableNames).
Find(&metrics)
for _, m := range metrics {
switch {
case cpuNames[m.Name] && m.Value > item.CPU:
item.CPU = m.Value
case memNames[m.Name] && m.Value > item.Mem:
item.Mem = m.Value
case tempNames[m.Name] && m.Value > item.Temp:
item.Temp = m.Value
}
}
items = append(items, item)
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": items, "total": len(items)}})
}
func snmpMetricLabel(name, index string) string {
labels := map[string]string{
"h3c_cpu_usage": "H3C CPU", "hw_cpu_usage": "华为 CPU", "cisco_cpu_5min": "Cisco CPU",
"ruijie_cpu_usage": "锐捷 CPU", "inspur_cpu_usage": "浪潮 CPU",
"h3c_mem_usage": "H3C 内存", "hw_mem_usage": "华为 内存",
"ruijie_mem_usage": "锐捷 内存", "inspur_mem_usage": "浪潮 内存",
"h3c_temperature": "H3C 温度", "hw_temperature": "华为 温度",
"ruijie_temperature": "锐捷 温度", "dell_temperature": "Dell 温度",
"inspur_temperature": "浪潮 温度", "cisco_sensor_value": "Cisco 传感器",
"h3c_fan_speed": "H3C 风扇", "hw_fan_speed": "华为 风扇",
"ruijie_fan_speed": "锐捷 风扇", "dell_fan_speed": "Dell 风扇", "inspur_fan_speed": "浪潮 风扇",
"ent_sensor_value": "实体传感器",
"if_in_octets": "入方向流量", "if_out_octets": "出方向流量",
}
label := labels[name]
if label == "" {
label = name
}
if index != "" {
label += " #" + index
}
return label
}
+53
View File
@@ -0,0 +1,53 @@
package controller
import (
"net/http"
"ezops/model"
"ezops/pkg/systask"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// SystemTaskHandler 内置系统任务查询 // Logan.Li
type SystemTaskHandler struct {
db *gorm.DB
dbDriver string
alertEngine *AlertEngine
}
func NewSystemTaskHandler(db *gorm.DB, driver string, alertEngine *AlertEngine) *SystemTaskHandler {
return &SystemTaskHandler{db: db, dbDriver: driver, alertEngine: alertEngine}
}
// ListSystemTasks 获取系统内置任务列表 // Logan.Li
// GET /api/v1/system-tasks
func (h *SystemTaskHandler) ListSystemTasks(c *gin.Context) {
extras := map[string]any{}
if h.alertEngine != nil {
extras["alert_engine"] = h.alertEngine.GetEngineStatus()
}
list := systask.List(h.db, extras)
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
Data: model.ListResponse{List: list, Total: int64(len(list))},
})
}
// RunSystemTask 手动执行系统任务 // Logan.Li
// POST /api/v1/system-tasks/:code/run
func (h *SystemTaskHandler) RunSystemTask(c *gin.Context) {
code := c.Param("code")
item, err := systask.RunManual(h.db, h.dbDriver, code)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "执行完成",
Data: item,
})
}
+50
View File
@@ -249,6 +249,56 @@ func (h *UserHandler) DeleteUser(c *gin.Context) {
})
}
// BatchDeleteUsers 批量删除用户 // Logan.Li
// POST /api/v1/admin/users/batch-delete
func (h *UserHandler) BatchDeleteUsers(c *gin.Context) {
var req model.IDsRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供要删除的用户 ID 列表"})
return
}
currentUID, _ := c.Get("userID")
currentID, _ := currentUID.(uint)
deleted := 0
skipped := 0
var skippedNames []string
for _, id := range req.IDs {
if uint(id) == currentID {
skipped++
skippedNames = append(skippedNames, "当前登录用户")
continue
}
var user model.User
if err := h.db.First(&user, id).Error; err != nil {
skipped++
continue
}
if user.IsAdmin {
skipped++
skippedNames = append(skippedNames, user.Username)
continue
}
if err := h.db.Delete(&user).Error; err != nil {
skipped++
continue
}
deleted++
}
msg := fmt.Sprintf("已删除 %d 个用户", deleted)
if skipped > 0 {
msg += fmt.Sprintf(",跳过 %d 个", skipped)
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: msg,
Data: gin.H{"deleted": deleted, "skipped": skipped, "skipped_names": skippedNames},
})
}
// BatchImportUsers 批量导入用户
// POST /api/v1/admin/users/batch-import
func (h *UserHandler) BatchImportUsers(c *gin.Context) {
+7 -5
View File
@@ -145,15 +145,17 @@ type UpdateAlertContactRequest struct {
Email *string `json:"email"`
}
// CreateAlertContactGroupRequest 创建告警联系组
// CreateAlertContactGroupRequest 创建告警联系组 // Logan.Li
type CreateAlertContactGroupRequest struct {
Name string `json:"name" binding:"required"`
ContactIDs []uint `json:"contact_ids"`
GroupName string `json:"group_name" binding:"required"`
Notes string `json:"notes"`
ContactIDs []uint `json:"contact_ids"`
}
// UpdateAlertContactGroupRequest 更新告警联系组
// UpdateAlertContactGroupRequest 更新告警联系组 // Logan.Li
type UpdateAlertContactGroupRequest struct {
Name *string `json:"name"`
GroupName *string `json:"group_name"`
Notes *string `json:"notes"`
ContactIDs *[]uint `json:"contact_ids"`
}
+1
View File
@@ -58,6 +58,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
+2
View File
@@ -140,6 +140,8 @@ github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93Ge
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
+96 -5
View File
@@ -12,11 +12,17 @@ import (
"syscall"
"time"
"ezops/controller"
"ezops/router"
"ezops/config"
"ezops/model"
"ezops/seed"
"ezops/pkg/appdir"
"ezops/pkg/auditcleaner"
"ezops/pkg/database"
snmppkg "ezops/pkg/snmp"
"ezops/pkg/cronscheduler"
"ezops/pkg/systask"
"ezops/pkg/logger"
ezredis "ezops/pkg/redis"
@@ -144,6 +150,8 @@ func main() {
// 初始化种子数据
seed.SeedData(db)
// 中间件监控默认目标(aiops-redis / aiops-mysql)// Logan.Li
controller.SeedDefaultMiddlewareTargets(db)
// 初始化缓存(Redka 内嵌 / Redis 外部)// Logan.Li
cache, err := ezredis.New(ezredis.Config{
@@ -197,19 +205,102 @@ func main() {
c.JSON(http.StatusOK, resp)
})
systask.Init(db)
// 注册路由
router.Register(r, db, log, cfg, cache)
// 用户定时任务调度(Linux 5 段 crontab)// Logan.Li
cronSched := cronscheduler.Start(db, func(jobID uint) {
if _, err := controller.RunCronJobByID(db, jobID); err != nil {
log.Warn("定时任务自动执行失败", zap.Uint("job_id", jobID), zap.Error(err))
}
})
defer cronSched.Stop()
// 定期清理旧的 agent 上报数据(保留 7 天,防止表膨胀影响查询性能)// Logan.Li
go func() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
cleanupSQL := database.CleanupAgentReportsSQL(cfg.Database.Driver)
for range ticker.C {
rows := db.Exec(cleanupSQL).RowsAffected
if rows > 0 {
log.Info("清理过期的 agent 上报数据", zap.Int64("rows", rows))
runAgentCleanup := func() {
start := time.Now()
cleanupSQL := database.CleanupAgentReportsSQL(cfg.Database.Driver)
r := db.Exec(cleanupSQL)
msg := fmt.Sprintf("清理 %d 条记录", r.RowsAffected)
status := "success"
if r.Error != nil {
status = "failed"
msg = r.Error.Error()
log.Warn("Agent 上报清理失败", zap.Error(r.Error))
} else if r.RowsAffected > 0 {
log.Info("清理过期的 agent 上报数据", zap.Int64("rows", r.RowsAffected))
}
systask.Record("agent_report_cleanup", status, msg, time.Since(start))
}
time.Sleep(2 * time.Minute)
runAgentCleanup()
for range ticker.C {
runAgentCleanup()
}
}()
// 定期清理超期 SNMP 采集指标(保留 7 天) // Logan.Li
go func() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
runSNMPCleanup := func() {
start := time.Now()
cleanupSQL := database.CleanupSNMPMetricsSQL(cfg.Database.Driver, snmppkg.MetricsRetentionDays)
r := db.Exec(cleanupSQL)
msg := fmt.Sprintf("清理 %d 条 SNMP 指标(保留 %d 天)", r.RowsAffected, snmppkg.MetricsRetentionDays)
status := "success"
if r.Error != nil {
status = "failed"
msg = r.Error.Error()
log.Warn("SNMP 指标清理失败", zap.Error(r.Error))
} else if r.RowsAffected > 0 {
log.Info("清理过期的 SNMP 指标", zap.Int64("rows", r.RowsAffected))
}
systask.Record("snmp_metrics_cleanup", status, msg, time.Since(start))
}
time.Sleep(3 * time.Minute)
runSNMPCleanup()
for range ticker.C {
runSNMPCleanup()
}
}()
// 每日按审计保留策略清理操作/登录/终端审计日志 // Logan.Li
go func() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
runAuditRetentionCleanup := func() {
start := time.Now()
result, err := auditcleaner.RunFromSettings(db)
if err != nil {
systask.Record("audit_log_cleanup", "failed", err.Error(), time.Since(start))
log.Warn("审计日志定时清理失败", zap.Error(err))
return
}
msg := fmt.Sprintf("清理 %d 条(保留 %d 天)", result.Total, result.RetentionDays)
systask.Record("audit_log_cleanup", "success", msg, time.Since(start))
if result.Total > 0 {
log.Info("审计日志定时清理完成",
zap.Int("retention_days", result.RetentionDays),
zap.Int64("total", result.Total),
)
}
now := time.Now()
summary := result.SummaryJSON()
db.Model(&model.AuditSettings{}).Where("id > 0").Updates(map[string]interface{}{
"last_cleanup_at": now,
"last_cleanup_summary": summary,
})
}
time.Sleep(time.Minute)
runAuditRetentionCleanup()
for range ticker.C {
runAuditRetentionCleanup()
}
}()
+60
View File
@@ -0,0 +1,60 @@
// 自动化运维写操作权限 — 脚本库 / 定时任务 // Logan.Li
package middleware
import (
"net/http"
"strings"
"ezops/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AutomationWrite 创建/修改/删除/执行脚本与定时任务需具备自动化运维权限 // Logan.Li
func AutomationWrite(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
if isAdmin, _ := c.Get("isAdmin"); isAdmin == true {
c.Next()
return
}
userID, ok := c.Get("userID")
if !ok {
c.JSON(http.StatusUnauthorized, model.Response{Code: 401, Message: "未认证"})
c.Abort()
return
}
codes := collectPermissionCodes(db, userID.(uint))
for _, code := range codes {
if code == "*" || strings.HasPrefix(code, "auto:") {
c.Next()
return
}
}
c.JSON(http.StatusForbidden, model.Response{
Code: 403,
Message: "需要自动化运维权限(auto:script 或 auto:cron),请联系管理员分配角色",
})
c.Abort()
}
}
// collectPermissionCodes 查询用户有效权限码 // Logan.Li
func collectPermissionCodes(db *gorm.DB, userID uint) []string {
var rows []struct{ Code string }
db.Raw(`
SELECT DISTINCT p.code AS code
FROM iam_user_roles ur
JOIN iam_roles r ON r.id = ur.role_id AND r.status = 1
JOIN role_permissions rp ON rp.role_id = r.id
JOIN iam_permissions p ON p.id = rp.permission_id AND p.status = 1
WHERE ur.user_id = ?
`, userID).Scan(&rows)
codes := make([]string, 0, len(rows))
for _, r := range rows {
if r.Code != "" {
codes = append(codes, r.Code)
}
}
return codes
}
+34
View File
@@ -0,0 +1,34 @@
package model
import "time"
// AlertNotifyCategory 告警通知策略分类(与监控中心四 Tab 对齐)// Logan.Li
type AlertNotifyCategory string
const (
AlertNotifyHost AlertNotifyCategory = "host"
AlertNotifyMiddleware AlertNotifyCategory = "middleware"
AlertNotifySite AlertNotifyCategory = "site"
AlertNotifySNMP AlertNotifyCategory = "snmp"
)
// AllAlertNotifyCategories 全部告警集合 // Logan.Li
var AllAlertNotifyCategories = []AlertNotifyCategory{
AlertNotifyHost, AlertNotifyMiddleware, AlertNotifySite, AlertNotifySNMP,
}
// AlertNotifyPolicy 告警集合级通知策略 — 绑定联系组/联系人 // Logan.Li
type AlertNotifyPolicy struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Category AlertNotifyCategory `gorm:"type:varchar(16);not null;uniqueIndex" json:"category"`
Label string `gorm:"type:varchar(64)" json:"label"`
Enabled bool `gorm:"default:false" json:"enabled"`
ContactGroupIDs string `gorm:"type:text;comment:联系组ID列表JSON" json:"contact_group_ids,omitempty"`
ContactIDs string `gorm:"type:text;comment:额外联系人ID列表JSON" json:"contact_ids,omitempty"`
NotifyMode string `gorm:"type:varchar(16);default:every;comment:every/once" json:"notify_mode"`
Description string `gorm:"type:varchar(256)" json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (AlertNotifyPolicy) TableName() string { return "monitor_alert_notify_policies" }
+15
View File
@@ -0,0 +1,15 @@
package model
import "time"
// AlertNotifySettings 告警消息模版(全局单行配置) // Logan.Li
type AlertNotifySettings struct {
ID uint `gorm:"primaryKey" json:"id"`
TitleTemplate string `gorm:"type:varchar(256);comment:标题模版" json:"title_template"`
FooterTemplate string `gorm:"type:varchar(256);comment:卡片底部模版" json:"footer_template"`
PlatformName string `gorm:"type:varchar(64);default:AIOps 智能运维平台;comment:平台署名" json:"platform_name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (AlertNotifySettings) TableName() string { return "monitor_alert_notify_settings" }
+29
View File
@@ -0,0 +1,29 @@
package model
import "time"
// AuditSettings 审计策略配置(单行) // Logan.Li
type AuditSettings struct {
ID uint `gorm:"primarykey" json:"id"`
RetentionDays int `gorm:"default:180;not null" json:"retention_days"`
RecordAll bool `gorm:"default:true" json:"record_all"`
LastCleanupAt *time.Time `json:"last_cleanup_at,omitempty"`
LastCleanupSummary string `gorm:"size:500" json:"last_cleanup_summary,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (AuditSettings) TableName() string { return "sys_audit_settings" }
// AllowedAuditRetentionDays 允许的日志保留天数 // Logan.Li
var AllowedAuditRetentionDays = []int{7, 15, 30, 90, 180, 365}
// IsValidRetentionDays 校验保留天数 // Logan.Li
func IsValidRetentionDays(days int) bool {
for _, d := range AllowedAuditRetentionDays {
if d == days {
return true
}
}
return false
}
+3 -3
View File
@@ -88,7 +88,7 @@ type CreateCronJobRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
CronExpr string `json:"cron_expr" binding:"required"`
Type string `json:"type" binding:"required,oneof=shell python command"`
Type string `json:"type" binding:"required,oneof=shell python powershell command bash"`
Content string `json:"content"`
ScriptID *uint `json:"script_id"`
TargetType string `json:"target_type" binding:"omitempty,oneof=server group tag"`
@@ -100,9 +100,9 @@ type CreateCronJobRequest struct {
// CreateScriptRequest 创建脚本请求
type CreateScriptRequest struct {
Name string `json:"name" binding:"required"`
Code string `json:"code" binding:"required"`
Code string `json:"code"` // 为空时由 handler 自动生成 // Logan.Li
Description string `json:"description"`
Type string `json:"type" binding:"omitempty,oneof=shell python"`
Type string `json:"type" binding:"omitempty,oneof=shell python powershell command bash"`
Content string `json:"content" binding:"required"`
Params string `json:"params"`
Timeout int `json:"timeout"`
+18 -7
View File
@@ -58,13 +58,24 @@ type StatusRequest struct {
// DashboardStats 仪表盘统计
type DashboardStats struct {
ServerStats ServerStats `json:"server_stats"`
K8sStats KubernetesClusterStats `json:"kubernetes_stats"`
AlertStats AlertStats `json:"alert_stats"`
PipelineStats PipelineStats `json:"pipeline_stats"`
CostStats CostStats `json:"cost_stats"`
TopResourceUsage TopResourceUsage `json:"top_resource_usage"`
ExpiringCerts []DomainCert `json:"expiring_certs"`
ServerStats ServerStats `json:"server_stats"`
K8sStats KubernetesClusterStats `json:"kubernetes_stats"`
AlertStats AlertStats `json:"alert_stats"`
PipelineStats PipelineStats `json:"pipeline_stats"`
AutomationStats AutomationStats `json:"automation_stats"`
OpsStats OpsOverviewStats `json:"ops_stats"`
CostStats CostStats `json:"cost_stats"`
TopResourceUsage TopResourceUsage `json:"top_resource_usage"`
ExpiringCerts []DomainCert `json:"expiring_certs"`
}
// OpsOverviewStats 运维概览统计(仪表盘快捷入口)// Logan.Li
type OpsOverviewStats struct {
DomainCount int64 `json:"domain_count"`
SiteTaskCount int64 `json:"site_task_count"`
MiddlewareCount int64 `json:"middleware_count"`
SnmpDeviceCount int64 `json:"snmp_device_count"`
MonitorTargetCount int64 `json:"monitor_target_count"`
}
// CostStats 成本统计
+9 -7
View File
@@ -179,13 +179,15 @@ type CreateKubernetesClusterRequest struct {
// KubernetesClusterStats K8s集群统计
type KubernetesClusterStats struct {
Total int64 `json:"total"`
Healthy int64 `json:"healthy"`
Warning int64 `json:"warning"`
Error int64 `json:"error"`
TotalNodes int64 `json:"total_nodes"`
TotalPods int64 `json:"total_pods"`
TotalServices int64 `json:"total_services"`
Total int64 `json:"total"`
Healthy int64 `json:"healthy"`
Warning int64 `json:"warning"`
Error int64 `json:"error"`
TotalNodes int64 `json:"total_nodes"`
TotalPods int64 `json:"total_pods"`
TotalServices int64 `json:"total_services"`
TotalIngresses int64 `json:"total_ingresses"`
TotalDeployments int64 `json:"total_deployments"`
}
// TopologyLayout 拓扑布局(保存节点位置 + 自定义节点 + 自定义连线) // Logan.Li
+1 -1
View File
@@ -6,7 +6,7 @@ import "time"
// 支持 OpenAI 兼容接口(OpenAI / DeepSeek / 智谱GLM / 通义千问 / Moonshot / Kimi / 小米MiMo / Anthropic(需代理) / Ollama / 自定义)
type LLMConfig struct {
ID uint `gorm:"primarykey" json:"id"`
Provider string `gorm:"size:50;not null;default:openai" json:"provider"` // openai/deepseek/glm/qwen/moonshot/kimi/mimo/anthropic/ollama/custom
Provider string `gorm:"size:50;not null;default:openai" json:"provider"` // deepseek/glm/glm-coding/kimi/moonshot/minimax/minimax-coding/mimo/siliconflow/openai/anthropic/ollama/vllm/custom
Model string `gorm:"size:100;not null;default:gpt-4o-mini" json:"model"` // 模型名,如 gpt-4o-mini / deepseek-v4-flash
APIKey string `gorm:"size:500" json:"api_key"` // API Key(GET 时会被脱敏)
Endpoint string `gorm:"size:500" json:"endpoint"` // 基础地址,如 https://api.openai.com/v1
+34
View File
@@ -0,0 +1,34 @@
package model
import "time"
// MonitorAlertCategory 监控告警规则分类 // Logan.Li
type MonitorAlertCategory string
const (
MonitorAlertHost MonitorAlertCategory = "host"
MonitorAlertMiddleware MonitorAlertCategory = "middleware"
MonitorAlertSNMP MonitorAlertCategory = "snmp"
MonitorAlertSite MonitorAlertCategory = "site"
)
// MonitorAlertRule 中间件/站点拨测告警规则(主机规则仍用 AgentRule)// Logan.Li
type MonitorAlertRule struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Category MonitorAlertCategory `gorm:"type:varchar(16);not null;index" json:"category"`
Name string `gorm:"type:varchar(128);not null" json:"name"`
TargetID uint `gorm:"default:0;index;comment:目标ID,0=全部" json:"target_id"`
Metric string `gorm:"type:varchar(64);not null" json:"metric"`
Operator string `gorm:"type:varchar(8);default:>" json:"operator"`
Threshold float64 `gorm:"type:decimal(12,4)" json:"threshold"`
Severity string `gorm:"type:varchar(16);default:warning" json:"severity"`
Enabled bool `gorm:"default:true" json:"enabled"`
DurationSec int `gorm:"default:0;comment:持续秒数(预留)" json:"duration_sec"`
Port int `gorm:"default:0" json:"port,omitempty"`
Description string `gorm:"type:varchar(256)" json:"description,omitempty"`
LastTriggered *time.Time `json:"last_triggered,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (MonitorAlertRule) TableName() string { return "monitor_alert_rules" }
+2 -1
View File
@@ -12,7 +12,8 @@ import (
type SiteTaskType string
const (
SiteTaskTypeHTTP SiteTaskType = "http" // HTTP/HTTPS拨测
SiteTaskTypeHTTP SiteTaskType = "http" // HTTP/HTTPS Web 拨测
SiteTaskTypeSSL SiteTaskType = "ssl" // SSL/TLS 证书拨测
SiteTaskTypeTCP SiteTaskType = "tcp" // TCP拨测
SiteTaskTypePing SiteTaskType = "ping" // ICMP Ping拨测
SiteTaskTypeDNS SiteTaskType = "dns" // DNS拨测
+16
View File
@@ -0,0 +1,16 @@
package model
import "time"
// SystemTaskStatus 内置系统任务运行状态(按 code 唯一) // Logan.Li
type SystemTaskStatus struct {
Code string `gorm:"primaryKey;size:64" json:"code"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
LastStatus string `gorm:"size:32" json:"last_status"` // success / failed / running
LastMessage string `gorm:"size:500" json:"last_message,omitempty"`
RunCount int64 `gorm:"default:0" json:"run_count"`
LastDurationMs int `json:"last_duration_ms"`
UpdatedAt time.Time `json:"updated_at"`
}
func (SystemTaskStatus) TableName() string { return "sys_system_task_status" }
+41 -11
View File
@@ -165,17 +165,13 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M
// 裁剪历史
trimmed := a.trimMessages(messages)
// 调用 LLM(流式推送思考增量,降低长推理等待感) // Logan.Li
// ReAct 须用 Complete:Stream 无法组装 tool_calls/tool_use(GLM/Claude 等会只回文字不执行) // Logan.Li
round := a.roundNum(iter)
resp, err := a.llm.Stream(ctx, &llm.CompletionRequest{
resp, err := a.llm.Complete(ctx, &llm.CompletionRequest{
Messages: trimmed,
Tools: llmTools,
Temperature: a.config.Temperature,
MaxTokens: a.config.MaxTokens,
}, func(delta string) {
if strings.TrimSpace(delta) != "" {
a.emit(Event{Type: EventThoughtDelta, Round: round, Thought: delta})
}
})
if err != nil {
a.emit(Event{Type: EventError, Message: "LLM 调用失败: " + err.Error()})
@@ -185,15 +181,32 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M
a.totalTokens += resp.Usage.TotalTokens
a.emit(Event{Type: EventProgress, Tokens: a.totalTokens})
// 推送完整思考文本(供持久化与回放) // Logan.Li
if strings.TrimSpace(resp.Message.Content) != "" {
a.emit(Event{Type: EventThought, Round: round, Thought: resp.Message.Content})
}
messages = append(messages, resp.Message)
// 无工具调用:首轮若仅描述计划未动手,注入提示重试一次 // Logan.Li
if len(resp.Message.ToolCalls) == 0 && totalToolCalls == 0 && iter < 2 {
// 计划阶段可展示简短思考,便于用户感知进度 // Logan.Li
if thought := strings.TrimSpace(resp.Message.Content); thought != "" {
a.emit(Event{Type: EventThought, Round: round, Thought: thought})
}
messages = append(messages, llm.Message{
Role: "user",
Content: "【系统提示】请立即调用 ssh_execute 工具在目标主机执行命令获取实际结果,不要仅用文字描述计划。",
})
continue
}
// 无工具调用 → 最终答案
if len(resp.Message.ToolCalls) == 0 {
answer := resp.Message.Content
answer := strings.TrimSpace(resp.Message.Content)
// 部分模型最终轮 content 为空,回退到最近工具输出或默认文案 // Logan.Li
if answer == "" {
if fallback := lastToolOutput(messages); fallback != "" {
answer = "任务已执行完成,命令输出如下:\n" + fallback
} else {
answer = "任务已完成。"
}
}
a.emit(Event{Type: EventDone, Answer: answer, Tokens: a.totalTokens})
a.triggerEvolve(ctx, query, serverInfo, answer, toolRecords, true, start)
return &RunResult{
@@ -202,6 +215,11 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M
}, nil
}
// 中间轮思考(后续还有 tool_calls,与终局 done 分开,避免重复输出) // Logan.Li
if thought := strings.TrimSpace(resp.Message.Content); thought != "" {
a.emit(Event{Type: EventThought, Round: round, Thought: thought})
}
// 处理工具调用
observations, records, failInc := a.processToolCalls(ctx, resp.Message.ToolCalls, toolCallHistory, round)
toolRecords = append(toolRecords, records...)
@@ -346,6 +364,18 @@ func (a *Agent) roundNum(iter int) int {
return a.config.RoundOffset + iter + 1
}
// lastToolOutput 取最近一条工具 observation,供最终答案为空时回退展示 // Logan.Li
func lastToolOutput(messages []llm.Message) string {
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "tool" {
if out := strings.TrimSpace(messages[i].Content); out != "" {
return out
}
}
}
return ""
}
// extractHistory 提取 system 之后的对话历史(供多轮追问) // Logan.Li
func (a *Agent) extractHistory(messages []llm.Message) []llm.Message {
if len(messages) <= 1 {
+2 -2
View File
@@ -245,7 +245,7 @@ func (c *AnthropicClient) Complete(ctx context.Context, req *CompletionRequest)
var toolCalls []ToolCall
for _, b := range parsed.Content {
switch b.Type {
case "text":
case "text", "thinking":
textBuf.WriteString(b.Text)
case "tool_use":
args := string(b.Input)
@@ -276,7 +276,7 @@ func (c *AnthropicClient) Complete(ctx context.Context, req *CompletionRequest)
// Stream 流式补全(Anthropic SSE) // Logan.Li
// 逐 token 回调 text_delta;usage 从 message_start(input) + message_delta(output) 取。
// 注:流式主要用于纯对话(智能助手),tool_use 流式增量在此不组装(Agent CLI 走 Complete)。
// 注:流式不组装 tool_use,仅用于智能助手纯对话;Agent CLI ReAct 循环必须使用 Complete。
func (c *AnthropicClient) Stream(ctx context.Context, req *CompletionRequest, onDelta func(string)) (*CompletionResponse, error) {
body, err := c.buildRequest(req, true)
if err != nil {
+79
View File
@@ -0,0 +1,79 @@
// 审计日志按保留天数清理 — 操作审计 / 登录日志 / 终端会话 / 会话命令 // Logan.Li
package auditcleaner
import (
"encoding/json"
"fmt"
"time"
"ezops/model"
"gorm.io/gorm"
)
// Result 单次清理统计 // Logan.Li
type Result struct {
RetentionDays int `json:"retention_days"`
CutoffTime time.Time `json:"cutoff_time"`
AuditLogs int64 `json:"audit_logs"`
LoginLogs int64 `json:"login_logs"`
TerminalSessions int64 `json:"terminal_sessions"`
SessionCommands int64 `json:"session_commands"`
Total int64 `json:"total"`
}
// SummaryJSON 生成可持久化的摘要 JSON // Logan.Li
func (r Result) SummaryJSON() string {
b, _ := json.Marshal(r)
return string(b)
}
// GetRetentionDays 读取当前保留天数,无配置时默认 180 天 // Logan.Li
func GetRetentionDays(db *gorm.DB) int {
var s model.AuditSettings
if err := db.First(&s).Error; err != nil || s.RetentionDays <= 0 {
return 180
}
return s.RetentionDays
}
// Run 删除 cutoff 之前的审计相关记录 // Logan.Li
func Run(db *gorm.DB, retentionDays int) (Result, error) {
if !model.IsValidRetentionDays(retentionDays) {
return Result{}, fmt.Errorf("无效的保留天数: %d", retentionDays)
}
cutoff := time.Now().AddDate(0, 0, -retentionDays)
res := Result{RetentionDays: retentionDays, CutoffTime: cutoff}
if r := db.Where("created_at < ?", cutoff).Delete(&model.SessionCommand{}); r.Error != nil {
return res, r.Error
} else {
res.SessionCommands = r.RowsAffected
}
if r := db.Where("start_time < ?", cutoff).Delete(&model.TerminalSession{}); r.Error != nil {
return res, r.Error
} else {
res.TerminalSessions = r.RowsAffected
}
if r := db.Where("login_at < ?", cutoff).Delete(&model.LoginLog{}); r.Error != nil {
return res, r.Error
} else {
res.LoginLogs = r.RowsAffected
}
if r := db.Where("created_at < ?", cutoff).Delete(&model.AuditLog{}); r.Error != nil {
return res, r.Error
} else {
res.AuditLogs = r.RowsAffected
}
res.Total = res.AuditLogs + res.LoginLogs + res.TerminalSessions + res.SessionCommands
return res, nil
}
// RunFromSettings 按库中配置执行清理 // Logan.Li
func RunFromSettings(db *gorm.DB) (Result, error) {
return Run(db, GetRetentionDays(db))
}
+138
View File
@@ -0,0 +1,138 @@
// 用户定时任务调度 — Linux 标准 5 段 crontab(分 时 日 月 周)// Logan.Li
package cronscheduler
import (
"log"
"strings"
"sync"
"time"
"ezops/model"
"github.com/robfig/cron/v3"
"gorm.io/gorm"
)
// Runner 执行定时任务回调(由 controller 注入)// Logan.Li
type Runner func(jobID uint)
type trackedEntry struct {
entryID cron.EntryID
expr string
}
// Scheduler 后台 Cron 调度器 // Logan.Li
type Scheduler struct {
db *gorm.DB
runner Runner
cron *cron.Cron
mu sync.Mutex
entries map[uint]trackedEntry
running sync.Map
}
// Start 启动调度器:加载已启用任务,并周期性同步 DB 变更 // Logan.Li
func Start(db *gorm.DB, runner Runner) *Scheduler {
s := &Scheduler{
db: db,
runner: runner,
cron: cron.New(cron.WithLocation(time.Local)),
entries: make(map[uint]trackedEntry),
}
s.cron.Start()
s.reload()
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
s.reload()
}
}()
log.Println("[CronScheduler] 已启动(Linux 5 段表达式:分 时 日 月 周)")
return s
}
func (s *Scheduler) Stop() {
s.cron.Stop()
}
func (s *Scheduler) reload() {
var jobs []model.CronJob
if err := s.db.Where("enabled = ?", true).Find(&jobs).Error; err != nil {
log.Printf("[CronScheduler] 加载任务失败: %v", err)
return
}
s.mu.Lock()
defer s.mu.Unlock()
active := make(map[uint]struct{}, len(jobs))
for _, job := range jobs {
expr := normalizeCronExpr(job.CronExpr)
if expr == "" {
if prev, ok := s.entries[job.ID]; ok {
s.cron.Remove(prev.entryID)
delete(s.entries, job.ID)
}
continue
}
active[job.ID] = struct{}{}
if prev, ok := s.entries[job.ID]; ok && prev.expr == expr {
continue
}
if prev, ok := s.entries[job.ID]; ok {
s.cron.Remove(prev.entryID)
}
jobID := job.ID
entryID, err := s.cron.AddFunc(expr, func() {
s.dispatch(jobID)
})
if err != nil {
log.Printf("[CronScheduler] 注册任务 %s(%d) 失败 expr=%q: %v", job.Name, job.ID, expr, err)
delete(s.entries, job.ID)
continue
}
s.entries[job.ID] = trackedEntry{entryID: entryID, expr: expr}
log.Printf("[CronScheduler] 已注册: %s id=%d expr=%s", job.Name, job.ID, expr)
}
for id, prev := range s.entries {
if _, ok := active[id]; !ok {
s.cron.Remove(prev.entryID)
delete(s.entries, id)
log.Printf("[CronScheduler] 已移除: id=%d", id)
}
}
}
func (s *Scheduler) dispatch(jobID uint) {
if _, loaded := s.running.LoadOrStore(jobID, true); loaded {
log.Printf("[CronScheduler] 任务 id=%d 仍在执行,跳过本次调度", jobID)
return
}
defer s.running.Delete(jobID)
if s.runner == nil {
return
}
s.runner(jobID)
}
// normalizeCronExpr 规整为 Linux crontab 五段式 // Logan.Li
func normalizeCronExpr(expr string) string {
expr = strings.TrimSpace(expr)
if expr == "" {
return ""
}
fields := strings.Fields(expr)
if len(fields) == 5 {
return strings.Join(fields, " ")
}
// 误填 6 段(含秒)时去掉首段,兼容常见 Quartz 写法 // Logan.Li
if len(fields) == 6 {
return strings.Join(fields[1:], " ")
}
return ""
}
+13 -2
View File
@@ -91,6 +91,10 @@ func AutoMigrate(db *gorm.DB) error {
&model.KubernetesIngress{},
&model.AlertRule{},
&model.AlertEvent{},
&model.AlertContact{}, // 报警联系人 // Logan.Li
&model.AlertContactGroup{}, // 报警联系组 // Logan.Li
&model.AlertNotifyPolicy{}, // 告警集合通知策略 // Logan.Li
&model.AlertNotifySettings{}, // 告警消息模版 // Logan.Li
&model.NotifyChannel{},
&model.MetricData{},
&model.Pipeline{},
@@ -105,6 +109,9 @@ func AutoMigrate(db *gorm.DB) error {
&model.NavigationGroup{},
&model.NavigationLink{},
&model.AuditLog{},
&model.AuditSettings{}, // 审计保留策略 // Logan.Li
&model.LoginLog{}, // 登录日志 // Logan.Li
&model.SystemTaskStatus{}, // 内置系统任务状态 // Logan.Li
&model.LDAPConfig{},
&model.ResourceModel{},
&model.CloudBilling{},
@@ -129,11 +136,15 @@ func AutoMigrate(db *gorm.DB) error {
&model.MetricValue{}, // 指标当前值 // Logan.Li
&model.MetricHistory{}, // 指标历史时序 // Logan.Li
&model.HostGroup{}, // 主机组 // Logan.Li
&model.SNMPDevice{}, // SNMP设备 // Logan.Li
&model.SNMPTemplate{}, // SNMP模板 // Logan.Li
&model.SNMPDevice{}, // SNMP设备 // Logan.Li
&model.SNMPTemplate{}, // SNMP模板 // Logan.Li
&model.SNMPTemplateOID{}, // SNMP模板OID // Logan.Li
&model.SNMPMetric{}, // SNMP采集指标 // Logan.Li
&model.SNMPInterface{}, // SNMP接口信息 // Logan.Li
&model.SiteTask{}, // 站点拨测任务 // Logan.Li
&model.SiteResult{}, // 拨测结果 // Logan.Li
&model.SiteAlert{}, // 拨测告警 // Logan.Li
&model.MonitorAlertRule{}, // 中间件/站点拨测告警规则 // Logan.Li
&model.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li
&model.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li
&model.LogDataSource{}, // Loki / ES 日志数据源 // Logan.Li
+20
View File
@@ -1,6 +1,7 @@
package database
import (
"fmt"
"strings"
"gorm.io/gorm"
@@ -40,4 +41,23 @@ func CleanupAgentReportsSQL(driver string) string {
default:
return "DELETE FROM monitor_agent_reports WHERE report_time < DATE_SUB(NOW(), INTERVAL 7 DAY)"
}
}
// CleanupSNMPMetricsSQL 清理超期 SNMP 采集指标 // Logan.Li
func CleanupSNMPMetricsSQL(driver string, retentionDays int) string {
if retentionDays <= 0 {
retentionDays = 7
}
switch driver {
case "sqlite":
return fmt.Sprintf(
"DELETE FROM ops_snmp_metrics WHERE timestamp < CAST(strftime('%%s', 'now', '-%d days') AS INTEGER)",
retentionDays,
)
default:
return fmt.Sprintf(
"DELETE FROM ops_snmp_metrics WHERE timestamp < UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL %d DAY))",
retentionDays,
)
}
}
+159
View File
@@ -0,0 +1,159 @@
// 脚本运行时探测与本地执行 — 适配 ./aiops 单二进制 / Docker 容器环境 // Logan.Li
package scriptrunner
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"runtime"
"time"
)
// RuntimeInfo 当前主机可用的脚本解释器
type RuntimeInfo struct {
Shell bool `json:"shell"`
ShellPath string `json:"shell_path,omitempty"`
Python bool `json:"python"`
PythonPath string `json:"python_path,omitempty"`
PowerShell bool `json:"powershell"`
PowerShellPath string `json:"powershell_path,omitempty"`
OS string `json:"os"`
Arch string `json:"arch"`
EmbedMode bool `json:"embed_mode"` // 单二进制 embed 部署
}
// DetectRuntime 探测 bash/sh、python3、powershell 是否可用 // Logan.Li
func DetectRuntime() RuntimeInfo {
info := RuntimeInfo{
OS: runtime.GOOS,
Arch: runtime.GOARCH,
EmbedMode: true,
}
for _, p := range []string{"/bin/bash", "/usr/bin/bash", "bash"} {
if path, ok := lookExecutable(p); ok {
info.Shell = true
info.ShellPath = path
break
}
}
if !info.Shell {
for _, p := range []string{"/bin/sh", "/usr/bin/sh", "sh"} {
if path, ok := lookExecutable(p); ok {
info.Shell = true
info.ShellPath = path
break
}
}
}
for _, p := range []string{"/usr/bin/python3", "/usr/local/bin/python3", "python3"} {
if path, ok := lookExecutable(p); ok {
info.Python = true
info.PythonPath = path
break
}
}
for _, p := range []string{"/usr/bin/pwsh", "/usr/local/bin/pwsh", "pwsh", "powershell"} {
if path, ok := lookExecutable(p); ok {
info.PowerShell = true
info.PowerShellPath = path
break
}
}
return info
}
func lookExecutable(name string) (string, bool) {
path, err := exec.LookPath(name)
if err != nil {
return "", false
}
return path, true
}
// CanRun 判断指定脚本类型是否可在本机执行 // Logan.Li
func CanRun(rt RuntimeInfo, scriptType string) (bool, string) {
switch scriptType {
case "shell", "bash", "command", "":
if rt.Shell {
return true, ""
}
return false, "当前环境未找到 bash/sh,无法执行 Shell 脚本(Docker 镜像需安装 bash)"
case "python":
if rt.Python {
return true, ""
}
return false, "当前环境未找到 python3,无法执行 Python 脚本(请在镜像中安装 python3)"
case "powershell":
if rt.PowerShell {
return true, ""
}
return false, "当前环境未找到 PowerShell (pwsh),无法执行 PowerShell 脚本"
default:
return false, fmt.Sprintf("不支持的脚本类型: %s", scriptType)
}
}
// Run 在 AIOps 本机执行脚本,返回合并输出、退出码、耗时(ms) // Logan.Li
func Run(scriptType, content string, timeoutSec int) (output string, exitCode int, durationMs int, err error) {
rt := DetectRuntime()
ok, reason := CanRun(rt, scriptType)
if !ok {
return reason + "\n", -1, 0, fmt.Errorf("%s", reason)
}
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":
py := rt.PythonPath
if py == "" {
py = "python3"
}
cmd = exec.CommandContext(ctx, py, "-c", content)
case "powershell":
pwsh := rt.PowerShellPath
if pwsh == "" {
pwsh = "pwsh"
}
cmd = exec.CommandContext(ctx, pwsh, "-NoProfile", "-NonInteractive", "-Command", content)
default:
sh := rt.ShellPath
if sh == "" {
sh = "bash"
}
cmd = exec.CommandContext(ctx, sh, "-c", content)
}
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
cmd.Env = os.Environ()
runErr := 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, ctx.Err()
}
if runErr != nil {
if exitError, ok := runErr.(*exec.ExitError); ok {
exitCode = exitError.ExitCode()
} else {
exitCode = -1
output += "\n" + runErr.Error()
}
return output, exitCode, durationMs, runErr
}
return output, 0, durationMs, nil
}
+277
View File
@@ -0,0 +1,277 @@
package siteprobe
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
"ezops/model"
)
// ProbeOutcome 单次拨测结果 // Logan.Li
type ProbeOutcome struct {
Status model.SiteResultStatus
HTTPStatus int
ResponseTime float64
TotalTime float64
RemoteIP string
ErrorMessage string
// HTTPS 证书信息 // Logan.Li
SSLCertExpiry *time.Time
SSLCertIssuer string
SSLCertSubject string
SSLDaysRemaining int
}
// Execute 按任务类型执行拨测 // Logan.Li
func Execute(ctx context.Context, task model.SiteTask) ProbeOutcome {
timeout := time.Duration(task.Timeout) * time.Second
if timeout <= 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
taskType := strings.ToLower(string(task.TaskType))
if taskType == "" {
taskType = inferTaskType(task)
}
switch taskType {
case "ssl":
return probeSSL(ctx, task)
case "tcp":
return probeTCP(ctx, task.URL)
case "dns":
return probeDNS(ctx, task.URL, task.DNSResolver)
case "ping":
return probePing(ctx, task.URL, task.Timeout)
case "http", "https":
return probeHTTP(ctx, task)
default:
return probeHTTP(ctx, task)
}
}
// InferTaskTypePublic 推断任务类型(供列表修复存量数据)// Logan.Li
func InferTaskTypePublic(task model.SiteTask) string {
return inferTaskType(task)
}
func inferTaskType(task model.SiteTask) string {
name := strings.ToLower(task.Name)
switch {
case strings.Contains(name, "tcp"):
return "tcp"
case strings.Contains(name, "dns"):
return "dns"
case strings.Contains(name, "ping"):
return "ping"
case strings.Contains(name, "ssl"):
return "ssl"
default:
return "http"
}
}
func probeHTTP(ctx context.Context, task model.SiteTask) ProbeOutcome {
start := time.Now()
url := task.URL
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
url = "http://" + url
}
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: !task.VerifySSL},
}
client := &http.Client{
Timeout: time.Until(deadlineFromCtx(ctx)),
Transport: transport,
}
if !task.FollowRedirect {
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
}
method := strings.ToUpper(string(task.Method))
if method == "" {
method = http.MethodGet
}
req, err := http.NewRequestWithContext(ctx, method, url, nil)
if err != nil {
return failOutcome(start, err.Error())
}
resp, err := client.Do(req)
elapsed := msSince(start)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return ProbeOutcome{Status: model.SiteResultTimeout, TotalTime: elapsed, ResponseTime: elapsed, ErrorMessage: "请求超时"}
}
return failOutcome(start, err.Error())
}
defer resp.Body.Close()
statusOK := resp.StatusCode == task.ExpectedStatus
if task.ExpectedStatus == 0 {
statusOK = resp.StatusCode >= 200 && resp.StatusCode < 400
}
if !statusOK {
return ProbeOutcome{
Status: model.SiteResultFail,
HTTPStatus: resp.StatusCode,
ResponseTime: elapsed,
TotalTime: elapsed,
ErrorMessage: fmt.Sprintf("状态码 %d 不符合期望 %d", resp.StatusCode, task.ExpectedStatus),
}
}
return ProbeOutcome{
Status: model.SiteResultSuccess,
HTTPStatus: resp.StatusCode,
ResponseTime: elapsed,
TotalTime: elapsed,
}
}
func probeTCP(ctx context.Context, target string) ProbeOutcome {
start := time.Now()
host, port, err := splitHostPort(target, "80")
if err != nil {
return failOutcome(start, err.Error())
}
d := net.Dialer{Timeout: time.Until(deadlineFromCtx(ctx))}
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
elapsed := msSince(start)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return ProbeOutcome{Status: model.SiteResultTimeout, TotalTime: elapsed, ResponseTime: elapsed, ErrorMessage: "TCP 连接超时"}
}
return failOutcome(start, err.Error())
}
defer conn.Close()
remote := ""
if addr := conn.RemoteAddr(); addr != nil {
remote = addr.String()
}
return ProbeOutcome{
Status: model.SiteResultSuccess,
ResponseTime: elapsed,
TotalTime: elapsed,
RemoteIP: remote,
}
}
func probeDNS(ctx context.Context, target, resolver string) ProbeOutcome {
start := time.Now()
domain := normalizeDomain(target)
if domain == "" {
return failOutcome(start, "域名不能为空")
}
r := &net.Resolver{}
if resolver != "" {
r = &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{Timeout: time.Until(deadlineFromCtx(ctx))}
return d.DialContext(ctx, network, resolver+":53")
},
}
}
addrs, err := r.LookupHost(ctx, domain)
elapsed := msSince(start)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return ProbeOutcome{Status: model.SiteResultTimeout, TotalTime: elapsed, ResponseTime: elapsed, ErrorMessage: "DNS 解析超时"}
}
return failOutcome(start, err.Error())
}
return ProbeOutcome{
Status: model.SiteResultSuccess,
ResponseTime: elapsed,
TotalTime: elapsed,
RemoteIP: strings.Join(addrs, ","),
}
}
func probePing(ctx context.Context, target string, timeoutSec int) ProbeOutcome {
start := time.Now()
host := normalizeDomain(target)
if host == "" {
return failOutcome(start, "主机不能为空")
}
if timeoutSec <= 0 {
timeoutSec = 5
}
cmd := exec.CommandContext(ctx, "ping", "-c", "1", "-W", strconv.Itoa(timeoutSec), host)
out, err := cmd.CombinedOutput()
elapsed := msSince(start)
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = err.Error()
}
if ctx.Err() == context.DeadlineExceeded {
return ProbeOutcome{Status: model.SiteResultTimeout, TotalTime: elapsed, ResponseTime: elapsed, ErrorMessage: "PING 超时"}
}
return ProbeOutcome{Status: model.SiteResultFail, TotalTime: elapsed, ResponseTime: elapsed, ErrorMessage: msg}
}
if m := regexp.MustCompile(`time[=<]([\d.]+)\s*ms`).FindStringSubmatch(string(out)); len(m) == 2 {
if v, parseErr := strconv.ParseFloat(m[1], 64); parseErr == nil {
elapsed = v
}
}
return ProbeOutcome{Status: model.SiteResultSuccess, ResponseTime: elapsed, TotalTime: elapsed, RemoteIP: host}
}
func splitHostPort(raw, defaultPort string) (string, string, error) {
raw = strings.TrimSpace(raw)
for _, prefix := range []string{"ssl://", "tcp://", "http://", "https://"} {
raw = strings.TrimPrefix(raw, prefix)
}
if host, port, err := net.SplitHostPort(raw); err == nil {
return host, port, nil
}
if raw == "" {
return "", "", fmt.Errorf("TCP 地址不能为空")
}
return raw, defaultPort, nil
}
func normalizeDomain(raw string) string {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "http://")
raw = strings.TrimPrefix(raw, "https://")
if host, _, err := net.SplitHostPort(raw); err == nil {
return host
}
return strings.TrimSuffix(raw, "/")
}
func failOutcome(start time.Time, msg string) ProbeOutcome {
elapsed := msSince(start)
return ProbeOutcome{
Status: model.SiteResultFail,
ResponseTime: elapsed,
TotalTime: elapsed,
ErrorMessage: msg,
}
}
func msSince(start time.Time) float64 {
return float64(time.Since(start).Milliseconds())
}
func deadlineFromCtx(ctx context.Context) time.Time {
if d, ok := ctx.Deadline(); ok {
return d
}
return time.Now().Add(10 * time.Second)
}
+108
View File
@@ -0,0 +1,108 @@
package siteprobe
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"net/url"
"strings"
"time"
"ezops/model"
)
// parseSSLTarget 解析 SSL 拨测地址,支持 example.com:443 / https://example.com // Logan.Li
func parseSSLTarget(raw string) (host, port string, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", fmt.Errorf("SSL 地址不能为空")
}
lower := strings.ToLower(raw)
// 兼容 https:example.com(单斜杠写法)
if strings.HasPrefix(lower, "https:") && !strings.HasPrefix(lower, "https://") {
raw = "https://" + strings.TrimSpace(raw[len("https:"):])
lower = strings.ToLower(raw)
}
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "ssl://") {
u, parseErr := url.Parse(raw)
if parseErr != nil || u.Host == "" {
return "", "", fmt.Errorf("SSL 地址格式无效: %s", raw)
}
if h, p, splitErr := net.SplitHostPort(u.Host); splitErr == nil {
return h, p, nil
}
defPort := "443"
if u.Scheme == "http" {
defPort = "80"
}
return u.Hostname(), defPort, nil
}
// 去掉路径后缀,如 example.com:443/foo
if idx := strings.Index(raw, "/"); idx >= 0 {
raw = raw[:idx]
}
return splitHostPort(raw, "443")
}
// probeSSL 纯 TLS 握手检测证书(不发起 HTTP 请求)// Logan.Li
func probeSSL(ctx context.Context, task model.SiteTask) ProbeOutcome {
start := time.Now()
host, port, err := parseSSLTarget(task.URL)
if err != nil {
return failOutcome(start, err.Error())
}
addr := net.JoinHostPort(host, port)
d := &net.Dialer{Timeout: time.Until(deadlineFromCtx(ctx))}
conn, err := tls.DialWithDialer(d, "tcp", addr, &tls.Config{
ServerName: host,
InsecureSkipVerify: !task.VerifySSL,
})
elapsed := msSince(start)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return ProbeOutcome{Status: model.SiteResultTimeout, TotalTime: elapsed, ResponseTime: elapsed, ErrorMessage: "TLS 握手超时"}
}
return failOutcome(start, fmt.Sprintf("TLS 握手失败: %s", err.Error()))
}
defer conn.Close()
remote := ""
if addr := conn.RemoteAddr(); addr != nil {
remote = addr.String()
}
out := ProbeOutcome{
Status: model.SiteResultSuccess,
ResponseTime: elapsed,
TotalTime: elapsed,
RemoteIP: remote,
}
applyCertsToOutcome(&out, conn.ConnectionState().PeerCertificates)
return out
}
// applyCertsToOutcome 从 TLS 证书链填充到期信息 // Logan.Li
func applyCertsToOutcome(out *ProbeOutcome, certs []*x509.Certificate) {
if len(certs) == 0 {
return
}
cert := certs[0]
expiry := cert.NotAfter
days := int(time.Until(expiry).Hours() / 24)
out.SSLCertExpiry = &expiry
out.SSLDaysRemaining = days
out.SSLCertIssuer = cert.Issuer.CommonName
if out.SSLCertIssuer == "" {
out.SSLCertIssuer = cert.Issuer.String()
}
out.SSLCertSubject = cert.Subject.CommonName
if out.SSLCertSubject == "" {
out.SSLCertSubject = cert.Subject.String()
}
}
// IsSSLProbeTask 是否为 SSL 证书拨测任务 // Logan.Li
func IsSSLProbeTask(task model.SiteTask) bool {
return strings.EqualFold(string(task.TaskType), "ssl")
}
+37
View File
@@ -0,0 +1,37 @@
package siteprobe
import "testing"
// SSL 地址解析单测 // Logan.Li
func TestParseSSLTarget(t *testing.T) {
cases := []struct {
raw string
host string
port string
wantFail bool
}{
{"example.com:443", "example.com", "443", false},
{"example.com", "example.com", "443", false},
{"https://example.com", "example.com", "443", false},
{"https://example.com:8443", "example.com", "8443", false},
{"https://example.com/path", "example.com", "443", false},
{"https:example.com", "example.com", "443", false},
{"ssl://example.com:443", "example.com", "443", false},
{"", "", "", true},
}
for _, c := range cases {
host, port, err := parseSSLTarget(c.raw)
if c.wantFail {
if err == nil {
t.Fatalf("expected error for %q", c.raw)
}
continue
}
if err != nil {
t.Fatalf("parseSSLTarget(%q): %v", c.raw, err)
}
if host != c.host || port != c.port {
t.Fatalf("parseSSLTarget(%q) = %s:%s, want %s:%s", c.raw, host, port, c.host, c.port)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
package siteprobe
import (
"context"
"log"
"sync"
"time"
"ezops/model"
"gorm.io/gorm"
)
// Worker 站点拨测后台采集器 // Logan.Li
type Worker struct {
db *gorm.DB
stopCh chan struct{}
wg sync.WaitGroup
}
// NewWorker 创建拨测 worker
func NewWorker(db *gorm.DB) *Worker {
return &Worker{db: db, stopCh: make(chan struct{})}
}
// Start 启动拨测循环
func (w *Worker) Start(intervalSec int) {
if intervalSec < 5 {
intervalSec = 10
}
w.wg.Add(1)
go func() {
defer w.wg.Done()
ticker := time.NewTicker(time.Duration(intervalSec) * time.Second)
defer ticker.Stop()
log.Printf("[SiteProbe] 拨测引擎启动,扫描间隔 %ds", intervalSec)
w.runOnce()
for {
select {
case <-ticker.C:
w.runOnce()
case <-w.stopCh:
log.Printf("[SiteProbe] 拨测引擎停止")
return
}
}
}()
}
// Stop 停止拨测
func (w *Worker) Stop() {
close(w.stopCh)
w.wg.Wait()
}
// RunTaskNow 立即执行单个任务拨测 // Logan.Li
func (w *Worker) RunTaskNow(taskID uint) error {
var task model.SiteTask
if err := w.db.First(&task, taskID).Error; err != nil {
return err
}
w.probeTask(task)
return nil
}
func (w *Worker) runOnce() {
var tasks []model.SiteTask
w.db.Where("status = ?", model.SiteTaskStatusRunning).Find(&tasks)
now := time.Now()
for _, task := range tasks {
if task.Frequency <= 0 {
task.Frequency = 60
}
if task.LastCheckAt != nil && now.Sub(*task.LastCheckAt) < time.Duration(task.Frequency)*time.Second {
continue
}
w.probeTask(task)
}
}
func (w *Worker) probeTask(task model.SiteTask) {
if task.TaskType == "" {
task.TaskType = model.SiteTaskType(inferTaskType(task))
}
outcome := Execute(context.Background(), task)
now := time.Now()
result := model.SiteResult{
TaskID: task.ID,
Status: outcome.Status,
HTTPStatusCode: outcome.HTTPStatus,
ResponseTime: outcome.ResponseTime,
TotalTime: outcome.TotalTime,
RemoteIP: outcome.RemoteIP,
ErrorMessage: outcome.ErrorMessage,
SSLCertExpiry: outcome.SSLCertExpiry,
SSLCertIssuer: outcome.SSLCertIssuer,
SSLCertSubject: outcome.SSLCertSubject,
SSLDaysRemaining: outcome.SSLDaysRemaining,
CheckedAt: now,
}
w.db.Create(&result)
lastResult := "fail"
if outcome.Status == model.SiteResultSuccess {
lastResult = "success"
}
avg := outcome.ResponseTime
if task.AvgResponse > 0 {
avg = (task.AvgResponse + outcome.ResponseTime) / 2
}
w.db.Model(&model.SiteTask{}).Where("id = ?", task.ID).Updates(map[string]interface{}{
"task_type": task.TaskType,
"last_result": lastResult,
"last_check_at": now,
"avg_response": avg,
})
}
+8
View File
@@ -0,0 +1,8 @@
package snmp
// SNMP 采集与保留策略常量 // Logan.Li
const (
DefaultCollectIntervalSec = 300 // 默认采集间隔 5 分钟
MinCollectIntervalSec = 300 // 最低采集间隔,避免高频轮询
MetricsRetentionDays = 7 // 指标保留天数
)
+130
View File
@@ -0,0 +1,130 @@
package snmp
// TemplateOIDDef 内置模板 OID 定义 // Logan.Li
type TemplateOIDDef struct {
Name string
OID string
Description string
DataType string
Unit string
IsTable bool
SortOrder int
}
// BuiltinTemplate 内置 SNMP 模板 // Logan.Li
type BuiltinTemplate struct {
Name string
Description string
DeviceType string
Vendor string
OIDs []TemplateOIDDef
}
// mergeOIDs 合并多组 OID 定义 // Logan.Li
func mergeOIDs(groups ...[]TemplateOIDDef) []TemplateOIDDef {
out := make([]TemplateOIDDef, 0)
for _, g := range groups {
out = append(out, g...)
}
return out
}
// commonNetworkOIDs 网络设备通用 IF-MIB 指标 // Logan.Li
var commonNetworkOIDs = []TemplateOIDDef{
{Name: "sys_uptime", OID: "1.3.6.1.2.1.1.3.0", Description: "系统运行时间", DataType: "timeticks", Unit: "ticks", SortOrder: 1},
{Name: "sys_name", OID: "1.3.6.1.2.1.1.5.0", Description: "系统名称", DataType: "string", SortOrder: 2},
{Name: "if_in_octets", OID: "1.3.6.1.2.1.31.1.1.1.6", Description: "接口入流量(64位)", DataType: "counter64", Unit: "bytes", IsTable: true, SortOrder: 10},
{Name: "if_out_octets", OID: "1.3.6.1.2.1.31.1.1.1.10", Description: "接口出流量(64位)", DataType: "counter64", Unit: "bytes", IsTable: true, SortOrder: 11},
{Name: "if_oper_status", OID: "1.3.6.1.2.1.2.2.1.8", Description: "接口运行状态", DataType: "integer", IsTable: true, SortOrder: 12},
{Name: "if_name", OID: "1.3.6.1.2.1.31.1.1.1.1", Description: "接口名称", DataType: "string", IsTable: true, SortOrder: 13},
{Name: "if_high_speed", OID: "1.3.6.1.2.1.31.1.1.1.15", Description: "接口速率", DataType: "gauge32", Unit: "Mbps", IsTable: true, SortOrder: 14},
}
// serverCommonOIDs 服务器 BMC 通用指标 // Logan.Li
var serverCommonOIDs = []TemplateOIDDef{
{Name: "sys_uptime", OID: "1.3.6.1.2.1.1.3.0", Description: "系统运行时间", DataType: "timeticks", Unit: "ticks", SortOrder: 1},
{Name: "sys_name", OID: "1.3.6.1.2.1.1.5.0", Description: "系统名称", DataType: "string", SortOrder: 2},
{Name: "if_in_octets", OID: "1.3.6.1.2.1.31.1.1.1.6", Description: "带外网卡入流量", DataType: "counter64", Unit: "bytes", IsTable: true, SortOrder: 10},
{Name: "if_out_octets", OID: "1.3.6.1.2.1.31.1.1.1.10", Description: "带外网卡出流量", DataType: "counter64", Unit: "bytes", IsTable: true, SortOrder: 11},
{Name: "ent_sensor_value", OID: "1.3.6.1.2.1.99.1.1.1.4", Description: "实体传感器值(温度/风扇兜底)", DataType: "gauge32", IsTable: true, SortOrder: 15},
{Name: "hr_storage_size", OID: "1.3.6.1.2.1.25.2.3.1.5", Description: "存储容量", DataType: "integer", Unit: "KB", IsTable: true, SortOrder: 16},
{Name: "hr_storage_used", OID: "1.3.6.1.2.1.25.2.3.1.6", Description: "存储已用", DataType: "integer", Unit: "KB", IsTable: true, SortOrder: 17},
}
// h3cOIDs H3C Comware 私有指标 // Logan.Li
var h3cOIDs = []TemplateOIDDef{
{Name: "h3c_cpu_usage", OID: "1.3.6.1.4.1.25506.2.6.1.1.1.1.6", Description: "CPU 使用率", DataType: "gauge32", Unit: "%", IsTable: true, SortOrder: 20},
{Name: "h3c_mem_usage", OID: "1.3.6.1.4.1.25506.2.6.1.1.1.1.8", Description: "内存使用率", DataType: "gauge32", Unit: "%", IsTable: true, SortOrder: 21},
{Name: "h3c_temperature", OID: "1.3.6.1.4.1.25506.2.6.1.1.1.1.12", Description: "设备温度", DataType: "gauge32", Unit: "°C", IsTable: true, SortOrder: 22},
{Name: "h3c_fan_speed", OID: "1.3.6.1.4.1.25506.2.6.1.1.1.1.14", Description: "风扇转速", DataType: "gauge32", Unit: "RPM", IsTable: true, SortOrder: 23},
}
// huaweiOIDs 华为 VRP 私有指标 // Logan.Li
var huaweiOIDs = []TemplateOIDDef{
{Name: "hw_cpu_usage", OID: "1.3.6.1.4.1.2011.5.25.31.1.1.1.1.5", Description: "CPU 使用率", DataType: "gauge32", Unit: "%", IsTable: true, SortOrder: 20},
{Name: "hw_mem_usage", OID: "1.3.6.1.4.1.2011.5.25.31.1.1.1.1.7", Description: "内存使用率", DataType: "gauge32", Unit: "%", IsTable: true, SortOrder: 21},
{Name: "hw_temperature", OID: "1.3.6.1.4.1.2011.5.25.31.1.1.1.1.11", Description: "设备温度", DataType: "gauge32", Unit: "°C", IsTable: true, SortOrder: 22},
{Name: "hw_fan_speed", OID: "1.3.6.1.4.1.2011.5.25.31.1.1.1.1.12", Description: "风扇转速", DataType: "gauge32", Unit: "RPM", IsTable: true, SortOrder: 23},
}
// ciscoOIDs 思科 IOS/NX-OS 私有指标 // Logan.Li
var ciscoOIDs = []TemplateOIDDef{
{Name: "cisco_cpu_5min", OID: "1.3.6.1.4.1.9.9.109.1.1.1.1.8", Description: "CPU 5分钟利用率", DataType: "gauge32", Unit: "%", IsTable: true, SortOrder: 20},
{Name: "cisco_mem_used", OID: "1.3.6.1.4.1.9.9.48.1.1.1.5", Description: "内存已用", DataType: "gauge32", Unit: "bytes", IsTable: true, SortOrder: 21},
{Name: "cisco_mem_free", OID: "1.3.6.1.4.1.9.9.48.1.1.1.6", Description: "内存空闲", DataType: "gauge32", Unit: "bytes", IsTable: true, SortOrder: 22},
{Name: "cisco_sensor_value", OID: "1.3.6.1.4.1.9.9.91.1.1.1.4", Description: "温度/风扇传感器", DataType: "gauge32", IsTable: true, SortOrder: 23},
}
// ruijieOIDs 锐捷 RGOS 私有指标 // Logan.Li
var ruijieOIDs = []TemplateOIDDef{
{Name: "ruijie_cpu_usage", OID: "1.3.6.1.4.1.4881.1.1.10.2.1.1.1.1.6", Description: "CPU 使用率", DataType: "gauge32", Unit: "%", IsTable: true, SortOrder: 20},
{Name: "ruijie_mem_usage", OID: "1.3.6.1.4.1.4881.1.1.10.2.1.2.1.1.6", Description: "内存使用率", DataType: "gauge32", Unit: "%", IsTable: true, SortOrder: 21},
{Name: "ruijie_temperature", OID: "1.3.6.1.4.1.4881.1.1.10.2.1.3.1.1.6", Description: "设备温度", DataType: "gauge32", Unit: "°C", IsTable: true, SortOrder: 22},
{Name: "ruijie_fan_speed", OID: "1.3.6.1.4.1.4881.1.1.10.2.1.4.1.1.6", Description: "风扇转速", DataType: "gauge32", Unit: "RPM", IsTable: true, SortOrder: 23},
}
// dellIdracOIDs Dell iDRAC 带外管理指标 // Logan.Li
var dellIdracOIDs = []TemplateOIDDef{
{Name: "dell_global_status", OID: "1.3.6.1.4.1.674.10892.5.4.1.0", Description: "整机健康状态", DataType: "integer", SortOrder: 20},
{Name: "dell_cpu_status", OID: "1.3.6.1.4.1.674.10892.5.4.701.12.6.1.8", Description: "CPU 状态", DataType: "integer", IsTable: true, SortOrder: 21},
{Name: "dell_temperature", OID: "1.3.6.1.4.1.674.10892.5.4.700.20.1.6.1.8", Description: "温度探头读数", DataType: "gauge32", Unit: "°C", IsTable: true, SortOrder: 22},
{Name: "dell_fan_speed", OID: "1.3.6.1.4.1.674.10892.5.4.700.20.1.7.1.8", Description: "散热风扇读数", DataType: "gauge32", Unit: "RPM", IsTable: true, SortOrder: 23},
{Name: "dell_disk_state", OID: "1.3.6.1.4.1.674.10892.5.5.1300.20.1.4.1.4", Description: "物理磁盘状态", DataType: "integer", IsTable: true, SortOrder: 24},
{Name: "dell_disk_capacity", OID: "1.3.6.1.4.1.674.10892.5.5.1300.20.1.4.1.11", Description: "物理磁盘容量", DataType: "gauge32", Unit: "MB", IsTable: true, SortOrder: 25},
}
// inspurBmcOIDs 浪潮 BMC 私有指标 // Logan.Li
var inspurBmcOIDs = []TemplateOIDDef{
{Name: "inspur_cpu_usage", OID: "1.3.6.1.4.1.37945.1.1.1.1.1.1", Description: "CPU 使用率", DataType: "gauge32", Unit: "%", IsTable: true, SortOrder: 20},
{Name: "inspur_mem_usage", OID: "1.3.6.1.4.1.37945.1.1.1.1.2.1", Description: "内存使用率", DataType: "gauge32", Unit: "%", IsTable: true, SortOrder: 21},
{Name: "inspur_temperature", OID: "1.3.6.1.4.1.37945.1.1.1.1.3.1", Description: "温度传感器", DataType: "gauge32", Unit: "°C", IsTable: true, SortOrder: 22},
{Name: "inspur_fan_speed", OID: "1.3.6.1.4.1.37945.1.1.1.1.4.1", Description: "风扇转速", DataType: "gauge32", Unit: "RPM", IsTable: true, SortOrder: 23},
{Name: "inspur_disk_status", OID: "1.3.6.1.4.1.37945.1.1.1.1.5.1", Description: "磁盘健康状态", DataType: "integer", IsTable: true, SortOrder: 24},
}
// BuiltinTemplates 全部内置模板 // Logan.Li
func BuiltinTemplates() []BuiltinTemplate {
net := func(vendor string, deviceType string, name string, desc string, priv []TemplateOIDDef) BuiltinTemplate {
return BuiltinTemplate{
Name: name, Description: desc, DeviceType: deviceType, Vendor: vendor,
OIDs: mergeOIDs(commonNetworkOIDs, priv),
}
}
srv := func(vendor string, name string, desc string, priv []TemplateOIDDef) BuiltinTemplate {
return BuiltinTemplate{
Name: name, Description: desc, DeviceType: "server", Vendor: vendor,
OIDs: mergeOIDs(serverCommonOIDs, priv),
}
}
return []BuiltinTemplate{
net("H3C", "switch", "H3C 交换机", "H3C Comware 交换机 — CPU/内存/温度/风扇/网卡流量", h3cOIDs),
net("H3C", "router", "H3C 路由器", "H3C Comware 路由器 — CPU/内存/温度/风扇/网卡流量", h3cOIDs),
net("Huawei", "switch", "华为交换机", "华为 VRP 交换机 — CPU/内存/温度/风扇/网卡流量", huaweiOIDs),
net("Huawei", "router", "华为路由器", "华为 VRP 路由器 — CPU/内存/温度/风扇/网卡流量", huaweiOIDs),
net("Cisco", "switch", "思科交换机", "Cisco IOS/NX-OS — CPU/内存/传感器/网卡流量", ciscoOIDs),
net("Cisco", "router", "思科路由器", "Cisco IOS — CPU/内存/传感器/网卡流量", ciscoOIDs),
net("Ruijie", "switch", "锐捷交换机", "锐捷 RGOS — CPU/内存/温度/风扇/网卡流量", ruijieOIDs),
srv("Dell", "Dell iDRAC", "Dell 服务器 iDRAC — CPU/温度/风扇/磁盘/带外网卡", dellIdracOIDs),
srv("Inspur", "浪潮 BMC", "浪潮服务器 BMC — CPU/内存/温度/风扇/磁盘/带外网卡", inspurBmcOIDs),
}
}
+226
View File
@@ -0,0 +1,226 @@
// 内置系统定时任务注册表 — 告警引擎 / 数据清理等 // Logan.Li
package systask
import (
"fmt"
"sync"
"time"
"ezops/model"
"ezops/pkg/auditcleaner"
"ezops/pkg/database"
snmppkg "ezops/pkg/snmp"
"gorm.io/gorm"
)
// Def 系统任务元数据 // Logan.Li
type Def struct {
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
Schedule string `json:"schedule"`
Category string `json:"category"` // monitor / cleanup
Runnable bool `json:"runnable"`
Enabled bool `json:"enabled"`
}
// Item API 列表项(元数据 + 运行状态) // Logan.Li
type Item struct {
Def
LastRunAt *time.Time `json:"last_run_at,omitempty"`
LastStatus string `json:"last_status,omitempty"`
LastMessage string `json:"last_message,omitempty"`
RunCount int64 `json:"run_count"`
LastDurationMs int `json:"last_duration_ms"`
Extra any `json:"extra,omitempty"`
}
var (
mu sync.RWMutex
db *gorm.DB
catalog = []Def{
{
Code: "alert_engine", Name: "告警引擎扫描",
Description: "扫描 Agent 指标阈值、主机离线、端口策略与日志告警规则,触发通知与自动恢复",
Schedule: "每 30 秒", Category: "monitor", Runnable: false, Enabled: true,
},
{
Code: "middleware_monitor", Name: "中间件监控采集",
Description: "直连 Redis/MySQL 等中间件目标采集指标并写入监控库",
Schedule: "每 30 秒", Category: "monitor", Runnable: false, Enabled: true,
},
{
Code: "agent_report_cleanup", Name: "Agent 上报清理",
Description: "清理 7 天前的 Agent 指标上报原始数据,防止表膨胀",
Schedule: "每 24 小时", Category: "cleanup", Runnable: true, Enabled: true,
},
{
Code: "audit_log_cleanup", Name: "审计日志清理",
Description: "按系统设置中的保留天数清理操作审计、登录日志、终端会话与命令记录",
Schedule: "每 24 小时", Category: "cleanup", Runnable: true, Enabled: true,
},
{
Code: "snmp_metrics_cleanup", Name: "SNMP 指标清理",
Description: "清理 7 天前的 SNMP 采集指标历史,防止表膨胀",
Schedule: "每 24 小时", Category: "cleanup", Runnable: true, Enabled: true,
},
}
)
// Init 注入数据库连接,并确保状态行存在 // Logan.Li
func Init(gdb *gorm.DB) {
mu.Lock()
db = gdb
mu.Unlock()
ensureStatusRows(gdb)
}
func ensureStatusRows(gdb *gorm.DB) {
for _, d := range catalog {
var row model.SystemTaskStatus
if gdb.Where("code = ?", d.Code).First(&row).Error == gorm.ErrRecordNotFound {
gdb.Create(&model.SystemTaskStatus{Code: d.Code, LastStatus: "pending"})
}
}
}
// Catalog 返回任务定义列表 // Logan.Li
func Catalog() []Def {
out := make([]Def, len(catalog))
copy(out, catalog)
return out
}
// Record 记录任务执行结果 // Logan.Li
func Record(code, status, message string, duration time.Duration) {
mu.RLock()
gdb := db
mu.RUnlock()
if gdb == nil {
return
}
now := time.Now()
updates := map[string]interface{}{
"last_run_at": now,
"last_status": status,
"last_message": truncate(message, 480),
"last_duration_ms": int(duration.Milliseconds()),
"run_count": gorm.Expr("run_count + 1"),
}
gdb.Model(&model.SystemTaskStatus{}).Where("code = ?", code).Updates(updates)
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}
// List 合并元数据与库中状态 // Logan.Li
func List(gdb *gorm.DB, extras map[string]any) []Item {
ensureStatusRows(gdb)
var rows []model.SystemTaskStatus
gdb.Find(&rows)
statusMap := make(map[string]model.SystemTaskStatus, len(rows))
for _, r := range rows {
statusMap[r.Code] = r
}
items := make([]Item, 0, len(catalog))
for _, d := range catalog {
item := Item{Def: d}
if st, ok := statusMap[d.Code]; ok {
item.LastRunAt = st.LastRunAt
item.LastStatus = st.LastStatus
item.LastMessage = st.LastMessage
item.RunCount = st.RunCount
item.LastDurationMs = st.LastDurationMs
}
if extras != nil {
if ex, ok := extras[d.Code]; ok {
item.Extra = ex
}
}
// 审计清理任务补充保留天数 // Logan.Li
if d.Code == "audit_log_cleanup" {
days := auditcleaner.GetRetentionDays(gdb)
if item.Extra == nil {
item.Extra = map[string]any{"retention_days": days}
}
}
if d.Code == "snmp_metrics_cleanup" && item.Extra == nil {
item.Extra = map[string]any{"retention_days": snmppkg.MetricsRetentionDays}
}
items = append(items, item)
}
return items
}
// RunManual 手动触发可运行的系统任务 // Logan.Li
func RunManual(gdb *gorm.DB, driver, code string) (Item, error) {
def, ok := findDef(code)
if !ok {
return Item{}, fmt.Errorf("未知系统任务: %s", code)
}
if !def.Runnable {
return Item{}, fmt.Errorf("任务 %s 不支持手动执行", def.Name)
}
start := time.Now()
var msg string
var err error
switch code {
case "agent_report_cleanup":
sql := database.CleanupAgentReportsSQL(driver)
r := gdb.Exec(sql)
err = r.Error
msg = fmt.Sprintf("清理 %d 条 Agent 上报记录", r.RowsAffected)
case "audit_log_cleanup":
var result auditcleaner.Result
result, err = auditcleaner.RunFromSettings(gdb)
if err == nil {
msg = fmt.Sprintf("清理 %d 条超期记录(保留 %d 天)", result.Total, result.RetentionDays)
now := time.Now()
gdb.Model(&model.AuditSettings{}).Where("id > 0").Updates(map[string]interface{}{
"last_cleanup_at": now,
"last_cleanup_summary": result.SummaryJSON(),
})
}
case "snmp_metrics_cleanup":
sql := database.CleanupSNMPMetricsSQL(driver, snmppkg.MetricsRetentionDays)
r := gdb.Exec(sql)
err = r.Error
msg = fmt.Sprintf("清理 %d 条 SNMP 指标(保留 %d 天)", r.RowsAffected, snmppkg.MetricsRetentionDays)
default:
return Item{}, fmt.Errorf("任务 %s 未实现手动执行", code)
}
duration := time.Since(start)
status := "success"
if err != nil {
status = "failed"
msg = err.Error()
}
Record(code, status, msg, duration)
items := List(gdb, nil)
for _, it := range items {
if it.Code == code {
return it, err
}
}
return Item{Def: def}, err
}
func findDef(code string) (Def, bool) {
for _, d := range catalog {
if d.Code == code {
return d, true
}
}
return Def{}, false
}
+59 -13
View File
@@ -7,6 +7,8 @@ import (
"ezops/model"
"ezops/service"
ezredis "ezops/pkg/redis"
"ezops/pkg/siteprobe"
"ezops/pkg/systask"
"ezops/pkg/toolinstaller"
"ezops/pkg/wshub"
"fmt"
@@ -189,6 +191,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
admin.POST("/users", authHandler.CreateUser)
admin.PUT("/users/:id", authHandler.UpdateUser)
admin.DELETE("/users/:id", authHandler.DeleteUser)
admin.POST("/users/batch-delete", authHandler.BatchDeleteUsers) // Logan.Li
admin.POST("/users/:id/reset-password", authHandler.ResetUserPassword)
admin.POST("/users/batch-import", authHandler.BatchImportUsers)
admin.GET("/users/import-template", authHandler.ImportTemplate)
@@ -211,6 +214,11 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
admin.GET("/users/:id/item-roles", itemRoleHandler.ListUserItemRoles)
admin.POST("/users/:id/item-roles", itemRoleHandler.AssignItemRole)
admin.DELETE("/users/:id/item-roles/:roleId", itemRoleHandler.RemoveItemRole)
// 审计策略(保留天数 + 立即清理) // Logan.Li
auditSettingsHandler := controller.NewAuditSettingsHandler(db)
admin.GET("/audit-settings", auditSettingsHandler.GetAuditSettings)
admin.PUT("/audit-settings", auditSettingsHandler.ApplyAuditSettings)
}
// LDAP 配置路由
@@ -566,12 +574,28 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
alerts.POST("/contact-groups", alertGroupV2.CreateGroup)
alerts.PUT("/contact-groups/:id", alertGroupV2.UpdateGroup)
alerts.DELETE("/contact-groups/:id", alertGroupV2.DeleteGroup)
// 告警集合通知策略 // Logan.Li
alerts.GET("/notify-policies", alertHandler.ListNotifyPolicies)
alerts.PUT("/notify-policies/:category", alertHandler.UpsertNotifyPolicy)
alerts.POST("/notify-policies/:category/test", alertHandler.TestNotifyPolicy)
alerts.GET("/notify-message-settings", alertHandler.GetNotifyMessageSettings)
alerts.PUT("/notify-message-settings", alertHandler.SaveNotifyMessageSettings)
// 告警静默规则
alerts.GET("/silences", alertHandler.ListSilences)
alerts.POST("/silences", alertHandler.CreateSilence)
alerts.PUT("/silences/:id", alertHandler.UpdateSilence)
alerts.DELETE("/silences/:id", alertHandler.DeleteSilence)
alerts.POST("/silences/:id/toggle", alertHandler.ToggleSilence)
// 监控指标目录 + 统一告警 + 中间件/站点规则 // Logan.Li
alerts.GET("/metric-catalog", alertHandler.GetMetricCatalog)
alerts.GET("/unified", alertHandler.ListUnifiedAlerts)
alerts.GET("/unified-rules", alertHandler.ListUnifiedAlertRules)
alerts.GET("/monitor-rules", alertHandler.ListMonitorAlertRules)
alerts.POST("/monitor-rules", alertHandler.CreateMonitorAlertRule)
alerts.PUT("/monitor-rules/:id", alertHandler.UpdateMonitorAlertRule)
alerts.DELETE("/monitor-rules/:id", alertHandler.DeleteMonitorAlertRule)
alerts.POST("/monitor-quick-setup", alertHandler.QuickSetupMonitorRules)
alerts.POST("/unified/:source/:id/resolve", alertHandler.ResolveUnifiedAlert)
// 告警引擎状态 — 查看当前 Leader 副本和分布式锁状态
alerts.GET("/engine/status", func(c *gin.Context) {
c.JSON(200, model.Response{Code: 200, Data: alertEngine.GetEngineStatus()})
@@ -672,27 +696,37 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
// 自动化运维路由
automationHandler := controller.NewAutomationHandler(db)
autoWrite := middleware.AutomationWrite(db)
cronJobs := authorized.Group("/cron-jobs")
{
cronJobs.GET("", automationHandler.ListCronJobs)
cronJobs.POST("", automationHandler.CreateCronJob)
cronJobs.POST("", autoWrite, automationHandler.CreateCronJob)
cronJobs.GET("/:id", automationHandler.GetCronJob)
cronJobs.PUT("/:id", automationHandler.UpdateCronJob)
cronJobs.DELETE("/batch-delete", automationHandler.BatchDeleteCronJobs)
cronJobs.DELETE("/:id", automationHandler.DeleteCronJob)
cronJobs.POST("/:id/toggle", automationHandler.ToggleCronJob)
cronJobs.POST("/:id/execute", automationHandler.ExecuteCronJob)
cronJobs.PUT("/:id", autoWrite, automationHandler.UpdateCronJob)
cronJobs.DELETE("/batch-delete", autoWrite, automationHandler.BatchDeleteCronJobs)
cronJobs.DELETE("/:id", autoWrite, automationHandler.DeleteCronJob)
cronJobs.POST("/:id/toggle", autoWrite, automationHandler.ToggleCronJob)
cronJobs.POST("/:id/execute", autoWrite, automationHandler.ExecuteCronJob)
}
// 内置系统任务(告警引擎 / 数据清理等) // Logan.Li
sysTaskHandler := controller.NewSystemTaskHandler(db, cfg.Database.Driver, alertEngine)
sysTasks := authorized.Group("/system-tasks")
{
sysTasks.GET("", sysTaskHandler.ListSystemTasks)
sysTasks.POST("/:code/run", autoWrite, sysTaskHandler.RunSystemTask)
}
scripts := authorized.Group("/scripts")
{
scripts.GET("/runtime", automationHandler.GetScriptRuntime)
scripts.GET("", automationHandler.ListScripts)
scripts.POST("", automationHandler.CreateScript)
scripts.POST("", autoWrite, automationHandler.CreateScript)
scripts.GET("/:id", automationHandler.GetScript)
scripts.PUT("/:id", automationHandler.UpdateScript)
scripts.POST("/batch-delete", automationHandler.BatchDeleteScripts)
scripts.DELETE("/:id", automationHandler.DeleteScript)
scripts.POST("/:id/execute", automationHandler.ExecuteScript)
scripts.POST("/batch-delete", autoWrite, automationHandler.BatchDeleteScripts)
scripts.DELETE("/:id", autoWrite, automationHandler.DeleteScript)
scripts.PUT("/:id", autoWrite, automationHandler.UpdateScript)
scripts.POST("/:id/execute", autoWrite, automationHandler.ExecuteScript)
}
// 远程执行路由 — Go 原生 SSH 批量执行替代 Ansible // Logan.Li
@@ -917,6 +951,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
mwGroup := authorized.Group("/middleware-monitors")
{
mwGroup.GET("", mwHandler.ListMiddlewareTargets)
mwGroup.POST("/seed-defaults", mwHandler.SeedDefaultMiddlewareTargetsAPI)
mwGroup.POST("", mwHandler.CreateMiddlewareTarget)
mwGroup.GET("/:id", mwHandler.GetMiddlewareTarget)
mwGroup.PUT("/:id", mwHandler.UpdateMiddlewareTarget)
@@ -939,7 +974,13 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
snmpGroup.GET("/templates", snmpHandler.ListTemplates)
snmpGroup.POST("/templates", snmpHandler.CreateTemplate)
snmpGroup.GET("/stats", snmpHandler.GetSNMPStats)
snmpGroup.POST("/devices/:id/collect", snmpHandler.CollectDeviceNow)
snmpGroup.GET("/devices/:id/metrics", snmpHandler.ListDeviceMetrics)
snmpGroup.GET("/devices/:id/metrics/series", snmpHandler.ListMetricSeries)
snmpGroup.GET("/devices/:id/metrics/history", snmpHandler.QueryMetricHistory)
snmpGroup.GET("/overview/metrics", snmpHandler.GetOverviewMetrics)
}
snmpHandler.StartCollector(300) // 每 5 分钟采集 SNMP 设备,指标保留 7 天 // Logan.Li
// Zabbix 代理 — 镜像 Zabbix API 为前端面板可用格式 // Logan.Li
zabbixHandler := controller.NewZabbixProxyHandler()
@@ -1032,7 +1073,8 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
authorized.POST("/servers/:id/assign-credential", credentialHandler.AssignCredential)
// 站点拨测 — HTTP/TCP/DNS/PING 主动监控 // Logan.Li
siteHandler := controller.NewSiteHandler(db)
siteProbeWorker := siteprobe.NewWorker(db)
siteHandler := controller.NewSiteHandler(db, siteProbeWorker)
siteGroup := authorized.Group("/site")
{
siteGroup.GET("/tasks", siteHandler.ListTasks)
@@ -1040,16 +1082,20 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
siteGroup.PUT("/tasks/:id", siteHandler.UpdateTask)
siteGroup.DELETE("/tasks/:id", siteHandler.DeleteTask)
siteGroup.DELETE("/tasks/batch-delete", siteHandler.BatchDeleteTasks)
siteGroup.POST("/tasks/:id/probe", siteHandler.ProbeNow)
siteGroup.GET("/results", siteHandler.ListResults)
siteGroup.GET("/stats", siteHandler.GetSiteStats)
}
siteProbeWorker.Start(10)
// 启动告警引擎(每 30 秒检查一次;alertEngine 在告警路由上方创建)
// 启动告警引擎(每 30 秒检查一次;alertEngine 在告警路由上方创建) // Logan.Li
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
start := time.Now()
alertEngine.Check()
systask.Record("alert_engine", "success", "", time.Since(start))
}
}()
}
+56 -1
View File
@@ -690,9 +690,64 @@ func SeedData(db *gorm.DB) {
}
}
// 7. 种子工具配置(kubectl/maven/node/ansible/git/docker)// Logan.Li
// 7. 默认审计保留策略(180 天) // Logan.Li
var auditSettingsCount int64
db.Model(&model.AuditSettings{}).Count(&auditSettingsCount)
if auditSettingsCount == 0 {
if err := db.Create(&model.AuditSettings{RetentionDays: 180, RecordAll: true}).Error; err != nil {
zap.L().Warn("failed to seed audit settings", zap.Error(err))
}
}
// 8. 补全自动化运维角色权限(已有库升级时确保 auto:* 已分配) // Logan.Li
ensureAutoOpsRolePermissions(db)
// 9. 种子工具配置(kubectl/maven/node/ansible/git/docker)// Logan.Li
ti := toolinstaller.New(db)
ti.SeedTools()
// 10. SNMP OID 模板(H3C/华为网络设备)// Logan.Li
SeedSNMPTemplates(db)
// 11. 站点/中间件/SNMP 默认告警规则(含 HTTPS 证书 < 30 天预警)// Logan.Li
SeedMonitorAlertRules(db)
}
// SeedMonitorAlertRules 初始化监控告警默认规则(幂等)// Logan.Li
func SeedMonitorAlertRules(db *gorm.DB) {
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
db.Where("name = ? AND category = ?", r.Name, r.Category).First(&exist)
if exist.ID == 0 {
if err := db.Create(&r).Error; err != nil {
zap.L().Warn("seed monitor alert rule failed", zap.String("name", r.Name), zap.Error(err))
}
}
}
}
// ensureAutoOpsRolePermissions 为运维类角色补充 auto:script / auto:cron 权限 // Logan.Li
func ensureAutoOpsRolePermissions(db *gorm.DB) {
autoCodes := []string{"auto:cron", "auto:script", "auto:ansible"}
var perms []model.Permission
db.Where("code IN ?", autoCodes).Find(&perms)
if len(perms) == 0 {
return
}
roleCodes := []string{"resource_admin", "release_admin", "k8s_admin", "super_admin"}
for _, rc := range roleCodes {
var role model.Role
if err := db.Where("code = ?", rc).First(&role).Error; err != nil {
continue
}
db.Model(&role).Association("Permissions").Append(perms)
}
}
// dedupeUint 去重 uint 切片
+42
View File
@@ -0,0 +1,42 @@
package seed
import (
snmppkg "ezops/pkg/snmp"
"ezops/model"
"go.uber.org/zap"
"gorm.io/gorm"
)
// SeedSNMPTemplates 种子 H3C/华为等内置 OID 模板 // Logan.Li
func SeedSNMPTemplates(db *gorm.DB) {
for _, bt := range snmppkg.BuiltinTemplates() {
var exist model.SNMPTemplate
db.Where("name = ? AND is_system = ?", bt.Name, true).First(&exist)
if exist.ID > 0 {
continue
}
tpl := model.SNMPTemplate{
Name: bt.Name, Description: bt.Description,
DeviceType: bt.DeviceType, Vendor: bt.Vendor,
IsDefault: bt.Vendor == "H3C" && bt.DeviceType == "switch",
IsSystem: true, Status: 1,
}
if err := db.Create(&tpl).Error; err != nil {
zap.L().Warn("seed snmp template failed", zap.String("name", bt.Name), zap.Error(err))
continue
}
for _, o := range bt.OIDs {
oid := model.SNMPTemplateOID{
TemplateID: tpl.ID, Name: o.Name, OID: o.OID,
Description: o.Description, DataType: o.DataType,
Unit: o.Unit, SortOrder: o.SortOrder,
}
if o.IsTable {
oid.Conversion = "table"
}
db.Create(&oid)
}
zap.L().Info("seeded snmp template", zap.String("name", bt.Name), zap.Int("oids", len(bt.OIDs)))
}
}
+6 -3
View File
@@ -484,7 +484,7 @@ func (s *AlertContactGroupService) GetByID(id uint) (*model.AlertContactGroup, e
}
func (s *AlertContactGroupService) Create(req *dto.CreateAlertContactGroupRequest) (*model.AlertContactGroup, error) {
g := &model.AlertContactGroup{GroupName: req.Name}
g := &model.AlertContactGroup{GroupName: req.GroupName, Notes: req.Notes}
if len(req.ContactIDs) > 0 {
g.ContactIDs = toJSONString(req.ContactIDs)
}
@@ -496,8 +496,11 @@ func (s *AlertContactGroupService) Create(req *dto.CreateAlertContactGroupReques
func (s *AlertContactGroupService) Update(id uint, req *dto.UpdateAlertContactGroupRequest) error {
updates := make(map[string]interface{})
if req.Name != nil {
updates["group_name"] = *req.Name
if req.GroupName != nil {
updates["group_name"] = *req.GroupName
}
if req.Notes != nil {
updates["notes"] = *req.Notes
}
if req.ContactIDs != nil {
updates["contact_ids"] = toJSONString(*req.ContactIDs)