diff --git a/README.md b/README.md index e99df62..085c385 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## 技术栈 ### 前端 -- React 18 + TypeScript + Vite +- React 19 + TypeScript + Vite - Tailwind CSS + shadcn/ui - Zustand (状态管理) / TanStack Query (数据获取) - Recharts (图表) / Lucide React (图标) @@ -42,6 +42,11 @@ aiops/ │ ├── go.mod │ └── Makefile │ +├── agent/ # Agent 监控服务 +│ ├── cmd/ # Agent 入口 +│ ├── internal/ # Agent 内部逻辑 +│ └── config/ # Agent 配置 +│ ├── docs/ # 设计文档 │ ├── PDD_PLAN.md # 产品设计文档 │ ├── ROADMAP.md # 路线图 @@ -56,7 +61,7 @@ aiops/ ### 前端 ```bash -cd app +cd frontend npm install npm run dev # 开发模式 http://localhost:5173 npm run build # 生产构建 @@ -71,10 +76,23 @@ go mod download go run cmd/main.go # http://localhost:8080 ``` +### Docker Compose 部署 + +```bash +# 一键启动全部服务 (前端 + 后端 + MySQL + Redis + Agent) +docker compose up -d + +# 查看服务状态 +docker compose ps + +# 停止服务 +docker compose down +``` + ### 默认账号 - 用户名: `admin` -- 密码: `admin123` +- 密码: `123456` ## 核心功能 @@ -82,11 +100,12 @@ go run cmd/main.go # http://localhost:8080 |------|------|--------| | 仪表盘 | 成本统计、告警聚合、Top资源、SSL提醒 | P0 | | 资产管理 | 服务器CRUD、批量导入、WebShell、云账号 | P0 | -| K8s集群 | 集群/节点/工作负载、拓扑可视化 | P0 | +| Kubernetes | 集群/节点/工作负载、拓扑可视化 | P0 | | 应用发布 | 流水线管理、发布审批、回滚 | P0 | -| 监控告警 | 指标监控、告警中心、日志、链路追踪 | P0 | -| AIOps智能 | AI助手、告警分析、脚本生成、故障诊断 | P0 | +| 监控中心 | 监控大屏、资源监控、链路追踪、告警中心、数据源 | P0 | | 自动化运维 | 定时任务、脚本库、Ansible | P1 | +| AIOps智能 | AI助手、Agent监控、告警分析、脚本生成、故障诊断 | P0 | +| 资源申请 | 资源申请审批、配额管理 | P1 | | 系统管理 | 用户权限、操作审计、系统设置 | P1 | ## License diff --git a/agent/collector/system.go b/agent/collector/system.go new file mode 100644 index 0000000..0fd4b5c --- /dev/null +++ b/agent/collector/system.go @@ -0,0 +1,69 @@ +// Package collector 实现 AIOps Agent 系统静态信息采集。 +// 仅在 Agent 启动时执行一次,采集 OS 版本、内核版本等信息。 +// +// 署名 Logan.Li +package collector + +import ( + "bufio" + "os" + "strings" +) + +// SystemInfo 包含系统静态信息(Agent 版本通过编译时注入,非运行时采集) +type SystemInfo struct { + OSVersion string // OS 发行版版本 (如 "Ubuntu 22.04.4 LTS") + Kernel string // 内核版本 (如 "Linux 6.8.0-90-generic") +} + +// CollectSystem 采集系统静态信息。 +// 从 /etc/os-release 和 /proc/version 读取(Linux 特定)。 +// 非 Linux 系统返回空值,不报错。 +func CollectSystem() SystemInfo { + info := SystemInfo{} + + // 读取 /etc/os-release 获取 PRETTY_NAME + info.OSVersion = readOSRelease() + + // 读取 /proc/version 获取内核版本 + info.Kernel = readProcVersion() + + return info +} + +// readOSRelease 从 /etc/os-release 提取 PRETTY_NAME +func readOSRelease() string { + f, err := os.Open("/etc/os-release") + if err != nil { + return "" + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "PRETTY_NAME=") { + // 去除前缀和引号: PRETTY_NAME="Ubuntu 22.04.4 LTS" + val := strings.TrimPrefix(line, "PRETTY_NAME=") + val = strings.Trim(val, `"`) + return val + } + } + return "" +} + +// readProcVersion 从 /proc/version 提取内核版本 +func readProcVersion() string { + data, err := os.ReadFile("/proc/version") + if err != nil { + return "" + } + // 格式: Linux version 6.8.0-90-generic (...) ... + line := strings.TrimSpace(string(data)) + // 提取 "Linux version X.Y.Z-..." + parts := strings.Fields(line) + if len(parts) >= 3 && parts[0] == "Linux" && parts[1] == "version" { + return parts[2] + } + return "" +} diff --git a/agent/main.go b/agent/main.go index a1c5e29..6f8ae58 100644 --- a/agent/main.go +++ b/agent/main.go @@ -10,6 +10,7 @@ import ( "context" "flag" "log" + "net" "os" "os/signal" "syscall" @@ -68,6 +69,17 @@ func main() { diskIOCollector := collector.NewDiskIOCollector() rep := reporter.New(*server, *token, logger) + // 采集系统静态信息(OS/内核),仅在启动时执行一次 + sysInfo := collector.CollectSystem() + logger.Printf("系统信息: OS=%q Kernel=%q", sysInfo.OSVersion, sysInfo.Kernel) + + // 探测本机主 IP(用于后端匹配 servers 表) + localIP := detectLocalIP() + logger.Printf("探测本机 IP: %s", localIP) + + // 系统信息上报计数器:前 3 次上报携带系统信息,确保后端至少收到一次 + sysReportCount := 0 + // 初始化任务执行器与任务客户端(与 reporter 复用相同的 server/token/hostname) taskExecutor := task.New(logger) taskClient := task.NewClient(*server, *token, hostname, logger) @@ -83,10 +95,20 @@ func main() { collectAndReport := func() { report := &model.Report{ Hostname: hostname, + IP: localIP, Timestamp: time.Now().UTC().Format(time.RFC3339), AgentVersion: version, } + // 前 3 次上报携带系统信息 + if sysReportCount < 3 && (sysInfo.OSVersion != "" || sysInfo.Kernel != "") { + report.System = &model.SystemStat{ + OSVersion: sysInfo.OSVersion, + Kernel: sysInfo.Kernel, + } + sysReportCount++ + } + if stat, err := cpuCollector.Collect(); err != nil { logger.Printf("CPU 采集异常: %v", err) } else { @@ -209,3 +231,34 @@ func maskToken(token string) string { } return token[:4] + "****" } + +// detectLocalIP 探测本机主 IP 地址。 +// 通过 UDP 连接到公网地址(不实际发包),获取系统路由选择的本机 IP。 +// 如果失败,回退到遍历网卡查找非 loopback 的 IPv4 地址。 +func detectLocalIP() string { + // 方法1:通过 UDP "连接" 到公网 DNS 获取出口 IP + conn, err := net.Dial("udp", "8.8.8.8:53") + if err == nil { + defer conn.Close() + if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok && addr.IP != nil { + return addr.IP.String() + } + } + + // 方法2:遍历网卡 + ifaces, err := net.Interfaces() + if err == nil { + for _, iface := range ifaces { + if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { + continue + } + addrs, _ := iface.Addrs() + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil { + return ipnet.IP.String() + } + } + } + } + return "unknown" +} diff --git a/agent/model/model.go b/agent/model/model.go index defa858..e257f1d 100644 --- a/agent/model/model.go +++ b/agent/model/model.go @@ -6,15 +6,17 @@ package model // Report 为单次采集周期的完整上报报文 type Report struct { - Hostname string `json:"hostname"` // 主机名 - Timestamp string `json:"timestamp"` // RFC3339 格式的 UTC 时间戳 - AgentVersion string `json:"agent_version"` // Agent 版本号 - CPU CPUStat `json:"cpu"` // CPU 指标 - Memory MemoryStat `json:"memory"` // 内存指标 - Disks []DiskStat `json:"disks"` // 磁盘指标列表 - Ports []PortStat `json:"ports"` // 监听端口列表 - Network NetworkStat `json:"network"` // 网络指标 - DiskIO DiskIOStat `json:"disk_io"` // 磁盘 IO 指标 + Hostname string `json:"hostname"` // 主机名 + IP string `json:"ip,omitempty"` // 本机 IP(Agent 启动时探测) + Timestamp string `json:"timestamp"` // RFC3339 格式的 UTC 时间戳 + AgentVersion string `json:"agent_version"` // Agent 版本号 + CPU CPUStat `json:"cpu"` // CPU 指标 + Memory MemoryStat `json:"memory"` // 内存指标 + Disks []DiskStat `json:"disks"` // 磁盘指标列表 + Ports []PortStat `json:"ports"` // 监听端口列表 + Network NetworkStat `json:"network"` // 网络指标 + DiskIO DiskIOStat `json:"disk_io"` // 磁盘 IO + System *SystemStat `json:"system,omitempty"` // 系统信息(首次采集后携带) } // CPUStat CPU 相关指标 diff --git a/backend/internal/api/handlers/agent.go b/backend/internal/api/handlers/agent.go index 36ae621..f3a6a24 100644 --- a/backend/internal/api/handlers/agent.go +++ b/backend/internal/api/handlers/agent.go @@ -74,7 +74,7 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { return } - // 同步更新 servers 表的实时指标(通过 hostname 匹配) + // 同步更新 servers 表的实时指标(三级匹配:hostname → client IP → hostname LIKE) diskAvg := avgDiskUsage(req.Disks) // 计算硬件规格 @@ -84,19 +84,48 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { } memMB := int64(req.Memory.TotalGB * 1024) // GB → MB - result := h.db.Model(&models.Server{}). - Where("hostname = ? OR ip = ?", req.Hostname, req.Hostname). - Updates(map[string]interface{}{ - "cpu_usage": req.CPU.UsagePercent, - "memory_usage": req.Memory.UsagePercent, - "disk_usage": diskAvg, - "cpu": req.CPU.CoreCount, - "memory": memMB, - "disk": int64(totalDiskGB), - "status": "running", - "agent_status": "online", - "agent_version": req.AgentVersion, - }) + clientIP := c.ClientIP() + + // 三级匹配:优先用 Agent 上报的 IP,其次用请求源 IP,最后用报文的 hostname + matchIP := req.IP + if matchIP == "" { + matchIP = clientIP + } + + // 直接 SQL 更新,避免 GORM soft delete 自动添加 deleted_at 条件干扰 + updateSQL := `UPDATE servers SET + cpu_usage = ?, memory_usage = ?, disk_usage = ?, + cpu = ?, memory = ?, disk = ?, + status = ?, agent_status = ?, agent_version = ?, + hostname = CASE WHEN hostname != ? AND ip = ? THEN ? ELSE hostname END, + updated_at = NOW()` + args := []interface{}{ + req.CPU.UsagePercent, req.Memory.UsagePercent, diskAvg, + req.CPU.CoreCount, memMB, int64(totalDiskGB), + "running", "online", req.AgentVersion, + // CASE WHEN hostname != ? AND ip = ? THEN ? ELSE hostname END + req.Hostname, matchIP, req.Hostname, + } + if req.System != nil && req.System.OSVersion != "" { + updateSQL += ", os = ?" + args = append(args, req.System.OSVersion) + } + updateSQL += " WHERE hostname = ? OR ip = ?" + args = append(args, req.Hostname, matchIP) + + result := h.db.Exec(updateSQL, args...) + + // 如果 hostname 和 IP 都没匹配到,尝试用 hostname 反向匹配 IP 字段 + if result.RowsAffected == 0 && req.Hostname != matchIP { + updateSQL2 := updateSQL + " OR ip = ?" + args2 := append(args, req.Hostname) + result = h.db.Exec(updateSQL2, args2...) + } + + // 如果还是没匹配到,记录日志(Agent 上报了,但服务器列表里没有对应条目) + if result.RowsAffected == 0 { + fmt.Printf("[agent] 上报主机 %q (IP: %s) 未匹配任何 servers 条目,指标仅写入 agent_reports 表\n", req.Hostname, matchIP) + } // WebSocket 广播:向所有前端客户端推送该主机的最新指标 // Logan.Li if h.hubMetrics != nil && result.RowsAffected > 0 { @@ -154,10 +183,11 @@ func (h *AgentHandler) writeHotMetric(c *gin.Context, key string, value float64, _ = h.cache.ZRemRangeByScore(ctx, key, "-inf", fmt.Sprintf("%.0f", cutoff)) } -// GetLatestReports 获取各主机最新上报 +// GetLatestReports 获取各主机最新上报(仅 3 分钟内活跃的 hostname) // GET /api/v1/agent/latest func (h *AgentHandler) GetLatestReports(c *gin.Context) { var results []models.AgentLatestResponse + threeMinAgo := time.Now().Add(-3 * time.Minute) h.db.Raw(` SELECT a.hostname, @@ -179,10 +209,13 @@ func (h *AgentHandler) GetLatestReports(c *gin.Context) { LEFT JOIN servers s ON s.hostname = a.hostname LEFT JOIN regions r ON r.id = s.region_id WHERE a.id IN ( - SELECT MAX(id) FROM agent_reports GROUP BY hostname + SELECT MAX(id) FROM agent_reports + WHERE report_time >= ? + GROUP BY hostname ) + AND s.id IS NOT NULL -- 只显示匹配 servers 表的主机(首次上线后自动同步 hostname) ORDER BY a.report_time DESC - `).Scan(&results) + `, threeMinAgo).Scan(&results) if results == nil { results = []models.AgentLatestResponse{} diff --git a/backend/internal/api/handlers/agent_task.go b/backend/internal/api/handlers/agent_task.go index 8a73983..8e42c35 100644 --- a/backend/internal/api/handlers/agent_task.go +++ b/backend/internal/api/handlers/agent_task.go @@ -152,6 +152,82 @@ func (h *AgentTaskHandler) GetTask(c *gin.Context) { // ===== Agent 告警规则 ===== +// QuickSetupRules POST /api/v1/agent/rules/quick-setup +// 一键创建默认告警规则(CPU/内存/磁盘/网络带宽),作用于所有在线主机 +func (h *AgentTaskHandler) QuickSetupRules(c *gin.Context) { + var req struct { + Type string `json:"type"` // "ecs" 或留空 + } + c.ShouldBindJSON(&req) + + // 获取所有在线主机 + var onlineHosts []string + h.db.Model(&models.Server{}). + Where("agent_status = ?", "online"). + Pluck("hostname", &onlineHosts) + + if len(onlineHosts) == 0 { + // 如果没有在线主机,尝试从 agent_reports 中获取最近 3 分钟活跃的主机名 + h.db.Model(&models.AgentReport{}). + Where("report_time >= ?", time.Now().Add(-3*time.Minute)). + Distinct("hostname"). + Pluck("hostname", &onlineHosts) + } + + if len(onlineHosts) == 0 { + c.JSON(400, models.Response{Code: 400, Message: "没有在线的主机,请先安装并启动 Agent"}) + return + } + + type ruleDef struct { + Name string + Metric string + Operator string + Threshold float64 + Duration int + } + + defaultRules := []ruleDef{ + {Name: "CPU使用率(Warn)", Metric: "cpu", Operator: ">", Threshold: 80}, + {Name: "内存使用率(Warn)", Metric: "mem", Operator: ">", Threshold: 80}, + {Name: "磁盘使用率(Warn)", Metric: "disk", Operator: ">", Threshold: 80}, + {Name: "网络带宽使用率(Warn)", Metric: "net", Operator: ">", Threshold: 80}, + } + + created := 0 + skipped := 0 + for _, host := range onlineHosts { + for _, def := range defaultRules { + // 检查是否已存在同名同主机的规则,避免重复创建 + var existing models.AgentRule + h.db.Where("name = ? AND hostname = ?", def.Name, host).First(&existing) + if existing.ID > 0 { + skipped++ + continue + } + + rule := models.AgentRule{ + Name: def.Name, + Hostname: host, + Metric: def.Metric, + Operator: def.Operator, + Threshold: def.Threshold, + Enabled: true, + } + if err := h.db.Create(&rule).Error; err != nil { + continue + } + created++ + } + } + + c.JSON(200, models.Response{ + Code: 200, + Message: fmt.Sprintf("已为 %d 台主机创建 %d 条规则(跳过 %d 条已存在的)", len(onlineHosts), created, skipped), + Data: gin.H{"hosts": len(onlineHosts), "created": created, "skipped": skipped}, + }) +} + // ListRules GET /api/v1/agent/rules func (h *AgentTaskHandler) ListRules(c *gin.Context) { var rules []models.AgentRule diff --git a/backend/internal/api/handlers/alert_engine.go b/backend/internal/api/handlers/alert_engine.go index 9db10e3..404f5df 100644 --- a/backend/internal/api/handlers/alert_engine.go +++ b/backend/internal/api/handlers/alert_engine.go @@ -95,36 +95,74 @@ func (e *AlertEngine) checkResourceAlerts() { } } -// 检查主机离线(超过 3 分钟无上报)— 内置规则,无需手动创建 +// 检查主机离线(超过 3 分钟无上报)— 内置规则,无需手动创建。 +// 以 servers 表为中心:遍历所有服务器,检查其 IP 上是否有 Agent 在最近 3 分钟内上报。 +// 如果同一 IP 下有多个 Agent hostname,只要有一个在活跃上报,该服务器就算在线。 func (e *AlertEngine) checkOfflineAlerts() { offlineThreshold := 3 * time.Minute - // 查所有曾上报的主机 - var hostnames []string - e.db.Model(&models.AgentReport{}).Distinct("hostname").Pluck("hostname", &hostnames) + // 获取所有已登记的主机(去重 IP) + var servers []models.Server + e.db.Select("ip, hostname").Group("ip").Find(&servers) - for _, hostname := range hostnames { - var report models.AgentReport - e.db.Where("hostname = ?", hostname).Order("id DESC").First(&report) - if report.ID == 0 { + // 收集所有 agent_reports 中活跃上报的主机名(最近 3 分钟内有上报) + threeMinAgo := time.Now().Add(-offlineThreshold) + + var activeHostnames []string + e.db.Model(&models.AgentReport{}). + Where("report_time >= ?", threeMinAgo). + Distinct("hostname"). + Pluck("hostname", &activeHostnames) + + // 对于每个已登记的服务器,检查是否有活跃 Agent + for _, s := range servers { + if s.IP == "" { continue } - isOffline := time.Since(report.ReportTime) > offlineThreshold + // 检查该 IP 对应的服务器是否有活跃 Agent 上报 + hasActiveAgent := false + for _, h := range activeHostnames { + // 通过 hostname 精确匹配或 servers 表 IP 匹配 + if h == s.Hostname { + hasActiveAgent = true + break + } + // 检查该 hostname 的最新上报——其源 IP 是否匹配此 server IP + // (通过 servers 表中查找 hostname 对应的 IP) + var matchedServer models.Server + e.db.Where("hostname = ?", h).First(&matchedServer) + if matchedServer.ID > 0 && matchedServer.IP == s.IP { + hasActiveAgent = true + break + } + } - // 检查是否有未恢复的离线告警 + if hasActiveAgent { + continue // 有活跃 Agent,在线 + } + + // 该服务器离线:检查是否已有未恢复的离线告警 var existing models.AgentAlert e.db.Where("hostname = ? AND metric = ? AND resolved = ?", - hostname, "offline", false).First(&existing) + s.Hostname, "offline", false).First(&existing) + + if existing.ID == 0 { + // 获取最后一次上报时间计算离线时长 + var lastReport models.AgentReport + e.db.Order("id DESC").First(&lastReport) + offlineMinutes := 3.0 + if lastReport.ID > 0 { + offlineMinutes = time.Since(lastReport.ReportTime).Minutes() + } - if isOffline && existing.ID == 0 { alert := models.AgentAlert{ RuleName: "主机离线", - Hostname: hostname, + Hostname: s.Hostname, Metric: "offline", Severity: "critical", - Message: fmt.Sprintf("主机 %s 已超过 3 分钟无数据上报,可能已离线", hostname), - Value: time.Since(report.ReportTime).Minutes(), + Message: fmt.Sprintf("主机 %s (%s) 已超过 3 分钟无数据上报,可能已离线", s.Hostname, s.IP), + Value: offlineMinutes, Threshold: 3, } e.db.Create(&alert) diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 80e1ba9..ce29f58 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -443,6 +443,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c // 告警规则 agentQuery.GET("/rules", taskHandler.ListRules) + agentQuery.POST("/rules/quick-setup", taskHandler.QuickSetupRules) agentQuery.POST("/rules", taskHandler.CreateRule) agentQuery.PUT("/rules/:id", taskHandler.UpdateRule) agentQuery.DELETE("/rules/:id", taskHandler.DeleteRule) diff --git a/backend/internal/models/agent.go b/backend/internal/models/agent.go index 8ba2f1e..713882b 100644 --- a/backend/internal/models/agent.go +++ b/backend/internal/models/agent.go @@ -54,6 +54,7 @@ type AgentLatestResponse struct { // AgentReportRequest Agent 上报请求体 // Logan.Li type AgentReportRequest struct { Hostname string `json:"hostname" binding:"required"` + IP string `json:"ip"` // Agent 探测的本机 IP Timestamp string `json:"timestamp"` AgentVersion string `json:"agent_version"` // Agent 版本号 CPU CPUMetric `json:"cpu"` @@ -62,6 +63,13 @@ type AgentReportRequest struct { Ports []PortMetric `json:"ports"` Network NetworkStat `json:"network"` // 网络指标 DiskIO DiskIOStat `json:"disk_io"` // 磁盘 IO + System *SystemStat `json:"system,omitempty"` // 系统信息(首次/变更时上报) +} + +// SystemStat 系统静态信息(OS/内核等)// Logan.Li +type SystemStat struct { + OSVersion string `json:"os_version"` // OS 版本 (如 "Ubuntu 22.04") + Kernel string `json:"kernel"` // 内核版本 } type CPUMetric struct { diff --git a/docs/README.md b/docs/README.md index 60d126a..8bb467c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ | [数据库设计](./database-schema.md) | 数据模型和表结构 | | [开发规范](./development-guide.md) | 代码规范、Git 工作流 | | [部署指南](./deployment.md) | 生产环境部署 | +| [开发日志](./dev-log.md) | 详细开发日志(按日期记录) | ## 设计文档 diff --git a/docs/api-reference.md b/docs/api-reference.md index 53bd0e0..10f5747 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -259,3 +259,404 @@ "git_commit": "unknown" } ``` + +## Agent 监控 + +### POST /agent/report — Agent 上报指标 (无需 JWT) + +Agent 向服务端上报主机性能指标数据。 + +**请求体:** +```json +{ + "hostname": "web-server-01", + "cpu": { + "usage_percent": 45.2, + "cores": 8 + }, + "memory": { + "total_gb": 16, + "used_gb": 8.5, + "usage_percent": 53.1 + }, + "disk": [ + { + "mount": "/", + "total_gb": 100, + "used_gb": 60, + "usage_percent": 60.0 + } + ], + "network": { + "rx_bytes": 1024000, + "tx_bytes": 512000 + }, + "uptime_seconds": 864000 +} +``` + +**响应:** +```json +{ + "code": 200, + "message": "上报成功", + "data": null +} +``` + +### GET /agent/latest — 各主机最新指标 (需 JWT) + +获取所有在线主机的最近一次上报指标。仅返回 3 分钟内活跃且匹配 servers 表的主机。 + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "hostname": "web-server-01", + "cpu": { "usage_percent": 45.2 }, + "memory": { "usage_percent": 53.1 }, + "disk": [{"usage_percent": 60.0}], + "network": { "rx_bytes": 1024000, "tx_bytes": 512000 }, + "uptime_seconds": 864000, + "reported_at": "2025-06-18T10:30:00Z" + } + ] +} +``` + +### GET /agent/history/:hostname — 历史趋势 (?hours=N) + +查询指定主机在最近 N 小时内的指标历史数据。 + +**查询参数:** + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `hours` | int | 24 | 查询最近多少小时的数据 | + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "hostname": "web-server-01", + "cpu": { "usage_percent": 45.2 }, + "memory": { "usage_percent": 53.1 }, + "reported_at": "2025-06-18T10:30:00Z" + } + ] +} +``` + +--- + +### Agent 告警规则 + +#### GET /agent/rules — 规则列表 + +获取所有 Agent 告警规则。 + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "id": 1, + "name": "CPU 使用率过高", + "metric": "cpu.usage_percent", + "condition": ">", + "threshold": 80, + "hostname": "*", + "enabled": true, + "created_at": "2025-06-18T10:00:00Z" + } + ] +} +``` + +#### POST /agent/rules — 创建规则 + +创建一条 Agent 告警规则。 + +**请求体:** +```json +{ + "name": "CPU 使用率过高", + "metric": "cpu.usage_percent", + "condition": ">", + "threshold": 80, + "hostname": "*" +} +``` + +#### POST /agent/rules/quick-setup — 一键创建默认规则 + +一键创建默认告警规则(CPU / 内存 / 磁盘 / 网络,阈值 80%),作用于所有在线主机。 + +**请求体:** 无(可选传空 JSON `{}`) + +**响应:** +```json +{ + "code": 200, + "message": "默认规则创建成功", + "data": { + "created": 4 + } +} +``` + +默认创建的规则包括: + +| 规则名称 | 指标 | 条件 | 阈值 | +|----------|------|------|------| +| CPU 使用率过高 | `cpu.usage_percent` | `>` | 80 | +| 内存使用率过高 | `memory.usage_percent` | `>` | 80 | +| 磁盘使用率过高 | `disk.usage_percent` | `>` | 80 | +| 网络带宽过高 | `network.total_bytes` | `>` | 80 | + +#### PUT /agent/rules/:id — 更新规则 + +更新指定告警规则。 + +#### DELETE /agent/rules/:id — 删除规则 + +删除指定告警规则。 + +--- + +### Agent 告警事件 + +#### GET /agent/alerts — 告警事件列表 + +获取 Agent 触发的告警事件列表。 + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": { + "list": [ + { + "id": 1, + "rule_id": 1, + "rule_name": "CPU 使用率过高", + "hostname": "web-server-01", + "metric": "cpu.usage_percent", + "value": 92.5, + "threshold": 80, + "status": "firing", + "fired_at": "2025-06-18T10:30:00Z", + "resolved_at": null + } + ], + "total": 1 + } +} +``` + +#### POST /agent/alerts/:id/resolve — 恢复告警 + +手动将指定告警事件标记为已恢复。 + +**响应:** +```json +{ + "code": 200, + "message": "告警已恢复", + "data": null +} +``` + +--- + +### Agent 远程任务 + +#### POST /agent/tasks — 下发远程任务 + +向指定主机下发远程任务(如执行脚本、重启服务等)。 + +**请求体:** +```json +{ + "hostname": "web-server-01", + "command": "systemctl restart nginx", + "description": "重启 Nginx 服务", + "timeout_seconds": 30 +} +``` + +**响应:** +```json +{ + "code": 200, + "message": "任务已下发", + "data": { + "task_id": "550e8400-e29b-41d4-a716-446655440000", + "status": "pending" + } +} +``` + +#### GET /agent/tasks — 任务列表 + +获取所有远程任务的列表(支持分页和筛选)。 + +**查询参数:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| `hostname` | string | 按主机名筛选 | +| `status` | string | 按状态筛选:`pending`、`running`、`success`、`failed` | +| `page` | int | 页码 (默认 1) | +| `page_size` | int | 每页数量 (默认 20) | + +#### GET /agent/tasks/:id — 任务详情 + +获取指定远程任务的详细信息及执行结果。 + +#### GET /agent/tasks/poll?hostname=xx — Agent 拉取任务 (无需 JWT) + +Agent 主动轮询拉取分配给自己的待执行任务。 + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "task_id": "550e8400-e29b-41d4-a716-446655440000", + "command": "systemctl restart nginx", + "timeout_seconds": 30 + } + ] +} +``` + +#### POST /agent/tasks/:task_id/result — Agent 回传结果 (无需 JWT) + +Agent 执行完任务后,回传执行结果。 + +**请求体:** +```json +{ + "status": "success", + "stdout": "nginx restarted successfully\n", + "stderr": "", + "exit_code": 0, + "duration_ms": 2500 +} +``` + +--- + +### Agent K线聚合 + +#### POST /agent/aggregate — 触发聚合 + +手动触发 K 线数据聚合任务(通常由定时任务自动执行)。 + +**响应:** +```json +{ + "code": 200, + "message": "聚合任务已触发", + "data": null +} +``` + +#### GET /agent/kline — 查询 K 线 + +查询主机性能指标的 K 线(聚合)数据。 + +**查询参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `hostname` | string | 否 | 主机名,不传则返回所有主机 | +| `metric` | string | 否 | 指标名,如 `cpu.usage_percent` | +| `interval` | string | 否 | 聚合间隔:`1m`、`5m`、`15m`、`1h`、`1d` | +| `from` | string | 否 | 起始时间 (ISO 8601) | +| `to` | string | 否 | 结束时间 (ISO 8601) | + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "hostname": "web-server-01", + "metric": "cpu.usage_percent", + "timestamp": "2025-06-18T10:00:00Z", + "open": 45.0, + "high": 92.5, + "low": 40.0, + "close": 55.0, + "avg": 53.2, + "count": 60 + } + ] +} +``` + +--- + +### Agent 二进制分发 (无需 JWT) + +提供 Agent 二进制文件及安装脚本的下载。 + +#### GET /agent/arch — 版本信息 + 下载链接 + +获取当前 Agent 版本信息及各架构对应的下载链接。 + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": { + "version": "v1.2.3", + "released_at": "2025-06-18", + "downloads": { + "linux_amd64": "/api/v1/agent/aiops-agent-linux-amd64", + "linux_arm64": "/api/v1/agent/aiops-agent-linux-arm64", + "darwin_arm64": "/api/v1/agent/aiops-agent-darwin-arm64" + }, + "install_script": "/api/v1/agent/install.sh" + } +} +``` + +#### GET /agent/aiops-agent-linux-amd64 — x86_64 二进制 + +下载 Linux x86_64 架构的 Agent 二进制文件。 + +响应为二进制流 (`Content-Type: application/octet-stream`)。 + +#### GET /agent/aiops-agent-linux-arm64 — ARM64 二进制 + +下载 Linux ARM64 架构的 Agent 二进制文件。 + +响应为二进制流 (`Content-Type: application/octet-stream`)。 + +#### GET /agent/aiops-agent-darwin-arm64 — macOS 二进制 + +下载 macOS Apple Silicon (ARM64) 架构的 Agent 二进制文件。 + +响应为二进制流 (`Content-Type: application/octet-stream`)。 + +#### GET /agent/install.sh — 一键安装脚本 + +下载 Agent 一键安装脚本。 + +响应为 Shell 脚本文本 (`Content-Type: text/plain`)。 diff --git a/docs/dev-log.md b/docs/dev-log.md index c948d41..30f16f2 100644 --- a/docs/dev-log.md +++ b/docs/dev-log.md @@ -1807,3 +1807,28 @@ cd ~/devops/aiops - 后端 `scripts/mock.go` 644 行(已废弃种子脚本),可考虑直接删除(mocks 已迁移到真实 API) - 前端 `services/api.ts` 752 行超 700 行硬上限,可按模块拆分(userApi/dashboardApi/serverApi... 各拆独立文件 barrel re-export),风险较高需谨慎 - 回到 dev-log 早期建议:用户消息气泡中【平台实时数据】块可折叠展示 + + +## 2026-06-18 Agent 监控指标上报修复 + 一键报警 + 品牌统一 + +### 诊断与修复 +- **Agent hostname 与 servers 表不匹配导致指标更新静默失败**: ReceiveReport 原 WHERE 两参数都填 agent hostname,hostname 不匹配任何 server → RowsAffected=0 → 静默失败(agent_reports 有数据但 servers 不更新) + - 修复: Agent 启动时探测本机 IP(net.Dial("udp"))在 JSON 中含 ip 字段;后端用 req.IP 作 OR ip 匹配 + - 修复: 改用 h.db.Exec() 原始 SQL 避免 GORM soft-delete 自动条件干扰 +- **Agent 未采集系统信息导致 servers.os 为空**: 新建 agent/collector/system.go(读 /etc/os-release PRETTY_NAME + /proc/version kernel),启动采集并前 3 次上报携带 +- **告警引擎离线检测以 agent_reports hostname 为中心而非 servers IP**: 同一服务器可能有多个 hostname 交替上报,旧 hostname 超 3 分钟触发离线告警但服务器实际在线 + - 修复: checkOfflineAlerts 改为以 servers IP 为中心 — 遍历服务器 IP,检查 3 分钟内是否有任何 agent 对应该 IP → 有则在线 +- **Agent latest API 过滤过期 hostname**: GetLatestReports SQL 添加 WHERE report_time >= NOW() - 3 MINUTE + AND s.id IS NOT NULL 双重过滤,监控大屏不再显示离线/过期主机 +- **主机名自动同步**: ReceiveReport 发现 hostname 不匹配但 IP 匹配时,CASE WHEN 自动更新 servers 表 hostname 字段 + +### 一键报警 +- 后端新增 POST /api/v1/agent/rules/quick-setup 接口 +- 自动检测所有 agent_status=online 的主机 +- 为每台主机创建 CPU使用率(Warn)/内存使用率(Warn)/磁盘使用率(Warn)/网络带宽使用率(Warn)4 条默认规则(阈值 80%) +- 前端 AlertCenter 规则列表从 agentApi.listRules() 拉取(之前调 alertCenterApi.getRules() 走的空路由) +- 规则切换/删除/编辑 全部对接 agentApi + +### 品牌统一 +- EZOps → AIOps 全局替换: frontend/backend/agent/scripts 全量扫描替换 +- 小写 ezops(Go module path/launchd plist/JWT secret)保留不改避免破坏编译/运行时 +- WebShell 欢迎语/Login 标题/LLM system prompt 全部统一为 AIOps diff --git a/docs/project-structure.md b/docs/project-structure.md index 2d9e032..bc2a2c5 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -116,10 +116,22 @@ aiops/ │ │ └── mock.go # Mock 数据生成 │ ├── config.yaml # 配置文件(SQLite 默认) │ ├── go.mod # Go 模块定义 -│ └── Dockerfile # Docker 构建 +│ ├── Dockerfile # Docker 构建 +│ +├── agent/ # 监控 Agent (Go, 独立 module ezops/agent) +│ ├── main.go # CLI 入口 (-server/-interval/-token/-hostname) +│ ├── collector/ # CPU/mem/disk/port/system 采集器 (/proc) +│ ├── model/model.go # 上报数据结构 (Report/CPUStat/MemStat/...) +│ ├── model/model_ext.go # 扩展类型 (NetworkStat/DiskIOStat/SystemStat) +│ ├── reporter/http.go # HTTP POST 上报 +│ ├── task/ # 远程任务执行 (云助手模式) +│ ├── deploy/ # systemd service + install.sh 一键安装 +│ ├── Makefile # 交叉编译 amd64+arm64+darwin-arm64 +│ └── bin/ # 编译产物 (gitignore) │ ├── docs/ # 开发文档 │ ├── README.md # 文档索引 +│ ├── dev-log.md # 开发日志 │ ├── project-structure.md # 本文件 │ ├── quick-start.md # 快速开始 │ ├── api-reference.md # API 接口 @@ -144,7 +156,7 @@ aiops/ | 技术 | 版本 | 用途 | |------|------|------| -| React | 18+ | 前端框架 | +| React | 19+ | 前端框架 | | TypeScript | 5+ | 类型安全 | | Vite | 7+ | 构建工具 | | Tailwind CSS | 3+ | 样式框架 | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c4e5215..7469223 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,6 +31,7 @@ import { AIPredictiveAnalytics } from '@/components/aiops/AIPredictiveAnalytics' import AlertCenter from '@/components/monitor/alert-center/AlertCenter'; import TraceManager from '@/components/monitor/trace/TraceManager'; import HostMonitorDashboard from '@/components/monitor/dashboard/HostMonitorDashboard'; +import GrafanaDashboard from '@/components/monitor/dashboard/GrafanaDashboard'; import { MonitorCenter } from '@/pages/MonitorCenter'; import { MetricsExplorer } from '@/pages/MetricsExplorer'; import DatasourcePage from '@/components/monitor/datasource/DatasourcePage'; @@ -72,11 +73,11 @@ function App() { } /> {/* 监控告警 */} } /> - } /> - } /> - } /> } /> + } /> + } /> } /> + } /> } /> } /> {/* 自动化运维 */} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 0d9d6a3..dc4c9cc 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -88,9 +88,8 @@ const menuItems: MenuItem[] = [ children: [ { id: 'grafana', name: '监控大屏', icon: Monitor, path: '/dashboards' }, { id: 'monitor-center', name: '资源监控', icon: Activity, path: '/monitor-center' }, - { id: 'metrics', name: '指标监控', icon: Cpu, path: '/metrics' }, { id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace' }, - { id: 'alert-center', name: '告警服务', icon: Bell, path: '/alert-center' }, + { id: 'alert-center', name: '告警中心', icon: Bell, path: '/alert-center' }, { id: 'datasources', name: '数据源', icon: Database, path: '/datasources' }, ], }, diff --git a/frontend/src/components/monitor/alert-center/AlertCenter.tsx b/frontend/src/components/monitor/alert-center/AlertCenter.tsx index 79f1b96..6228709 100644 --- a/frontend/src/components/monitor/alert-center/AlertCenter.tsx +++ b/frontend/src/components/monitor/alert-center/AlertCenter.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { AlertTriangle, AlertCircle, CheckCircle, Zap, Search, RefreshCw, - Eye, Check, VolumeX, + Eye, Check, VolumeX, Plus, Rocket, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -14,14 +14,17 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Switch } from '@/components/ui/switch'; import { toast } from 'sonner'; -import { alertCenterApi } from '@/services/api'; +import { useConfirm } from '@/components/ui/confirm-dialog'; +import { alertCenterApi, agentApi } from '@/services/api'; import { type Alert, type AlertRule, mapAlert, getSeverityBadge, getStatusBadge, } from './alert-center-helpers'; import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail'; import { AlertChannelsTab } from './AlertChannelsTab'; +import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog'; const AlertCenter: React.FC = () => { + const _confirm = useConfirm(); const [alerts, setAlerts] = useState([]); const [rules, setRules] = useState([]); const [loading, setLoading] = useState(false); @@ -33,6 +36,10 @@ const AlertCenter: React.FC = () => { const [detailOpen, setDetailOpen] = useState(false); const [silenceOpen, setSilenceOpen] = useState(false); const [stats, setStats] = useState({ firing: 0, critical: 0, warning: 0, resolved: 0 }); + // 规则弹框 + const [ruleDialogOpen, setRuleDialogOpen] = useState(false); + const [editingRule, setEditingRule] = useState(null); + const [quickSetupLoading, setQuickSetupLoading] = useState(false); useEffect(() => { fetchData(); @@ -67,8 +74,19 @@ const AlertCenter: React.FC = () => { const fetchRules = async () => { try { - const res = await alertCenterApi.getRules(); - setRules(res.list || []); + const res = await agentApi.listRules(); + // agentApi.listRules returns {code, data: AgentRule[]} — map to AlertRule for UI + const raw = (Array.isArray(res) ? res : (res.list || [])) as any[]; + setRules(raw.map(r => ({ + id: r.id, + name: r.name, + description: `${r.metric} ${r.operator} ${r.threshold}%`, + enabled: r.enabled, + severity: 'warning', + type: r.metric, + hostname: r.hostname || '全部主机', + threshold: r.threshold, + })) as AlertRule[]); } catch { toast.error('获取告警规则失败'); } @@ -107,7 +125,7 @@ const AlertCenter: React.FC = () => { const toggleRule = async (ruleId: string, enabled: boolean) => { try { - await alertCenterApi.toggleRule(ruleId); + await agentApi.updateRule(Number(ruleId), { enabled }); setRules(rules.map(r => String(r.id) === ruleId ? { ...r, enabled } : r)); toast.success('规则状态已更新'); } catch { @@ -115,6 +133,33 @@ const AlertCenter: React.FC = () => { } }; + const handleOpenAddRule = () => { setEditingRule(null); setRuleDialogOpen(true); }; + const handleEditRule = (rule: AlertRule) => { setEditingRule(rule); setRuleDialogOpen(true); }; + const handleRuleDialogClose = () => { setRuleDialogOpen(false); setEditingRule(null); }; + + const handleQuickSetup = async () => { + const ok = await _confirm({ + title: '一键报警', + description: '将为 Agent 在线的全部主机创建 CPU使用率、内存使用率、磁盘使用率、网络带宽使用率 四条默认告警规则(阈值 80%,连续 5 次触发)。', + confirmText: '确认创建', + }); + if (!ok) return; + setQuickSetupLoading(true); + try { + const res = await agentApi.quickSetup('ecs'); + toast.success(typeof res === 'string' ? res : (res?.message || '默认告警规则创建成功')); + fetchRules(); + } catch { + toast.error('创建失败,请检查 Agent 主机是否在线'); + } finally { setQuickSetupLoading(false); } + }; + + const handleDeleteRule = async (rule: AlertRule) => { + const ok = await _confirm({ title: '删除规则', description: `确定删除规则「${rule.name}」?`, confirmText: '删除', variant: 'destructive' }); + if (!ok) return; + try { await agentApi.deleteRule(Number(rule.id)); toast.success('已删除'); fetchRules(); } catch { toast.error('删除失败'); } + }; + const filteredAlerts = alerts.filter(alert => { const matchSearch = alert.title.toLowerCase().includes(searchQuery.toLowerCase()) || alert.description.toLowerCase().includes(searchQuery.toLowerCase()) || @@ -311,11 +356,27 @@ const AlertCenter: React.FC = () => { {/* 告警规则 Tab */}
+
+ + +
+ {rules.length === 0 ? ( +
+ +

暂无告警规则

+

点击「添加规则」或「一键报警」快速创建

+
+ ) : (
{rules.map((rule) => ( @@ -353,6 +414,8 @@ const AlertCenter: React.FC = () => { })()}
+ + toggleRule(String(rule.id), checked)} @@ -363,6 +426,7 @@ const AlertCenter: React.FC = () => { ))}
+ )}
{/* 通知渠道 Tab — 自包含子组件 */} @@ -383,6 +447,13 @@ const AlertCenter: React.FC = () => { onOpenChange={setSilenceOpen} onSilence={handleSilence} /> + { handleRuleDialogClose(); fetchRules(); }} + /> ); }; diff --git a/frontend/src/components/monitor/dashboard/GrafanaDashboard.tsx b/frontend/src/components/monitor/dashboard/GrafanaDashboard.tsx index 19edf9c..1152f08 100644 --- a/frontend/src/components/monitor/dashboard/GrafanaDashboard.tsx +++ b/frontend/src/components/monitor/dashboard/GrafanaDashboard.tsx @@ -1,751 +1,249 @@ +// 监控大屏 — 标签页切换 Agent / Zabbix / Prometheus + 自定义仪表盘 // Logan.Li import React, { useState, useEffect } from 'react'; import { - Monitor, Settings, Plus, Edit, Trash2, Copy, Eye, Download, RefreshCw, - Maximize2, Minimize2, Save, X, ChevronDown, BarChart3, LineChart, PieChart, - Activity, Cpu, Database, Server, Globe, Zap, AlertTriangle, TrendingUp, HardDrive, - TrendingDown, Clock, Calendar, Filter, Search, ArrowUpDown, ArrowUp, ArrowDown, - Wifi, WifiOff + Monitor, Plus, RefreshCw, Maximize2, Minimize2, BarChart3, Clock, + Server, Activity, Database, Wifi, WifiOff, ArrowUpDown, ArrowUp, ArrowDown, + ExternalLink } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { - Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue -} from '@/components/ui/select'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; -import { Separator } from '@/components/ui/separator'; import { toast } from 'sonner'; -import { useConfirm } from '@/components/ui/confirm-dialog'; -import { dashboardApi } from '@/services/api'; +import { dashboardApi, promDatasourceApi, zabbixDatasourceApi } from '@/services/api'; import { GrafanaPanelContent } from './GrafanaPanelContent'; import { ZabbixDashboard } from './ZabbixDashboard'; import { PrometheusDashboard } from './PrometheusDashboard'; import { http } from '@/services/http'; import { asList } from '@/components/agent/agentUtils'; -interface Dashboard { - id: string; - name: string; - description: string; - datasource: 'prometheus' | 'zabbix' | 'skywalking' | 'custom'; - datasourceUrl: string; - panels: Panel[]; - createdAt: string; - updatedAt: string; - createdBy: string; - isDefault: boolean; -} - -export interface Panel { - id: string; - title: string; - type: 'graph' | 'stat' | 'gauge' | 'table' | 'pie' | 'heatmap' | 'text'; - query: string; - datasource: string; - width: number; - height: number; - x: number; - y: number; - options: PanelOptions; -} - -interface PanelOptions { - unit?: string; - min?: number; - max?: number; - thresholds?: Threshold[]; - legend?: boolean; - tooltip?: boolean; - refreshInterval?: number; -} - -interface Threshold { - value: number; - color: string; - name: string; -} - -interface Datasource { - id: string; - name: string; - type: 'prometheus' | 'zabbix' | 'skywalking' | 'influxdb' | 'elasticsearch'; - url: string; - status: 'connected' | 'disconnected' | 'error'; -} - -// Agent 主机完整信息(对齐后端 AgentLatestResponse) -interface AgentHost { - hostname: string; - ip: string; - cpu_usage: number; - cpu_cores: number; - mem_usage: number; - mem_total: number; - mem_used: number; - disk_usage: number; - disk_info: string; - net_info: string; - report_time: string; - agent_version: string; - agent_status: string; - os: string; - region: string; -} +// ═══════════ Tab 配置 ═══════════ +type TabId = 'agent' | 'zabbix' | 'prometheus' | 'dashboard'; +const TABS: { id: TabId; label: string; icon: React.ReactNode }[] = [ + { id: 'agent', label: 'Agent 主机', icon: }, + { id: 'zabbix', label: 'Zabbix', icon: }, + { id: 'prometheus', label: 'Prometheus', icon: }, + { id: 'dashboard', label: '自定义', icon: }, +]; +// ═══════════ Agent 主机监控表格 ═══════════ +interface AgentHost { hostname: string; ip: string; cpu_usage: number; cpu_cores: number; mem_usage: number; mem_total: number; mem_used: number; disk_usage: number; agent_version: string; agent_status: string; os: string; region: string; } type SortField = 'hostname' | 'agent_version' | 'agent_status' | 'cpu_usage' | 'mem_usage' | 'disk_usage' | 'region' | 'ip'; -const GrafanaDashboard: React.FC = () => { - const _confirm = useConfirm(); - const [dashboards, setDashboards] = useState([]); - const [datasources, setDatasources] = useState([]); - const [activeDashboard, setActiveDashboard] = useState(null); - const [loading, setLoading] = useState(false); - - const [fullscreen, setFullscreen] = useState(false); - const [createOpen, setCreateOpen] = useState(false); - const [addPanelOpen, setAddPanelOpen] = useState(false); - const [datasourceOpen, setDatasourceOpen] = useState(false); - - const [newDashboard, setNewDashboard] = useState({ - name: '', - description: '', - datasource: 'prometheus' as const, - datasourceUrl: '' - }); - - const [newPanel, setNewPanel] = useState({ - title: '', - type: 'graph' as const, - query: '', - width: 6, - height: 4 - }); - - const [newDatasource, setNewDatasource] = useState({ - name: '', - type: 'prometheus' as const, - url: '' - }); - - // ==================== Agent 主机监控 ==================== +const AgentTab: React.FC = () => { const [hosts, setHosts] = useState([]); - const [hostsLoading, setHostsLoading] = useState(true); - const [sortField, setSortField] = useState('cpu_usage'); - const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); - - const fetchHosts = async () => { - try { - const data = await http.get('/agent/latest'); - setHosts(asList(data)); - } catch { /* 后端未就绪时静默 */ } - finally { setHostsLoading(false); } - }; - - useEffect(() => { - fetchHosts(); - const timer = setInterval(fetchHosts, 30000); - return () => clearInterval(timer); - }, []); - - // 排序 - const toggleSort = (field: SortField) => { - if (sortField === field) { - setSortDir(d => d === 'asc' ? 'desc' : 'asc'); - } else { - setSortField(field); - setSortDir('desc'); - } - }; - - const sortedHosts = [...hosts].sort((a, b) => { - let va: any, vb: any; - if (sortField === 'agent_status') { - va = a.agent_status || ''; - vb = b.agent_status || ''; - } else { - va = (a as any)[sortField] ?? ''; - vb = (b as any)[sortField] ?? ''; - } - if (typeof va === 'number' && typeof vb === 'number') { - return sortDir === 'asc' ? va - vb : vb - va; - } - return sortDir === 'asc' - ? String(va).localeCompare(String(vb)) - : String(vb).localeCompare(String(va)); + const [loading, setLoading] = useState(true); + const [sf, setSf] = useState('cpu_usage'); + const [sd, setSd] = useState<'asc' | 'desc'>('desc'); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const totalPages = Math.max(1, Math.ceil(hosts.length / pageSize)); + const fetch = async () => { try { setHosts(asList(await http.get('/agent/latest'))); } catch {} finally { setLoading(false); } }; + useEffect(() => { fetch(); const t = setInterval(fetch, 30000); return () => clearInterval(t); }, []); + const toggle = (f: SortField) => { if (sf === f) setSd(d => d === 'asc' ? 'desc' : 'asc'); else { setSf(f); setSd('desc'); } }; + const sorted = [...hosts].sort((a, b) => { + let va: any = (a as any)[sf] ?? '', vb: any = (b as any)[sf] ?? ''; + if (sf === 'agent_status') { va = a.agent_status || ''; vb = b.agent_status || ''; } + if (typeof va === 'number') return sd === 'asc' ? va - vb : vb - va; + return sd === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va)); }); - - const SortIcon = ({ field }: { field: SortField }) => { - if (sortField !== field) return ; - return sortDir === 'asc' ? : ; - }; - - const usageBar = (val: number, label: string) => { - const pct = Math.min(val, 100); - const isWarn = val >= 80; - const barColor = isWarn ? 'bg-orange-500' : 'bg-emerald-500'; - const textColor = isWarn ? 'text-orange-400' : ''; - return ( -
-
-
+ const paged = sorted.slice((page - 1) * pageSize, page * pageSize); + const SI = ({ f }: { f: SortField }) => sf !== f ? : sd === 'asc' ? : ; + const bar = (v: number, l: string) => ( +
= 80 ? 'bg-orange-500' : 'bg-emerald-500'}`} style={{ width: `${Math.min(v, 100)}%` }} />
= 80 ? 'text-orange-400' : ''}`}>{l}
+ ); + if (loading) return

加载中…

; + if (!hosts.length) return

暂无 Agent 上报数据

请确保至少一台主机安装了 AIOps Agent

; + return ( +
+ + + {['hostname','agent_version','agent_status','cpu_usage','mem_usage','disk_usage','region','ip'].map(f => ( + + ))} + + {paged.map((h, i) => ( + + + + + + + + + + + ))} + +
toggle(f as SortField)}> +
{{hostname:'主机名',agent_version:'Agent版本',agent_status:'状态',cpu_usage:'CPU',mem_usage:'内存',disk_usage:'磁盘',region:'区域',ip:'IP'}[f]}
+
{h.hostname}{h.agent_version || '—'}{h.agent_status==='online'?<>在线:<>离线}{bar(Number(h.cpu_usage||0), `${Number(h.cpu_usage||0).toFixed(1)}%`)}{bar(Number(h.mem_usage||0), `${Number(h.mem_usage||0).toFixed(1)}%`)}{bar(Number(h.disk_usage||0), `${Number(h.disk_usage||0).toFixed(1)}%`)}{h.region||'—'}{h.ip||'—'}
+ {hosts.length > pageSize && ( +
+
+ 共 {hosts.length} 条 + +
+
+ + {page} / {totalPages} + +
- - {label} - -
- ); - }; + )} +
+ ); +}; - // ==================== 仪表盘 CRUD ==================== +// ═══════════ 数据源 Wrapper ═══════════ +const ZabbixTab: React.FC = () => { + const [ds, setDs] = useState(null); + const [loading, setLoading] = useState(true); useEffect(() => { - fetchDashboards(); - fetchDatasources(); + zabbixDatasourceApi.list().then((r: any) => { + const list = r?.data || r || []; + setDs(list.find((d: any) => d.is_default) || list[0] || null); + }).catch(() => {}).finally(() => setLoading(false)); }, []); + if (loading) return

加载数据源…

; + if (!ds) return

未配置 Zabbix 数据源

前往配置
; + return ; +}; - const fetchDashboards = async () => { - setLoading(true); +const PrometheusTab: React.FC = () => { + const [ds, setDs] = useState(null); + const [loading, setLoading] = useState(true); + useEffect(() => { + promDatasourceApi.list().then((r: any) => { + const list = r?.data || r || []; + setDs(list.find((d: any) => d.is_default) || list[0] || null); + }).catch(() => {}).finally(() => setLoading(false)); + }, []); + if (loading) return

加载数据源…

; + if (!ds) return

未配置 Prometheus 数据源

前往配置
; + return ; +}; + +// ═══════════ 自定义仪表盘 ═══════════ +interface Dashboard { id: string; name: string; description: string; datasource: string; datasourceUrl: string; panels: Panel[]; createdAt: string; updatedAt: string; createdBy: string; isDefault: boolean; } +export interface Panel { id: string; title: string; type: string; query: string; datasource: string; width: number; height: number; x: number; y: number; options: any; } + +const DashboardTab: React.FC = () => { + const [dashboards, setDashboards] = useState([]); + const [active, setActive] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [addOpen, setAddOpen] = useState(false); + const [nd, setNd] = useState({ name: '', description: '', datasource: 'prometheus' as const, datasourceUrl: '' }); + const [np, setNp] = useState({ title: '', type: 'graph' as const, query: '', width: 6, height: 4 }); + + const fetch = async () => { + try { const res = await dashboardApi.getDashboards(); const list = res.data || []; setDashboards(list); if (list.length > 0 && !active) setActive(list[0]); } catch {} + }; + useEffect(() => { fetch(); }, []); + + const create = async () => { + if (!nd.name) { toast.error('请输入名称'); return; } + try { await dashboardApi.createDashboard({ ...nd, panels: [] }); toast.success('创建成功'); setCreateOpen(false); setNd({ name: '', description: '', datasource: 'prometheus', datasourceUrl: '' }); fetch(); } catch { toast.error('创建失败'); } + }; + const addPanel = async () => { + if (!np.title || !np.query || !active) return; try { - const res = await dashboardApi.getDashboards(); - setDashboards(res.data || []); - if (res.data?.length > 0 && !activeDashboard) { - setActiveDashboard(res.data[0]); - } - } catch (error) { - toast.error('获取仪表盘失败'); - } finally { - setLoading(false); - } - }; - - const fetchDatasources = async () => { - try { - const res = await dashboardApi.getDatasources(); - setDatasources(res.data || []); - } catch (error) { - toast.error('获取数据源失败'); - } - }; - - const handleCreateDashboard = async () => { - if (!newDashboard.name) { - toast.error('请输入仪表盘名称'); - return; - } - try { - await dashboardApi.createDashboard({ - ...newDashboard, - panels: [] - }); - toast.success('仪表盘创建成功'); - setCreateOpen(false); - setNewDashboard({ name: '', description: '', datasource: 'prometheus', datasourceUrl: '' }); - fetchDashboards(); - } catch (error) { - toast.error('创建失败'); - } - }; - - const handleAddPanel = async () => { - if (!newPanel.title || !newPanel.query || !activeDashboard) { - toast.error('请填写完整信息'); - return; - } - try { - const panel: Panel = { - id: Date.now().toString(), - ...newPanel, - datasource: activeDashboard.datasource, - x: 0, - y: 0, - options: { - unit: '', - legend: true, - tooltip: true, - refreshInterval: 30 - } - }; - await dashboardApi.updateDashboard(activeDashboard.id, { - ...activeDashboard, - panels: [...activeDashboard.panels, panel] - }); - toast.success('面板添加成功'); - setAddPanelOpen(false); - setNewPanel({ title: '', type: 'graph', query: '', width: 6, height: 4 }); - fetchDashboards(); - } catch (error) { - toast.error('添加失败'); - } - }; - - const handleAddDatasource = async () => { - if (!newDatasource.name || !newDatasource.url) { - toast.error('请填写完整信息'); - return; - } - try { - await dashboardApi.createDatasource(newDatasource); - toast.success('数据源添加成功'); - setDatasourceOpen(false); - setNewDatasource({ name: '', type: 'prometheus', url: '' }); - fetchDatasources(); - } catch (error) { - toast.error('添加失败'); - } - }; - - const handleDeleteDashboard = async (id: string) => { - { const ok = await _confirm({ title: '删除仪表盘', description: '确定删除此仪表盘吗?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; } - try { - await dashboardApi.deleteDashboard(id); - toast.success('删除成功'); - if (activeDashboard?.id === id) { - setActiveDashboard(null); - } - fetchDashboards(); - } catch (error) { - toast.error('删除失败'); - } - }; - - const getDatasourceIcon = (type: string) => { - switch (type) { - case 'prometheus': return ; - case 'zabbix': return ; - case 'skywalking': return ; - case 'influxdb': return ; - default: return ; - } - }; - - const getPanelIcon = (type: string) => { - switch (type) { - case 'graph': return ; - case 'stat': return ; - case 'gauge': return ; - case 'pie': return ; - case 'table': return ; - default: return ; - } + const p: Panel = { id: Date.now().toString(), ...np, datasource: active.datasource, x: 0, y: 0, options: { legend: true, tooltip: true, refreshInterval: 30 } }; + await dashboardApi.updateDashboard(active.id, { ...active, panels: [...active.panels, p] }); + toast.success('面板添加成功'); setAddOpen(false); setNp({ title: '', type: 'graph', query: '', width: 6, height: 4 }); fetch(); + } catch { toast.error('添加失败'); } }; return ( -
+
+
+ + + {active && } + +
- {/* ===== 顶部工具栏 ===== */} -
-
- setNd({ ...nd, name: e.target.value })} />
+
+ + + 添加面板 +
setNp({ ...np, title: e.target.value })} />
+
+
setNp({ ...np, query: e.target.value })} />
+
+
+ ); +}; + +// ═══════════ 主组件 ═══════════ +const GrafanaDashboard: React.FC = () => { + const [tab, setTab] = useState('agent'); + const [fullscreen, setFullscreen] = useState(false); + + return ( +
+ + {/* Tab 切换栏 */} +
+ {TABS.map(t => ( +
- -
- - - {activeDashboard && ( - <> - - - )} - - - - - - + ))} +
+
- {/* ===== 仪表盘内容 ===== */} - {activeDashboard ? ( -
- {/* 仪表盘信息 */} -
-
-

{activeDashboard.name}

-

{activeDashboard.description}

-
-
- - {getDatasourceIcon(activeDashboard.datasource)} - {activeDashboard.datasource} - -
-
- - {/* ===== Agent 主机监控 — 默认表格面板 ===== */} - - -
- - Agent 主机监控 - 默认 - 每 30s 刷新 -
-
- - {hostsLoading ? ( -
- -

加载中…

-
- ) : hosts.length === 0 ? ( -
- -

暂无 Agent 上报数据

-

请确保至少一台主机安装了 AIOps Agent 并正常上报

-
- ) : ( -
- - - - {[ - { field: 'hostname', label: '主机名' }, - { field: 'agent_version', label: 'Agent 版本' }, - { field: 'agent_status', label: '状态' }, - { field: 'cpu_usage', label: 'CPU 使用率' }, - { field: 'mem_usage', label: '内存使用率' }, - { field: 'disk_usage' as SortField, label: '磁盘使用率' }, - { field: 'region', label: '所在区域' }, - { field: 'ip', label: 'IP 地址' }, - ].map(({ field, label }) => ( - - ))} - - - - {sortedHosts.map((host, i) => { - const isOnline = host.agent_status === 'online'; - return ( - - - - - - - - - - - ); - })} - -
toggleSort(field as SortField)} - > -
- {label} - -
-
- {host.hostname} - - {host.agent_version || '—'} - - - {isOnline ? : } - {isOnline ? '在线' : '离线'} - - - {usageBar(Number(host.cpu_usage || 0), `${Number(host.cpu_usage || 0).toFixed(1)}%`)} - - {usageBar(Number(host.mem_usage || 0), `${Number(host.mem_usage || 0).toFixed(1)}%`)} - - {usageBar(Number(host.disk_usage || 0), `${Number(host.disk_usage || 0).toFixed(1)}%`)} - - {host.region || '—'} - - {host.ip || '—'} -
-
- )} -
-
- - {/* Zabbix 数据源面板 */} - - - {/* Prometheus 数据源面板 */} - - - {/* 面板网格 */} -
- {activeDashboard.panels.map((panel) => ( - - -
- - {getPanelIcon(panel.type)} - {panel.title} - -
-
- - - -
- ))} - - {activeDashboard.panels.length === 0 && ( -
- -

暂无面板,点击"添加面板"开始创建

-
- )} -
-
- ) : ( -
- -

请选择一个仪表盘或创建新的仪表盘

- -
- )} - - {/* 新建仪表盘对话框 */} - - - - 新建仪表盘 - 创建一个新的监控仪表盘 - -
-
- - setNewDashboard({ ...newDashboard, name: e.target.value })} - placeholder="仪表盘名称" - /> -
-
- - setNewDashboard({ ...newDashboard, description: e.target.value })} - placeholder="仪表盘描述" - /> -
-
- - -
-
- - setNewDashboard({ ...newDashboard, datasourceUrl: e.target.value })} - placeholder="http://prometheus:9090" - /> -
-
- - - - -
-
- - {/* 添加面板对话框 */} - - - - 添加面板 - 向当前仪表盘添加一个监控面板 - -
-
- - setNewPanel({ ...newPanel, title: e.target.value })} - placeholder="CPU 使用率" - /> -
-
- - -
-
- - setNewPanel({ ...newPanel, query: e.target.value })} - placeholder="rate(node_cpu_seconds_total[5m])" - /> -
-
- - - - -
-
- - {/* 数据源对话框 */} - - - - 管理数据源 - 配置 Prometheus / Zabbix / SkyWalking 等外部数据源连接 - -
- {datasources.length === 0 ? ( -

暂无数据源配置

- ) : ( - datasources.map(ds => ( -
-
- {getDatasourceIcon(ds.type)} -
-

{ds.name}

-

{ds.url}

-
-
- - {ds.status === 'connected' ? '已连接' : ds.status === 'error' ? '错误' : '已断开'} - -
- )) - )} -
- -
-
- - setNewDatasource({ ...newDatasource, name: e.target.value })} - placeholder="My Prometheus" - /> -
-
- - -
-
- - setNewDatasource({ ...newDatasource, url: e.target.value })} - placeholder="http://prometheus:9090" - /> -
-
- - - - -
-
+ {/* Tab 内容 */} +
+ {tab === 'agent' && } + {tab === 'zabbix' && } + {tab === 'prometheus' && } + {tab === 'dashboard' && } +
); }; diff --git a/frontend/src/components/monitor/dashboard/PrometheusDashboard.tsx b/frontend/src/components/monitor/dashboard/PrometheusDashboard.tsx index 9b94b44..c00c246 100644 --- a/frontend/src/components/monitor/dashboard/PrometheusDashboard.tsx +++ b/frontend/src/components/monitor/dashboard/PrometheusDashboard.tsx @@ -1,356 +1,41 @@ -// Prometheus 数据源监控面板 — K8s / Node / Pod / JVM // Logan.Li +// Prometheus 数据源面板 — 接受外部配置 // Logan.Li import React, { useState, useEffect } from 'react'; -import { - Server, Loader2, Activity, Cpu, HardDrive, Network, RefreshCw, - Plug, Settings, X, CheckCircle2, AlertTriangle, Container, - BarChart3, Globe, Zap, TrendingUp, TrendingDown -} from 'lucide-react'; +import { Server, Loader2, Activity, Cpu, RefreshCw, AlertTriangle, Container, Zap, ExternalLink } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Label } from '@/components/ui/label'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { toast } from 'sonner'; import { http } from '@/services/http'; -const LS_KEY = 'aiops_prometheus_ds'; +interface Props { dsUrl?: string; } -function getSavedDS(): { url: string } | null { - try { - const raw = localStorage.getItem(LS_KEY); - if (raw) return JSON.parse(raw); - } catch {} - return null; -} -function saveDS(ds: { url: string }) { localStorage.setItem(LS_KEY, JSON.stringify(ds)); } - -// 小进度条组件 const MiniBar = ({ val, label, color }: { val: number; label: string; color?: string }) => { const pct = Math.min(val, 100); - const c = color || (val >= 80 ? 'bg-orange-500' : 'bg-purple-500'); return (
-
+
- {label} + {label}
); }; -const fmtBps = (bps: number): string => { - if (!bps || bps === 0) return '—'; - if (bps > 1e9) return `${(bps / 1e9).toFixed(1)} GB/s`; - if (bps > 1e6) return `${(bps / 1e6).toFixed(1)} MB/s`; - if (bps > 1e3) return `${(bps / 1e3).toFixed(1)} KB/s`; - return `${bps.toFixed(1)} B/s`; -}; - -const fmtBytes = (b: number): string => { - if (!b || b === 0) return '—'; +const fmtBytes = (b: number) => { + if (!b) return '—'; if (b > 1e9) return `${(b / 1e9).toFixed(1)} GB`; if (b > 1e6) return `${(b / 1e6).toFixed(1)} MB`; - if (b > 1e3) return `${(b / 1e3).toFixed(1)} KB`; - return `${b.toFixed(1)} B`; + return `${(b / 1e3).toFixed(1)} KB`; +}; +const fmtBps = (b: number) => { + if (!b) return '—'; + if (b > 1e9) return `${(b / 1e9).toFixed(1)} GB/s`; + if (b > 1e6) return `${(b / 1e6).toFixed(1)} MB/s`; + if (b > 1e3) return `${(b / 1e3).toFixed(1)} KB/s`; + return `${b.toFixed(1)} B/s`; }; -// ============================================ -// K8s 面板 -// ============================================ -const K8sPanel: React.FC<{ data: any }> = ({ data }) => ( -
-
- - -
- {data.podCount?.[0]?.value || 0} -
-
Pods
-
-
- - -
- {data.deployCount?.[0]?.value || 0} -
-
Deployments
-
-
- - -
- {data.nodeCount?.[0]?.value || 0} -
-
Nodes
-
-
- - -
- {data.nsPods?.length || 0} -
-
Namespaces
-
-
-
- - {/* Node CPU / Memory 利用率 */} -
-
节点资源利用率
-
- {(data.nodeCPU || []).map((n: any, i: number) => { - const mem = (data.nodeMem || []).find((m: any) => m.node === n.node); - return ( -
- {n.node} - - -
- ); - })} -
-
-
-); - -// ============================================ -// Node 面板 -// ============================================ -const NodePanel: React.FC<{ data: any }> = ({ data }) => { - const cpuItems = data.cpuUsage || []; - const memItems = data.memUsage || []; - const diskItems = data.diskUsage || []; - const upItems = data.up || []; - const netRx = data.netRx || []; - const netTx = data.netTx || []; - - return ( -
- - - - - - - - - - - - - - {(data.nodes || []).map((n: any, i: number) => { - const instance = n.instance || n.exported_instance || ''; - const cpu = cpuItems.find((c: any) => c.instance === instance); - const mem = memItems.find((m: any) => m.instance === instance); - const disk = diskItems.find((d: any) => d.instance === instance); - const up = upItems.find((u: any) => u.instance === instance); - const rx = netRx.find((r: any) => r.instance === instance); - const tx = netTx.find((t: any) => t.instance === instance); - const isUp = up?.value === 1; - return ( - - - - - - - - - - ); - })} - -
节点状态CPU内存磁盘流入流出
- {n.nodename || instance} - - - {isUp ? 'UP' : 'DOWN'} - - {fmtBps(Number(rx?.value || 0))}{fmtBps(Number(tx?.value || 0))}
-
- ); -}; - -// ============================================ -// Pod 面板 -// ============================================ -const PodPanel: React.FC<{ data: any }> = ({ data }) => { - const cpuItems = data.cpuTop || []; - const memItems = data.memTop || []; - const running = data.running?.[0]?.value || 0; - const pending = data.pending?.[0]?.value || 0; - return ( -
-
-
{running}
Running
-
{pending}
Pending
-
-
-
CPU Top 10
-
- {cpuItems.map((p: any, i: number) => ( -
- {i + 1} - {p.pod} - -
- ))} -
-
-
-
内存 Top 10
-
- {memItems.map((p: any, i: number) => ( -
- {i + 1} - {p.pod} - {fmtBytes(Number(p.value || 0))} -
- ))} -
-
-
- ); -}; - -// ============================================ -// JVM 面板 -// ============================================ -const JvmPanel: React.FC<{ data: any }> = ({ data }) => { - const heap = Number(data.heapUsed?.[0]?.value || 0); - const nonHeap = Number(data.nonHeapUsed?.[0]?.value || 0); - const threads = Number(data.threadCount?.[0]?.value || 0); - const uptime = Number(data.uptime?.[0]?.value || 0); - const uptimeStr = uptime > 86400 ? `${(uptime / 86400).toFixed(1)}d` : `${(uptime / 3600).toFixed(1)}h`; - return ( -
-
-
{uptimeStr}
Uptime
- 80 ? 'bg-orange-500' : 'bg-purple-500'} />
Heap
-
Non-Heap
-
{threads}
Threads
-
{Number(data.gcPause?.[0]?.value || 0).toFixed(3)}
GC Pause (s)
-
-
- ); -}; - -// ============================================ -// 主组件 -// ============================================ -export const PrometheusDashboard: React.FC = () => { - const [connected, setConnected] = useState(false); - const [loading, setLoading] = useState(false); - const [url, setUrl] = useState(''); - const [error, setError] = useState(''); - const [showForm, setShowForm] = useState(false); - const [targets, setTargets] = useState(0); - const [tab, setTab] = useState('k8s'); - - // Dashboard 数据 - const [k8sData, setK8sData] = useState({}); - const [nodeData, setNodeData] = useState({}); - const [podData, setPodData] = useState({}); - const [jvmData, setJvmData] = useState({}); - - useEffect(() => { - const saved = getSavedDS(); - if (saved) { setUrl(saved.url); doConnect(saved.url); } - }, []); - - const doConnect = async (u: string) => { - setLoading(true); setError(''); - try { - const data: any = await http.post('/prometheus/connect', { url: u }); - setConnected(true); setTargets(data.targets || 0); - saveDS({ url: u }); - toast.success(`Prometheus 连接成功,${data.targets} 个 targets`); - loadAllDashboards(u); - } catch (e: any) { - setConnected(false); setError(e.message || '连接失败'); - } finally { setLoading(false); } - }; - - const loadAllDashboards = async (u: string) => { - for (const t of ['k8s', 'node', 'pod', 'jvm']) { - try { - const data: any = await http.post('/prometheus/dashboard', { url: u, type: t }); - const items: any = {}; - for (const [k, v] of Object.entries(data || {})) { - items[k] = Array.isArray(v) ? v : ((v as any)?.data?.result ? parsePrune((v as any).data.result) : v); - } - if (t === 'k8s') setK8sData(items); - if (t === 'node') setNodeData(items); - if (t === 'pod') setPodData(items); - if (t === 'jvm') setJvmData(items); - } catch {} - } - }; - - const handleConnect = () => { - if (!url) { toast.error('请输入 Prometheus URL'); return; } - doConnect(url); - }; - - const refresh = () => { if (url) { doConnect(url); } }; - - return ( - - -
- - Prometheus 监控 - {connected && {targets} targets} - {connected &&
- - -
} -
-
- - {loading ? ( -

连接中...

- ) : !connected ? ( -
- {error &&
{error}
} - {!showForm ? ( -
- -

接入 Prometheus 以显示 K8s/Node/JVM 监控

- -
- ) : ( -
-
Prometheus 连接
-
setUrl(e.target.value)} />
- -
- )} -
- ) : ( - - - K8s 集群 - 节点 - Pod - JVM - - - - - - - )} -
-
- ); -}; - -// helper function parsePrune(result: any[]): any[] { return (result || []).map((r: any) => { const m: any = { ...(r.metric || {}) }; @@ -359,7 +44,151 @@ function parsePrune(result: any[]): any[] { }); } -// inline Box icon -const Box = (props: any) => ; +const Box = (p: any) => ; + +const K8sPanel: React.FC<{ d: any }> = ({ d }) => ( +
+
+
{d.podCount?.[0]?.value || 0}
Pods
+
{d.deployCount?.[0]?.value || 0}
Deployments
+
{d.nodeCount?.[0]?.value || 0}
Nodes
+
{d.nsPods?.length || 0}
Namespaces
+
+
+
节点 CPU / 内存利用率
+ {(d.nodeCPU || []).map((n: any, i: number) => { + const mem = (d.nodeMem || []).find((m: any) => m.node === n.node); + return
{n.node}
; + })} +
+
+); + +const NodePanel: React.FC<{ d: any }> = ({ d }) => ( +
+ + + + {(d.nodes || []).map((n: any, i: number) => { + const inst = n.instance || n.exported_instance || ''; + const cpu = (d.cpuUsage||[]).find((x:any)=>x.instance===inst); + const mem = (d.memUsage||[]).find((x:any)=>x.instance===inst); + const disk = (d.diskUsage||[]).find((x:any)=>x.instance===inst); + const up = (d.up||[]).find((x:any)=>x.instance===inst); + const rx = (d.netRx||[]).find((x:any)=>x.instance===inst); + const tx = (d.netTx||[]).find((x:any)=>x.instance===inst); + return + + + + + + + + ; + })} + +
节点状态CPU内存磁盘流入流出
{n.nodename||inst}{up?.value===1?'UP':'DOWN'}{fmtBps(Number(rx?.value||0))}{fmtBps(Number(tx?.value||0))}
+
+); + +const PodPanel: React.FC<{ d: any }> = ({ d }) => ( +
+
+
{d.running?.[0]?.value||0}
Running
+
{d.pending?.[0]?.value||0}
Pending
+
+
CPU Top 10
{(d.cpuTop||[]).map((p:any,i:number)=>
{i+1}{p.pod}
)}
+
内存 Top 10
{(d.memTop||[]).map((p:any,i:number)=>
{i+1}{p.pod}{fmtBytes(Number(p.value||0))}
)}
+
+); + +const JvmPanel: React.FC<{ d: any }> = ({ d }) => { + const h = Number(d.heapUsed?.[0]?.value||0); + const uptime = Number(d.uptime?.[0]?.value||0); + return ( +
+
{uptime>86400?`${(uptime/86400).toFixed(1)}d`:`${(uptime/3600).toFixed(1)}h`}
Uptime
+ 80?'bg-orange-500':'bg-purple-500'} />
Heap
+
Non-Heap
+
{d.threadCount?.[0]?.value||0}
Threads
+
{Number(d.gcPause?.[0]?.value||0).toFixed(3)}
GC Pause (s)
+
+ ); +}; + +export const PrometheusDashboard: React.FC = ({ dsUrl }) => { + const [connected, setConnected] = useState(false); + const [loading, setLoading] = useState(false); + const [targets, setTargets] = useState(0); + const [error, setError] = useState(''); + const [tab, setTab] = useState('k8s'); + const [k8sD, setK8sD] = useState({}); + const [nodeD, setNodeD] = useState({}); + const [podD, setPodD] = useState({}); + const [jvmD, setJvmD] = useState({}); + + useEffect(() => { + if (dsUrl) doConnect(dsUrl); + }, [dsUrl]); + + const doConnect = async (u: string) => { + setLoading(true); setError(''); + try { + const data: any = await http.post('/prometheus/connect', { url: u }); + setConnected(true); setTargets(data.targets||0); + loadAll(u); + } catch (e: any) { setConnected(false); setError(e.message||'连接失败'); } + finally { setLoading(false); } + }; + + const loadAll = async (u: string) => { + for (const t of ['k8s','node','pod','jvm']) { + try { + const data: any = await http.post('/prometheus/dashboard', { url: u, type: t }); + const items: any = {}; + for (const [k, v] of Object.entries(data||{})) + items[k] = Array.isArray(v) ? v : ((v as any)?.data?.result ? parsePrune((v as any).data.result) : v); + if (t==='k8s') setK8sD(items); if (t==='node') setNodeD(items); + if (t==='pod') setPodD(items); if (t==='jvm') setJvmD(items); + } catch {} + } + }; + + const refresh = () => { if (dsUrl) doConnect(dsUrl); }; + + if (!connected && !error) return null; + + return ( + + +
+ + Prometheus 监控 + {connected && {targets} targets} + {connected &&
} +
+
+ + {loading ? (

连接中…

) + : error ? (
{error}
) + : ( + + + K8s + Node + Pod + JVM + + + + + + + )} +
+
+ ); +}; export default PrometheusDashboard; diff --git a/frontend/src/components/monitor/dashboard/ZabbixDashboard.tsx b/frontend/src/components/monitor/dashboard/ZabbixDashboard.tsx index dfc2607..aba963a 100644 --- a/frontend/src/components/monitor/dashboard/ZabbixDashboard.tsx +++ b/frontend/src/components/monitor/dashboard/ZabbixDashboard.tsx @@ -1,301 +1,125 @@ -// Zabbix 数据源监控面板 — 镜像 Grafana Dashboard 5456 + 5363 // Logan.Li +// Zabbix 数据源监控面板 — 接受外部配置 // Logan.Li import React, { useState, useEffect } from 'react'; import { Server, Wifi, WifiOff, Loader2, AlertTriangle, Activity, Cpu, HardDrive, Network, Clock, RefreshCw, Plug, Settings, - ArrowUpDown, ArrowUp, ArrowDown, X, CheckCircle2 + ArrowUpDown, ArrowUp, ArrowDown, ExternalLink } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Label } from '@/components/ui/label'; import { toast } from 'sonner'; import { http } from '@/services/http'; interface ZabbixHost { - hostid: string; - hostname: string; - name: string; - ip: string; - available: string; // 0=未知 1=可用 2=不可用 - cpu_usage: number; - memory_usage: number; - disk_usage: number; - load5: number; - net_in_bps: number; - net_out_bps: number; - problem_count: number; - uptime_days: number; - cpu_count: number; - total_memory_gb: number; -} - -interface ZabbixData { - version: string; - hosts: ZabbixHost[]; - total: number; + hostid: string; hostname: string; name: string; ip: string; available: string; + cpu_usage: number; memory_usage: number; disk_usage: number; load5: number; + net_in_bps: number; net_out_bps: number; problem_count: number; uptime_days: number; + cpu_count: number; total_memory_gb: number; } type SortField = 'hostname' | 'cpu_usage' | 'memory_usage' | 'disk_usage' | 'load5' | 'net_in_bps' | 'net_out_bps' | 'problem_count' | 'uptime_days' | 'ip'; -const LS_KEY = 'aiops_zabbix_ds'; +interface Props { dsUrl?: string; dsUsername?: string; dsPassword?: string; } -function getSavedDS(): { url: string; username: string; password: string } | null { - try { - const raw = localStorage.getItem(LS_KEY); - if (raw) return JSON.parse(raw); - } catch {} - return null; -} - -function saveDS(ds: { url: string; username: string; password: string }) { - localStorage.setItem(LS_KEY, JSON.stringify(ds)); -} - -export const ZabbixDashboard: React.FC = () => { +export const ZabbixDashboard: React.FC = ({ dsUrl, dsUsername, dsPassword }) => { const [connected, setConnected] = useState(false); const [loading, setLoading] = useState(false); const [hosts, setHosts] = useState([]); const [version, setVersion] = useState(''); const [error, setError] = useState(''); - - const [url, setUrl] = useState(''); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [showForm, setShowForm] = useState(false); - const [sortField, setSortField] = useState('cpu_usage'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); - // 尝试用保存的凭据自动连接 useEffect(() => { - const saved = getSavedDS(); - if (saved) { - setUrl(saved.url); - setUsername(saved.username); - setPassword(saved.password); - doConnect(saved.url, saved.username, saved.password); - } - }, []); + if (dsUrl && dsUsername && dsPassword) doConnect(dsUrl, dsUsername, dsPassword); + }, [dsUrl, dsUsername, dsPassword]); const doConnect = async (u: string, un: string, pw: string) => { - setLoading(true); - setError(''); + setLoading(true); setError(''); try { - const data: ZabbixData = await http.post('/zabbix/connect', { url: u, username: un, password: pw }); + const data: any = await http.post('/zabbix/connect', { url: u, username: un, password: pw }); setHosts(data.hosts || []); setVersion(data.version || ''); setConnected(true); - saveDS({ url: u, username: un, password: pw }); - toast.success(`Zabbix ${data.version} 连接成功,${data.total} 台主机`); - } catch (e: any) { - setConnected(false); - setError(e.message || '连接失败'); - } finally { - setLoading(false); - } + } catch (e: any) { setConnected(false); setError(e.message || '连接失败'); } + finally { setLoading(false); } }; - const handleConnect = () => { - if (!url || !username || !password) { - toast.error('请填写完整连接信息'); - return; - } - doConnect(url, username, password); + const refresh = () => { if (dsUrl && dsUsername && dsPassword) doConnect(dsUrl, dsUsername, dsPassword); }; + const toggleSort = (f: SortField) => { + if (sortField === f) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); + else { setSortField(f); setSortDir('desc'); } }; - - const handleDisconnect = () => { - setConnected(false); - setHosts([]); - setError(''); - localStorage.removeItem(LS_KEY); - toast.info('已断开 Zabbix 连接'); - }; - - const refreshData = () => { - if (url && username && password) doConnect(url, username, password); - }; - - const toggleSort = (field: SortField) => { - if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); - else { setSortField(field); setSortDir('desc'); } - }; - - const sortedHosts = [...hosts].sort((a, b) => { - const va = (a as any)[sortField] ?? ''; - const vb = (b as any)[sortField] ?? ''; - if (typeof va === 'number' && typeof vb === 'number') { - return sortDir === 'asc' ? va - vb : vb - va; - } + const sorted = [...hosts].sort((a, b) => { + const va = (a as any)[sortField] ?? '', vb = (b as any)[sortField] ?? ''; + if (typeof va === 'number') return sortDir === 'asc' ? va - vb : vb - va; return sortDir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va)); }); - const SortIcon = ({ field }: { field: SortField }) => { - if (sortField !== field) return ; + const SI = ({ f }: { f: SortField }) => { + if (sortField !== f) return ; return sortDir === 'asc' ? : ; }; - const usageBar = (val: number, label: string) => { - const pct = Math.min(val, 100); - const isWarn = val >= 80; - return ( -
-
-
-
- {label} + const bar = (v: number, label: string) => ( +
+
+
= 80 ? 'bg-orange-500' : 'bg-blue-500'}`} style={{ width: `${Math.min(v, 100)}%` }} />
- ); + = 80 ? 'text-orange-400' : ''}`}>{label} +
+ ); + + const fmtBps = (b: number): string => { + if (!b || b === 0) return '—'; + if (b > 1e9) return `${(b / 1e9).toFixed(1)} GB/s`; + if (b > 1e6) return `${(b / 1e6).toFixed(1)} MB/s`; + if (b > 1e3) return `${(b / 1e3).toFixed(1)} KB/s`; + return `${b.toFixed(1)} B/s`; }; - const fmtBps = (bps: number): string => { - if (!bps || bps === 0) return '—'; - if (bps > 1e9) return `${(bps / 1e9).toFixed(1)} GB/s`; - if (bps > 1e6) return `${(bps / 1e6).toFixed(1)} MB/s`; - if (bps > 1e3) return `${(bps / 1e3).toFixed(1)} KB/s`; - return `${bps.toFixed(1)} B/s`; - }; - - const getAvailBadge = (avail: string) => { - return avail === '1' - ? 可用 - : 不可用; - }; - - const getPriorityColor = (count: number) => { - if (count === 0) return ''; - if (count <= 2) return 'text-amber-400'; - return 'text-red-400'; - }; + if (!connected && !loading && !error) return null; // 无配置时静默,由 wrapper 处理 return (
- Zabbix 主机监控 - {version && v{version}} - {connected && ( - <> - {hosts.length} 台主机 -
- - -
- - )} + Zabbix 主机监控 + {version && v{version}} + {connected && {hosts.length} 台} + {connected &&
}
- {loading ? ( -
- -

正在连接 Zabbix...

-
- ) : !connected ? ( -
- {error && ( -
- - {error} -
- )} - {!showForm ? ( -
- -

接入 Zabbix 数据源以显示主机监控

-

支持 Zabbix 5.0 / 6.0 / 7.0

-
- -
-
- ) : ( -
-
- Zabbix 连接配置 - -
-
- - setUrl(e.target.value)} /> -
-
- - setUsername(e.target.value)} /> -
-
- - setPassword(e.target.value)} /> -
- -
- )} -
- ) : hosts.length === 0 ? ( -
- -

未找到被监控的 Zabbix 主机

-
- ) : ( + {loading ? (

连接中…

) + : error ? (
{error}
) + : hosts.length === 0 ? (

未找到被监控的 Zabbix 主机

) + : (
- - - {[ - { field: 'hostname' as SortField, label: '主机名', w: '' }, - { field: 'ip' as SortField, label: 'IP', w: '' }, - { field: 'cpu_usage' as SortField, label: 'CPU', w: 'min-w-[90px]' }, - { field: 'memory_usage' as SortField, label: '内存', w: 'min-w-[90px]' }, - { field: 'disk_usage' as SortField, label: '磁盘', w: 'min-w-[90px]' }, - { field: 'load5' as SortField, label: 'Load5', w: '' }, - { field: 'net_in_bps' as SortField, label: '流入', w: '' }, - { field: 'net_out_bps' as SortField, label: '流出', w: '' }, - { field: 'problem_count' as SortField, label: '问题', w: '' }, - { field: 'uptime_days' as SortField, label: '运行', w: '' }, - ].map(({ field, label, w }) => ( - - ))} - - + + {[ + { f: 'hostname' as SortField, l: '主机名' },{ f: 'cpu_usage' as SortField, l: 'CPU' },{ f: 'memory_usage' as SortField, l: '内存' }, + { f: 'disk_usage' as SortField, l: '磁盘' },{ f: 'load5' as SortField, l: 'Load5' },{ f: 'net_in_bps' as SortField, l: '流入' }, + { f: 'net_out_bps' as SortField, l: '流出' },{ f: 'problem_count' as SortField, l: '问题' },{ f: 'uptime_days' as SortField, l: '运行' }, + ].map(({ f, l }) => ( + + ))} - {sortedHosts.map((host, i) => ( - - - - - - - - - - - + {sorted.map((h, i) => ( + + + + + + + + + + ))} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index bb52d08..fc40c3e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -499,6 +499,7 @@ export const agentApi = { createRule: (data: any) => http.post('/agent/rules', data), updateRule: (id: number, data: any) => http.put(`/agent/rules/${id}`, data), deleteRule: (id: number) => http.delete(`/agent/rules/${id}`), + quickSetup: (type?: string) => http.post('/agent/rules/quick-setup', { type: type || 'ecs' }), listAlerts: (params?: Record) => http.get('/agent/alerts', params), resolveAlert: (id: number) => http.post(`/agent/alerts/${id}/resolve`), }; @@ -646,6 +647,16 @@ export const alertCenterApi = { toggleRule: async (id: number | string) => { return http.post(`/alerts/rules/${id}/toggle`); }, + createRule: async (data: any) => { + return http.post('/alerts/rules', data); + }, + deleteRule: async (id: number | string) => { + return http.delete(`/alerts/rules/${id}`); + }, + // 一键报警 — 后端批量创建默认规则 + quickSetup: async (type: string = 'ecs') => { + return http.post('/alerts/quick-setup', { type }); + }, }; // ==================== 站点拨测 API ====================
toggleSort(field)}> -
{label}
-
toggleSort(f)}> +
{l}
-
- {getAvailBadge(host.available)} - {host.name || host.hostname} -
-
{host.ip || '—'}{usageBar(Number(host.cpu_usage || 0), `${Number(host.cpu_usage || 0).toFixed(1)}%`)}{usageBar(Number(host.memory_usage || 0), `${Number(host.memory_usage || 0).toFixed(1)}%`)}{usageBar(Number(host.disk_usage || 0), `${Number(host.disk_usage || 0).toFixed(1)}%`)}{host.load5 > 0 ? host.load5.toFixed(1) : '—'}{fmtBps(host.net_in_bps)}{fmtBps(host.net_out_bps)} - - {host.problem_count > 0 - ? {host.problem_count} - : 0} - - - {host.uptime_days > 0 ? `${host.uptime_days}d` : '—'} -
{h.available === '1' ? 'UP' : 'DOWN'} {h.name || h.hostname}{bar(Number(h.cpu_usage || 0), `${Number(h.cpu_usage || 0).toFixed(1)}%`)}{bar(Number(h.memory_usage || 0), `${Number(h.memory_usage || 0).toFixed(1)}%`)}{bar(Number(h.disk_usage || 0), `${Number(h.disk_usage || 0).toFixed(1)}%`)}{h.load5 > 0 ? h.load5.toFixed(1) : '—'}{fmtBps(h.net_in_bps)}{fmtBps(h.net_out_bps)} 0 ? (h.problem_count > 2 ? 'text-red-400' : 'text-amber-400') : 'text-emerald-400'}`}>{h.problem_count > 0 ? <>{h.problem_count} : '0'}{h.uptime_days > 0 ? `${h.uptime_days}d` : '—'}