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

+1 -1
View File
@@ -98,7 +98,7 @@ ARG KUBECTL_VERSION=v1.32.2
# kubectl 通过 install-kubectl.sh 下载(避免构建上下文携带 60MB 二进制)// Logan.Li
COPY scripts/setup-alpine-mirrors.sh scripts/install-kubectl.sh /tmp/
RUN chmod +x /tmp/setup-alpine-mirrors.sh && /tmp/setup-alpine-mirrors.sh && \
apk add --no-cache ca-certificates tzdata bash curl && \
apk add --no-cache ca-certificates tzdata bash curl python3 && \
chmod +x /tmp/install-kubectl.sh && \
TARGET_OS=linux TARGET_ARCH="${TARGETARCH}" KUBECTL_VERSION="${KUBECTL_VERSION}" \
/tmp/install-kubectl.sh /usr/local/bin/kubectl && \
+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)
+1504 -3
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -10,7 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.1",
@@ -60,9 +62,11 @@
"react-dom": "^19.2.0",
"react-hook-form": "^7.70.0",
"react-i18next": "^17.0.8",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.2.2",
"react-router-dom": "^7.13.2",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
+15 -1
View File
@@ -5,7 +5,21 @@ import { http } from '@/utils/request';
export const agentApi = {
getLatest: () => http.get('/agent/latest'),
getHistory: (hostname: string, hours = 1) => http.get(`/agent/history/${hostname}?hours=${hours}`),
getHistory: (
hostname: string,
query: { minutes?: number; hours?: number; start?: string; end?: string } = { hours: 1 },
) => {
const params = new URLSearchParams();
if (query.start && query.end) {
params.set('start', query.start);
params.set('end', query.end);
} else if (query.minutes != null) {
params.set('minutes', String(query.minutes));
} else {
params.set('hours', String(query.hours ?? 1));
}
return http.get(`/agent/history/${hostname}?${params.toString()}`);
},
createTask: (data: { hostname: string; type: string; command: string; timeout?: number }) => http.post('/agent/tasks', data),
listTasks: (params?: Record<string, string>) => http.get('/agent/tasks', params),
// 直连 SSH 执行命令(即时返回)
+3 -2
View File
@@ -47,8 +47,9 @@ export const aiApi = {
getConfig: () => http.get<LLMConfigData>('/aiops/llm-config'),
// 保存 LLM 配置(api_key 若为脱敏值则后端保留原值)
saveConfig: (data: LLMConfigData) => http.put<LLMConfigData>('/aiops/llm-config', data),
// 测试 LLM 连接(询问模型身份,返回模型回答) // Logan.Li
testConnection: () => http.post<LLMTestResult>('/aiops/llm-config/test'),
// 测试 LLM 连接(可传草稿配置,保存前即可验证) // Logan.Li
testConnection: (data?: Partial<LLMConfigData>) =>
http.post<LLMTestResult>('/aiops/llm-config/test', data ?? {}),
// AI 对话(messages 含历史 + 当前用户消息,session_id 区分多轮对话)
chat: (messages: { role: 'user' | 'assistant'; content: string }[], sessionId?: string) =>
http.post<ChatResult>('/aiops/chat', { messages, session_id: sessionId || '' }),
+69
View File
@@ -0,0 +1,69 @@
// 监控指标目录 & 统一告警 API // Logan.Li
import { http } from '@/utils/request';
export interface MetricCatalogItem {
key: string;
label: string;
unit: string;
type: string;
default_op: string;
default_warn: number;
default_crit: number;
min: number;
max: number;
collect_source: string;
description: string;
operators: string[];
}
export interface MetricCatalogCategory {
id: 'host' | 'middleware' | 'snmp' | 'site';
label: string;
description: string;
metrics: MetricCatalogItem[];
}
export interface UnifiedAlertRule {
id: number;
category: 'host' | 'middleware' | 'snmp' | 'site';
name: string;
target?: string;
target_id?: number;
metric: string;
operator: string;
threshold: number;
enabled: boolean;
severity: string;
description?: string;
rule_type: 'agent' | 'monitor';
port?: number;
}
export const alertMetricsApi = {
getCatalog: () =>
http.get<{ categories: MetricCatalogCategory[] }>('/alerts/metric-catalog'),
getUnifiedAlerts: (params?: {
page?: number; page_size?: number;
status?: string; severity?: string; source_type?: string;
}) => http.get<{ list: unknown[]; total: number; stats: Record<string, number> }>('/alerts/unified', params),
getUnifiedRules: (category?: string) =>
http.get<{ list: UnifiedAlertRule[]; total: number }>('/alerts/unified-rules', { category: category || 'all' }),
createMonitorRule: (data: Record<string, unknown>) =>
http.post('/alerts/monitor-rules', data),
updateMonitorRule: (id: number, data: Record<string, unknown>) =>
http.put(`/alerts/monitor-rules/${id}`, data),
deleteMonitorRule: (id: number) =>
http.delete(`/alerts/monitor-rules/${id}`),
monitorQuickSetup: (category?: string) =>
http.post('/alerts/monitor-quick-setup', { category: category || 'all' }),
resolveUnified: (source: string, id: number) =>
http.post(`/alerts/unified/${source}/${id}/resolve`),
};
+23
View File
@@ -3,6 +3,12 @@
import { http, type ListResponse } from '@/utils/request';
import type { AlertRuleInput } from '@/types/monitor';
export interface NotifyMessageSettings {
title_template: string;
footer_template: string;
platform_name: string;
}
export const alertApi = {
getAlerts: async (params?: { page?: number; page_size?: number; status?: string; severity?: string }) => {
return http.get<ListResponse>('/alerts', params);
@@ -70,4 +76,21 @@ export const alertApi = {
deleteContactGroup: async (id: number | string) => {
return http.delete(`/alerts/contact-groups/${id}`);
},
// 告警集合通知策略 // Logan.Li
getNotifyPolicies: async () => {
return http.get('/alerts/notify-policies');
},
upsertNotifyPolicy: async (category: string, data: Record<string, unknown>) => {
return http.put(`/alerts/notify-policies/${category}`, data);
},
testNotifyPolicy: async (category: string) => {
return http.post(`/alerts/notify-policies/${category}/test`);
},
// 告警消息模版(标题 / 底部) // Logan.Li
getNotifyMessageSettings: async () => {
return http.get('/alerts/notify-message-settings');
},
saveNotifyMessageSettings: async (data: NotifyMessageSettings) => {
return http.put('/alerts/notify-message-settings', data);
},
};
+2
View File
@@ -3,6 +3,8 @@
export { alertApi } from './alert';
export { alertCenterApi } from './alert-center';
export { siteApi } from './site';
export { alertMetricsApi } from './alert-metrics';
export type { MetricCatalogItem, MetricCatalogCategory, UnifiedAlertRule } from './alert-metrics';
export { traceApi } from './trace';
export { traceDatasourceApi, type TraceDataSource } from './trace-datasource';
export { dashboardApi } from './grafana';
+12 -2
View File
@@ -20,6 +20,14 @@ export const snmpApi = {
getDevices: () => http.get('/snmp/devices'),
createDevice: (data: Record<string, unknown>) => http.post('/snmp/devices', data),
deleteDevice: (id: number | string) => http.delete(`/snmp/devices/${id}`),
getTemplates: () => http.get('/snmp/templates'),
collectDevice: (id: number | string) => http.post(`/snmp/devices/${id}/collect`),
getDeviceMetrics: (id: number | string) => http.get(`/snmp/devices/${id}/metrics`),
getDeviceMetricSeries: (id: number | string) => http.get(`/snmp/devices/${id}/metrics/series`),
getDeviceMetricHistory: (id: number | string, params: {
metric_name: string; index?: string; hours?: number; limit?: number;
}) => http.get(`/snmp/devices/${id}/metrics/history`, params),
getOverviewMetrics: () => http.get('/snmp/overview/metrics'),
};
// 中间件监控 — Redis/MySQL 后端直连采集 (M15)
@@ -32,8 +40,10 @@ export const middlewareMonitorApi = {
delete: (id: number | string) => http.delete(`/middleware-monitors/${id}`),
test: (id: number | string) => http.post(`/middleware-monitors/${id}/test`),
collectNow: () => http.post('/middleware-monitors/collect'),
getMetrics: (id: number | string, params?: Record<string, string | number | undefined>) =>
http.get(`/middleware-monitors/${id}/metrics`, params),
seedDefaults: () => http.post('/middleware-monitors/seed-defaults'),
getMetrics: (id: number | string, params?: {
metric?: string; hours?: number; start?: number; end?: number;
}) => http.get(`/middleware-monitors/${id}/metrics`, params),
};
export const prometheusProxyApi = {
+24 -15
View File
@@ -3,19 +3,28 @@
import { http } from '@/utils/request';
export const siteApi = {
getStats: async () => {
return http.get('/site/stats');
},
getTasks: async () => {
return http.get<{ list: unknown[]; total: number }>('/site/tasks');
},
createTask: async (data: { name: string; url: string; type: string; frequency_sec?: number; timeout_sec?: number; expected_status_code?: number }) => {
return http.post('/site/tasks', data);
},
deleteTask: async (id: number | string) => {
return http.delete(`/site/tasks/${id}`);
},
toggleTask: async (id: number | string, enabled: boolean) => {
return http.put(`/site/tasks/${id}`, { enabled });
},
getStats: async () => http.get('/site/stats'),
getTasks: async () => http.get<{ list: unknown[]; total: number }>('/site/tasks'),
createTask: async (data: {
name: string; url: string; type: string;
frequency_sec?: number; timeout_sec?: number; expected_status_code?: number;
enabled?: boolean;
}) => http.post('/site/tasks', data),
updateTask: async (id: number | string, data: {
name?: string; url?: string; type?: string;
frequency_sec?: number; timeout_sec?: number; expected_status_code?: number;
enabled?: boolean;
}) => http.put(`/site/tasks/${id}`, data),
deleteTask: async (id: number | string) => http.delete(`/site/tasks/${id}`),
toggleTask: async (id: number | string, enabled: boolean) =>
http.put(`/site/tasks/${id}`, { enabled }),
probeNow: async (id: number | string) => http.post(`/site/tasks/${id}/probe`),
getResults: async (
taskId: number | string,
opts?: { limit?: number; hours?: number },
) => http.get<{ list: unknown[]; total: number }>('/site/results', {
task_id: taskId,
limit: opts?.limit ?? 500,
...(opts?.hours ? { hours: opts.hours } : {}),
}),
};
+27
View File
@@ -0,0 +1,27 @@
// 审计策略配置 API // Logan.Li
import { http } from '@/utils/request';
export interface AuditCleanupStats {
retention_days: number;
cutoff_time: string;
audit_logs: number;
login_logs: number;
terminal_sessions: number;
session_commands: number;
total: number;
}
export interface AuditSettings {
retention_days: number;
record_all: boolean;
last_cleanup_at?: string | null;
last_cleanup_summary?: string;
allowed_retention_days?: number[];
}
export const auditSettingsApi = {
get: async () => http.get<AuditSettings>('/admin/audit-settings'),
apply: async (data: { retention_days: number; record_all: boolean }) =>
http.put<AuditSettings & { cleanup?: AuditCleanupStats }>('/admin/audit-settings', data),
};
+2
View File
@@ -1,3 +1,5 @@
// 系统管理 API 聚合出口 // Logan.Li
export { userApi, ldapApi } from './user';
export { auditSettingsApi } from './audit-settings';
export type { AuditSettings, AuditCleanupStats } from './audit-settings';
+3
View File
@@ -21,6 +21,9 @@ export const userApi = {
deleteUser: async (id: number | string) => {
return http.delete(`/admin/users/${id}`);
},
batchDelete: async (ids: number[]) => {
return http.post('/admin/users/batch-delete', { ids });
},
batchImport: async (data: { users: Record<string, unknown>[]; skip_existing?: boolean }) => {
return http.post('/admin/users/batch-import', data);
},
+1
View File
@@ -3,3 +3,4 @@
export * from './cron-job';
export * from './script';
export * from './remote-exec';
export * from './system-task';
+15
View File
@@ -2,7 +2,22 @@
import { http, type ListResponse } from '@/utils/request';
export interface ScriptRuntimeInfo {
shell: boolean;
shell_path?: string;
python: boolean;
python_path?: string;
powershell: boolean;
powershell_path?: string;
os: string;
arch: string;
embed_mode: boolean;
}
export const scriptApi = {
getRuntime: async () => {
return http.get<ScriptRuntimeInfo>('/scripts/runtime');
},
getScripts: async (params?: { page?: number; page_size?: number }) => {
return http.get<ListResponse>('/scripts', params);
},
+24
View File
@@ -0,0 +1,24 @@
// 内置系统任务 API // Logan.Li
import { http, type ListResponse } from '@/utils/request';
export interface SystemTaskItem {
code: string;
name: string;
description: string;
schedule: string;
category: 'monitor' | 'cleanup' | string;
runnable: boolean;
enabled: boolean;
last_run_at?: string | null;
last_status?: string;
last_message?: string;
run_count?: number;
last_duration_ms?: number;
extra?: Record<string, unknown>;
}
export const systemTaskApi = {
list: async () => http.get<ListResponse<SystemTaskItem>>('/system-tasks'),
run: async (code: string) => http.post<SystemTaskItem>(`/system-tasks/${code}/run`),
};
@@ -49,7 +49,7 @@ export function HostDetailDialog({
let active = true;
setLoading(true);
agentApi
.getHistory(host.hostname, hours)
.getHistory(host.hostname, { hours })
.then((data) => {
if (active) setHistory(asList(data));
})
@@ -1,18 +1,34 @@
// Agent 告警常量与辅助函数(从 AgentAlerts.tsx 提取) // Logan.Li
// Agent 告警常量与辅助函数 // Logan.Li
import { Badge } from '@/components/ui/badge';
import { AlertTriangle, Cpu, MemoryStick, HardDrive, WifiOff, Network } from 'lucide-react';
import {
AlertTriangle, Cpu, MemoryStick, HardDrive, WifiOff, Network,
Activity, Gauge, Timer,
} from 'lucide-react';
import type { JsonData } from '@/types/common';
// 监控指标选项(含图标,告警事件/规则/编辑弹窗共用) // Logan.Li
// 主机监控指标(与后端 alert_engine 对齐)// Logan.Li
export const METRIC_OPTIONS = [
{ value: 'cpu', label: 'CPU', icon: Cpu },
{ value: 'mem', label: '内存', icon: MemoryStick },
{ value: 'disk', label: '磁盘', icon: HardDrive },
{ value: 'cpu', label: 'CPU 使用率', icon: Cpu },
{ value: 'cpu_iowait', label: 'CPU IO 等待', icon: Cpu },
{ value: 'mem', label: '内存使用率', icon: MemoryStick },
{ value: 'mem_available', label: '可用内存(GB)', icon: MemoryStick },
{ value: 'swap', label: 'Swap 使用率', icon: MemoryStick },
{ value: 'load1', label: '1分钟负载', icon: Gauge },
{ value: 'disk', label: '磁盘使用率', icon: HardDrive },
{ value: 'disk_inode', label: 'Inode 使用率', icon: HardDrive },
{ value: 'disk_io_util', label: '磁盘 IO 利用率', icon: HardDrive },
{ value: 'tcp_established', label: 'TCP 已建立', icon: Network },
{ value: 'tcp_time_wait', label: 'TCP TIME_WAIT', icon: Network },
{ value: 'tcp_close_wait', label: 'TCP CLOSE_WAIT', icon: Network },
{ value: 'net_rx_bps', label: '入网带宽', icon: Network },
{ value: 'net_tx_bps', label: '出网带宽', icon: Network },
{ value: 'net_rx_errors', label: '入网错误', icon: Network },
{ value: 'fd', label: '文件描述符', icon: Activity },
{ value: 'offline', label: '主机离线', icon: WifiOff },
{ value: 'port_down', label: '端口不通', icon: Network },
{ value: 'health_probe', label: '健康探针', icon: Timer },
];
// 比较运算符选项 // Logan.Li
export const OPERATOR_OPTIONS = [
{ value: '>', label: '> 大于' },
{ value: '<', label: '< 小于' },
@@ -21,19 +37,16 @@ export const OPERATOR_OPTIONS = [
{ value: '==', label: '= 等于' },
];
// 严重程度选项(含徽章配色) // Logan.Li
export const SEVERITY_OPTIONS = [
{ value: 'critical', label: '严重', color: 'bg-red-500/15 dg-text-red border-red-500/30' },
{ value: 'warning', label: '警告', color: 'bg-amber-500/15 dg-text-yellow border-amber-500/30' },
{ value: 'info', label: '提示', color: 'bg-blue-500/15 dg-text-blue border-blue-500/30' },
];
// 获取指标中文标签 // Logan.Li
export function getMetricLabel(metric: string) {
return METRIC_OPTIONS.find((m) => m.value === metric)?.label || metric;
}
// 兼容多种列表响应格式(数组 / {list} / {items} / {data}) // Logan.Li
export function asList(data: JsonData): unknown[] {
if (!data) return [];
if (Array.isArray(data)) return data;
@@ -43,7 +56,6 @@ export function asList(data: JsonData): unknown[] {
return [];
}
// 严重程度徽章(告警事件 + 规则列表共用) // Logan.Li
export function severityBadge(sev: string) {
const opt = SEVERITY_OPTIONS.find((s) => s.value === sev) || SEVERITY_OPTIONS[1];
return (
@@ -52,4 +64,4 @@ export function severityBadge(sev: string) {
{opt.label}
</Badge>
);
}
}
+329 -124
View File
@@ -2,21 +2,22 @@
// 关联主机后,用自然语言描述任务,AI 通过 SSH 工具自主排障/部署,循环重试直到解决。
// 通过 WebSocket 实时展示思考、执行的命令、输出与结果;高危命令弹框确认。
import { useState, useRef, useEffect, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { serverApi, buildAgentCliWsUrl } from '@/api';
import { toast } from 'sonner';
import {
Send, Square, Terminal, ChevronDown, ChevronRight,
Shield, Brain, Cpu, Database, Loader2, Bot, Lightbulb, Server,
Send, Square, Terminal, Shield, Brain, Cpu, Database, Loader2, Bot, Lightbulb, Server,
PanelLeftClose, History, Rocket,
} from 'lucide-react';
import AgentCLIHistory from './AgentCLIHistory';
import { MessageView, type ChatItem } from './AgentCLIMessages';
import { AgentChatTimeline, type ChatItem } from './AgentCLIMessages';
import type { AgentCliSession } from '@/api/aiops/agent-cli';
import { PageShell } from '@/components/layout/PageShell';
@@ -39,24 +40,49 @@ interface WsEvent {
command?: string;
}
// 四大核心模块说明(页面 tips) // Logan.Li
// 四大核心模块说明(灯泡 Popover) // Logan.Li
const TIPS = [
{ icon: Cpu, title: 'LLM + 工具层(llm / tools)', color: 'text-sky-400',
desc: 'OpenAI 兼容 LLM 客户端(复用「大模型设置」中的配置);工具系统注册 ssh_execute(远程执行命令,复用 pkg/remoteexec)与 analyze_output(超长输出分析)。AI 借助工具真正动手操作服务器。' },
{ icon: Shield, title: '安全 + Prompt(safety / prompt)', color: 'dg-text-yellow',
desc: '五级风险评估 + 三级安全模式:危险命令(rm -rf /、mkfs)硬性拒绝,高危命令(reboot、restart)弹框确认;五层 System Prompt 定义角色、安全规则、工具与 ReAct 工作方式。' },
{ icon: Brain, title: 'ReAct 核心引擎(core)', color: 'dg-text-purple',
desc: '「思考→行动→观察」循环(最多 16 轮):每轮调用工具、读取输出,失败则分析原因换法重试;自适应检查点 + 循环检测(同参超过 3 次拒绝),直到真正解决问题而非假装解决。' },
{ icon: Database, title: '自进化引擎(evolver)', color: 'dg-text-green',
desc: '四层记忆(经验 / 环境 / 事实 / 程序):任务完成后异步学习经验,下次同类任务自动注入历史经验,越用越聪明;学习失败优雅降级,绝不影响主流程。' },
{ icon: Cpu, title: 'LLM + 工具层', color: 'text-sky-400',
desc: 'OpenAI 兼容 LLM;工具含 ssh_execute(远程执行)与 analyze_output(输出分析)。' },
{ icon: Shield, title: '安全 + Prompt', color: 'dg-text-yellow',
desc: '五级风险评估 + 三级安全模式;危险命令硬性拒绝,高危命令弹框确认。' },
{ icon: Brain, title: 'ReAct 核心引擎', color: 'dg-text-purple',
desc: '思考→行动→观察循环(最多 16 轮),失败换法重试,循环检测防死循环。' },
{ icon: Database, title: '自进化引擎', color: 'dg-text-green',
desc: '四层记忆异步学习,同类任务自动注入历史经验,越用越聪明。' },
];
// 安全模式说明(灯泡 Popover 展示) // Logan.Li
const SAFETY_MODES = [
{ value: 'strict', label: '严格', desc: '中风险及以上需确认' },
{ value: 'balanced', label: '均衡', desc: '高风险及以上需确认' },
{ value: 'permissive', label: '宽松', desc: '仅危险命令拦截' },
{
value: 'strict',
label: '严格',
short: '中风险+确认',
desc: 'chmod、文件修改、服务重启等中风险及以上命令均需弹框确认。适合生产环境或不熟悉的主机。',
examples: 'chmod、mv、systemctl restart',
},
{
value: 'balanced',
label: '均衡',
short: '高风险+确认',
desc: '日常排障命令(查看日志、ps、df)自动执行;删除文件、重启服务等高风险操作才需确认。推荐日常使用。',
examples: 'cat/grep/df 自动 · rm/reboot 确认',
recommended: true,
},
{
value: 'permissive',
label: '宽松',
short: '仅拦截危险',
desc: '除 rm -rf /、mkfs 等危险命令硬性拦截外,其余全部自动执行。仅适合隔离测试环境。',
examples: 'rm -rf / 拦截 · 其余自动',
},
] as const;
// formatServerLabel 主机下拉展示文案 // Logan.Li
function formatServerLabel(s: { hostname: string; ip: string }) {
return `${s.hostname}(${s.ip})`;
}
export default function AgentCLI() {
const confirm = useConfirm();
@@ -68,11 +94,14 @@ export default function AgentCLI() {
const [running, setRunning] = useState(false);
const [round, setRound] = useState(0);
const [tokens, setTokens] = useState(0);
const [tipsOpen, setTipsOpen] = useState(false);
const [connected, setConnected] = useState(false);
const [replayMode, setReplayMode] = useState(false);
const [viewMode, setViewMode] = useState(false);
const [viewSession, setViewSession] = useState<AgentCliSession | null>(null);
const [canContinue, setCanContinue] = useState(false);
const [historyRefresh, setHistoryRefresh] = useState(0);
const [historyOpen, setHistoryOpen] = useState(true);
const [scrollAtTop, setScrollAtTop] = useState(true);
const [scrollAtBottom, setScrollAtBottom] = useState(true);
const wsRef = useRef<WebSocket | null>(null);
const idSeq = useRef(0);
@@ -83,6 +112,8 @@ export default function AgentCLI() {
setItems((prev) => [...prev, { ...it, id: nextId() }]);
}, []);
const currentSafety = SAFETY_MODES.find((m) => m.value === safetyMode) ?? SAFETY_MODES[1];
// 加载主机列表 // Logan.Li
useEffect(() => {
serverApi.getServers({ page: 1, page_size: 500 }).then((res) => {
@@ -96,7 +127,8 @@ export default function AgentCLI() {
setItems([]);
setRound(0); setTokens(0); setRunning(false);
setConnected(false);
setReplayMode(false);
setViewMode(false);
setViewSession(null);
setCanContinue(false);
const ws = new WebSocket(buildAgentCliWsUrl(serverId));
wsRef.current = ws;
@@ -118,7 +150,7 @@ export default function AgentCLI() {
setRunning(false);
if (!everConnected) {
push({ kind: 'error', content: '主机连接被拒绝:常见为该主机未关联可用的 SSH 凭据(密码或密钥),或大模型未启用。请到「服务器管理」确认该主机凭据、到「大模型设置」确认已启用模型后重试。' });
} else if (!replayMode) {
} else if (!viewMode) {
push({ kind: 'info', content: '连接已关闭' });
}
};
@@ -135,10 +167,30 @@ export default function AgentCLI() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverId]);
// 自动滚到底部 // Logan.Li
const updateScrollState = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const threshold = 48;
setScrollAtTop(el.scrollTop <= threshold);
setScrollAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight <= threshold);
}, []);
const scrollToTop = () => scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
const scrollToBottom = () => {
const el = scrollRef.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
};
// 自动滚到底部(仅当用户已在底部附近时) // Logan.Li
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}, [items]);
const el = scrollRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
if (nearBottom || items.length <= 2) {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}
updateScrollState();
}, [items, updateScrollState]);
// 处理后端事件 // Logan.Li
const handleEvent = (ev: WsEvent) => {
@@ -146,6 +198,11 @@ export default function AgentCLI() {
switch (type) {
case 'session':
push({ kind: 'info', content: ev.message || '' });
// 仅「恢复历史会话」时进入可追问(新任务开始的 session 事件文案不同) // Logan.Li
if (ev.message?.includes('已恢复历史会话')) {
setViewMode(false);
setCanContinue(true);
}
break;
case 'progress':
if (ev.round) setRound(ev.round);
@@ -201,12 +258,34 @@ export default function AgentCLI() {
case 'confirm':
handleConfirm(ev);
break;
case 'done':
push({ kind: 'done', content: ev.answer || '', round: ev.round });
case 'done': {
const text = (ev.answer || ev.message || '').trim();
setItems((prev) => {
const norm = (s?: string) => (s || '').trim().replace(/\s+/g, ' ');
// 移除与 done 同文的 thought,避免终局总结显示两遍 // Logan.Li
let next = prev.filter((it) => {
if (it.kind !== 'thought' || !text) return true;
return norm(it.content) !== norm(text);
});
const last = next[next.length - 1];
if (last?.kind === 'done') {
if (text) {
next[next.length - 1] = { ...last, content: text, round: ev.round ?? last.round };
}
return next;
}
return [...next, {
id: nextId(),
kind: 'done',
content: text || '任务已完成,请查看上方工具执行结果。',
round: ev.round,
}];
});
setRunning(false);
setCanContinue(true);
setHistoryRefresh((k) => k + 1);
break;
}
case 'error':
case 'aborted':
push({ kind: type === 'aborted' ? 'info' : 'error', content: ev.message || (type === 'aborted' ? '任务已中断' : '发生错误') });
@@ -240,7 +319,8 @@ export default function AgentCLI() {
return;
}
if (running) return;
setReplayMode(false);
setViewMode(false);
setViewSession(null);
const msgType = canContinue ? 'continue' : 'start';
push({ kind: 'user', content: q });
wsRef.current.send(JSON.stringify({ type: msgType, query: q, safety_mode: safetyMode }));
@@ -251,67 +331,43 @@ export default function AgentCLI() {
const stop = () => wsRef.current?.send(JSON.stringify({ type: 'abort' }));
const handleReplay = (replayItems: ChatItem[], sess: AgentCliSession) => {
setReplayMode(true);
// handleViewHistory 查看历史记录(只读,不执行命令) // Logan.Li
const handleViewHistory = (replayItems: ChatItem[], sess: AgentCliSession) => {
setViewMode(true);
setViewSession(sess);
setCanContinue(false);
setItems([
{ id: nextId(), kind: 'info', content: `回放历史任务:${sess.title}(${sess.status})` },
{
id: nextId(), kind: 'info',
content: `查看历史记录:${sess.title}(${sess.status === 'done' ? '已完成' : sess.status})— 仅浏览,点击「继续追问」可在此基础上接着执行`,
},
...replayItems,
]);
setRunning(false);
};
// resumeHistorySession 恢复历史会话上下文,进入可追问状态 // Logan.Li
const resumeHistorySession = () => {
if (!viewSession) return;
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
toast.error('连接未就绪,请稍后重试');
return;
}
wsRef.current.send(JSON.stringify({ type: 'resume', session_id: viewSession.id }));
push({ kind: 'info', content: `正在恢复会话「${viewSession.title}」…` });
};
const handleNewTask = () => {
setReplayMode(false);
setViewMode(false);
setViewSession(null);
setCanContinue(false);
setItems([]);
setRound(0);
setTokens(0);
};
const toolbar = (
<div className="flex items-center gap-3 flex-wrap">
<Server className="w-4 h-4 dg-text-secondary" />
<Select value={serverId} onValueChange={setServerId}>
<SelectTrigger className="w-56 dg-input">
<SelectValue placeholder="选择目标主机" />
</SelectTrigger>
<SelectContent className="dg-input max-h-72">
{servers.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.hostname}({s.ip})
</SelectItem>
))}
</SelectContent>
</Select>
<Shield className="w-4 h-4 dg-text-secondary" />
<Select value={safetyMode} onValueChange={setSafetyMode}>
<SelectTrigger className="w-28 dg-input">
<SelectValue />
</SelectTrigger>
<SelectContent className="dg-input">
{SAFETY_MODES.map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
{connected && (
<Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5" />已连接
</Badge>
)}
{replayMode && (
<Badge className="bg-amber-500/15 dg-text-yellow border-amber-500/30">回放模式</Badge>
)}
{canContinue && !running && !replayMode && (
<Badge className="bg-violet-500/15 dg-text-purple border-violet-500/30">可追问</Badge>
)}
{running && (
<Badge className="bg-blue-500/15 dg-text-blue border-blue-500/30">
<Loader2 className="w-3 h-3 mr-1 animate-spin" />第 {round} 轮 · {tokens} tokens
</Badge>
)}
</div>
);
const selectedServer = servers.find((s) => String(s.id) === serverId);
const canResumeView = viewSession && ['done', 'aborted', 'error'].includes(viewSession.status);
return (
<PageShell
@@ -319,45 +375,159 @@ export default function AgentCLI() {
title="Agent CLI"
subtitle="自然语言驱动 SSH 自主排障与部署"
icon={<Bot className="h-5 w-5" />}
toolbar={toolbar}
className="h-full"
contentClassName="h-full flex gap-3"
contentClassName="h-full flex flex-col min-h-0 gap-2"
>
<AgentCLIHistory
serverId={serverId}
onReplay={handleReplay}
onNewTask={handleNewTask}
refreshKey={historyRefresh}
/>
{/* 工作区控制栏:主机 + 安全模式 + 状态,不挤占对话区宽度 // Logan.Li */}
<div className="flex items-center gap-2 flex-wrap shrink-0 px-0.5">
<Select value={serverId} onValueChange={setServerId}>
<SelectTrigger className="w-80 max-w-[22rem] h-8 text-xs dg-input min-w-0">
<Server className="w-3.5 h-3.5 mr-1.5 dg-text-secondary shrink-0" />
<SelectValue placeholder="选择目标主机" className="truncate">
{selectedServer ? formatServerLabel(selectedServer) : undefined}
</SelectValue>
</SelectTrigger>
<SelectContent className="dg-input max-h-72 min-w-[var(--radix-select-trigger-width)]">
{servers.map((s) => (
<SelectItem key={s.id} value={String(s.id)} title={formatServerLabel(s)}>
<span className="truncate block max-w-[20rem]">{formatServerLabel(s)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex-1 min-w-0 flex flex-col gap-3">
{/* tips 面板 */}
<Card className="dg-card">
<CardHeader className="py-2 px-3 cursor-pointer select-none" onClick={() => setTipsOpen((v) => !v)}>
<div className="flex items-center gap-2 text-[var(--dg-text-secondary)] text-sm">
{tipsOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
<Lightbulb className="w-4 h-4 dg-text-yellow" />
<span className="font-medium">核心能力与设计思路</span>
</div>
</CardHeader>
{tipsOpen && (
<CardContent className="pt-2 grid grid-cols-1 md:grid-cols-2 gap-3">
{TIPS.map((t) => (
<div key={t.title} className="flex gap-2.5 p-2.5 rounded-lg bg-[var(--dg-bg-tertiary)]/40 border border-[var(--dg-border)]">
<t.icon className={`w-5 h-5 mt-0.5 shrink-0 ${t.color}`} />
<div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{t.title}</div>
<div className="text-xs dg-text-secondary leading-relaxed mt-0.5">{t.desc}</div>
<div className="flex items-center gap-1">
<Select value={safetyMode} onValueChange={setSafetyMode}>
<SelectTrigger className="w-24 h-8 text-xs dg-input">
<Shield className="w-3.5 h-3.5 mr-1 dg-text-secondary shrink-0" />
<SelectValue />
</SelectTrigger>
<SelectContent className="dg-input">
{SAFETY_MODES.map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-yellow hover:bg-[var(--dg-bg-hover)]" title="安全模式说明">
<Lightbulb className="w-4 h-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 dg-input p-3 space-y-2.5">
<div className="text-sm font-medium text-[var(--dg-text-primary)]">安全模式说明</div>
<p className="text-[11px] dg-text-muted leading-relaxed">
控制 AI 执行远程命令前是否需要你确认。危险命令(如 rm -rf /)任何模式均硬性拦截。
</p>
{SAFETY_MODES.map((m) => (
<div
key={m.value}
className={`rounded-md p-2.5 border text-xs ${
m.value === safetyMode
? 'border-blue-500/40 bg-blue-500/10'
: 'border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/40'
}`}
>
<div className="flex items-center gap-2">
<span className="font-medium text-[var(--dg-text-primary)]">{m.label}</span>
<span className="text-[10px] dg-text-muted">{m.short}</span>
{'recommended' in m && m.recommended && (
<Badge className="text-[9px] px-1 py-0 bg-violet-500/15 dg-text-purple border-violet-500/30">推荐</Badge>
)}
</div>
<p className="dg-text-secondary leading-relaxed mt-1">{m.desc}</p>
<p className="text-[10px] dg-text-muted mt-1">例:{m.examples}</p>
</div>
))}
</CardContent>
)}
</Card>
</PopoverContent>
</Popover>
</div>
{/* 对话流 */}
<Card className="flex-1 min-h-0 dg-card flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto p-4 space-y-3">
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs dg-text-secondary"
onClick={() => setHistoryOpen((v) => !v)}
title={historyOpen ? '收起历史任务' : '展开历史任务'}
>
{historyOpen ? <PanelLeftClose className="w-3.5 h-3.5 mr-1" /> : <History className="w-3.5 h-3.5 mr-1" />}
历史
</Button>
<div className="flex-1 min-w-[1rem]" />
{connected && (
<Badge className="h-6 text-[10px] bg-emerald-500/15 dg-text-green border-emerald-500/30">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1" />已连接
</Badge>
)}
{viewMode && (
<Badge className="h-6 text-[10px] bg-amber-500/15 dg-text-yellow border-amber-500/30">查看记录</Badge>
)}
{canContinue && !running && !viewMode && (
<Badge className="h-6 text-[10px] bg-violet-500/15 dg-text-purple border-violet-500/30">可追问</Badge>
)}
{running && (
<Badge className="h-6 text-[10px] bg-blue-500/15 dg-text-blue border-blue-500/30">
<Loader2 className="w-3 h-3 mr-1 animate-spin" />第 {round} 轮 · {tokens}t
</Badge>
)}
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-muted hover:dg-text-secondary" title="核心能力说明">
<Lightbulb className="w-4 h-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 dg-input p-3 space-y-2">
<div className="text-sm font-medium text-[var(--dg-text-primary)]">核心能力</div>
{TIPS.map((t) => (
<div key={t.title} className="flex gap-2 p-2 rounded-md bg-[var(--dg-bg-tertiary)]/40 border border-[var(--dg-border)]">
<t.icon className={`w-4 h-4 mt-0.5 shrink-0 ${t.color}`} />
<div>
<div className="text-xs font-medium text-[var(--dg-text-primary)]">{t.title}</div>
<div className="text-[10px] dg-text-secondary leading-relaxed mt-0.5">{t.desc}</div>
</div>
</div>
))}
</PopoverContent>
</Popover>
</div>
{/* 主工作区:可折叠历史 + 对话区全宽 // Logan.Li */}
<div className="flex flex-1 min-h-0 gap-2">
{historyOpen && (
<AgentCLIHistory
serverId={serverId}
onView={handleViewHistory}
onNewTask={handleNewTask}
refreshKey={historyRefresh}
onCollapse={() => setHistoryOpen(false)}
/>
)}
<Card className="flex-1 min-w-0 min-h-0 dg-card flex flex-col">
{viewMode && viewSession && (
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--dg-border)] bg-amber-500/5 shrink-0 flex-wrap">
<span className="text-xs dg-text-secondary flex-1 min-w-[12rem]">
历史记录(只读)· {viewSession.title}
</span>
{canResumeView && (
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={resumeHistorySession}>
继续追问
</Button>
)}
<Button size="sm" variant="ghost" className="h-7 text-xs" onClick={handleNewTask}>
新建任务
</Button>
</div>
)}
<div className="relative flex-1 min-h-0 flex flex-col">
<div
ref={scrollRef}
onScroll={updateScrollState}
className="flex-1 min-h-0 overflow-y-auto p-4 space-y-3"
>
{items.length === 0 && (
<div className="h-full flex flex-col items-center justify-center dg-text-muted gap-3">
<Terminal className="w-10 h-10 opacity-40" />
@@ -374,37 +544,72 @@ export default function AgentCLI() {
<div className="text-sm">正在连接主机...</div>
</>
) : (
<div className="text-sm">描述你要执行的任务,例如「检查磁盘使用率并清理大文件」</div>
<div className="text-sm text-center max-w-md">
描述你要执行的任务,例如「检查磁盘使用率并清理大文件」
<div className="text-xs dg-text-muted mt-2">
当前安全模式:{currentSafety.label}({currentSafety.short})
</div>
</div>
)}
</div>
)}
{items.map((it) => (
<MessageView key={it.id} item={it} />
))}
<AgentChatTimeline items={items} activeRound={round} />
</div>
<div className="border-t border-[var(--dg-border)] p-3 flex items-end gap-2">
{items.length > 0 && (
<div className="absolute right-3 bottom-3 z-10 flex flex-col gap-1.5">
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-full shadow-md border border-[var(--dg-border)] bg-[var(--surface-raised)]/95 opacity-90 hover:opacity-100"
onClick={scrollToTop}
disabled={scrollAtTop}
title="去顶部"
>
<Rocket className="h-3.5 w-3.5 -rotate-45" />
</Button>
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-full shadow-md border border-[var(--dg-border)] bg-[var(--surface-raised)]/95 opacity-90 hover:opacity-100"
onClick={scrollToBottom}
disabled={scrollAtBottom}
title="去底部"
>
<Rocket className="h-3.5 w-3.5 rotate-[135deg]" />
</Button>
</div>
)}
</div>
{/* 加宽输入区:多行 + 独立操作栏 // Logan.Li */}
<div className="border-t border-[var(--dg-border)] p-3 space-y-2 shrink-0">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
placeholder={
replayMode ? '回放模式,点击「新建」开始新任务'
viewMode ? '查看模式中,点击「继续追问」或「新建任务」'
: canContinue ? '继续追问,回车发送(Shift+回车换行)'
: serverId ? '输入任务描述,回车发送(Shift+回车换行)' : '请先选择目标主机'
}
rows={1}
disabled={replayMode}
className="flex-1 resize-none dg-input max-h-32 disabled:opacity-50"
rows={3}
disabled={viewMode}
className="w-full resize-none dg-input min-h-[5rem] max-h-40 text-sm leading-relaxed disabled:opacity-50"
/>
{running ? (
<Button variant="destructive" onClick={stop} className="bg-red-600 hover:bg-red-700">
<Square className="w-4 h-4 mr-1" /> 停止
</Button>
) : (
<Button onClick={send} disabled={!input.trim() || replayMode} className="bg-blue-600 hover:bg-blue-700">
<Send className="w-4 h-4 mr-1" /> 执行
</Button>
)}
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] dg-text-muted">Shift+回车换行 · 回车发送</span>
{running ? (
<Button variant="destructive" size="sm" onClick={stop} className="bg-red-600 hover:bg-red-700">
<Square className="w-3.5 h-3.5 mr-1" /> 停止
</Button>
) : (
<Button size="sm" onClick={send} disabled={!input.trim() || viewMode} className="bg-blue-600 hover:bg-blue-700">
<Send className="w-3.5 h-3.5 mr-1" /> 执行
</Button>
)}
</div>
</div>
</Card>
</div>
@@ -1,4 +1,4 @@
// AgentCLIHistory.tsx - Agent CLI 会话历史侧栏(列表/回放/删除) // Logan.Li
// AgentCLIHistory.tsx - Agent CLI 会话历史侧栏(列表/回放/删除/折叠) // Logan.Li
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -7,13 +7,14 @@ import { useConfirm } from '@/components/ui/confirm-dialog';
import { agentCliApi, type AgentCliSession } from '@/api/aiops/agent-cli';
import { messagesToChatItems, type ChatItem } from './AgentCLIMessages';
import { toast } from 'sonner';
import { History, Trash2, Plus, Loader2 } from 'lucide-react';
import { History, Trash2, Plus, Loader2, PanelLeftClose } from 'lucide-react';
interface Props {
serverId: string;
onReplay: (items: ChatItem[], session: AgentCliSession) => void;
onView: (items: ChatItem[], session: AgentCliSession) => void;
onNewTask: () => void;
refreshKey?: number;
onCollapse?: () => void;
}
// statusBadge 会话状态标签样式 // Logan.Li
@@ -26,7 +27,7 @@ function statusBadge(status: string) {
}
}
export default function AgentCLIHistory({ serverId, onReplay, onNewTask, refreshKey }: Props) {
export default function AgentCLIHistory({ serverId, onView, onNewTask, refreshKey, onCollapse }: Props) {
const confirm = useConfirm();
const [sessions, setSessions] = useState<AgentCliSession[]>([]);
const [loading, setLoading] = useState(false);
@@ -54,12 +55,12 @@ export default function AgentCLIHistory({ serverId, onReplay, onNewTask, refresh
useEffect(() => { loadSessions(); }, [loadSessions, refreshKey]);
const handleReplay = async (sess: AgentCliSession) => {
const handleView = async (sess: AgentCliSession) => {
try {
const detail = await agentCliApi.getSession(sess.id);
const items = messagesToChatItems(detail?.messages || []);
setActiveId(sess.id);
onReplay(items, sess);
onView(items, sess);
} catch {
toast.error('加载会话详情失败');
}
@@ -89,40 +90,46 @@ export default function AgentCLIHistory({ serverId, onReplay, onNewTask, refresh
};
return (
<div className="w-72 shrink-0 flex flex-col bg-[var(--dg-bg-secondary)]/60 border border-[var(--dg-border)] rounded-lg overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-[var(--dg-border)]">
<History className="w-4 h-4 dg-text-secondary" />
<span className="text-sm font-medium text-[var(--dg-text-primary)] flex-1">历史任务</span>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => { setActiveId(null); onNewTask(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />新建
<div className="w-56 shrink-0 flex flex-col bg-[var(--dg-bg-secondary)]/60 border border-[var(--dg-border)] rounded-lg overflow-hidden">
<div className="flex items-center gap-1.5 px-2.5 py-2 border-b border-[var(--dg-border)]">
<History className="w-3.5 h-3.5 dg-text-secondary shrink-0" />
<span className="text-xs font-medium text-[var(--dg-text-primary)] flex-1 truncate">历史</span>
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => { setActiveId(null); onNewTask(); }}>
<Plus className="w-3 h-3 mr-0.5" />新建
</Button>
{onCollapse && (
<Button variant="ghost" size="icon" className="h-6 w-6 dg-text-muted hover:dg-text-secondary" onClick={onCollapse} title="收起历史">
<PanelLeftClose className="w-3.5 h-3.5" />
</Button>
)}
</div>
<ScrollArea className="flex-1 min-h-0">
{!serverId ? (
<div className="p-4 text-xs dg-text-muted text-center">选择主机后查看历史</div>
<div className="p-3 text-[10px] dg-text-muted text-center">选择主机后查看</div>
) : loading ? (
<div className="p-4 flex justify-center"><Loader2 className="w-5 h-5 animate-spin dg-text-muted" /></div>
<div className="p-3 flex justify-center"><Loader2 className="w-4 h-4 animate-spin dg-text-muted" /></div>
) : sessions.length === 0 ? (
<div className="p-4 text-xs dg-text-muted text-center">暂无历史任务</div>
<div className="p-3 text-[10px] dg-text-muted text-center">暂无历史</div>
) : (
<div className="p-1.5 space-y-1">
<div className="p-1 space-y-0.5">
{sessions.map((s) => (
<button
key={s.id}
type="button"
onClick={() => handleReplay(s)}
className={`w-full text-left rounded-md px-2.5 py-2 transition-colors group ${
onClick={() => handleView(s)}
title="查看历史记录"
className={`w-full text-left rounded-md px-2 py-1.5 transition-colors group ${
activeId === s.id ? 'bg-blue-500/15 border border-blue-500/30' : 'hover:bg-[var(--dg-bg-hover)]/60 border border-transparent'
}`}
>
<div className="flex items-start gap-1.5">
<div className="flex items-start gap-1">
<div className="flex-1 min-w-0">
<div className="text-xs text-[var(--dg-text-primary)] truncate">{s.title}</div>
<div className="text-[11px] text-[var(--dg-text-primary)] truncate leading-tight">{s.title}</div>
<div className="text-[10px] dg-text-muted mt-0.5">
{s.rounds} 轮 · {s.total_tokens || 0} tokens
{s.rounds}轮 · {s.total_tokens || 0}t
</div>
</div>
<Badge className={`text-[10px] border shrink-0 ${statusBadge(s.status)}`}>
<Badge className={`text-[9px] px-1 py-0 border shrink-0 ${statusBadge(s.status)}`}>
{s.status === 'done' ? '完成' : s.status === 'running' ? '进行' : s.status === 'aborted' ? '中断' : '失败'}
</Badge>
<button
@@ -130,7 +137,7 @@ export default function AgentCLIHistory({ serverId, onReplay, onNewTask, refresh
className="opacity-0 group-hover:opacity-100 p-0.5 dg-text-muted hover:dg-text-red shrink-0"
onClick={(e) => handleDelete(e, s)}
>
<Trash2 className="w-3.5 h-3.5" />
<Trash2 className="w-3 h-3" />
</button>
</div>
</button>
@@ -1,11 +1,12 @@
// AgentCLIMessages.tsx - Agent CLI 对话流消息渲染子组件 // Logan.Li
import { useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Badge } from '@/components/ui/badge';
import {
Terminal, ChevronDown, ChevronRight, Brain,
AlertTriangle, CheckCircle2, Loader2,
AlertTriangle, CheckCircle2, Loader2, Layers,
} from 'lucide-react';
import type { AgentCliMessage } from '@/api/aiops/agent-cli';
import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown';
// 对话流条目模型 // Logan.Li
export interface ChatItem {
@@ -89,8 +90,126 @@ export function messagesToChatItems(msgs: AgentCliMessage[]): ChatItem[] {
return items;
}
// normalizeChatText 归一化文本用于去重比较 // Logan.Li
function normalizeChatText(s?: string): string {
return (s || '').trim().replace(/\s+/g, ' ');
}
// dedupeDoneThought 移除与 done 同文的 thought,终局总结只保留 done(Markdown) // Logan.Li
export function dedupeDoneThought(items: ChatItem[]): ChatItem[] {
const doneTexts = new Set(
items.filter((it) => it.kind === 'done').map((it) => normalizeChatText(it.content)),
);
if (doneTexts.size === 0) return items;
return items.filter((it) => {
if (it.kind !== 'thought') return true;
return !doneTexts.has(normalizeChatText(it.content));
});
}
// calcLatestRound 取当前最新 ReAct 轮次 // Logan.Li
export function calcLatestRound(items: ChatItem[], runningRound = 0): number {
if (runningRound > 0) return runningRound;
return items.reduce((max, it) => Math.max(max, it.round ?? 0), 0);
}
type TimelineSegment =
| { type: 'single'; item: ChatItem }
| { type: 'round'; round: number; items: ChatItem[] };
// groupChatItems 按轮次聚合 thought/tool,便于整轮折叠 // Logan.Li
function groupChatItems(items: ChatItem[]): TimelineSegment[] {
const segments: TimelineSegment[] = [];
let i = 0;
while (i < items.length) {
const it = items[i];
if (it.round != null && (it.kind === 'thought' || it.kind === 'tool')) {
const r = it.round;
const group: ChatItem[] = [];
while (i < items.length) {
const cur = items[i];
if (cur.round !== r || (cur.kind !== 'thought' && cur.kind !== 'tool')) break;
group.push(cur);
i++;
}
segments.push({ type: 'round', round: r, items: group });
} else {
segments.push({ type: 'single', item: it });
i++;
}
}
return segments;
}
// AgentChatTimeline 对话时间线(旧轮自动折叠) // Logan.Li
export function AgentChatTimeline({ items, activeRound = 0 }: { items: ChatItem[]; activeRound?: number }) {
const displayItems = useMemo(() => dedupeDoneThought(items), [items]);
const latestRound = useMemo(() => calcLatestRound(displayItems, activeRound), [displayItems, activeRound]);
const segments = useMemo(
() => groupChatItems(displayItems).filter((seg) => seg.type !== 'round' || seg.items.length > 0),
[displayItems],
);
return (
<>
{segments.map((seg, idx) => {
if (seg.type === 'single') {
return <MessageView key={seg.item.id} item={seg.item} />;
}
return (
<RoundGroup
key={`round-${seg.round}-${idx}`}
round={seg.round}
items={seg.items}
isLatest={seg.round === latestRound}
/>
);
})}
</>
);
}
// RoundGroup 单轮 ReAct 块(非最新轮默认折叠) // Logan.Li
function RoundGroup({ round, items, isLatest }: { round: number; items: ChatItem[]; isLatest: boolean }) {
const hasPending = items.some((it) => it.pending || it.streaming);
const [open, setOpen] = useState(isLatest || hasPending);
useEffect(() => {
if (!isLatest && !hasPending) setOpen(false);
else if (isLatest) setOpen(true);
}, [isLatest, hasPending]);
const toolCount = items.filter((it) => it.kind === 'tool').length;
const preview = items.find((it) => it.kind === 'thought')?.content?.slice(0, 60) || '';
return (
<div className="rounded-lg border border-[var(--dg-border)]/80 overflow-hidden">
<button
type="button"
className="flex items-center gap-1.5 w-full px-2.5 py-2 text-xs text-left bg-[var(--dg-bg-tertiary)]/50 hover:bg-[var(--dg-bg-hover)]/40 transition-colors"
onClick={() => setOpen((v) => !v)}
>
{open ? <ChevronDown className="w-3.5 h-3.5 dg-text-secondary shrink-0" /> : <ChevronRight className="w-3.5 h-3.5 dg-text-secondary shrink-0" />}
<Layers className="w-3.5 h-3.5 dg-text-purple shrink-0" />
<span className="font-medium text-[var(--dg-text-primary)]">第 {round} 轮</span>
<span className="dg-text-muted">{toolCount > 0 ? `· ${toolCount} 个工具` : ''}</span>
{!open && preview && (
<span className="ml-1 dg-text-muted truncate flex-1">{preview}{preview.length >= 60 ? '…' : ''}</span>
)}
</button>
{open && (
<div className="p-2 space-y-2 bg-[var(--dg-bg-secondary)]/20">
{items.map((it) => (
<MessageView key={it.id} item={it} inRoundGroup />
))}
</div>
)}
</div>
);
}
// MessageView 单条消息渲染(按 kind 分流) // Logan.Li
export function MessageView({ item }: { item: ChatItem }) {
export function MessageView({ item, inRoundGroup = false }: { item: ChatItem; inRoundGroup?: boolean }) {
if (item.kind === 'user') {
return (
<div className="flex justify-end">
@@ -102,37 +221,54 @@ export function MessageView({ item }: { item: ChatItem }) {
return <div className="text-center text-xs dg-text-muted py-1">{item.content}</div>;
}
if (item.kind === 'thought') {
return <ThoughtBlock text={item.content || ''} round={item.round} streaming={item.streaming} />;
return (
<ThoughtBlock
text={item.content || ''}
round={item.round}
streaming={item.streaming}
hideRoundLabel={inRoundGroup}
/>
);
}
if (item.kind === 'tool') {
return <ToolBlock item={item} />;
return <ToolBlock item={item} inRoundGroup={inRoundGroup} />;
}
if (item.kind === 'done') {
const text = item.content?.trim() || '任务已完成,请查看上方工具执行结果。';
return (
<div className="flex gap-2 rounded-lg bg-emerald-500/10 border border-emerald-500/30 p-3">
<CheckCircle2 className="w-5 h-5 dg-text-green shrink-0 mt-0.5" />
<div className="text-sm text-emerald-100 whitespace-pre-wrap leading-relaxed">{item.content}</div>
<div className="flex-1 min-w-0">
{looksLikeMarkdown(text) ? (
<AgentMarkdown content={text} />
) : (
<div className="text-sm text-[var(--dg-text-primary)] whitespace-pre-wrap leading-relaxed">{text}</div>
)}
</div>
</div>
);
}
return (
<div className="flex gap-2 rounded-lg bg-red-500/10 border border-red-500/30 p-3">
<AlertTriangle className="w-5 h-5 dg-text-red shrink-0 mt-0.5" />
<div className="text-sm text-red-100 whitespace-pre-wrap">{item.content}</div>
<div className="text-sm text-[var(--dg-text-primary)] whitespace-pre-wrap">{item.content || '发生错误'}</div>
</div>
);
}
// ThoughtBlock AI 思考(可折叠,灰底斜体,支持流式) // Logan.Li
function ThoughtBlock({ text, round, streaming }: { text: string; round?: number; streaming?: boolean }) {
const [open, setOpen] = useState(streaming || false);
function ThoughtBlock({
text, round, streaming, hideRoundLabel,
}: { text: string; round?: number; streaming?: boolean; hideRoundLabel?: boolean }) {
const [open, setOpen] = useState(hideRoundLabel ? true : !!streaming);
const preview = text.length > 80 ? text.slice(0, 80) + '...' : text;
return (
<div className="rounded-lg bg-[var(--dg-bg-tertiary)]/40 border border-[var(--dg-border)] p-2.5">
<button className="flex items-center gap-1.5 text-xs dg-text-secondary w-full text-left" onClick={() => setOpen((v) => !v)}>
<button type="button" className="flex items-center gap-1.5 text-xs dg-text-secondary w-full text-left" onClick={() => setOpen((v) => !v)}>
{open ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
<Brain className="w-3.5 h-3.5 dg-text-purple" />
{round != null && <span className="dg-text-muted">第 {round} 轮思考</span>}
{!hideRoundLabel && round != null && <span className="dg-text-muted">第 {round} 轮思考</span>}
{hideRoundLabel && <span className="dg-text-muted">思考</span>}
{streaming && <Loader2 className="w-3 h-3 dg-text-purple animate-spin" />}
<span className="ml-1 dg-text-muted truncate">{!open && preview}</span>
</button>
@@ -142,16 +278,16 @@ function ThoughtBlock({ text, round, streaming }: { text: string; round?: number
}
// ToolBlock 工具调用块(命令 + 风险 + 可展开输出) // Logan.Li
function ToolBlock({ item }: { item: ChatItem }) {
const [open, setOpen] = useState(true);
function ToolBlock({ item, inRoundGroup }: { item: ChatItem; inRoundGroup?: boolean }) {
const [open, setOpen] = useState(!item.pending);
return (
<div className="rounded-lg bg-[var(--dg-bg-tertiary)]/40 border border-[var(--dg-border)] overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)]/60">
<button className="flex items-center gap-1.5 flex-1 min-w-0 text-left" onClick={() => setOpen((v) => !v)}>
<button type="button" className="flex items-center gap-1.5 flex-1 min-w-0 text-left" onClick={() => setOpen((v) => !v)}>
{open ? <ChevronDown className="w-3.5 h-3.5 dg-text-secondary" /> : <ChevronRight className="w-3.5 h-3.5 dg-text-secondary" />}
<Terminal className="w-3.5 h-3.5 text-sky-400 shrink-0" />
<span className="text-xs dg-text-secondary shrink-0">{item.toolName}</span>
{item.round != null && <span className="text-xs dg-text-muted">· R{item.round}</span>}
{!inRoundGroup && item.round != null && <span className="text-xs dg-text-muted">· R{item.round}</span>}
</button>
{item.risk && <Badge className={`text-[10px] border ${riskClass(item.risk)}`}>{item.risk}</Badge>}
{item.pending && <Loader2 className="w-3.5 h-3.5 dg-text-blue animate-spin" />}
@@ -169,7 +305,13 @@ function ToolBlock({ item }: { item: ChatItem }) {
{!item.pending && item.output != null && (
<>
{item.duration && <div className="text-[10px] dg-text-muted">耗时 {item.duration}</div>}
<pre className="text-xs text-[var(--dg-text-secondary)] bg-[var(--dg-bg-primary)]/40 rounded px-2 py-1.5 overflow-x-auto max-h-80 whitespace-pre-wrap">{item.output || '(无输出)'}</pre>
{(item.toolName === 'analyze_output' || looksLikeMarkdown(item.output)) ? (
<div className="bg-[var(--dg-bg-primary)]/40 rounded px-2 py-1.5 max-h-80 overflow-y-auto">
<AgentMarkdown content={item.output || '(无输出)'} />
</div>
) : (
<pre className="text-xs text-[var(--dg-text-secondary)] bg-[var(--dg-bg-primary)]/40 rounded px-2 py-1.5 overflow-x-auto max-h-80 whitespace-pre-wrap">{item.output || '(无输出)'}</pre>
)}
</>
)}
</div>
@@ -0,0 +1,98 @@
// AgentMarkdown.tsx - Agent CLI 富文本 Markdown 渲染(总结/分析报告) // Logan.Li
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { cn } from '@/lib/utils';
interface Props {
content: string;
className?: string;
}
// looksLikeMarkdown 简单检测是否含 Markdown 结构 // Logan.Li
export function looksLikeMarkdown(text: string): boolean {
const t = text.trim();
if (!t) return false;
return (
/^#{1,3}\s/m.test(t)
|| /^\|.+\|/m.test(t)
|| /^>\s/m.test(t)
|| /^[-*]\s/m.test(t)
|| /\*\*[^*]+\*\*/.test(t)
);
}
export function AgentMarkdown({ content, className }: Props) {
return (
<div className={cn('agent-md min-w-0', className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-base font-bold text-[var(--dg-text-primary)] mt-3 mb-2 first:mt-0">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-sm font-semibold text-[var(--dg-text-primary)] mt-3 mb-1.5 first:mt-0">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] mt-2 mb-1 first:mt-0">{children}</h3>
),
p: ({ children }) => (
<p className="text-sm text-[var(--dg-text-secondary)] leading-relaxed my-1.5">{children}</p>
),
ul: ({ children }) => (
<ul className="text-sm text-[var(--dg-text-secondary)] list-disc pl-5 my-1.5 space-y-0.5">{children}</ul>
),
ol: ({ children }) => (
<ol className="text-sm text-[var(--dg-text-secondary)] list-decimal pl-5 my-1.5 space-y-0.5">{children}</ol>
),
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
strong: ({ children }) => (
<strong className="font-semibold text-[var(--dg-text-primary)]">{children}</strong>
),
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-[var(--dg-blue)]/50 pl-3 my-2 text-sm dg-text-muted italic">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="my-2 overflow-x-auto rounded-md border border-[var(--dg-border)]">
<table className="w-full text-xs border-collapse">{children}</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-[var(--dg-bg-tertiary)]/80">{children}</thead>
),
th: ({ children }) => (
<th className="px-2.5 py-1.5 text-left font-medium text-[var(--dg-text-primary)] border-b border-[var(--dg-border)] whitespace-nowrap">
{children}
</th>
),
td: ({ children }) => (
<td className="px-2.5 py-1.5 text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]/60">
{children}
</td>
),
tr: ({ children }) => <tr className="hover:bg-[var(--dg-bg-hover)]/30">{children}</tr>,
code: ({ className: codeClass, children }) => {
const isBlock = codeClass?.includes('language-');
if (isBlock) {
return (
<pre className="text-xs bg-[var(--dg-bg-primary)]/60 rounded px-2 py-1.5 overflow-x-auto my-2">
<code>{children}</code>
</pre>
);
}
return (
<code className="text-xs px-1 py-0.5 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)]">
{children}
</code>
);
},
hr: () => <hr className="my-3 border-[var(--dg-border)]" />,
}}
>
{content}
</ReactMarkdown>
</div>
);
}
+293 -54
View File
@@ -1,78 +1,162 @@
// LLMConfig.tsx - 大模型设置独立页(AIOps 智能菜单下统一 LLM 配置入口) // Logan.Li
// 调用既有 /aiops/llm-config 接口(GET/PUT/POST test),Agent CLI 与智能助手共用同一配置。
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
import {
Loader2, Save, Wifi, KeyRound, Brain, Sparkles, MessageSquare, AlertCircle,
ExternalLink, CheckCircle2, ShoppingBag,
} from 'lucide-react';
import { aiApi, type LLMConfigData, type LLMTestResult } from '@/api';
import { PageShell } from '@/components/layout/PageShell';
// 单个提供商配置:默认 endpoint 与候选模型列表(第一个为推荐/最新模型) // Logan.Li
// 单个提供商配置:默认 endpoint、候选模型、套餐/充值引导链接 // Logan.Li
interface ProviderMeta {
id: string;
name: string;
endpoint: string;
models: string[];
apiKeyOptional?: boolean; // 本地/自建模型 API Key 可留空 // Logan.Li
planUrl?: string;
planLabel?: string;
planHint?: string;
}
// 提供商列表(后端按 provider 自动路由协议) // Logan.Li
// Anthropic 协议(glm-coding / anthropic):端点不含 /v1,后端拼 /v1/messages。
// OpenAI 协议(其余):端点含 /v1,后端拼 /chat/completions。
const PROVIDERS: ProviderMeta[] = [
{
id: 'openai', name: 'OpenAI', endpoint: 'https://api.openai.com/v1',
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'o3-mini', 'o1-mini'],
},
// 连接凭证快照(仅含影响连通的字段,切换后需重新验证) // Logan.Li
const connectionSnapshot = (c: LLMConfigData) =>
`${c.provider}|${c.model}|${c.endpoint}|${c.api_key}`;
// 云服务(按量 / 通用 API) // Logan.Li
const CLOUD_GENERAL_PROVIDERS: ProviderMeta[] = [
{
id: 'deepseek', name: 'DeepSeek', endpoint: 'https://api.deepseek.com/v1',
models: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-reasoner', 'deepseek-chat'],
planUrl: 'https://platform.deepseek.com',
planLabel: '获取 API Key',
planHint: '在 DeepSeek 开放平台注册并充值后创建 API Key',
},
{
id: 'glm', name: '智谱 GLM (通用)', endpoint: 'https://open.bigmodel.cn/api/paas/v4',
models: ['glm-4.5', 'glm-4.6', 'glm-4-flash', 'glm-4-air', 'glm-4-plus'],
planUrl: 'https://open.bigmodel.cn/',
planLabel: '智谱开放平台',
planHint: '注册智谱账号并开通模型服务后获取 API Key',
},
{
id: 'glm-coding', name: '智谱 GLM (编码套餐)', endpoint: 'https://open.bigmodel.cn/api/anthropic',
models: ['GLM-5.2', 'GLM-5.1'],
},
{
id: 'qwen', name: '通义千问 (DashScope)', endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
models: ['qwen-max', 'qwen-plus', 'qwen-turbo', 'qwen-coder-plus'],
},
{
id: 'moonshot', name: 'Moonshot (Kimi)', endpoint: 'https://api.moonshot.cn/v1',
id: 'moonshot', name: 'Kimi (Moonshot)', endpoint: 'https://api.moonshot.cn/v1',
models: ['kimi-latest', 'moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'],
planUrl: 'https://www.kimi.com/membership/pricing',
planLabel: '购买 Kimi 套餐',
planHint: 'Kimi 通用 API 需在会员页开通并充值',
},
{
id: 'kimi', name: 'Kimi 编码套餐', endpoint: 'https://api.kimi.com/coding/v1',
models: ['kimi-for-coding', 'kimi-coding-latest'],
id: 'minimax', name: 'MiniMax (按量)', endpoint: 'https://api.minimaxi.com/v1',
models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'],
planUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key',
planLabel: '获取按量 API Key',
planHint: '按 Token 计费,在开放平台创建接口密钥(与编码套餐订阅密钥不同)',
},
{
id: 'mimo', name: '小米 MiMo', endpoint: 'https://api.mimo.mi.com/v1',
models: ['mimo-v2', 'mimo-v2-flash'],
planUrl: 'https://mimo.mi.com/',
planLabel: 'MiMo 开放平台',
},
{
id: 'siliconflow', name: '硅基流动', endpoint: 'https://api.siliconflow.cn/v1',
models: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct', 'Pro/deepseek-ai/DeepSeek-R1'],
planUrl: 'https://cloud.siliconflow.cn/',
planLabel: '硅基流动控制台',
planHint: '国内模型聚合平台,一个 Key 可调用多家开源模型',
},
{
id: 'openai', name: 'OpenAI', endpoint: 'https://api.openai.com/v1',
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'o3-mini', 'o4-mini', 'o1-mini'],
planUrl: 'https://platform.openai.com/api-keys',
planLabel: '创建 API Key',
planHint: '在 platform.openai.com 注册并充值后创建 API Key;国内需自备网络',
},
{
id: 'anthropic', name: 'Anthropic Claude', endpoint: 'https://api.anthropic.com',
models: ['claude-sonnet-4-6', 'claude-opus-4-8', 'claude-haiku-4-5'],
planUrl: 'https://console.anthropic.com/settings/billing',
planLabel: 'Anthropic 账单',
planHint: '国内直连可能受限,需自备网络或使用「大模型自定义」接入兼容代理',
},
];
// 编码套餐 Coding Plan(固定月费,适合 Agent CLI 高频调用) // Logan.Li
const CLOUD_CODING_PROVIDERS: ProviderMeta[] = [
{
id: 'glm-coding', name: '智谱 GLM 编码套餐', endpoint: 'https://open.bigmodel.cn/api/anthropic',
models: ['GLM-5.2', 'GLM-5.1'],
planUrl: 'https://bigmodel.cn/glm-coding',
planLabel: '购买 GLM 编码套餐',
planHint: '需单独购买编码套餐,走 Anthropic 协议;Key 与通用 GLM 不互通',
},
{
id: 'kimi', name: 'Kimi 编码套餐', endpoint: 'https://api.kimi.com/coding/v1',
models: ['kimi-for-coding', 'kimi-coding-latest'],
planUrl: 'https://www.kimi.com/membership/pricing',
planLabel: '购买 Kimi 套餐',
planHint: 'Kimi 编码会员专用端点,适合编程与 Agent 场景',
},
{
id: 'minimax-coding', name: 'MiniMax 编码套餐', endpoint: 'https://api.minimaxi.com/anthropic',
models: ['MiniMax-M3', 'MiniMax-M3[1m]', 'MiniMax-M2.7'],
planUrl: 'https://platform.minimaxi.com/user-center/payment/token-plan',
planLabel: '购买 Token 套餐',
planHint: '使用订阅密钥(Subscription Key),走 Anthropic 协议;与按量 API Key 不互通',
},
];
const CLOUD_PROVIDERS: ProviderMeta[] = [...CLOUD_GENERAL_PROVIDERS, ...CLOUD_CODING_PROVIDERS];
// 自定义接入:手动填请求地址 + API Key + 模型名(OpenAI 兼容) // Logan.Li
const CUSTOM_PROVIDERS: ProviderMeta[] = [
{
id: 'custom', name: '大模型自定义', endpoint: '', models: [],
apiKeyOptional: true,
planHint: '填写 OpenAI 兼容的请求地址、API Key(无鉴权可留空)和模型名;适用于 OpenRouter、内网代理、其他未列出的服务',
},
];
// 本地/自建模型(API Key 可选,置于列表底部) // Logan.Li
const LOCAL_PROVIDERS: ProviderMeta[] = [
{
id: 'ollama', name: 'Ollama 本地', endpoint: 'http://localhost:11434/v1',
models: ['qwen2.5:7b', 'llama3.2', 'deepseek-r1:7b'],
apiKeyOptional: true,
},
{
id: 'custom', name: '自定义 (OpenAI 兼容)', endpoint: '', models: [] },
id: 'vllm', name: 'vLLM 自建', endpoint: 'http://localhost:8000/v1',
models: [],
apiKeyOptional: true,
},
];
// 生成参数(温度/max_tokens/系统提示词)已按 Claude Code 模式精简:UI 不再暴露,走内置默认。 // Logan.Li
const PROVIDERS: ProviderMeta[] = [...CLOUD_PROVIDERS, ...CUSTOM_PROVIDERS, ...LOCAL_PROVIDERS];
// 模型名需手动输入的提供商(空 models 或需填接入点 ID) // Logan.Li
const needsManualModel = (provider: string, prov?: ProviderMeta) =>
provider === 'custom' || provider === 'vllm' || !prov?.models.length;
const endpointPlaceholder = (provider: string) => {
switch (provider) {
case 'custom': return 'https://openrouter.ai/api/v1';
case 'ollama': return 'http://localhost:11434/v1';
case 'vllm': return 'http://localhost:8000/v1';
default: return 'https://api.xxx.com/v1';
}
};
const defaultCfg: LLMConfigData = {
provider: 'deepseek', model: 'deepseek-v4-pro', api_key: '',
endpoint: 'https://api.deepseek.com/v1', temperature: 0.7, max_tokens: 8192,
@@ -86,9 +170,12 @@ export default function LLMConfigPage() {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<LLMTestResult | null>(null);
const [modelMode, setModelMode] = useState<'select' | 'input'>('select');
const [verifiedSnapshot, setVerifiedSnapshot] = useState<string | null>(null);
useEffect(() => { load(); }, []);
const load = async () => {
const currentSnapshot = useMemo(() => connectionSnapshot(cfg), [cfg]);
const isVerified = verifiedSnapshot === currentSnapshot;
const load = useCallback(async () => {
setLoading(true);
try {
const saved = await aiApi.getConfig();
@@ -96,47 +183,100 @@ export default function LLMConfigPage() {
setCfg(merged);
const prov = PROVIDERS.find((x) => x.id === merged.provider);
const inList = prov ? prov.models.includes(merged.model) : false;
setModelMode(inList || merged.provider === 'custom' ? 'select' : 'input');
setModelMode(needsManualModel(merged.provider, prov) || !inList ? 'input' : 'select');
if (saved.configured) {
setVerifiedSnapshot(connectionSnapshot(merged));
}
}
catch { /* 首次无配置,使用默认 */ }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const invalidateVerify = () => {
setVerifiedSnapshot(null);
setTestResult(null);
};
const save = async () => {
if (!isVerified) {
toast.error('请先测试连接并通过验证');
return;
}
setSaving(true);
try { await aiApi.saveConfig(cfg); toast.success('配置已保存'); }
try {
await aiApi.saveConfig(cfg);
toast.success('配置已保存');
}
catch { toast.error('保存失败'); }
finally { setSaving(false); }
};
const test = async () => {
const keyOptional = prov?.apiKeyOptional ?? false;
if (!keyOptional && !cfg.api_key?.trim()) {
toast.error('请先填写 API Key');
return;
}
if (!cfg.model?.trim()) {
toast.error('请先填写模型名');
return;
}
if (!cfg.endpoint?.trim()) {
toast.error('请先填写接口地址');
return;
}
setTesting(true);
setTestResult(null);
try {
const res = await aiApi.testConnection();
const res = await aiApi.testConnection(cfg);
setTestResult(res);
toast.success('连接成功');
setVerifiedSnapshot(currentSnapshot);
toast.success('连接验证通过,可以保存');
}
catch {
setVerifiedSnapshot(null);
}
catch { /* 拦截器已提示 */ }
finally { setTesting(false); }
};
const onProvider = (p: string) => {
const prov = PROVIDERS.find((x) => x.id === p);
if (!prov) return;
const model = prov.models[0] || cfg.model;
setModelMode('select');
setCfg({ ...cfg, provider: p, endpoint: prov.endpoint || cfg.endpoint, model });
const next = PROVIDERS.find((x) => x.id === p);
if (!next) return;
const model = next.models[0] || '';
setModelMode(needsManualModel(p, next) ? 'input' : 'select');
invalidateVerify();
const endpoint = p === 'custom' ? '' : (next.endpoint || cfg.endpoint);
setCfg({ ...cfg, provider: p, endpoint, model });
};
const onModel = (m: string) => {
if (m === '__manual') {
setModelMode('input');
return;
}
invalidateVerify();
setCfg({ ...cfg, model: m });
};
const set = <K extends keyof LLMConfigData>(k: K, v: LLMConfigData[K]) => setCfg({ ...cfg, [k]: v });
const set = <K extends keyof LLMConfigData>(k: K, v: LLMConfigData[K]) => {
if (k === 'provider' || k === 'model' || k === 'api_key' || k === 'endpoint') {
invalidateVerify();
}
setCfg({ ...cfg, [k]: v });
};
const prov = PROVIDERS.find((x) => x.id === cfg.provider);
const unknownProvider = Boolean(cfg.provider && !prov);
const models = prov?.models ?? [];
const manualModel = needsManualModel(cfg.provider, prov);
const isCustom = cfg.provider === 'custom';
const apiKeyOptional = prov?.apiKeyOptional ?? false;
const endpointRequired = apiKeyOptional || cfg.provider === 'custom';
const canTest = Boolean(cfg.model?.trim())
&& (endpointRequired ? Boolean(cfg.endpoint?.trim()) : true)
&& (apiKeyOptional || Boolean(cfg.api_key?.trim()));
return (
<PageShell
@@ -157,15 +297,45 @@ export default function LLMConfigPage() {
<Select value={cfg.provider} onValueChange={onProvider}>
<SelectTrigger className="dg-input"><SelectValue /></SelectTrigger>
<SelectContent className="dg-input">
{PROVIDERS.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
{unknownProvider && (
<SelectGroup>
<SelectLabel>已保存</SelectLabel>
<SelectItem value={cfg.provider}>{cfg.provider}</SelectItem>
</SelectGroup>
)}
<SelectGroup>
<SelectLabel>云服务</SelectLabel>
{CLOUD_GENERAL_PROVIDERS.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>编码套餐 Coding Plan</SelectLabel>
{CLOUD_CODING_PROVIDERS.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>自定义接入</SelectLabel>
{CUSTOM_PROVIDERS.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>本地 / 自建</SelectLabel>
{LOCAL_PROVIDERS.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">模型</Label>
{isCustom || modelMode === 'input' ? (
{manualModel || modelMode === 'input' ? (
<Input value={cfg.model} onChange={(e) => set('model', e.target.value)}
placeholder={isCustom ? '自定义模型名' : '手动输入模型名'} className="dg-input" />
placeholder={isCustom ? '如 deepseek/deepseek-chat 或 gpt-4o' : '手动输入模型名'}
className="dg-input" />
) : (
<Select value={cfg.model} onValueChange={onModel}>
<SelectTrigger className="dg-input">
@@ -179,21 +349,69 @@ export default function LLMConfigPage() {
)}
</div>
</div>
{unknownProvider && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-[var(--dg-text-secondary)]">
<AlertCircle className="w-4 h-4 shrink-0 text-amber-500" />
<span>当前提供商「{cfg.provider}」不在预设列表中,可继续使用或切换到其他提供商。</span>
</div>
)}
{prov?.planUrl && (
<div className="flex items-start gap-3 rounded-lg bg-[var(--dg-bg-tertiary)] px-3 py-2.5">
<ShoppingBag className="w-4 h-4 shrink-0 mt-0.5 text-[var(--dg-orange)]" />
<div className="flex-1 min-w-0 space-y-1">
<div className="text-sm text-[var(--dg-text-secondary)]">
{prov.planHint || `使用 ${prov.name} 需先开通服务并获取 API Key`}
</div>
<a
href={prov.planUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs dg-text-blue hover:underline"
>
{prov.planLabel || '前往开通'}
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
)}
{isCustom && prov?.planHint && (
<div className="flex items-start gap-3 rounded-lg bg-[var(--dg-bg-tertiary)] px-3 py-2.5">
<KeyRound className="w-4 h-4 shrink-0 mt-0.5 text-[var(--dg-blue)]" />
<div className="text-sm text-[var(--dg-text-secondary)]">{prov.planHint}</div>
</div>
)}
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)] flex items-center gap-1"><KeyRound className="w-3.5 h-3.5" />API Key</Label>
<Label className="text-[var(--dg-text-secondary)] flex items-center gap-1">
<KeyRound className="w-3.5 h-3.5" />
API Key{apiKeyOptional && <span className="text-xs dg-text-muted font-normal">(可选)</span>}
</Label>
<Input type="password" value={cfg.api_key} onChange={(e) => set('api_key', e.target.value)}
placeholder="sk-..." className="dg-input" />
placeholder={apiKeyOptional ? '无鉴权可留空' : 'sk-...'} className="dg-input" />
{apiKeyOptional && (
<p className="text-xs dg-text-muted">本地或内网自建通常无需密钥;若网关启用了鉴权再填写。</p>
)}
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">接口地址(Endpoint)</Label>
<Input value={cfg.endpoint} onChange={(e) => set('endpoint', e.target.value)}
placeholder="https://api.xxx.com/v1" className="dg-input" />
placeholder={endpointPlaceholder(cfg.provider)} className="dg-input" />
{prov?.planHint && !prov.planUrl && (
<p className="text-xs dg-text-muted">{prov.planHint}</p>
)}
</div>
</CardContent>
</Card>
<Card className="dg-card">
<CardHeader><CardTitle className="text-[var(--dg-text-primary)] text-base flex items-center gap-2"><Sparkles className="w-4 h-4 text-[var(--dg-orange)]" />推理 / Plan 模式</CardTitle></CardHeader>
<CardHeader>
<CardTitle className="text-[var(--dg-text-primary)] text-base flex items-center gap-2">
<Sparkles className="w-4 h-4 text-[var(--dg-orange)]" />推理 / Plan 模式
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
@@ -212,14 +430,25 @@ export default function LLMConfigPage() {
<Switch checked={cfg.enabled} onCheckedChange={(v) => set('enabled', v)} />
<span>启用 LLM 服务</span>
</div>
<Button onClick={save} disabled={saving} className="dg-btn-primary">
{saving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Save className="w-4 h-4 mr-1" />} 保存配置
<Button variant="outline" onClick={test} disabled={testing || !canTest} className="dg-btn-secondary">
{testing
? <Loader2 className="w-4 h-4 mr-1 animate-spin" />
: <Wifi className="w-4 h-4 mr-1" />}
测试连接
</Button>
<Button variant="outline" onClick={test} disabled={testing || !cfg.enabled}
className="dg-btn-secondary">
{testing ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Wifi className="w-4 h-4 mr-1" />} 测试连接
<Button onClick={save} disabled={saving || !isVerified} className="dg-btn-primary">
{saving
? <Loader2 className="w-4 h-4 mr-1 animate-spin" />
: <Save className="w-4 h-4 mr-1" />}
保存配置
</Button>
{!cfg.enabled && <span className="text-xs dg-text-muted">请先保存并启用后再测试</span>}
{isVerified ? (
<span className="inline-flex items-center gap-1 text-xs text-[var(--dg-green)]">
<CheckCircle2 className="w-3.5 h-3.5" />已验证,可保存
</span>
) : (
<span className="text-xs dg-text-muted">切换提供商或修改凭证后,请先测试连接</span>
)}
</div>
{testResult && (
@@ -231,12 +460,16 @@ export default function LLMConfigPage() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-sm text-[var(--dg-text-secondary)] whitespace-pre-wrap leading-relaxed">{testResult.answer || '(模型未返回内容)'}</div>
<div className="text-sm text-[var(--dg-text-secondary)] whitespace-pre-wrap leading-relaxed">
{testResult.answer || '(模型未返回内容)'}
</div>
<div className="flex flex-wrap gap-3 text-xs dg-text-muted">
<span className="bg-[var(--dg-bg-tertiary)] px-2 py-1 rounded">模型: {testResult.model || '未知'}</span>
<span className="bg-[var(--dg-bg-tertiary)] px-2 py-1 rounded">提供商: {testResult.provider}</span>
<span className="bg-[var(--dg-bg-tertiary)] px-2 py-1 rounded">消耗 Token: {testResult.tokens_used ?? '-'}</span>
{testResult.reasoning_enabled && <span className="bg-amber-900/40 dg-text-yellow px-2 py-1 rounded">推理模式已开启</span>}
{testResult.reasoning_enabled && (
<span className="bg-amber-900/40 dg-text-yellow px-2 py-1 rounded">推理模式已开启</span>
)}
</div>
</CardContent>
</Card>
@@ -244,8 +477,14 @@ export default function LLMConfigPage() {
<div className="flex items-start gap-2 text-xs dg-text-muted">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<span>测试时会向当前模型发送「你是什么模型?请简短回答。」,并在页面上方展示模型返回内容,用于确认接入的是目标模型。</span>
<span>
云服务:选择提供商 → 开通并填写 API Key → 测试连接 → 保存。
编码套餐:GLM / Kimi / MiniMax 固定月费套餐,适合 Agent CLI 高频调用(注意 Key 与按量版不互通)。
大模型自定义:填请求地址 + API Key + 模型名,兼容 OpenRouter 等 OpenAI 格式服务。
本地/自建(Ollama、vLLM):填写 Endpoint 与模型名即可,API Key 可留空。
测试时会发送「你是什么模型?请简短回答。」以确认接入目标模型。
</span>
</div>
</PageShell>
);
}
}
+25 -11
View File
@@ -6,6 +6,7 @@ import { useUserStore, useLanguageStore } from '@/store';
import { authApi } from '@/api';
import { useTheme } from '@/hooks/useTheme';
import { ThemePicker } from '@/components/layout/ThemePicker';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '@/i18n';
import {
@@ -57,17 +58,26 @@ export function Login() {
}}
/>
{/* 语言 / 主题 */}
<div className="fixed top-4 right-4 z-50 flex items-center gap-2">
{/* 语言 / 主题 — 与 Header 同款 Button,避免 dg-btn-secondary 挤压图标 // Logan.Li */}
<div
className={cn(
'fixed top-4 right-4 z-50 flex items-center gap-0.5 p-1 rounded-lg',
'border border-[var(--border-default)] bg-[var(--surface-raised)]/90 backdrop-blur-sm',
)}
style={{ boxShadow: 'var(--shadow-dropdown)' }}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t('header.language')}
className="dg-btn-secondary h-9 w-9 p-0 rounded-lg"
title={t('header.language')}
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
>
<Languages className="size-4" />
</button>
<Languages className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
@@ -81,13 +91,14 @@ export function Login() {
key={lang.code}
className={cn(
'flex items-center gap-2.5 cursor-pointer',
'focus:bg-[var(--surface-hover)] focus:text-[var(--text-primary)]',
language === lang.code ? 'text-[var(--text-primary)]' : 'dg-text-secondary',
)}
onClick={() => setLanguage(lang.code as AppLanguage)}
>
<span className="text-base">{lang.flag}</span>
<span className="flex-1 text-sm">{lang.label}</span>
{language === lang.code && <Check className="size-4 shrink-0 text-[var(--brand)]" />}
{language === lang.code && <Check className="h-4 w-4 shrink-0 text-[var(--brand)]" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -95,13 +106,16 @@ export function Login() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t('header.theme')}
className="dg-btn-secondary h-9 w-9 p-0 rounded-lg"
title={t('header.theme')}
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
>
<Palette className="size-4" />
</button>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
+32 -27
View File
@@ -4,20 +4,21 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Clock, Server, ArrowRight } from 'lucide-react';
import { dashboardStatsApi, serverApi, kubernetesApi } from '@/api';
import { dashboardStatsApi, kubernetesApi } from '@/api';
import { StatStrip } from '@/components/ui/stat-strip';
import { CostOverviewCard, TopResourceUsageCard, ResourceUsageCard } from './DashboardCharts';
import {
AlertMessagesCard,
CertificateExpiryCard,
ReleaseHistoryCard,
ServerStatusCard,
OpsOverviewCard,
KubernetesClusterCard,
} from './DashboardRecent';
import type {
DashboardRelease,
DashboardServer,
DashboardCluster,
DashboardAutomationStats,
DashboardOpsStats,
} from './DashboardRecent';
import { SkeletonDashboard } from '@/components/layout/SkeletonCard';
import { PageShell } from '@/components/layout/PageShell';
@@ -41,7 +42,6 @@ export function Dashboard() {
const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [stats, setStats] = useState<JsonData>(null);
const [servers, setServers] = useState<DashboardServer[]>([]);
const [clusters, setClusters] = useState<DashboardCluster[]>([]);
const [alerts, setAlerts] = useState<AlertEventRecord[]>([]);
const [releases, setReleases] = useState<DashboardRelease[]>([]);
@@ -51,15 +51,11 @@ export function Dashboard() {
const refreshData = useCallback(async () => {
setRefreshing(true);
try {
const [statsRes, serversRes, clustersRes, alertsRes, releasesRes, metricsRes] = await Promise.all([
const [statsRes, clustersRes, alertsRes, releasesRes, metricsRes] = await Promise.all([
dashboardStatsApi.getStats().catch((e) => {
console.error('Failed to fetch dashboard stats:', e);
return null;
}),
serverApi.getServers({ page: 1, page_size: 100 }).catch((e) => {
console.error('Failed to fetch servers:', e);
return null;
}),
kubernetesApi.getClusters().catch((e) => {
console.error('Failed to fetch k8s clusters:', e);
return null;
@@ -79,7 +75,6 @@ export function Dashboard() {
]);
setStats(statsRes);
setServers(serversRes?.list ?? []);
setClusters(clustersRes?.list ?? []);
setAlerts(Array.isArray(alertsRes) ? alertsRes : []);
setReleases(Array.isArray(releasesRes) ? releasesRes : []);
@@ -95,8 +90,8 @@ export function Dashboard() {
useEffect(() => {
refreshData();
// 每 5 秒自动刷新
const interval = setInterval(refreshData, 5000);
// 每 30 秒自动刷新(集群列表含实时统计,不宜过频)// Logan.Li
const interval = setInterval(refreshData, 30_000);
return () => clearInterval(interval);
}, [refreshData]);
@@ -108,6 +103,9 @@ export function Dashboard() {
const k8sStats = stats?.kubernetes_stats;
const alertStats = stats?.alert_stats;
const costStats = stats?.cost_stats;
const automationStats = stats?.automation_stats as DashboardAutomationStats | undefined;
const opsStats = stats?.ops_stats as DashboardOpsStats | undefined;
return (
<PageShell
@@ -136,18 +134,19 @@ export function Dashboard() {
<StatStrip
items={[
{ label: t('dashboard.stat.servers'), value: serverStats?.total ?? 0, color: 'blue' },
{ label: t('dashboard.stat.kubernetes'), value: k8sStats?.total ?? 0, color: 'purple' },
{ label: t('dashboard.stat.running'), value: serverStats?.running ?? 0, color: 'emerald' },
{ label: t('dashboard.stat.k8sClusters'), value: k8sStats?.total ?? 0, color: 'purple' },
{ label: t('dashboard.stat.healthyClusters'), value: k8sStats?.healthy ?? 0, color: 'cyan' },
{ label: t('dashboard.stat.cronJobs'), value: automationStats?.cron_job_count ?? 0, color: 'cyan' },
{ label: t('dashboard.stat.siteProbes'), value: opsStats?.site_task_count ?? 0, color: 'green' },
{ label: t('dashboard.stat.alerts'), value: alertStats?.firing ?? 0, color: alertStats?.firing > 0 ? 'red' : 'muted' },
{ label: t('dashboard.stat.critical'), value: alertStats?.critical ?? 0, color: 'red' },
{ label: t('dashboard.stat.monthlyCost'), value: `¥${Number(costStats?.month_cost ?? 0).toLocaleString()}`, color: 'amber' },
]}
/>
)}
contentClassName="space-y-6"
>
<div>
{/* 资源使用率趋势 — 第一行 */}
<div className="animate-fade-in-delay-1">
<ResourceUsageCard
avgCpu={Number(serverStats?.avg_cpu ?? 0)}
avgMemory={Number(serverStats?.avg_memory ?? 0)}
@@ -161,23 +160,29 @@ export function Dashboard() {
/>
</div>
{/* Cost & Top Resource Usage */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 animate-fade-in-delay-3">
<CostOverviewCard costStats={costStats} />
{/* 运维概览 → K8s 集群 → Top5 资源使用 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 animate-fade-in-delay-2">
<OpsOverviewCard
automation={automationStats}
ops={opsStats}
serverCount={serverStats?.total}
hostMonitorCount={serverStats?.running}
firingAlerts={alertStats?.firing}
/>
<KubernetesClusterCard clusters={clusters} />
<TopResourceUsageCard topUsage={stats?.top_resource_usage} />
</div>
{/* Alert Messages & Certificate Expiry */}
{/* 费用概览 & 发布历史 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 animate-fade-in-delay-3">
<AlertMessagesCard alerts={alerts} />
<CertificateExpiryCard expCerts={expCerts} />
<CostOverviewCard costStats={costStats} />
<ReleaseHistoryCard releases={releases} />
</div>
{/* Bottom Row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 animate-fade-in-delay-4">
<ReleaseHistoryCard releases={releases} />
<ServerStatusCard servers={servers} />
<KubernetesClusterCard clusters={clusters} />
{/* 告警消息 & 证书到期提醒 — 置底 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 animate-fade-in-delay-4">
<AlertMessagesCard alerts={alerts} />
<CertificateExpiryCard expCerts={expCerts} />
</div>
</PageShell>
);
@@ -10,8 +10,12 @@ import {
ArrowRight,
Cloud,
Shield,
Clock3,
Globe,
Database,
Server,
Circle,
Monitor,
Activity,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { AlertEventRecord, CertExpiryRecord } from '@/types';
@@ -27,14 +31,6 @@ export interface DashboardRelease {
[key: string]: unknown;
}
export interface DashboardServer {
id: number | string;
status: string;
hostname?: string;
ip?: string;
[key: string]: unknown;
}
export interface DashboardCluster {
id: number | string;
code?: string;
@@ -44,9 +40,34 @@ export interface DashboardCluster {
memory_usage?: number;
node_count?: number;
pod_count?: number;
ingress_count?: number;
deployment_count?: number;
service_count?: number;
[key: string]: unknown;
}
export interface DashboardAutomationStats {
cron_job_count?: number;
script_count?: number;
today_executions?: number;
success_rate?: number;
}
export interface DashboardOpsStats {
domain_count?: number;
site_task_count?: number;
middleware_count?: number;
snmp_device_count?: number;
monitor_target_count?: number;
}
export interface DashboardPipelineStats {
today_count?: number;
success?: number;
failed?: number;
running?: number;
}
/**
* DashboardRecent - 优化后的仪表盘底部卡片组件
*
@@ -336,84 +357,70 @@ export function ReleaseHistoryCard({ releases }: { releases: DashboardRelease[]
);
}
export function ServerStatusCard({ servers }: { servers: DashboardServer[] }) {
/** 运维概览 — 资产 / 监控 / 任务快捷入口 // Logan.Li */
export function OpsOverviewCard({
automation,
ops,
serverCount,
hostMonitorCount,
firingAlerts,
}: {
automation?: DashboardAutomationStats;
ops?: DashboardOpsStats;
serverCount?: number;
hostMonitorCount?: number;
firingAlerts?: number;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const runningServers = servers.filter((s) => s.status === 'running').length;
const stoppedServers = servers.filter((s) => s.status === 'stopped').length;
const maintenanceServers = servers.filter((s) => s.status === 'maintenance').length;
const tiles = [
{ key: 'servers', label: t('dashboard.ops.servers'), value: serverCount ?? 0, icon: Server, path: '/servers', color: 'text-[var(--dg-blue)]' },
{ key: 'domains', label: t('dashboard.ops.domains'), value: ops?.domain_count ?? 0, icon: Globe, path: '/domains', color: 'text-[var(--dg-cyan)]' },
{ key: 'host', label: t('dashboard.ops.hostMonitor'), value: hostMonitorCount ?? serverCount ?? 0, icon: Monitor, path: '/host-monitor', color: 'text-[var(--dg-green)]' },
{ key: 'mw', label: t('dashboard.ops.middleware'), value: ops?.middleware_count ?? 0, icon: Database, path: '/monitor-center?tab=middleware', color: 'text-[var(--dg-red)]' },
{ key: 'site', label: t('dashboard.ops.siteProbes'), value: ops?.site_task_count ?? 0, icon: Activity, path: '/monitor-center?tab=site', color: 'text-[var(--dg-yellow)]' },
{ key: 'cron', label: t('dashboard.ops.cronJobs'), value: automation?.cron_job_count ?? 0, icon: Clock3, path: '/cronjobs', color: 'text-[var(--dg-purple)]' },
];
return (
<Card className="dg-card dg-card-hover">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[var(--dg-text-primary)]">{t('dashboard.serversStatus.title')}</CardTitle>
<CardDescription className="text-[var(--dg-text-muted)]">{t('dashboard.serversStatus.description')}</CardDescription>
<CardTitle className="text-[var(--dg-text-primary)]">{t('dashboard.ops.title')}</CardTitle>
<CardDescription className="text-[var(--dg-text-muted)]">{t('dashboard.ops.description')}</CardDescription>
</div>
<Button
variant="ghost"
size="sm"
className="dg-btn-ghost text-xs"
onClick={() => navigate('/servers')}
>
{t('dashboard.serversStatus.viewAll')}
<Button variant="ghost" size="sm" className="dg-btn-ghost text-xs" onClick={() => navigate('/dashboards')}>
{t('dashboard.ops.monitorScreen')}
<ArrowRight className="h-3.5 w-3.5 ml-1" />
</Button>
</div>
</CardHeader>
<CardContent>
{/* Status Summary */}
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="text-center p-3 rounded-xl bg-[var(--dg-green)]/8 border border-[var(--dg-green)]/15">
<div className="text-2xl font-bold text-[var(--dg-green)]">{runningServers}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{t('dashboard.serversStatus.running')}</div>
</div>
<div className="text-center p-3 rounded-xl bg-[var(--dg-yellow)]/8 border border-[var(--dg-yellow)]/15">
<div className="text-2xl font-bold text-[var(--dg-yellow)]">{maintenanceServers}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{t('dashboard.serversStatus.maintenance')}</div>
</div>
<div className="text-center p-3 rounded-xl bg-[var(--dg-text-muted)]/8 border border-[var(--dg-border)]">
<div className="text-2xl font-bold text-[var(--dg-text-muted)]">{stoppedServers}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{t('dashboard.serversStatus.stopped')}</div>
</div>
</div>
{/* Server List */}
<div className="space-y-1.5">
{servers.slice(0, 4).map((server) => (
<div
key={server.id}
className="flex items-center gap-3 p-2.5 rounded-lg bg-[var(--dg-bg-tertiary)]/50 hover:bg-[var(--dg-bg-hover)] transition-all duration-200"
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-3">
{tiles.map((tile) => (
<button
key={tile.key}
type="button"
onClick={() => navigate(tile.path)}
className="flex items-center gap-2.5 p-2.5 rounded-xl bg-[var(--dg-bg-tertiary)]/50 hover:bg-[var(--dg-bg-hover)] transition-all text-left"
>
<Circle
className={cn(
'h-2.5 w-2.5 flex-shrink-0 fill-current',
server.status === 'running' ? 'text-[var(--dg-green)]' :
server.status === 'maintenance' ? 'text-[var(--dg-yellow)]' :
'text-[var(--dg-text-muted)]'
)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-[var(--dg-text-primary)] truncate">{server.hostname}</p>
<p className="text-xs text-[var(--dg-text-muted)]">{server.ip}</p>
<tile.icon className={cn('h-4 w-4 shrink-0', tile.color)} />
<div className="min-w-0">
<div className="text-base font-bold text-[var(--dg-text-primary)] leading-none">{tile.value}</div>
<div className="text-[10px] text-[var(--dg-text-muted)] mt-0.5 truncate">{tile.label}</div>
</div>
<div className="text-right flex-shrink-0">
<p className="text-xs text-[var(--dg-text-muted)]">
{t('dashboard.serversStatus.cpu')} <span className="text-[var(--dg-text-secondary)] font-medium">{Number(server.cpu_usage ?? 0).toFixed(1)}%</span>
</p>
<p className="text-xs text-[var(--dg-text-muted)]">
{t('dashboard.serversStatus.memory')} <span className="text-[var(--dg-text-secondary)] font-medium">{Number(server.memory_usage ?? 0).toFixed(1)}%</span>
</p>
</div>
</div>
</button>
))}
{servers.length === 0 && (
<div className="flex flex-col items-center justify-center py-6 text-[var(--dg-text-muted)]">
<Server className="h-8 w-8 mb-2 opacity-30" />
<p className="text-sm">{t('dashboard.serversStatus.empty')}</p>
</div>
)}
</div>
<div className="flex items-center justify-between text-xs px-1 pt-1 border-t border-[var(--dg-border)]">
<span className="text-[var(--dg-text-muted)]">
{t('dashboard.ops.todayExecutions')}: <span className="text-[var(--dg-text-primary)] font-medium">{automation?.today_executions ?? 0}</span>
</span>
<button type="button" className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]" onClick={() => navigate('/alert-center')}>
{t('dashboard.ops.firingAlerts')}: <span className={cn('font-medium', (firingAlerts ?? 0) > 0 ? 'text-[var(--dg-red)]' : 'text-[var(--dg-text-primary)]')}>{firingAlerts ?? 0}</span>
</button>
</div>
</CardContent>
</Card>
@@ -454,6 +461,20 @@ export function KubernetesClusterCard({ clusters }: { clusters: DashboardCluster
{clusters.map((cluster) => {
const cpuUsage = Number(cluster.cpu_usage ?? 0);
const memoryUsage = Number(cluster.memory_usage ?? 0);
const hasUsage = cpuUsage > 0 || memoryUsage > 0;
const metrics = hasUsage
? [
{ label: t('dashboard.clusters.nodes'), value: cluster.node_count ?? 0 },
{ label: t('dashboard.clusters.pods'), value: cluster.pod_count ?? 0 },
{ label: t('dashboard.clusters.cpu'), value: `${cpuUsage.toFixed(1)}%` },
{ label: t('dashboard.clusters.memory'), value: `${memoryUsage.toFixed(1)}%` },
]
: [
{ label: t('dashboard.clusters.nodes'), value: cluster.node_count ?? 0 },
{ label: t('dashboard.clusters.pods'), value: cluster.pod_count ?? 0 },
{ label: t('dashboard.clusters.ingress'), value: cluster.ingress_count ?? 0 },
{ label: t('dashboard.clusters.deployments'), value: cluster.deployment_count ?? 0 },
];
return (
<div
key={cluster.id}
@@ -478,12 +499,7 @@ export function KubernetesClusterCard({ clusters }: { clusters: DashboardCluster
</Badge>
</div>
<div className="grid grid-cols-4 gap-2 text-center">
{[
{ label: t('dashboard.clusters.nodes'), value: cluster.node_count ?? 0 },
{ label: t('dashboard.clusters.pods'), value: cluster.pod_count ?? 0 },
{ label: t('dashboard.clusters.cpu'), value: `${cpuUsage.toFixed(1)}%` },
{ label: t('dashboard.clusters.memory'), value: `${memoryUsage.toFixed(1)}%` },
].map((item) => (
{metrics.map((item) => (
<div key={item.label} className="p-1.5 rounded-lg bg-[var(--dg-bg-primary)]/50">
<div className="text-sm font-semibold text-[var(--dg-text-primary)]">{item.value}</div>
<div className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{item.label}</div>
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import {
Plus, Pencil, Trash2, RefreshCw, Key, Shield, Search,
@@ -39,7 +40,7 @@ export default function CredentialManager() {
...(filterType && filterType !== 'all' ? { secret_type: filterType } : {}),
};
const { data: credentials = [], isLoading: loading, refetch } = useCredentials(listParams);
const { data: credentials = [], isLoading: loading, isFetching, refetch } = useCredentials(listParams);
const { deleteMutation, toggleActiveMutation } = useCredentialMutations();
const handleDelete = async (cred: Credential) => {
@@ -118,8 +119,8 @@ export default function CredentialManager() {
toolbar={toolbar}
actions={(
<>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4 mr-1" />{t('common.refresh')}
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn('w-4 h-4 mr-1', isFetching && 'animate-spin')} />{t('common.refresh')}
</Button>
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditing(null); setDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{t('deploy.cred.addCredential')}
@@ -33,7 +33,7 @@ import { StatStrip } from '@/components/ui/stat-strip';
export function PipelineManager() {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState('');
const { data: pipelines = [], isLoading: loading, refetch: refetchPipelines } = usePipelines();
const { data: pipelines = [], isLoading: loading, isFetching, refetch: refetchPipelines } = usePipelines();
const { triggerMutation, deleteMutation, stopRunMutation } = usePipelineMutations();
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
const [runs, setRuns] = useState<Record<number, Record<string, unknown>[]>>({});
@@ -210,8 +210,8 @@ export function PipelineManager() {
toolbar={toolbar}
actions={(
<>
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchPipelines()}>
<Clock className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />{t('common.refresh')}
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchPipelines()} disabled={isFetching}>
<Clock className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />{t('common.refresh')}
</Button>
<Button className="dg-btn-primary" onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4 mr-2" />{t('deploy.pipeline.newPipeline')}
+1 -12
View File
@@ -21,7 +21,6 @@ import {
FileCode,
Shield,
Users,
Menu,
Network,
CloudCog,
Globe,
@@ -389,17 +388,7 @@ export function Sidebar() {
</span>
)}
</div>
{!sidebarCollapsed && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] flex-shrink-0"
onClick={toggleSidebar}
title={t('header.collapseSidebar')}
>
<Menu className="h-4 w-4" />
</Button>
)}
{/* 折叠按钮统一放在 Header,避免与顶栏重复 // Logan.Li */}
</div>
{/* Menu Items */}
@@ -1,8 +1,9 @@
// 告警中心主组件 — TanStack Query 编排 // Logan.Li
import React, { useState, useEffect, useMemo } from 'react';
import { cn } from '@/lib/utils';
import {
AlertTriangle, CheckCircle, Search, RefreshCw,
Eye, Check, VolumeX, Plus, Rocket,
Eye, Check, VolumeX, Plus,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -12,23 +13,24 @@ import { StatStrip } from '@/components/ui/stat-strip';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
import { PageShell } from '@/components/layout/PageShell';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { PageLoading } from '@/components/layout/PageLoading';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { alertCenterApi, alertApi } from '@/api';
import { useAlerts, useAgentAlertRules, useAlertMutations } from '@/hooks/api';
import { useAlerts, useUnifiedAlertRules, useMetricCatalog, useAlertMutations } from '@/hooks/api';
import {
type Alert, type AlertRule, getSeverityBadge, getStatusBadge,
getChannelTypeLabel,
type Alert, getSeverityBadge, getStatusBadge, getChannelTypeLabel,
} from './alert-center-helpers';
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog';
import { MetricCatalogTab } from './MetricCatalogTab';
import { AlertRulesTab } from './AlertRulesTab';
import { ContactDialog } from './ContactDialog';
import { ContactGroupDialog } from './ContactGroupDialog';
import SilenceRuleTab from './SilenceRuleTab';
import { NotifyPolicyTab } from './NotifyPolicyTab';
import type { UnifiedAlertRule } from '@/api';
import type { Contact, ContactGroup } from './alert-center-helpers';
import type { JsonData } from '@/types/common';
import { useTranslation } from 'react-i18next';
@@ -39,13 +41,11 @@ const AlertCenter: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
const [severityFilter, setSeverityFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const [sourceFilter, setSourceFilter] = useState('all');
const [activeTab, setActiveTab] = useState('alerts');
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [silenceOpen, setSilenceOpen] = useState(false);
// 规则弹框
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [editingRule, setEditingRule] = useState<JsonData>(null);
// 联系人 & 联系组
const [contacts, setContacts] = useState<Contact[]>([]);
const [contactGroups, setContactGroups] = useState<ContactGroup[]>([]);
@@ -58,20 +58,17 @@ const AlertCenter: React.FC = () => {
const [pageSize] = useState(10);
const alertParams = useMemo(() => ({
page,
page_size: pageSize,
severity: severityFilter,
status: statusFilter,
}), [page, pageSize, severityFilter, statusFilter]);
page, page_size: pageSize,
severity: severityFilter, status: statusFilter, source_type: sourceFilter,
}), [page, pageSize, severityFilter, statusFilter, sourceFilter]);
const { data: alertData, isLoading: loading, refetch: refetchAlerts } = useAlerts(alertParams);
const { data: rules = [], refetch: refetchRules } = useAgentAlertRules();
const { data: alertData, isLoading: loading, isFetching: fetchingAlerts, refetch: refetchAlerts } = useAlerts(alertParams);
const { data: rules = [], isFetching: fetchingRules, refetch: refetchRules } = useUnifiedAlertRules('all');
const { data: catalog = [] } = useMetricCatalog();
const {
ackMutation,
resolveMutation,
quickSetupMutation,
toggleAgentRuleMutation,
deleteAgentRuleMutation,
ackMutation, resolveMutation, quickSetupMutation, monitorQuickSetupMutation,
toggleAgentRuleMutation, deleteAgentRuleMutation,
deleteMonitorRuleMutation, toggleMonitorRuleMutation,
} = useAlertMutations();
const alerts = alertData?.list ?? [];
@@ -86,15 +83,15 @@ const AlertCenter: React.FC = () => {
const fetchContacts = async () => {
try {
const res: JsonData = await alertApi.getContacts();
setContacts((res?.data?.list || res?.list || []) as Contact[]);
const res = await alertApi.getContacts() as { list?: Contact[] };
setContacts(res?.list || []);
} catch { /* silent */ }
};
const fetchContactGroups = async () => {
try {
const res: JsonData = await alertApi.getContactGroups();
setContactGroups((res?.data?.list || res?.list || []) as ContactGroup[]);
const res = await alertApi.getContactGroups() as { list?: ContactGroup[] };
setContactGroups(res?.list || []);
} catch { /* silent */ }
};
@@ -140,19 +137,6 @@ const AlertCenter: React.FC = () => {
}
};
const toggleRule = async (ruleId: string, enabled: boolean) => {
try {
await toggleAgentRuleMutation.mutateAsync({ id: Number(ruleId), enabled });
toast.success(t('alert.ruleUpdated'));
} catch {
toast.error(t('alert.updateFailed'));
}
};
const handleOpenAddRule = () => { setEditingRule(null); setRuleDialogOpen(true); };
const handleEditRule = (rule: AlertRule) => { setEditingRule(rule); setRuleDialogOpen(true); };
const handleRuleDialogClose = () => { setRuleDialogOpen(false); setEditingRule(null); };
const handleQuickSetup = async () => {
const ok = await _confirm({
title: t('alert.quickSetup'),
@@ -168,24 +152,32 @@ const AlertCenter: React.FC = () => {
}
};
const handleDeleteRule = async (rule: AlertRule) => {
const handleDeleteAgentRule = async (rule: UnifiedAlertRule) => {
const ok = await _confirm({ title: t('alert.deleteRule'), description: `${rule.name}`, confirmText: t('common.delete'), variant: 'destructive' });
if (!ok) return;
try { await deleteAgentRuleMutation.mutateAsync(Number(rule.id)); toast.success(t('alert.deleted')); } catch { toast.error(t('alert.deleteFailed')); }
try { await deleteAgentRuleMutation.mutateAsync(rule.id); toast.success(t('alert.deleted')); } catch { toast.error(t('alert.deleteFailed')); }
};
const handleFilterChange = (type: 'severity' | 'status', value: string) => {
const handleDeleteMonitorRule = async (rule: UnifiedAlertRule) => {
const ok = await _confirm({ title: t('alert.deleteRule'), description: `${rule.name}`, confirmText: t('common.delete'), variant: 'destructive' });
if (!ok) return;
try { await deleteMonitorRuleMutation.mutateAsync(rule.id); toast.success(t('alert.deleted')); } catch { toast.error(t('alert.deleteFailed')); }
};
const handleFilterChange = (type: 'severity' | 'status' | 'source', value: string) => {
if (type === 'severity') setSeverityFilter(value);
else setStatusFilter(value);
setPage(1); // 过滤条件变化时重置到第一页
else if (type === 'status') setStatusFilter(value);
else setSourceFilter(value);
setPage(1);
};
const pageTabs = [
{ id: 'alerts', label: t('alert.tab.alerts') },
{ id: 'rules', label: t('alert.tab.rules') },
{ id: 'silences', label: t('alert.tab.silences') },
{ id: 'metrics', label: '指标目录' },
{ id: 'contacts', label: t('alert.tab.contacts') },
{ id: 'groups', label: t('alert.tab.groups') },
{ id: 'notify', label: '通知策略' },
];
const alertsToolbar = activeTab === 'alerts' ? (
@@ -222,11 +214,24 @@ const AlertCenter: React.FC = () => {
<SelectItem value="resolved">{t('alert.status.resolved')}</SelectItem>
</SelectContent>
</Select>
<Select value={sourceFilter} onValueChange={(v) => handleFilterChange('source', v)}>
<SelectTrigger className="w-[120px] h-8 text-xs">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部来源</SelectItem>
<SelectItem value="host">主机监控</SelectItem>
<SelectItem value="middleware">中间件监控</SelectItem>
<SelectItem value="snmp">SNMP 监控</SelectItem>
<SelectItem value="site">站点拨测</SelectItem>
<SelectItem value="log">日志告警</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1 text-xs dg-text-muted">
<span>{t('alert.totalItems', { count: total })}</span>
<Button variant="outline" size="sm" onClick={() => refetchAlerts()} className="h-7 text-xs px-2 dg-btn-secondary">
<RefreshCw className="w-3 h-3 mr-1" />{t('common.refresh')}
<Button variant="outline" size="sm" onClick={() => refetchAlerts()} disabled={fetchingAlerts} className="h-7 text-xs px-2 dg-btn-secondary">
<RefreshCw className={cn('w-3 h-3 mr-1', fetchingAlerts && 'animate-spin')} />{t('common.refresh')}
</Button>
</div>
</div>
@@ -253,18 +258,9 @@ const AlertCenter: React.FC = () => {
/>
)}
actions={activeTab === 'rules' ? (
<>
<Button className="dg-btn-primary" onClick={handleOpenAddRule}>
<Plus className="w-4 h-4 mr-1" />{t('alert.addRule')}
</Button>
<Button variant="secondary" className="dg-btn-secondary" onClick={handleQuickSetup} disabled={quickSetupMutation.isPending}>
<Rocket className="w-4 h-4 mr-1" />
{quickSetupMutation.isPending ? t('alert.quickSetupCreating') : t('alert.quickSetup')}
</Button>
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchRules()}>
<RefreshCw className="w-4 h-4 mr-2" />{t('common.refresh')}
</Button>
</>
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchRules()} disabled={fetchingRules}>
<RefreshCw className={cn('w-4 h-4 mr-2', fetchingRules && 'animate-spin')} />{t('common.refresh')}
</Button>
) : activeTab === 'contacts' ? (
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditingContact(null); setContactDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />添加联系人
@@ -393,66 +389,22 @@ const AlertCenter: React.FC = () => {
)}
{activeTab === 'rules' && (
rules.length === 0 ? (
<PageEmpty
icon={<AlertTriangle className="w-10 h-10" />}
title={t('alert.noRules')}
description={t('alert.noRulesHint')}
/>
) : (
<div className="grid grid-cols-1 gap-4">
{rules.map((rule) => (
<Card key={rule.id}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium">{rule.name}</span>
{getSeverityBadge(rule.severity)}
<Badge variant={rule.enabled ? 'default' : 'secondary'}>
{rule.enabled ? t('alert.enabled') : t('alert.disabled')}
</Badge>
</div>
<p className="text-sm text-muted-foreground mb-2">{rule.description}</p>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{rule.expression && (
<span className="font-mono bg-muted px-2 py-1 rounded">{rule.expression}</span>
)}
{rule.duration ? <span>{t('alert.durationSeconds', { count: rule.duration })}</span> : null}
</div>
{(() => {
let chs: string[] = [];
try {
const raw = rule.notify_channels;
if (raw) chs = Array.isArray(raw) ? raw : JSON.parse(raw);
} catch { /* ignore */ }
if (!chs.length) return null;
return (
<div className="flex gap-1 mt-2">
{chs.map(ch => (
<Badge key={ch} variant="outline" className="text-xs">{ch}</Badge>
))}
</div>
);
})()}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => handleEditRule(rule)}>{t('common.edit')}</Button>
<Button variant="ghost" size="sm" className="dg-text-red" onClick={() => handleDeleteRule(rule)}>{t('common.delete')}</Button>
<Switch
checked={rule.enabled}
onCheckedChange={(checked) => toggleRule(String(rule.id), checked)}
/>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)
<AlertRulesTab
rules={rules}
loading={fetchingRules}
catalog={catalog}
onRefresh={() => refetchRules()}
onToggleAgent={(id, enabled) => toggleAgentRuleMutation.mutate({ id, enabled })}
onDeleteAgent={handleDeleteAgentRule}
onDeleteMonitor={handleDeleteMonitorRule}
onToggleMonitor={(id, enabled) => toggleMonitorRuleMutation.mutate({ id, enabled })}
onQuickSetupHost={handleQuickSetup}
onQuickSetupMonitor={(cat) => monitorQuickSetupMutation.mutate(cat)}
quickSetupPending={quickSetupMutation.isPending || monitorQuickSetupMutation.isPending}
/>
)}
{activeTab === 'silences' && <SilenceRuleTab />}
{activeTab === 'metrics' && <MetricCatalogTab />}
{activeTab === 'contacts' && (
contacts.length === 0 ? (
@@ -503,6 +455,8 @@ const AlertCenter: React.FC = () => {
)
)}
{activeTab === 'notify' && <NotifyPolicyTab />}
{activeTab === 'groups' && (
contactGroups.length === 0 ? (
<PageEmpty title="暂无报警联系组" description="点击「添加联系组」创建" />
@@ -564,13 +518,6 @@ const AlertCenter: React.FC = () => {
onOpenChange={setSilenceOpen}
onSilence={handleSilence}
/>
<AlertRuleDialog
open={ruleDialogOpen}
onOpenChange={handleRuleDialogClose}
editingRule={editingRule}
notifyChannels={[]}
onSaved={() => { handleRuleDialogClose(); refetchRules(); }}
/>
<ContactDialog
open={contactDialogOpen}
onOpenChange={setContactDialogOpen}
@@ -0,0 +1,188 @@
// 告警规则 Tab — 主机/中间件/SNMP/站点 四类分栏 // Logan.Li
import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Plus, Rocket, Server, Database, Network, Globe } from 'lucide-react';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { PageLoading } from '@/components/layout/PageLoading';
import { getSeverityBadge } from './alert-center-helpers';
import { AgentRuleDialog } from '@/components/agent/AgentRuleDialog';
import { MonitorRuleDialog } from './MonitorRuleDialog';
import type { MetricCatalogCategory, UnifiedAlertRule } from '@/api';
import type { JsonData } from '@/types/common';
type RuleCategory = 'host' | 'middleware' | 'snmp' | 'site';
type MonitorCategory = 'middleware' | 'snmp' | 'site';
const RULE_CATEGORIES: Array<{
id: RuleCategory;
label: string;
icon: typeof Server;
desc: string;
}> = [
{ id: 'host', label: '主机监控', icon: Server, desc: 'Agent CPU/内存/磁盘/离线' },
{ id: 'middleware', label: '中间件', icon: Database, desc: 'Redis / MySQL 指标' },
{ id: 'snmp', label: 'SNMP', icon: Network, desc: '网络设备 / BMC' },
{ id: 'site', label: '站点拨测', icon: Globe, desc: 'HTTP/HTTPS 可用性与证书' },
];
interface Props {
rules: UnifiedAlertRule[];
loading: boolean;
catalog: MetricCatalogCategory[];
onRefresh: () => void;
onToggleAgent: (id: number, enabled: boolean) => void;
onDeleteAgent: (rule: UnifiedAlertRule) => void;
onDeleteMonitor: (rule: UnifiedAlertRule) => void;
onToggleMonitor: (id: number, enabled: boolean) => void;
onQuickSetupHost: () => void;
onQuickSetupMonitor: (cat: string) => void;
quickSetupPending: boolean;
}
export function AlertRulesTab({
rules, loading, catalog, onRefresh,
onToggleAgent, onDeleteAgent, onDeleteMonitor, onToggleMonitor,
onQuickSetupHost, onQuickSetupMonitor, quickSetupPending,
}: Props) {
const [activeCat, setActiveCat] = useState<RuleCategory>('host');
const [agentDialogOpen, setAgentDialogOpen] = useState(false);
const [monitorDialogOpen, setMonitorDialogOpen] = useState(false);
const [monitorCategory, setMonitorCategory] = useState<MonitorCategory>('middleware');
const [editingAgent, setEditingAgent] = useState<JsonData>(null);
const [editingMonitor, setEditingMonitor] = useState<UnifiedAlertRule | null>(null);
const filtered = rules.filter((r) => r.category === activeCat);
const catMeta = RULE_CATEGORIES.find((c) => c.id === activeCat)!;
const getMetricsFor = (cat: MonitorCategory) =>
catalog.find((c) => c.id === cat)?.metrics || [];
const openAdd = () => {
if (activeCat === 'host') {
setEditingAgent(null);
setAgentDialogOpen(true);
return;
}
setMonitorCategory(activeCat as MonitorCategory);
setEditingMonitor(null);
setMonitorDialogOpen(true);
};
const handleQuickSetup = () => {
if (activeCat === 'host') {
onQuickSetupHost();
} else {
onQuickSetupMonitor(activeCat);
}
};
if (loading) return <PageLoading label="加载规则..." />;
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{RULE_CATEGORIES.map((cat) => {
const Icon = cat.icon;
const count = rules.filter((r) => r.category === cat.id).length;
return (
<Button
key={cat.id}
variant={activeCat === cat.id ? 'default' : 'outline'}
size="sm"
className="h-8 text-xs gap-1.5"
onClick={() => setActiveCat(cat.id)}
>
<Icon className="h-3.5 w-3.5" />
{cat.label}
{count > 0 && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">{count}</Badge>
)}
</Button>
);
})}
</div>
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-muted-foreground flex-1 min-w-[200px]">{catMeta.desc}</p>
<Button size="sm" className="h-8" onClick={openAdd}>
<Plus className="h-3.5 w-3.5 mr-1" /> 添加规则
</Button>
<Button size="sm" variant="secondary" className="h-8" onClick={handleQuickSetup} disabled={quickSetupPending}>
<Rocket className="h-3.5 w-3.5 mr-1" /> 默认规则
</Button>
</div>
{filtered.length === 0 ? (
<PageEmpty title={`暂无${catMeta.label}规则`} description="点击「添加规则」或「默认规则」创建" />
) : (
<div className="grid grid-cols-1 gap-3">
{filtered.map((rule) => (
<Card key={`${rule.rule_type}-${rule.id}`}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<span className="font-medium text-sm">{rule.name}</span>
{getSeverityBadge(rule.severity)}
<Badge variant={rule.enabled ? 'default' : 'secondary'} className="text-[10px]">
{rule.enabled ? '启用' : '停用'}
</Badge>
</div>
<p className="text-xs text-muted-foreground font-mono">
{rule.metric} {rule.operator} {rule.threshold}
{rule.target ? ` · ${rule.target}` : ''}
</p>
{rule.description && (
<p className="text-[10px] text-muted-foreground mt-0.5">{rule.description}</p>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Button variant="ghost" size="sm" className="h-7 text-xs"
onClick={() => {
if (rule.rule_type === 'agent') {
setEditingAgent(rule as unknown as JsonData);
setAgentDialogOpen(true);
} else {
setEditingMonitor(rule);
setMonitorCategory(rule.category as MonitorCategory);
setMonitorDialogOpen(true);
}
}}>编辑</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red"
onClick={() => rule.rule_type === 'agent' ? onDeleteAgent(rule) : onDeleteMonitor(rule)}>删除</Button>
<Switch checked={rule.enabled}
onCheckedChange={(v) => rule.rule_type === 'agent'
? onToggleAgent(rule.id, v)
: onToggleMonitor(rule.id, v)} />
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
<AgentRuleDialog
open={agentDialogOpen}
onOpenChange={setAgentDialogOpen}
editingRule={editingAgent}
onSuccess={() => { setAgentDialogOpen(false); onRefresh(); }}
/>
<MonitorRuleDialog
open={monitorDialogOpen}
onOpenChange={setMonitorDialogOpen}
category={monitorCategory}
metrics={getMetricsFor(monitorCategory)}
editing={editingMonitor ? {
id: editingMonitor.id, name: editingMonitor.name, metric: editingMonitor.metric,
operator: editingMonitor.operator, threshold: editingMonitor.threshold,
severity: editingMonitor.severity, enabled: editingMonitor.enabled,
} : null}
onSaved={onRefresh}
/>
</div>
);
}
@@ -0,0 +1,129 @@
// 监控指标目录 — 主机/中间件/站点拨测可采集指标 // Logan.Li
import { useEffect, useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Loader2, RefreshCw, Server, Database, Globe, Network } from 'lucide-react';
import { alertMetricsApi, type MetricCatalogCategory } from '@/api';
const CATEGORY_ORDER = ['host', 'middleware', 'snmp', 'site'];
const categoryIcons: Record<string, typeof Server> = {
host: Server,
middleware: Database,
snmp: Network,
site: Globe,
};
export function MetricCatalogTab() {
const [categories, setCategories] = useState<MetricCatalogCategory[]>([]);
const [loading, setLoading] = useState(true);
const [activeCat, setActiveCat] = useState<string>('host');
const load = async () => {
setLoading(true);
try {
const data = await alertMetricsApi.getCatalog();
const cats = (data as { categories?: MetricCatalogCategory[] })?.categories || [];
setCategories(cats);
if (cats.length > 0 && !cats.find(c => c.id === activeCat)) {
setActiveCat(cats[0].id);
}
} catch { /* ignore */ } finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const current = categories.find(c => c.id === activeCat);
if (loading) {
return (
<div className="flex justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
主机 / 中间件 / SNMP / 站点拨测可采集指标,含默认告警阈值参考
</p>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={load}>
<RefreshCw className="h-3 w-3 mr-1" /> 刷新
</Button>
</div>
<div className="flex flex-wrap gap-2">
{[...categories].sort((a, b) => {
const ai = CATEGORY_ORDER.indexOf(a.id);
const bi = CATEGORY_ORDER.indexOf(b.id);
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
}).map(cat => {
const Icon = categoryIcons[cat.id] || Server;
return (
<Button
key={cat.id}
variant={activeCat === cat.id ? 'default' : 'outline'}
size="sm"
className="h-8 text-xs gap-1.5"
onClick={() => setActiveCat(cat.id)}
>
<Icon className="h-3.5 w-3.5" />
{cat.label}
<Badge variant="secondary" className="text-[10px] h-4 px-1">{cat.metrics.length}</Badge>
</Button>
);
})}
</div>
{current && (
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-3">{current.description}</p>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="px-2 py-2 text-left font-medium">指标</th>
<th className="px-2 py-2 text-left font-medium">单位</th>
<th className="px-2 py-2 text-left font-medium">类型</th>
<th className="px-2 py-2 text-left font-medium">警告阈值</th>
<th className="px-2 py-2 text-left font-medium">严重阈值</th>
<th className="px-2 py-2 text-left font-medium">上限</th>
<th className="px-2 py-2 text-left font-medium">说明</th>
</tr>
</thead>
<tbody className="divide-y">
{current.metrics.map(m => (
<tr key={m.key} className="hover:bg-muted/30">
<td className="px-2 py-2">
<div className="font-medium">{m.label}</div>
<code className="text-[10px] text-muted-foreground">{m.key}</code>
</td>
<td className="px-2 py-2 text-muted-foreground">{m.unit || '—'}</td>
<td className="px-2 py-2">
<Badge variant="outline" className="text-[10px]">{m.type}</Badge>
</td>
<td className="px-2 py-2 dg-text-yellow">
{m.default_op} {m.default_warn}{m.unit && m.unit !== '' ? m.unit : ''}
</td>
<td className="px-2 py-2 dg-text-red">
{m.default_op} {m.default_crit}{m.unit && m.unit !== '' ? m.unit : ''}
</td>
<td className="px-2 py-2 text-muted-foreground">{m.max}</td>
<td className="px-2 py-2 text-muted-foreground max-w-[200px]">{m.description}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,174 @@
// 中间件/站点拨测告警规则弹框 // Logan.Li
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Loader2 } from 'lucide-react';
import { alertMetricsApi, type MetricCatalogItem } from '@/api';
import { SEVERITY_OPTIONS } from '@/components/agent/agentAlertHelper';
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
category: 'middleware' | 'snmp' | 'site';
metrics: MetricCatalogItem[];
editing?: { id: number; name: string; metric: string; operator: string; threshold: number; severity: string; enabled: boolean; target_id?: number } | null;
onSaved: () => void;
}
const OPERATORS = [
{ value: '>', label: '> 大于' },
{ value: '<', label: '< 小于' },
{ value: '>=', label: '≥ 大于等于' },
{ value: '<=', label: '≤ 小于等于' },
{ value: '==', label: '= 等于' },
];
export function MonitorRuleDialog({ open, onOpenChange, category, metrics, editing, onSaved }: Props) {
const [form, setForm] = useState({
name: '', metric: '', operator: '>', threshold: '0', severity: 'warning', enabled: true,
});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
if (editing) {
setForm({
name: editing.name,
metric: editing.metric,
operator: editing.operator || '>',
threshold: String(editing.threshold),
severity: editing.severity || 'warning',
enabled: editing.enabled,
});
} else {
const first = metrics[0];
setForm({
name: '', metric: first?.key || '', operator: first?.default_op || '>',
threshold: String(first?.default_warn ?? 0), severity: 'warning', enabled: true,
});
}
}, [open, editing, metrics]);
const selectedMetric = metrics.find(m => m.key === form.metric);
const handleMetricChange = (key: string) => {
const m = metrics.find(x => x.key === key);
setForm(prev => ({
...prev,
metric: key,
operator: m?.default_op || prev.operator,
threshold: String(m?.default_warn ?? prev.threshold),
}));
};
const handleSave = async () => {
if (!form.name.trim() || !form.metric) {
toast.error('请填写规则名称和指标');
return;
}
setSaving(true);
try {
const payload = {
category, name: form.name.trim(), metric: form.metric,
operator: form.operator, threshold: Number(form.threshold) || 0,
severity: form.severity, enabled: form.enabled,
};
if (editing) {
await alertMetricsApi.updateMonitorRule(editing.id, payload);
toast.success('规则已更新');
} else {
await alertMetricsApi.createMonitorRule(payload);
toast.success('规则已创建');
}
onOpenChange(false);
onSaved();
} catch (e) {
toast.error((e as { message?: string }).message || '保存失败');
} finally {
setSaving(false);
}
};
const catLabels: Record<string, string> = {
middleware: '中间件', snmp: 'SNMP', site: '站点拨测',
};
const catLabel = catLabels[category] || category;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle>{editing ? '编辑' : '新建'}{catLabel}告警规则</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
基于监控中心采集指标配置阈值告警
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-1.5">
<Label>规则名称</Label>
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="例如:Redis 连接数过高" />
</div>
<div className="grid gap-1.5">
<Label>监控指标</Label>
<Select value={form.metric} onValueChange={handleMetricChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{metrics.map(m => (
<SelectItem key={m.key} value={m.key}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
{selectedMetric && (
<p className="text-[10px] text-muted-foreground">{selectedMetric.description}</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>运算符</Label>
<Select value={form.operator} onValueChange={v => setForm({ ...form, operator: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{OPERATORS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label>阈值{selectedMetric?.unit ? ` (${selectedMetric.unit})` : ''}</Label>
<Input type="number" value={form.threshold}
min={selectedMetric?.min} max={selectedMetric?.max}
onChange={e => setForm({ ...form, threshold: e.target.value })} />
</div>
</div>
<div className="grid gap-1.5">
<Label>严重程度</Label>
<Select value={form.severity} onValueChange={v => setForm({ ...form, severity: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{SEVERITY_OPTIONS.map(s => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Switch checked={form.enabled} onCheckedChange={v => setForm({ ...form, enabled: v })} />
<Label>启用规则</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>取消</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />} 保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more