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