From 471353e3d75488d57116f5b53f6f2f339623fd1e Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 1 Jul 2026 23:44:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9B=91=E6=8E=A7=E5=91=8A=E8=AD=A6?= =?UTF-8?q?=E4=BD=93=E7=B3=BB=E7=BB=9F=E4=B8=80=E9=87=8D=E6=9E=84=20+=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8C=96=E4=BB=BB=E5=8A=A1=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E5=BC=95=E6=93=8E=20+=20=E7=9B=91=E6=8E=A7=E5=A4=A7=E5=B1=8F?= =?UTF-8?q?=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 告警中心 - 新增统一告警(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 --- Dockerfile | 2 +- backend/controller/agent.go | 76 +- backend/controller/agent_cli.go | 42 +- backend/controller/agent_cli_history.go | 89 + backend/controller/alert_channels.go | 56 +- backend/controller/alert_engine.go | 99 +- backend/controller/alert_engine_monitor.go | 329 ++++ backend/controller/alert_engine_snmp.go | 184 ++ backend/controller/alert_metric_catalog.go | 107 ++ backend/controller/alert_notify_dispatch.go | 181 ++ backend/controller/alert_notify_message.go | 137 ++ backend/controller/alert_notify_policy.go | 129 ++ backend/controller/alert_unified.go | 215 +++ backend/controller/audit_setting.go | 121 ++ backend/controller/automation.go | 49 +- backend/controller/automation_cron.go | 108 +- backend/controller/automation_script.go | 27 +- backend/controller/cron_job_run.go | 116 ++ backend/controller/dashboard.go | 31 +- backend/controller/k8s_cluster.go | 4 + backend/controller/k8s_cluster_stats.go | 109 ++ backend/controller/llm.go | 19 +- backend/controller/llm_chat.go | 71 +- backend/controller/middleware_monitor.go | 207 +-- .../controller/middleware_monitor_redis.go | 101 ++ backend/controller/middleware_monitor_seed.go | 120 ++ backend/controller/monitor_alert_rule.go | 285 ++++ backend/controller/refactored_v2.go | 4 +- backend/controller/site.go | 230 ++- backend/controller/site_view.go | 211 +++ backend/controller/snmp.go | 52 +- backend/controller/snmp_collect.go | 201 +++ backend/controller/snmp_metrics_history.go | 273 +++ backend/controller/system_task.go | 53 + backend/controller/user_admin.go | 50 + backend/dto/dto.go | 12 +- backend/go.mod | 1 + backend/go.sum | 2 + backend/main.go | 101 +- backend/middleware/automation_access.go | 60 + backend/model/alert_notify_policy.go | 34 + backend/model/alert_notify_settings.go | 15 + backend/model/audit_setting.go | 29 + backend/model/automation.go | 6 +- backend/model/common.go | 25 +- backend/model/kubernetes.go | 16 +- backend/model/llm.go | 2 +- backend/model/monitor_alert_rule.go | 34 + backend/model/site.go | 3 +- backend/model/system_task.go | 16 + backend/pkg/agentcli/core/agent.go | 52 +- backend/pkg/agentcli/llm/anthropic.go | 4 +- backend/pkg/auditcleaner/cleaner.go | 79 + backend/pkg/cronscheduler/scheduler.go | 138 ++ backend/pkg/database/database.go | 15 +- backend/pkg/database/dialect.go | 20 + backend/pkg/scriptrunner/runtime.go | 159 ++ backend/pkg/siteprobe/executor.go | 277 +++ backend/pkg/siteprobe/ssl.go | 108 ++ backend/pkg/siteprobe/ssl_test.go | 37 + backend/pkg/siteprobe/worker.go | 118 ++ backend/pkg/snmp/constants.go | 8 + backend/pkg/snmp/templates.go | 130 ++ backend/pkg/systask/registry.go | 226 +++ backend/router/routes.go | 72 +- backend/seed/seed.go | 57 +- backend/seed/snmp_seed.go | 42 + backend/service/service.go | 9 +- frontend/package-lock.json | 1507 ++++++++++++++++- frontend/package.json | 4 + frontend/src/api/agent/agent.ts | 16 +- frontend/src/api/aiops/assistant.ts | 5 +- frontend/src/api/monitor/alert-metrics.ts | 69 + frontend/src/api/monitor/alert.ts | 23 + frontend/src/api/monitor/index.ts | 2 + frontend/src/api/monitor/metrics.ts | 14 +- frontend/src/api/monitor/site.ts | 39 +- frontend/src/api/system/audit-settings.ts | 27 + frontend/src/api/system/index.ts | 2 + frontend/src/api/system/user.ts | 3 + frontend/src/api/task/index.ts | 1 + frontend/src/api/task/script.ts | 15 + frontend/src/api/task/system-task.ts | 24 + .../src/components/agent/HostDetailDialog.tsx | 2 +- .../src/components/agent/agentAlertHelper.tsx | 36 +- frontend/src/components/aiops/AgentCLI.tsx | 453 +++-- .../src/components/aiops/AgentCLIHistory.tsx | 53 +- .../src/components/aiops/AgentCLIMessages.tsx | 174 +- .../src/components/aiops/AgentMarkdown.tsx | 98 ++ frontend/src/components/aiops/LLMConfig.tsx | 347 +++- frontend/src/components/auth/Login.tsx | 36 +- .../src/components/dashboard/Dashboard.tsx | 59 +- .../components/dashboard/DashboardRecent.tsx | 168 +- .../components/deploy/CredentialManager.tsx | 7 +- .../src/components/deploy/PipelineManager.tsx | 6 +- frontend/src/components/layout/Sidebar.tsx | 13 +- .../monitor/alert-center/AlertCenter.tsx | 195 +-- .../monitor/alert-center/AlertRulesTab.tsx | 188 ++ .../monitor/alert-center/MetricCatalogTab.tsx | 129 ++ .../alert-center/MonitorRuleDialog.tsx | 174 ++ .../monitor/alert-center/NotifyPolicyTab.tsx | 403 +++++ .../alert-center/SilenceRulesPanel.tsx | 170 ++ .../alert-center/alert-center-helpers.tsx | 34 +- .../src/components/server/ServerManager.tsx | 7 +- .../src/components/server/ServerTableRow.tsx | 3 +- .../components/system/CredentialManager.tsx | 5 +- .../components/system/LdapImportDialog.tsx | 18 +- .../system/LdapManagementSection.tsx | 4 +- .../src/components/system/SystemManager.tsx | 97 +- .../src/components/system/SystemRolesTab.tsx | 8 + .../components/system/SystemSettingsTab.tsx | 268 ++- .../src/components/system/SystemUserTab.tsx | 59 +- .../components/system/TerminalSessionsTab.tsx | 6 +- .../src/components/system/useAuditSettings.ts | 89 + .../components/system/useLdapManagement.ts | 49 +- .../src/components/task/CronJobFormDialog.tsx | 35 +- .../src/components/task/CronJobManager.tsx | 428 ++--- .../src/components/task/CronJobStatsPanel.tsx | 148 ++ frontend/src/components/task/CronJobTable.tsx | 182 ++ .../src/components/task/CronJobViewDialog.tsx | 109 ++ .../task/ExecutionHistoryDialog.tsx | 290 +++- frontend/src/components/task/ScriptCard.tsx | 72 - .../src/components/task/ScriptCodeEditor.tsx | 67 + .../components/task/ScriptDetailDialog.tsx | 49 - .../components/task/ScriptExecutionsTable.tsx | 69 - .../src/components/task/ScriptFormDialog.tsx | 34 +- .../src/components/task/ScriptLogsDialog.tsx | 39 - .../src/components/task/ScriptManager.tsx | 265 ++- .../components/task/ScriptRuntimeBanner.tsx | 76 + frontend/src/components/task/ScriptTable.tsx | 84 + .../src/components/task/SystemTasksPanel.tsx | 219 +++ .../src/components/task/TerminalOutput.tsx | 138 ++ .../src/components/task/cronJobHelpers.tsx | 121 +- .../src/components/task/script-helpers.tsx | 65 +- frontend/src/components/ui/checkbox.tsx | 12 +- frontend/src/components/ui/switch.tsx | 11 +- frontend/src/hooks/api/index.ts | 5 +- frontend/src/hooks/api/queryKeys.ts | 4 +- frontend/src/hooks/api/useAlerts.ts | 124 +- frontend/src/hooks/api/useHostMonitor.ts | 17 +- frontend/src/hooks/useQueryRefresh.ts | 15 + frontend/src/locales/en-US.json | 18 + frontend/src/locales/zh-CN.json | 18 + .../pages/approval/ApprovalWorkflowPage.tsx | 104 +- .../src/pages/audit/AuditOperationsTab.tsx | 7 +- .../terminal-audit/ActiveSessionsPanel.tsx | 6 +- .../terminal-audit/HistorySessionsPanel.tsx | 7 +- .../terminal-audit/SessionCommandsPanel.tsx | 5 +- frontend/src/pages/monitor/HostMonitor.tsx | 171 +- .../src/pages/monitor/MiddlewareMonitor.tsx | 689 +++----- frontend/src/pages/monitor/MonitorCenter.tsx | 35 +- frontend/src/pages/monitor/SNMPMonitor.tsx | 644 ++++--- frontend/src/pages/monitor/SiteMonitor.tsx | 675 +++----- .../monitor/host-monitor/ChartToolbar.tsx | 46 + .../monitor/host-monitor/DualLineChart.tsx | 89 +- .../host-monitor/HealthProbeSection.tsx | 7 +- .../host-monitor/HostCoreMetricsStrip.tsx | 54 + .../host-monitor/HostTimeRangePicker.tsx | 126 ++ .../monitor/host-monitor/MetricChartCard.tsx | 79 +- .../monitor/host-monitor/MetricIconBox.tsx | 36 + .../host-monitor/ZoomableAreaChart.tsx | 361 ++++ .../pages/monitor/host-monitor/timeRange.ts | 62 + .../src/pages/monitor/host-monitor/types.ts | 45 +- .../src/pages/monitor/host-monitor/utils.ts | 110 +- .../middleware-monitor/InstanceCard.tsx | 85 + .../middleware-monitor/MiddlewareDialog.tsx | 152 ++ .../monitor/middleware-monitor/MwGaugeBar.tsx | 32 + .../middleware-monitor/MwHealthStrip.tsx | 40 + .../middleware-monitor/MwOverviewCharts.tsx | 141 ++ .../MwTargetChartsPanel.tsx | 228 +++ .../middleware-monitor/MwTrendChart.tsx | 82 + .../monitor/middleware-monitor/helpers.ts | 251 +++ .../pages/monitor/shared/ChartExpandShell.tsx | 60 + .../pages/monitor/shared/ChartRangeHint.tsx | 21 + .../monitor/shared/MonitorRangeChartFrame.tsx | 50 + .../monitor/shared/monitorChartTheme.tsx | 85 + .../pages/monitor/shared/useChartRangeZoom.ts | 87 + .../site-monitor/SiteProtocolPieChart.tsx | 82 + .../monitor/site-monitor/SiteResultsPanel.tsx | 283 ++++ .../site-monitor/SiteTaskDetailDialog.tsx | 42 + .../src/pages/monitor/site-monitor/helpers.ts | 144 ++ .../observability/SiteEventTimeline.tsx | 48 + .../observability/SiteProtocolObs.tsx | 225 +++ .../site-monitor/observability/analytics.ts | 205 +++ .../snmp-monitor/SnmpDeviceChartsPanel.tsx | 178 ++ .../snmp-monitor/SnmpMetricTrendChart.tsx | 63 + .../snmp-monitor/SnmpOverviewCharts.tsx | 135 ++ .../snmp-monitor/SnmpTemplatesCollapsible.tsx | 75 + .../src/pages/monitor/snmp-monitor/helpers.ts | 142 ++ frontend/src/utils/request.ts | 12 +- 190 files changed, 16640 insertions(+), 3484 deletions(-) create mode 100644 backend/controller/agent_cli_history.go create mode 100644 backend/controller/alert_engine_monitor.go create mode 100644 backend/controller/alert_engine_snmp.go create mode 100644 backend/controller/alert_metric_catalog.go create mode 100644 backend/controller/alert_notify_dispatch.go create mode 100644 backend/controller/alert_notify_message.go create mode 100644 backend/controller/alert_notify_policy.go create mode 100644 backend/controller/alert_unified.go create mode 100644 backend/controller/audit_setting.go create mode 100644 backend/controller/cron_job_run.go create mode 100644 backend/controller/k8s_cluster_stats.go create mode 100644 backend/controller/middleware_monitor_redis.go create mode 100644 backend/controller/middleware_monitor_seed.go create mode 100644 backend/controller/monitor_alert_rule.go create mode 100644 backend/controller/site_view.go create mode 100644 backend/controller/snmp_collect.go create mode 100644 backend/controller/snmp_metrics_history.go create mode 100644 backend/controller/system_task.go create mode 100644 backend/middleware/automation_access.go create mode 100644 backend/model/alert_notify_policy.go create mode 100644 backend/model/alert_notify_settings.go create mode 100644 backend/model/audit_setting.go create mode 100644 backend/model/monitor_alert_rule.go create mode 100644 backend/model/system_task.go create mode 100644 backend/pkg/auditcleaner/cleaner.go create mode 100644 backend/pkg/cronscheduler/scheduler.go create mode 100644 backend/pkg/scriptrunner/runtime.go create mode 100644 backend/pkg/siteprobe/executor.go create mode 100644 backend/pkg/siteprobe/ssl.go create mode 100644 backend/pkg/siteprobe/ssl_test.go create mode 100644 backend/pkg/siteprobe/worker.go create mode 100644 backend/pkg/snmp/constants.go create mode 100644 backend/pkg/snmp/templates.go create mode 100644 backend/pkg/systask/registry.go create mode 100644 backend/seed/snmp_seed.go create mode 100644 frontend/src/api/monitor/alert-metrics.ts create mode 100644 frontend/src/api/system/audit-settings.ts create mode 100644 frontend/src/api/task/system-task.ts create mode 100644 frontend/src/components/aiops/AgentMarkdown.tsx create mode 100644 frontend/src/components/monitor/alert-center/AlertRulesTab.tsx create mode 100644 frontend/src/components/monitor/alert-center/MetricCatalogTab.tsx create mode 100644 frontend/src/components/monitor/alert-center/MonitorRuleDialog.tsx create mode 100644 frontend/src/components/monitor/alert-center/NotifyPolicyTab.tsx create mode 100644 frontend/src/components/monitor/alert-center/SilenceRulesPanel.tsx create mode 100644 frontend/src/components/system/useAuditSettings.ts create mode 100644 frontend/src/components/task/CronJobStatsPanel.tsx create mode 100644 frontend/src/components/task/CronJobTable.tsx create mode 100644 frontend/src/components/task/CronJobViewDialog.tsx delete mode 100644 frontend/src/components/task/ScriptCard.tsx create mode 100644 frontend/src/components/task/ScriptCodeEditor.tsx delete mode 100644 frontend/src/components/task/ScriptDetailDialog.tsx delete mode 100644 frontend/src/components/task/ScriptExecutionsTable.tsx delete mode 100644 frontend/src/components/task/ScriptLogsDialog.tsx create mode 100644 frontend/src/components/task/ScriptRuntimeBanner.tsx create mode 100644 frontend/src/components/task/ScriptTable.tsx create mode 100644 frontend/src/components/task/SystemTasksPanel.tsx create mode 100644 frontend/src/components/task/TerminalOutput.tsx create mode 100644 frontend/src/hooks/useQueryRefresh.ts create mode 100644 frontend/src/pages/monitor/host-monitor/ChartToolbar.tsx create mode 100644 frontend/src/pages/monitor/host-monitor/HostCoreMetricsStrip.tsx create mode 100644 frontend/src/pages/monitor/host-monitor/HostTimeRangePicker.tsx create mode 100644 frontend/src/pages/monitor/host-monitor/MetricIconBox.tsx create mode 100644 frontend/src/pages/monitor/host-monitor/ZoomableAreaChart.tsx create mode 100644 frontend/src/pages/monitor/host-monitor/timeRange.ts create mode 100644 frontend/src/pages/monitor/middleware-monitor/InstanceCard.tsx create mode 100644 frontend/src/pages/monitor/middleware-monitor/MiddlewareDialog.tsx create mode 100644 frontend/src/pages/monitor/middleware-monitor/MwGaugeBar.tsx create mode 100644 frontend/src/pages/monitor/middleware-monitor/MwHealthStrip.tsx create mode 100644 frontend/src/pages/monitor/middleware-monitor/MwOverviewCharts.tsx create mode 100644 frontend/src/pages/monitor/middleware-monitor/MwTargetChartsPanel.tsx create mode 100644 frontend/src/pages/monitor/middleware-monitor/MwTrendChart.tsx create mode 100644 frontend/src/pages/monitor/middleware-monitor/helpers.ts create mode 100644 frontend/src/pages/monitor/shared/ChartExpandShell.tsx create mode 100644 frontend/src/pages/monitor/shared/ChartRangeHint.tsx create mode 100644 frontend/src/pages/monitor/shared/MonitorRangeChartFrame.tsx create mode 100644 frontend/src/pages/monitor/shared/monitorChartTheme.tsx create mode 100644 frontend/src/pages/monitor/shared/useChartRangeZoom.ts create mode 100644 frontend/src/pages/monitor/site-monitor/SiteProtocolPieChart.tsx create mode 100644 frontend/src/pages/monitor/site-monitor/SiteResultsPanel.tsx create mode 100644 frontend/src/pages/monitor/site-monitor/SiteTaskDetailDialog.tsx create mode 100644 frontend/src/pages/monitor/site-monitor/helpers.ts create mode 100644 frontend/src/pages/monitor/site-monitor/observability/SiteEventTimeline.tsx create mode 100644 frontend/src/pages/monitor/site-monitor/observability/SiteProtocolObs.tsx create mode 100644 frontend/src/pages/monitor/site-monitor/observability/analytics.ts create mode 100644 frontend/src/pages/monitor/snmp-monitor/SnmpDeviceChartsPanel.tsx create mode 100644 frontend/src/pages/monitor/snmp-monitor/SnmpMetricTrendChart.tsx create mode 100644 frontend/src/pages/monitor/snmp-monitor/SnmpOverviewCharts.tsx create mode 100644 frontend/src/pages/monitor/snmp-monitor/SnmpTemplatesCollapsible.tsx create mode 100644 frontend/src/pages/monitor/snmp-monitor/helpers.ts diff --git a/Dockerfile b/Dockerfile index 0f0e50f..53d3092 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 && \ diff --git a/backend/controller/agent.go b/backend/controller/agent.go index eaa94e2..2e23bdf 100644 --- a/backend/controller/agent.go +++ b/backend/controller/agent.go @@ -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) diff --git a/backend/controller/agent_cli.go b/backend/controller/agent_cli.go index afa6aa5..434ab4d 100644 --- a/backend/controller/agent_cli.go +++ b/backend/controller/agent_cli.go @@ -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{}{ diff --git a/backend/controller/agent_cli_history.go b/backend/controller/agent_cli_history.go new file mode 100644 index 0000000..550ed3f --- /dev/null +++ b/backend/controller/agent_cli_history.go @@ -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 +} \ No newline at end of file diff --git a/backend/controller/alert_channels.go b/backend/controller/alert_channels.go index 043436f..6785ba9 100644 --- a/backend/controller/alert_channels.go +++ b/backend/controller/alert_channels.go @@ -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"), - ) -} diff --git a/backend/controller/alert_engine.go b/backend/controller/alert_engine.go index 09cadf3..5dbd877 100644 --- a/backend/controller/alert_engine.go +++ b/backend/controller/alert_engine.go @@ -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 检查给定告警是否被静默规则匹配 diff --git a/backend/controller/alert_engine_monitor.go b/backend/controller/alert_engine_monitor.go new file mode 100644 index 0000000..70f4ddb --- /dev/null +++ b/backend/controller/alert_engine_monitor.go @@ -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 +} \ No newline at end of file diff --git a/backend/controller/alert_engine_snmp.go b/backend/controller/alert_engine_snmp.go new file mode 100644 index 0000000..246b393 --- /dev/null +++ b/backend/controller/alert_engine_snmp.go @@ -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_" +} \ No newline at end of file diff --git a/backend/controller/alert_metric_catalog.go b/backend/controller/alert_metric_catalog.go new file mode 100644 index 0000000..5192806 --- /dev/null +++ b/backend/controller/alert_metric_catalog.go @@ -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}}) +} \ No newline at end of file diff --git a/backend/controller/alert_notify_dispatch.go b/backend/controller/alert_notify_dispatch.go new file mode 100644 index 0000000..1ba16cf --- /dev/null +++ b/backend/controller/alert_notify_dispatch.go @@ -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) + } + } +} \ No newline at end of file diff --git a/backend/controller/alert_notify_message.go b/backend/controller/alert_notify_message.go new file mode 100644 index 0000000..be331dc --- /dev/null +++ b/backend/controller/alert_notify_message.go @@ -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"), + ) +} \ No newline at end of file diff --git a/backend/controller/alert_notify_policy.go b/backend/controller/alert_notify_policy.go new file mode 100644 index 0000000..3d119fd --- /dev/null +++ b/backend/controller/alert_notify_policy.go @@ -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 +} \ No newline at end of file diff --git a/backend/controller/alert_unified.go b/backend/controller/alert_unified.go new file mode 100644 index 0000000..f205988 --- /dev/null +++ b/backend/controller/alert_unified.go @@ -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 +} \ No newline at end of file diff --git a/backend/controller/audit_setting.go b/backend/controller/audit_setting.go new file mode 100644 index 0000000..7c6e3bc --- /dev/null +++ b/backend/controller/audit_setting.go @@ -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, + }, + }) +} \ No newline at end of file diff --git a/backend/controller/automation.go b/backend/controller/automation.go index fc68c0e..feaaffd 100644 --- a/backend/controller/automation.go +++ b/backend/controller/automation.go @@ -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 自动化运维处理器 diff --git a/backend/controller/automation_cron.go b/backend/controller/automation_cron.go index 1ad6a9a..1b5ad9e 100644 --- a/backend/controller/automation_cron.go +++ b/backend/controller/automation_cron.go @@ -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{ diff --git a/backend/controller/automation_script.go b/backend/controller/automation_script.go index 8e4352d..ff3d63a 100644 --- a/backend/controller/automation_script.go +++ b/backend/controller/automation_script.go @@ -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" diff --git a/backend/controller/cron_job_run.go b/backend/controller/cron_job_run.go new file mode 100644 index 0000000..13b5ab9 --- /dev/null +++ b/backend/controller/cron_job_run.go @@ -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 +} \ No newline at end of file diff --git a/backend/controller/dashboard.go b/backend/controller/dashboard.go index dd1a85e..be01b56 100644 --- a/backend/controller/dashboard.go +++ b/backend/controller/dashboard.go @@ -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) // 本周告警 diff --git a/backend/controller/k8s_cluster.go b/backend/controller/k8s_cluster.go index 997ff43..c0d31df 100644 --- a/backend/controller/k8s_cluster.go +++ b/backend/controller/k8s_cluster.go @@ -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) diff --git a/backend/controller/k8s_cluster_stats.go b/backend/controller/k8s_cluster_stats.go new file mode 100644 index 0000000..568850f --- /dev/null +++ b/backend/controller/k8s_cluster_stats.go @@ -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, + }) + } +} \ No newline at end of file diff --git a/backend/controller/llm.go b/backend/controller/llm.go index 79e02db..b524ff2 100644 --- a/backend/controller/llm.go +++ b/backend/controller/llm.go @@ -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" diff --git a/backend/controller/llm_chat.go b/backend/controller/llm_chat.go index a251b6d..98612b1 100644 --- a/backend/controller/llm_chat.go +++ b/backend/controller/llm_chat.go @@ -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, }}) } diff --git a/backend/controller/middleware_monitor.go b/backend/controller/middleware_monitor.go index 59172cb..d07df03 100644 --- a/backend/controller/middleware_monitor.go +++ b/backend/controller/middleware_monitor.go @@ -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 } diff --git a/backend/controller/middleware_monitor_redis.go b/backend/controller/middleware_monitor_redis.go new file mode 100644 index 0000000..3d79d18 --- /dev/null +++ b/backend/controller/middleware_monitor_redis.go @@ -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 +} \ No newline at end of file diff --git a/backend/controller/middleware_monitor_seed.go b/backend/controller/middleware_monitor_seed.go new file mode 100644 index 0000000..0a31d92 --- /dev/null +++ b/backend/controller/middleware_monitor_seed.go @@ -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 +} \ No newline at end of file diff --git a/backend/controller/monitor_alert_rule.go b/backend/controller/monitor_alert_rule.go new file mode 100644 index 0000000..4ac3d47 --- /dev/null +++ b/backend/controller/monitor_alert_rule.go @@ -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: "已恢复"}) +} \ No newline at end of file diff --git a/backend/controller/refactored_v2.go b/backend/controller/refactored_v2.go index 0bc497c..6a73436 100644 --- a/backend/controller/refactored_v2.go +++ b/backend/controller/refactored_v2.go @@ -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) diff --git a/backend/controller/site.go b/backend/controller/site.go index b29f3ee..d5818c1 100644 --- a/backend/controller/site.go +++ b/backend/controller/site.go @@ -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, }}) -} +} \ No newline at end of file diff --git a/backend/controller/site_view.go b/backend/controller/site_view.go new file mode 100644 index 0000000..d306934 --- /dev/null +++ b/backend/controller/site_view.go @@ -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 +} \ No newline at end of file diff --git a/backend/controller/snmp.go b/backend/controller/snmp.go index 3a7bcef..5ea6285 100644 --- a/backend/controller/snmp.go +++ b/backend/controller/snmp.go @@ -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 模板 diff --git a/backend/controller/snmp_collect.go b/backend/controller/snmp_collect.go new file mode 100644 index 0000000..000a899 --- /dev/null +++ b/backend/controller/snmp_collect.go @@ -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() +} \ No newline at end of file diff --git a/backend/controller/snmp_metrics_history.go b/backend/controller/snmp_metrics_history.go new file mode 100644 index 0000000..51a6596 --- /dev/null +++ b/backend/controller/snmp_metrics_history.go @@ -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 +} \ No newline at end of file diff --git a/backend/controller/system_task.go b/backend/controller/system_task.go new file mode 100644 index 0000000..c3d235d --- /dev/null +++ b/backend/controller/system_task.go @@ -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, + }) +} \ No newline at end of file diff --git a/backend/controller/user_admin.go b/backend/controller/user_admin.go index 4f725f8..ba2498a 100644 --- a/backend/controller/user_admin.go +++ b/backend/controller/user_admin.go @@ -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) { diff --git a/backend/dto/dto.go b/backend/dto/dto.go index 521c1ac..b531a0a 100644 --- a/backend/dto/dto.go +++ b/backend/dto/dto.go @@ -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"` } diff --git a/backend/go.mod b/backend/go.mod index 92e505a..5d289e0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index e8d2f69..6fea326 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/main.go b/backend/main.go index d215e66..dd20d7b 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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() } }() diff --git a/backend/middleware/automation_access.go b/backend/middleware/automation_access.go new file mode 100644 index 0000000..f3cbb74 --- /dev/null +++ b/backend/middleware/automation_access.go @@ -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 +} \ No newline at end of file diff --git a/backend/model/alert_notify_policy.go b/backend/model/alert_notify_policy.go new file mode 100644 index 0000000..a2cf85a --- /dev/null +++ b/backend/model/alert_notify_policy.go @@ -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" } \ No newline at end of file diff --git a/backend/model/alert_notify_settings.go b/backend/model/alert_notify_settings.go new file mode 100644 index 0000000..94ce018 --- /dev/null +++ b/backend/model/alert_notify_settings.go @@ -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" } \ No newline at end of file diff --git a/backend/model/audit_setting.go b/backend/model/audit_setting.go new file mode 100644 index 0000000..04f5576 --- /dev/null +++ b/backend/model/audit_setting.go @@ -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 +} \ No newline at end of file diff --git a/backend/model/automation.go b/backend/model/automation.go index ba0b090..db85718 100644 --- a/backend/model/automation.go +++ b/backend/model/automation.go @@ -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"` diff --git a/backend/model/common.go b/backend/model/common.go index 28f4cb4..532b308 100644 --- a/backend/model/common.go +++ b/backend/model/common.go @@ -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 成本统计 diff --git a/backend/model/kubernetes.go b/backend/model/kubernetes.go index 2433a93..7efbdb2 100644 --- a/backend/model/kubernetes.go +++ b/backend/model/kubernetes.go @@ -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 diff --git a/backend/model/llm.go b/backend/model/llm.go index 95a4084..7efd4ea 100644 --- a/backend/model/llm.go +++ b/backend/model/llm.go @@ -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 diff --git a/backend/model/monitor_alert_rule.go b/backend/model/monitor_alert_rule.go new file mode 100644 index 0000000..2ba039d --- /dev/null +++ b/backend/model/monitor_alert_rule.go @@ -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" } \ No newline at end of file diff --git a/backend/model/site.go b/backend/model/site.go index f741257..44fd9fb 100644 --- a/backend/model/site.go +++ b/backend/model/site.go @@ -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拨测 diff --git a/backend/model/system_task.go b/backend/model/system_task.go new file mode 100644 index 0000000..4d52b5b --- /dev/null +++ b/backend/model/system_task.go @@ -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" } \ No newline at end of file diff --git a/backend/pkg/agentcli/core/agent.go b/backend/pkg/agentcli/core/agent.go index 35d2ea4..b9e6238 100644 --- a/backend/pkg/agentcli/core/agent.go +++ b/backend/pkg/agentcli/core/agent.go @@ -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 { diff --git a/backend/pkg/agentcli/llm/anthropic.go b/backend/pkg/agentcli/llm/anthropic.go index ba6919e..3bf2e91 100644 --- a/backend/pkg/agentcli/llm/anthropic.go +++ b/backend/pkg/agentcli/llm/anthropic.go @@ -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 { diff --git a/backend/pkg/auditcleaner/cleaner.go b/backend/pkg/auditcleaner/cleaner.go new file mode 100644 index 0000000..ef78a12 --- /dev/null +++ b/backend/pkg/auditcleaner/cleaner.go @@ -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)) +} \ No newline at end of file diff --git a/backend/pkg/cronscheduler/scheduler.go b/backend/pkg/cronscheduler/scheduler.go new file mode 100644 index 0000000..6a555c0 --- /dev/null +++ b/backend/pkg/cronscheduler/scheduler.go @@ -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 "" +} \ No newline at end of file diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index 1329fb3..b0ce5ac 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -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 diff --git a/backend/pkg/database/dialect.go b/backend/pkg/database/dialect.go index 378d28d..e022072 100644 --- a/backend/pkg/database/dialect.go +++ b/backend/pkg/database/dialect.go @@ -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, + ) + } } \ No newline at end of file diff --git a/backend/pkg/scriptrunner/runtime.go b/backend/pkg/scriptrunner/runtime.go new file mode 100644 index 0000000..14715ad --- /dev/null +++ b/backend/pkg/scriptrunner/runtime.go @@ -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 +} \ No newline at end of file diff --git a/backend/pkg/siteprobe/executor.go b/backend/pkg/siteprobe/executor.go new file mode 100644 index 0000000..f1c8fec --- /dev/null +++ b/backend/pkg/siteprobe/executor.go @@ -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) +} \ No newline at end of file diff --git a/backend/pkg/siteprobe/ssl.go b/backend/pkg/siteprobe/ssl.go new file mode 100644 index 0000000..aadb2ca --- /dev/null +++ b/backend/pkg/siteprobe/ssl.go @@ -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") +} \ No newline at end of file diff --git a/backend/pkg/siteprobe/ssl_test.go b/backend/pkg/siteprobe/ssl_test.go new file mode 100644 index 0000000..cb47549 --- /dev/null +++ b/backend/pkg/siteprobe/ssl_test.go @@ -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) + } + } +} \ No newline at end of file diff --git a/backend/pkg/siteprobe/worker.go b/backend/pkg/siteprobe/worker.go new file mode 100644 index 0000000..992474b --- /dev/null +++ b/backend/pkg/siteprobe/worker.go @@ -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, + }) +} \ No newline at end of file diff --git a/backend/pkg/snmp/constants.go b/backend/pkg/snmp/constants.go new file mode 100644 index 0000000..7885673 --- /dev/null +++ b/backend/pkg/snmp/constants.go @@ -0,0 +1,8 @@ +package snmp + +// SNMP 采集与保留策略常量 // Logan.Li +const ( + DefaultCollectIntervalSec = 300 // 默认采集间隔 5 分钟 + MinCollectIntervalSec = 300 // 最低采集间隔,避免高频轮询 + MetricsRetentionDays = 7 // 指标保留天数 +) \ No newline at end of file diff --git a/backend/pkg/snmp/templates.go b/backend/pkg/snmp/templates.go new file mode 100644 index 0000000..07faf70 --- /dev/null +++ b/backend/pkg/snmp/templates.go @@ -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), + } +} \ No newline at end of file diff --git a/backend/pkg/systask/registry.go b/backend/pkg/systask/registry.go new file mode 100644 index 0000000..3a3434b --- /dev/null +++ b/backend/pkg/systask/registry.go @@ -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 +} \ No newline at end of file diff --git a/backend/router/routes.go b/backend/router/routes.go index 1a28173..0d77498 100644 --- a/backend/router/routes.go +++ b/backend/router/routes.go @@ -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)) } }() } diff --git a/backend/seed/seed.go b/backend/seed/seed.go index e59f1af..12bbdac 100644 --- a/backend/seed/seed.go +++ b/backend/seed/seed.go @@ -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 切片 diff --git a/backend/seed/snmp_seed.go b/backend/seed/snmp_seed.go new file mode 100644 index 0000000..c9b27c8 --- /dev/null +++ b/backend/seed/snmp_seed.go @@ -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))) + } +} \ No newline at end of file diff --git a/backend/service/service.go b/backend/service/service.go index 7cee482..d77c53c 100644 --- a/backend/service/service.go +++ b/backend/service/service.go @@ -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) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 68a898f..76ba6a0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,9 @@ "name": "my-app", "version": "0.0.0", "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", @@ -58,9 +60,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", @@ -1879,6 +1883,19 @@ "@lezer/common": "^1.1.0" } }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, "node_modules/@codemirror/lang-yaml": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", @@ -1908,6 +1925,15 @@ "style-mod": "^4.0.0" } }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", + "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, "node_modules/@codemirror/lint": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", @@ -2744,6 +2770,17 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@lezer/yaml": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", @@ -4959,13 +4996,39 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/history": { "version": "4.7.11", "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", @@ -4979,6 +5042,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.12.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", @@ -5029,6 +5107,12 @@ "@types/react-router": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.52.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.52.0.tgz", @@ -5351,6 +5435,12 @@ "react-dom": ">=17.0.0" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", @@ -5567,6 +5657,16 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -5696,6 +5796,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5713,6 +5823,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -5823,6 +5973,16 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -6055,7 +6215,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6075,6 +6234,19 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6082,12 +6254,34 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -6386,6 +6580,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6402,6 +6606,12 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6661,6 +6871,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -6687,6 +6937,16 @@ "void-elements": "3.1.0" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/i18next": { "version": "26.3.3", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.3.tgz", @@ -6752,6 +7012,12 @@ "node": ">=0.8.19" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/input-otp": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", @@ -6771,6 +7037,30 @@ "node": ">=12" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -6800,6 +7090,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6823,6 +7123,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6833,6 +7143,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -7015,6 +7337,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -7056,6 +7388,298 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7066,6 +7690,569 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7110,7 +8297,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -7260,6 +8446,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7517,6 +8728,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7639,6 +8860,33 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -7923,6 +9171,72 @@ "regjsparser": "bin/parser" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8099,6 +9413,30 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -8118,6 +9456,24 @@ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", "license": "MIT" }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -8284,6 +9640,26 @@ "node": ">=8.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -8422,6 +9798,93 @@ "node": ">=4" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -8535,6 +9998,34 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -8770,6 +10261,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index c61e196..3c4a085 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/api/agent/agent.ts b/frontend/src/api/agent/agent.ts index 0b2f389..35ad7fc 100644 --- a/frontend/src/api/agent/agent.ts +++ b/frontend/src/api/agent/agent.ts @@ -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) => http.get('/agent/tasks', params), // 直连 SSH 执行命令(即时返回) diff --git a/frontend/src/api/aiops/assistant.ts b/frontend/src/api/aiops/assistant.ts index 3e21e8d..590de37 100644 --- a/frontend/src/api/aiops/assistant.ts +++ b/frontend/src/api/aiops/assistant.ts @@ -47,8 +47,9 @@ export const aiApi = { getConfig: () => http.get('/aiops/llm-config'), // 保存 LLM 配置(api_key 若为脱敏值则后端保留原值) saveConfig: (data: LLMConfigData) => http.put('/aiops/llm-config', data), - // 测试 LLM 连接(询问模型身份,返回模型回答) // Logan.Li - testConnection: () => http.post('/aiops/llm-config/test'), + // 测试 LLM 连接(可传草稿配置,保存前即可验证) // Logan.Li + testConnection: (data?: Partial) => + http.post('/aiops/llm-config/test', data ?? {}), // AI 对话(messages 含历史 + 当前用户消息,session_id 区分多轮对话) chat: (messages: { role: 'user' | 'assistant'; content: string }[], sessionId?: string) => http.post('/aiops/chat', { messages, session_id: sessionId || '' }), diff --git a/frontend/src/api/monitor/alert-metrics.ts b/frontend/src/api/monitor/alert-metrics.ts new file mode 100644 index 0000000..62e3dbb --- /dev/null +++ b/frontend/src/api/monitor/alert-metrics.ts @@ -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 }>('/alerts/unified', params), + + getUnifiedRules: (category?: string) => + http.get<{ list: UnifiedAlertRule[]; total: number }>('/alerts/unified-rules', { category: category || 'all' }), + + createMonitorRule: (data: Record) => + http.post('/alerts/monitor-rules', data), + + updateMonitorRule: (id: number, data: Record) => + 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`), +}; \ No newline at end of file diff --git a/frontend/src/api/monitor/alert.ts b/frontend/src/api/monitor/alert.ts index 8d034f6..80d9b69 100644 --- a/frontend/src/api/monitor/alert.ts +++ b/frontend/src/api/monitor/alert.ts @@ -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('/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) => { + 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); + }, }; \ No newline at end of file diff --git a/frontend/src/api/monitor/index.ts b/frontend/src/api/monitor/index.ts index 5b7af99..6ad17df 100644 --- a/frontend/src/api/monitor/index.ts +++ b/frontend/src/api/monitor/index.ts @@ -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'; diff --git a/frontend/src/api/monitor/metrics.ts b/frontend/src/api/monitor/metrics.ts index ee43e16..2972001 100644 --- a/frontend/src/api/monitor/metrics.ts +++ b/frontend/src/api/monitor/metrics.ts @@ -20,6 +20,14 @@ export const snmpApi = { getDevices: () => http.get('/snmp/devices'), createDevice: (data: Record) => 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) => - 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 = { diff --git a/frontend/src/api/monitor/site.ts b/frontend/src/api/monitor/site.ts index de9b4f1..d1796d2 100644 --- a/frontend/src/api/monitor/site.ts +++ b/frontend/src/api/monitor/site.ts @@ -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 } : {}), + }), }; \ No newline at end of file diff --git a/frontend/src/api/system/audit-settings.ts b/frontend/src/api/system/audit-settings.ts new file mode 100644 index 0000000..70a9d03 --- /dev/null +++ b/frontend/src/api/system/audit-settings.ts @@ -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('/admin/audit-settings'), + apply: async (data: { retention_days: number; record_all: boolean }) => + http.put('/admin/audit-settings', data), +}; \ No newline at end of file diff --git a/frontend/src/api/system/index.ts b/frontend/src/api/system/index.ts index 039673f..2e8e1f9 100644 --- a/frontend/src/api/system/index.ts +++ b/frontend/src/api/system/index.ts @@ -1,3 +1,5 @@ // 系统管理 API 聚合出口 // Logan.Li export { userApi, ldapApi } from './user'; +export { auditSettingsApi } from './audit-settings'; +export type { AuditSettings, AuditCleanupStats } from './audit-settings'; diff --git a/frontend/src/api/system/user.ts b/frontend/src/api/system/user.ts index da298fd..b1f417d 100644 --- a/frontend/src/api/system/user.ts +++ b/frontend/src/api/system/user.ts @@ -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[]; skip_existing?: boolean }) => { return http.post('/admin/users/batch-import', data); }, diff --git a/frontend/src/api/task/index.ts b/frontend/src/api/task/index.ts index 4a7f24c..3043939 100644 --- a/frontend/src/api/task/index.ts +++ b/frontend/src/api/task/index.ts @@ -3,3 +3,4 @@ export * from './cron-job'; export * from './script'; export * from './remote-exec'; +export * from './system-task'; diff --git a/frontend/src/api/task/script.ts b/frontend/src/api/task/script.ts index 985edf5..1ada533 100644 --- a/frontend/src/api/task/script.ts +++ b/frontend/src/api/task/script.ts @@ -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('/scripts/runtime'); + }, getScripts: async (params?: { page?: number; page_size?: number }) => { return http.get('/scripts', params); }, diff --git a/frontend/src/api/task/system-task.ts b/frontend/src/api/task/system-task.ts new file mode 100644 index 0000000..0c6fd0e --- /dev/null +++ b/frontend/src/api/task/system-task.ts @@ -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; +} + +export const systemTaskApi = { + list: async () => http.get>('/system-tasks'), + run: async (code: string) => http.post(`/system-tasks/${code}/run`), +}; \ No newline at end of file diff --git a/frontend/src/components/agent/HostDetailDialog.tsx b/frontend/src/components/agent/HostDetailDialog.tsx index 1393044..7a038c1 100644 --- a/frontend/src/components/agent/HostDetailDialog.tsx +++ b/frontend/src/components/agent/HostDetailDialog.tsx @@ -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)); }) diff --git a/frontend/src/components/agent/agentAlertHelper.tsx b/frontend/src/components/agent/agentAlertHelper.tsx index 70874d8..87202a0 100644 --- a/frontend/src/components/agent/agentAlertHelper.tsx +++ b/frontend/src/components/agent/agentAlertHelper.tsx @@ -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} ); -} +} \ No newline at end of file diff --git a/frontend/src/components/aiops/AgentCLI.tsx b/frontend/src/components/aiops/AgentCLI.tsx index e541492..8598d4b 100644 --- a/frontend/src/components/aiops/AgentCLI.tsx +++ b/frontend/src/components/aiops/AgentCLI.tsx @@ -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(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(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 = ( -
- - - - - {connected && ( - - 已连接 - - )} - {replayMode && ( - 回放模式 - )} - {canContinue && !running && !replayMode && ( - 可追问 - )} - {running && ( - - 第 {round} 轮 · {tokens} tokens - - )} -
- ); + const selectedServer = servers.find((s) => String(s.id) === serverId); + const canResumeView = viewSession && ['done', 'aborted', 'error'].includes(viewSession.status); return ( } - toolbar={toolbar} className="h-full" - contentClassName="h-full flex gap-3" + contentClassName="h-full flex flex-col min-h-0 gap-2" > - + {/* 工作区控制栏:主机 + 安全模式 + 状态,不挤占对话区宽度 // Logan.Li */} +
+ -
- {/* tips 面板 */} - - setTipsOpen((v) => !v)}> -
- {tipsOpen ? : } - - 核心能力与设计思路 -
-
- {tipsOpen && ( - - {TIPS.map((t) => ( -
- -
-
{t.title}
-
{t.desc}
+
+ + + + + + +
安全模式说明
+

+ 控制 AI 执行远程命令前是否需要你确认。危险命令(如 rm -rf /)任何模式均硬性拦截。 +

+ {SAFETY_MODES.map((m) => ( +
+
+ {m.label} + {m.short} + {'recommended' in m && m.recommended && ( + 推荐 + )}
+

{m.desc}

+

例:{m.examples}

))} - - )} - +
+
+
- {/* 对话流 */} - -
+ + +
+ + {connected && ( + + 已连接 + + )} + {viewMode && ( + 查看记录 + )} + {canContinue && !running && !viewMode && ( + 可追问 + )} + {running && ( + + 第 {round} 轮 · {tokens}t + + )} + + + + + + +
核心能力
+ {TIPS.map((t) => ( +
+ +
+
{t.title}
+
{t.desc}
+
+
+ ))} +
+
+
+ + {/* 主工作区:可折叠历史 + 对话区全宽 // Logan.Li */} +
+ {historyOpen && ( + setHistoryOpen(false)} + /> + )} + + + {viewMode && viewSession && ( +
+ + 历史记录(只读)· {viewSession.title} + + {canResumeView && ( + + )} + +
+ )} +
+
{items.length === 0 && (
@@ -374,37 +544,72 @@ export default function AgentCLI() {
正在连接主机...
) : ( -
描述你要执行的任务,例如「检查磁盘使用率并清理大文件」
+
+ 描述你要执行的任务,例如「检查磁盘使用率并清理大文件」 +
+ 当前安全模式:{currentSafety.label}({currentSafety.short}) +
+
)}
)} - {items.map((it) => ( - - ))} +
-
+ {items.length > 0 && ( +
+ + +
+ )} +
+ + {/* 加宽输入区:多行 + 独立操作栏 // Logan.Li */} +