docs: update README, API reference, dev-log, project structure with Agent monitoring + quick-alarm + latest fixes
- README.md: React 19, default pwd 123456, Docker Compose deploy, correct module list - api-reference.md: add Agent monitoring section (report/latest/rules/quick-setup/alerts/tasks/kline) - dev-log.md: new entry documenting hostname/IP fix, system collector, offline detection, quick alarm - project-structure.md: add agent/ directory, dev-log.md link, React 19
This commit is contained in:
1 parent
c51d0b8b8b
commit
4e3f8cb306
20 files changed
+1297
-1326
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package model
|
||||
// Report 为单次采集周期的完整上报报文
|
||||
type Report struct {
|
||||
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 指标
|
||||
@@ -14,7 +15,8 @@ type Report struct {
|
||||
Disks []DiskStat `json:"disks"` // 磁盘指标列表
|
||||
Ports []PortStat `json:"ports"` // 监听端口列表
|
||||
Network NetworkStat `json:"network"` // 网络指标
|
||||
DiskIO DiskIOStat `json:"disk_io"` // 磁盘 IO 指标
|
||||
DiskIO DiskIOStat `json:"disk_io"` // 磁盘 IO
|
||||
System *SystemStat `json:"system,omitempty"` // 系统信息(首次采集后携带)
|
||||
}
|
||||
|
||||
// CPUStat CPU 相关指标
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
| [数据库设计](./database-schema.md) | 数据模型和表结构 |
|
||||
| [开发规范](./development-guide.md) | 代码规范、Git 工作流 |
|
||||
| [部署指南](./deployment.md) | 生产环境部署 |
|
||||
| [开发日志](./dev-log.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`)。
|
||||
@@ -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
|
||||
@@ -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+ | 样式框架 |
|
||||
|
||||
@@ -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() {
|
||||
<Route path="deploy" element={<AppReleaseManager />} />
|
||||
{/* 监控告警 */}
|
||||
<Route path="metrics" element={<MetricsExplorer />} />
|
||||
<Route path="alert-center" element={<AlertCenter />} />
|
||||
<Route path="trace" element={<TraceManager />} />
|
||||
<Route path="dashboards" element={<HostMonitorDashboard />} />
|
||||
<Route path="datasources" element={<DatasourcePage />} />
|
||||
<Route path="trace" element={<TraceManager />} />
|
||||
<Route path="dashboards" element={<GrafanaDashboard />} />
|
||||
<Route path="alerts" element={<AlertManager />} />
|
||||
<Route path="alert-center" element={<AlertCenter />} />
|
||||
<Route path="logs" element={<AlertManager />} />
|
||||
<Route path="approval" element={<ApprovalFlowManager />} />
|
||||
{/* 自动化运维 */}
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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<Alert[]>([]);
|
||||
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||
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<any>(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 */}
|
||||
<TabsContent value="rules" className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleOpenAddRule}>
|
||||
<Plus className="w-4 h-4 mr-1" />添加规则
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleQuickSetup} disabled={quickSetupLoading}>
|
||||
<Rocket className="w-4 h-4 mr-1" />
|
||||
{quickSetupLoading ? '创建中…' : '一键报警'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" onClick={fetchRules}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
{rules.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<AlertTriangle className="w-10 h-10 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">暂无告警规则</p>
|
||||
<p className="text-xs mt-1">点击「添加规则」或「一键报警」快速创建</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{rules.map((rule) => (
|
||||
<Card key={rule.id}>
|
||||
@@ -353,6 +414,8 @@ const AlertCenter: React.FC = () => {
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditRule(rule)}>编辑</Button>
|
||||
<Button variant="ghost" size="sm" className="text-red-400" onClick={() => handleDeleteRule(rule)}>删除</Button>
|
||||
<Switch
|
||||
checked={rule.enabled}
|
||||
onCheckedChange={(checked) => toggleRule(String(rule.id), checked)}
|
||||
@@ -363,6 +426,7 @@ const AlertCenter: React.FC = () => {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 通知渠道 Tab — 自包含子组件 */}
|
||||
@@ -383,6 +447,13 @@ const AlertCenter: React.FC = () => {
|
||||
onOpenChange={setSilenceOpen}
|
||||
onSilence={handleSilence}
|
||||
/>
|
||||
<AlertRuleDialog
|
||||
open={ruleDialogOpen}
|
||||
onOpenChange={handleRuleDialogClose}
|
||||
editingRule={editingRule}
|
||||
notifyChannels={[]}
|
||||
onSaved={() => { handleRuleDialogClose(); fetchRules(); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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: <Server className="w-3.5 h-3.5" /> },
|
||||
{ id: 'zabbix', label: 'Zabbix', icon: <Activity className="w-3.5 h-3.5" /> },
|
||||
{ id: 'prometheus', label: 'Prometheus', icon: <Activity className="w-3.5 h-3.5" /> },
|
||||
{ id: 'dashboard', label: '自定义', icon: <Monitor className="w-3.5 h-3.5" /> },
|
||||
];
|
||||
|
||||
// ═══════════ 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<Dashboard[]>([]);
|
||||
const [datasources, setDatasources] = useState<Datasource[]>([]);
|
||||
const [activeDashboard, setActiveDashboard] = useState<Dashboard | null>(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<AgentHost[]>([]);
|
||||
const [hostsLoading, setHostsLoading] = useState(true);
|
||||
const [sortField, setSortField] = useState<SortField>('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<SortField>('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 <ArrowUpDown className="w-3 h-3 text-slate-500" />;
|
||||
return sortDir === 'asc' ? <ArrowUp className="w-3 h-3 text-emerald-400" /> : <ArrowDown className="w-3 h-3 text-emerald-400" />;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-2 min-w-[90px]">
|
||||
<div className="flex-1 h-2.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`w-14 text-right text-xs font-mono tabular-nums ${textColor}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
|
||||
const SI = ({ f }: { f: SortField }) => sf !== f ? <ArrowUpDown className="w-3 h-3 text-slate-500" /> : sd === 'asc' ? <ArrowUp className="w-3 h-3 text-emerald-400" /> : <ArrowDown className="w-3 h-3 text-emerald-400" />;
|
||||
const bar = (v: number, l: string) => (
|
||||
<div className="flex items-center gap-2 min-w-[90px]"><div className="flex-1 h-2.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden"><div className={`h-full rounded-full transition-all duration-500 ${v >= 80 ? 'bg-orange-500' : 'bg-emerald-500'}`} style={{ width: `${Math.min(v, 100)}%` }} /></div><span className={`w-14 text-right text-xs font-mono tabular-nums ${v >= 80 ? 'text-orange-400' : ''}`}>{l}</span></div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 仪表盘 CRUD ====================
|
||||
useEffect(() => {
|
||||
fetchDashboards();
|
||||
fetchDatasources();
|
||||
}, []);
|
||||
|
||||
const fetchDashboards = async () => {
|
||||
setLoading(true);
|
||||
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 <Activity className="w-4 h-4" />;
|
||||
case 'zabbix': return <Server className="w-4 h-4" />;
|
||||
case 'skywalking': return <Globe className="w-4 h-4" />;
|
||||
case 'influxdb': return <Database className="w-4 h-4" />;
|
||||
default: return <Database className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPanelIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'graph': return <LineChart className="w-4 h-4" />;
|
||||
case 'stat': return <Activity className="w-4 h-4" />;
|
||||
case 'gauge': return <Cpu className="w-4 h-4" />;
|
||||
case 'pie': return <PieChart className="w-4 h-4" />;
|
||||
case 'table': return <BarChart3 className="w-4 h-4" />;
|
||||
default: return <BarChart3 className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Activity className="h-6 w-6 mx-auto mb-2 animate-spin" /><p className="text-xs">加载中…</p></div>;
|
||||
if (!hosts.length) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Server className="h-8 w-8 mx-auto mb-2" /><p className="text-sm">暂无 Agent 上报数据</p><p className="text-xs mt-1">请确保至少一台主机安装了 AIOps Agent</p></div>;
|
||||
return (
|
||||
<div className={`space-y-4 ${fullscreen ? 'fixed inset-0 z-50 bg-background p-4' : ''}`}>
|
||||
|
||||
{/* ===== 顶部工具栏 ===== */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Select
|
||||
value={activeDashboard?.id}
|
||||
onValueChange={(id) => {
|
||||
const dashboard = dashboards.find(d => d.id === id);
|
||||
if (dashboard) setActiveDashboard(dashboard);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[250px]">
|
||||
<SelectValue placeholder="选择仪表盘" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dashboards.map(d => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getDatasourceIcon(d.datasource)}
|
||||
{d.name}
|
||||
{d.isDefault && <Badge variant="outline" className="text-xs">默认</Badge>}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{activeDashboard && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>更新于 {activeDashboard.updatedAt}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchDashboards}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
|
||||
{activeDashboard && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setAddPanelOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加面板
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setFullscreen(!fullscreen)}>
|
||||
{fullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setDatasourceOpen(true)}>
|
||||
<Database className="w-4 h-4 mr-2" />
|
||||
数据源
|
||||
</Button>
|
||||
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新建仪表盘
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 仪表盘内容 ===== */}
|
||||
{activeDashboard ? (
|
||||
<div className="space-y-4">
|
||||
{/* 仪表盘信息 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{activeDashboard.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">{activeDashboard.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="gap-1">
|
||||
{getDatasourceIcon(activeDashboard.datasource)}
|
||||
{activeDashboard.datasource}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Agent 主机监控 — 默认表格面板 ===== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-emerald-500/20">
|
||||
<CardHeader className="py-3 px-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-emerald-400" />
|
||||
<CardTitle className="text-sm font-medium text-[var(--dg-text-primary)]">Agent 主机监控</CardTitle>
|
||||
<Badge variant="outline" className="text-[10px] bg-emerald-500/10 text-emerald-400 border-emerald-500/30">默认</Badge>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)] ml-auto">每 30s 刷新</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3 pt-0">
|
||||
{hostsLoading ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-muted)]">
|
||||
<Activity className="h-6 w-6 mx-auto mb-2 animate-spin text-slate-600" />
|
||||
<p className="text-xs">加载中…</p>
|
||||
</div>
|
||||
) : hosts.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-muted)]">
|
||||
<Server className="h-8 w-8 mx-auto mb-2 text-slate-600" />
|
||||
<p className="text-sm">暂无 Agent 上报数据</p>
|
||||
<p className="text-xs mt-1">请确保至少一台主机安装了 AIOps Agent 并正常上报</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<th
|
||||
key={field}
|
||||
className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] cursor-pointer hover:text-[var(--dg-text-primary)] select-none whitespace-nowrap"
|
||||
onClick={() => toggleSort(field as SortField)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{label}
|
||||
<SortIcon field={field as SortField} />
|
||||
</div>
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b"><tr>
|
||||
{['hostname','agent_version','agent_status','cpu_usage','mem_usage','disk_usage','region','ip'].map(f => (
|
||||
<th key={f} className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] cursor-pointer hover:text-[var(--dg-text-primary)] select-none whitespace-nowrap" onClick={() => toggle(f as SortField)}>
|
||||
<div className="flex items-center gap-1">{{hostname:'主机名',agent_version:'Agent版本',agent_status:'状态',cpu_usage:'CPU',mem_usage:'内存',disk_usage:'磁盘',region:'区域',ip:'IP'}[f]}<SI f={f as SortField} /></div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
))}</tr></thead>
|
||||
<tbody>
|
||||
{sortedHosts.map((host, i) => {
|
||||
const isOnline = host.agent_status === 'online';
|
||||
return (
|
||||
<tr
|
||||
key={host.hostname + i}
|
||||
className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50 transition-colors"
|
||||
>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
<span className="font-medium text-[var(--dg-text-primary)]">{host.hostname}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap text-[var(--dg-text-secondary)] font-mono">
|
||||
{host.agent_version || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
<Badge
|
||||
variant={isOnline ? 'default' : 'secondary'}
|
||||
className={`text-[10px] ${isOnline ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-500/10 text-slate-400 border-slate-500/20'}`}
|
||||
>
|
||||
{isOnline ? <Wifi className="w-3 h-3 mr-1" /> : <WifiOff className="w-3 h-3 mr-1" />}
|
||||
{isOnline ? '在线' : '离线'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
{usageBar(Number(host.cpu_usage || 0), `${Number(host.cpu_usage || 0).toFixed(1)}%`)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
{usageBar(Number(host.mem_usage || 0), `${Number(host.mem_usage || 0).toFixed(1)}%`)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
{usageBar(Number(host.disk_usage || 0), `${Number(host.disk_usage || 0).toFixed(1)}%`)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap text-[var(--dg-text-secondary)]">
|
||||
{host.region || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap font-mono text-[var(--dg-text-secondary)]">
|
||||
{host.ip || '—'}
|
||||
</td>
|
||||
{paged.map((h, i) => (
|
||||
<tr key={h.hostname + i} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2.5 font-medium text-[var(--dg-text-primary)]">{h.hostname}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">{h.agent_version || '—'}</td>
|
||||
<td className="px-3 py-2.5"><Badge className={`text-[10px] ${h.agent_status==='online'?'bg-emerald-500/20 text-emerald-400':'bg-slate-500/10 text-slate-400'}`}>{h.agent_status==='online'?<><Wifi className="w-3 h-3 mr-1"/>在线</>:<><WifiOff className="w-3 h-3 mr-1"/>离线</>}</Badge></td>
|
||||
<td className="px-3 py-2.5">{bar(Number(h.cpu_usage||0), `${Number(h.cpu_usage||0).toFixed(1)}%`)}</td>
|
||||
<td className="px-3 py-2.5">{bar(Number(h.mem_usage||0), `${Number(h.mem_usage||0).toFixed(1)}%`)}</td>
|
||||
<td className="px-3 py-2.5">{bar(Number(h.disk_usage||0), `${Number(h.disk_usage||0).toFixed(1)}%`)}</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)]">{h.region||'—'}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">{h.ip||'—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{hosts.length > pageSize && (
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs">
|
||||
<div className="flex items-center gap-2 text-[var(--dg-text-muted)]">
|
||||
<span>共 {hosts.length} 条</span>
|
||||
<select className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded px-2 py-0.5 text-xs" value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}>
|
||||
<option value={10}>10/页</option>
|
||||
<option value={20}>20/页</option>
|
||||
<option value={30}>30/页</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
<span className="px-2 text-[var(--dg-text-primary)]">{page} / {totalPages}</span>
|
||||
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Zabbix 数据源面板 */}
|
||||
<ZabbixDashboard />
|
||||
|
||||
{/* Prometheus 数据源面板 */}
|
||||
<PrometheusDashboard />
|
||||
|
||||
{/* 面板网格 */}
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{activeDashboard.panels.map((panel) => (
|
||||
<Card
|
||||
key={panel.id}
|
||||
className={`col-span-${panel.width}`}
|
||||
style={{ gridColumn: `span ${panel.width}`, minHeight: `${panel.height * 80}px` }}
|
||||
>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
{getPanelIcon(panel.type)}
|
||||
{panel.title}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 flex-1">
|
||||
<GrafanaPanelContent panel={panel} />
|
||||
</CardContent>
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════ 数据源 Wrapper ═══════════
|
||||
const ZabbixTab: React.FC = () => {
|
||||
const [ds, setDs] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
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 <div className="text-center py-20 text-[var(--dg-text-muted)]"><Activity className="h-6 w-6 mx-auto mb-2 animate-spin" /><p className="text-xs">加载数据源…</p></div>;
|
||||
if (!ds) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Database className="h-8 w-8 mx-auto mb-2" /><p className="text-sm">未配置 Zabbix 数据源</p><a href="/datasources" className="text-xs text-blue-400 hover:underline inline-flex items-center gap-1">前往配置 <ExternalLink className="w-3 h-3" /></a></div>;
|
||||
return <ZabbixDashboard dsUrl={ds.url} dsUsername={ds.username} dsPassword={ds.password} />;
|
||||
};
|
||||
|
||||
const PrometheusTab: React.FC = () => {
|
||||
const [ds, setDs] = useState<any>(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 <div className="text-center py-20 text-[var(--dg-text-muted)]"><Activity className="h-6 w-6 mx-auto mb-2 animate-spin" /><p className="text-xs">加载数据源…</p></div>;
|
||||
if (!ds) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Database className="h-8 w-8 mx-auto mb-2" /><p className="text-sm">未配置 Prometheus 数据源</p><a href="/datasources" className="text-xs text-blue-400 hover:underline inline-flex items-center gap-1">前往配置 <ExternalLink className="w-3 h-3" /></a></div>;
|
||||
return <PrometheusDashboard dsUrl={ds.url} />;
|
||||
};
|
||||
|
||||
// ═══════════ 自定义仪表盘 ═══════════
|
||||
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<Dashboard[]>([]);
|
||||
const [active, setActive] = useState<Dashboard | null>(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 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={active?.id} onValueChange={id => { const d = dashboards.find(x => x.id === id); if (d) setActive(d); }}>
|
||||
<SelectTrigger className="w-[240px] h-8 text-xs"><SelectValue placeholder="选择仪表盘" /></SelectTrigger>
|
||||
<SelectContent>{dashboards.map(d => <SelectItem key={d.id} value={d.id}>{d.name}{d.isDefault ? ' (默认)' : ''}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={fetch}><RefreshCw className="w-3 h-3 mr-1" />刷新</Button>
|
||||
{active && <Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={() => setAddOpen(true)}><Plus className="w-3 h-3 mr-1" />添加面板</Button>}
|
||||
<Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={() => setCreateOpen(true)}><Plus className="w-3 h-3 mr-1" />新建</Button>
|
||||
</div>
|
||||
|
||||
{active ? (
|
||||
active.panels.length === 0 ? (
|
||||
<div className="text-center py-16 text-[var(--dg-text-muted)]"><BarChart3 className="w-10 h-10 mx-auto mb-2 opacity-50" /><p className="text-xs">暂无面板</p></div>
|
||||
) : (
|
||||
<div className="grid grid-cols-12 gap-3">
|
||||
{active.panels.map(p => (
|
||||
<Card key={p.id} style={{ gridColumn: `span ${p.width}`, minHeight: `${p.height * 80}px` }}>
|
||||
<CardHeader className="py-2 px-3"><CardTitle className="text-[11px] font-medium">{p.title}</CardTitle></CardHeader>
|
||||
<CardContent className="p-3 pt-0"><GrafanaPanelContent panel={p} /></CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{activeDashboard.panels.length === 0 && (
|
||||
<div className="col-span-12 py-16 text-center text-muted-foreground">
|
||||
<BarChart3 className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p>暂无面板,点击"添加面板"开始创建</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
<Monitor className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p>请选择一个仪表盘或创建新的仪表盘</p>
|
||||
<Button className="mt-4" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新建仪表盘
|
||||
<div className="text-center py-16 text-[var(--dg-text-muted)]"><Monitor className="w-10 h-10 mx-auto mb-2 opacity-50" /><p className="text-xs">暂无仪表盘</p></div>
|
||||
)}
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}><DialogContent><DialogHeader><DialogTitle>新建仪表盘</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3"><div><Label>名称</Label><Input value={nd.name} onChange={e => setNd({ ...nd, name: e.target.value })} /></div>
|
||||
<div><Label>数据源</Label><Select value={nd.datasource} onValueChange={v => setNd({ ...nd, datasource: v as any })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="prometheus">Prometheus</SelectItem><SelectItem value="zabbix">Zabbix</SelectItem><SelectItem value="skywalking">SkyWalking</SelectItem><SelectItem value="custom">自定义</SelectItem></SelectContent></Select></div></div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button><Button onClick={create}>创建</Button></DialogFooter></DialogContent></Dialog>
|
||||
|
||||
<Dialog open={addOpen} onOpenChange={setAddOpen}><DialogContent><DialogHeader><DialogTitle>添加面板</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3"><div><Label>标题</Label><Input value={np.title} onChange={e => setNp({ ...np, title: e.target.value })} /></div>
|
||||
<div><Label>类型</Label><Select value={np.type} onValueChange={v => setNp({ ...np, type: v as any })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="graph">折线图</SelectItem><SelectItem value="stat">数值</SelectItem><SelectItem value="gauge">仪表盘</SelectItem><SelectItem value="table">表格</SelectItem></SelectContent></Select></div>
|
||||
<div><Label>查询</Label><Input value={np.query} onChange={e => setNp({ ...np, query: e.target.value })} /></div></div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setAddOpen(false)}>取消</Button><Button onClick={addPanel}>添加</Button></DialogFooter></DialogContent></Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════ 主组件 ═══════════
|
||||
const GrafanaDashboard: React.FC = () => {
|
||||
const [tab, setTab] = useState<TabId>('agent');
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${fullscreen ? 'fixed inset-0 z-50 bg-background p-4 overflow-auto' : ''}`}>
|
||||
|
||||
{/* Tab 切换栏 */}
|
||||
<div className="flex items-center gap-1 p-1 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-md text-xs font-medium transition-colors ${
|
||||
tab === t.id
|
||||
? 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] shadow-sm'
|
||||
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{t.icon}
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setFullscreen(!fullscreen)}>
|
||||
{fullscreen ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 新建仪表盘对话框 */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建仪表盘</DialogTitle>
|
||||
<DialogDescription>创建一个新的监控仪表盘</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>名称</Label>
|
||||
<Input
|
||||
value={newDashboard.name}
|
||||
onChange={(e) => setNewDashboard({ ...newDashboard, name: e.target.value })}
|
||||
placeholder="仪表盘名称"
|
||||
/>
|
||||
{/* Tab 内容 */}
|
||||
<div className="min-h-[50vh]">
|
||||
{tab === 'agent' && <AgentTab />}
|
||||
{tab === 'zabbix' && <ZabbixTab />}
|
||||
{tab === 'prometheus' && <PrometheusTab />}
|
||||
{tab === 'dashboard' && <DashboardTab />}
|
||||
</div>
|
||||
<div>
|
||||
<Label>描述</Label>
|
||||
<Input
|
||||
value={newDashboard.description}
|
||||
onChange={(e) => setNewDashboard({ ...newDashboard, description: e.target.value })}
|
||||
placeholder="仪表盘描述"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>数据源类型</Label>
|
||||
<Select
|
||||
value={newDashboard.datasource}
|
||||
onValueChange={(v) => setNewDashboard({ ...newDashboard, datasource: v as any })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="prometheus">Prometheus</SelectItem>
|
||||
<SelectItem value="zabbix">Zabbix</SelectItem>
|
||||
<SelectItem value="skywalking">SkyWalking</SelectItem>
|
||||
<SelectItem value="custom">自定义</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>数据源 URL</Label>
|
||||
<Input
|
||||
value={newDashboard.datasourceUrl}
|
||||
onChange={(e) => setNewDashboard({ ...newDashboard, datasourceUrl: e.target.value })}
|
||||
placeholder="http://prometheus:9090"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreateDashboard}>创建</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 添加面板对话框 */}
|
||||
<Dialog open={addPanelOpen} onOpenChange={setAddPanelOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加面板</DialogTitle>
|
||||
<DialogDescription>向当前仪表盘添加一个监控面板</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>面板标题</Label>
|
||||
<Input
|
||||
value={newPanel.title}
|
||||
onChange={(e) => setNewPanel({ ...newPanel, title: e.target.value })}
|
||||
placeholder="CPU 使用率"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>图表类型</Label>
|
||||
<Select
|
||||
value={newPanel.type}
|
||||
onValueChange={(v) => setNewPanel({ ...newPanel, type: v as any })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="graph">折线图</SelectItem>
|
||||
<SelectItem value="stat">数值</SelectItem>
|
||||
<SelectItem value="gauge">仪表盘</SelectItem>
|
||||
<SelectItem value="pie">饼图</SelectItem>
|
||||
<SelectItem value="table">表格</SelectItem>
|
||||
<SelectItem value="text">文本</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>PromQL / 查询语句</Label>
|
||||
<Input
|
||||
value={newPanel.query}
|
||||
onChange={(e) => setNewPanel({ ...newPanel, query: e.target.value })}
|
||||
placeholder="rate(node_cpu_seconds_total[5m])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddPanelOpen(false)}>取消</Button>
|
||||
<Button onClick={handleAddPanel}>添加</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 数据源对话框 */}
|
||||
<Dialog open={datasourceOpen} onOpenChange={setDatasourceOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>管理数据源</DialogTitle>
|
||||
<DialogDescription>配置 Prometheus / Zabbix / SkyWalking 等外部数据源连接</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
{datasources.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">暂无数据源配置</p>
|
||||
) : (
|
||||
datasources.map(ds => (
|
||||
<div key={ds.id} className="flex items-center justify-between p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2">
|
||||
{getDatasourceIcon(ds.type)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{ds.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{ds.url}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={ds.status === 'connected' ? 'default' : 'secondary'}
|
||||
className={ds.status === 'connected' ? 'bg-green-500' : ds.status === 'error' ? 'bg-red-500' : ''}
|
||||
>
|
||||
{ds.status === 'connected' ? '已连接' : ds.status === 'error' ? '错误' : '已断开'}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>数据源名称</Label>
|
||||
<Input
|
||||
value={newDatasource.name}
|
||||
onChange={(e) => setNewDatasource({ ...newDatasource, name: e.target.value })}
|
||||
placeholder="My Prometheus"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>类型</Label>
|
||||
<Select
|
||||
value={newDatasource.type}
|
||||
onValueChange={(v) => setNewDatasource({ ...newDatasource, type: v as any })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="prometheus">Prometheus</SelectItem>
|
||||
<SelectItem value="zabbix">Zabbix</SelectItem>
|
||||
<SelectItem value="skywalking">SkyWalking</SelectItem>
|
||||
<SelectItem value="influxdb">InfluxDB</SelectItem>
|
||||
<SelectItem value="elasticsearch">Elasticsearch</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>URL</Label>
|
||||
<Input
|
||||
value={newDatasource.url}
|
||||
onChange={(e) => setNewDatasource({ ...newDatasource, url: e.target.value })}
|
||||
placeholder="http://prometheus:9090"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDatasourceOpen(false)}>关闭</Button>
|
||||
<Button onClick={handleAddDatasource}>添加数据源</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-1.5 min-w-[80px]">
|
||||
<div className="flex-1 h-2 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all ${c}`} style={{ width: `${pct}%` }} />
|
||||
<div className={`h-full rounded-full ${color || 'bg-purple-500'}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="w-14 text-right text-[11px] font-mono tabular-nums text-[var(--dg-text-secondary)]">{label}</span>
|
||||
<span className="w-14 text-right text-[11px] font-mono text-[var(--dg-text-secondary)]">{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3 text-center">
|
||||
<div className="text-2xl font-bold text-purple-400">
|
||||
{data.podCount?.[0]?.value || 0}
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Pods</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-400">
|
||||
{data.deployCount?.[0]?.value || 0}
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Deployments</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3 text-center">
|
||||
<div className="text-2xl font-bold text-emerald-400">
|
||||
{data.nodeCount?.[0]?.value || 0}
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Nodes</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3 text-center">
|
||||
<div className="text-2xl font-bold text-amber-400">
|
||||
{data.nsPods?.length || 0}
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Namespaces</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Node CPU / Memory 利用率 */}
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] p-3">
|
||||
<div className="text-xs font-medium text-[var(--dg-text-primary)] mb-2 flex items-center gap-1.5"><Cpu className="w-3 h-3 text-purple-400" />节点资源利用率</div>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{(data.nodeCPU || []).map((n: any, i: number) => {
|
||||
const mem = (data.nodeMem || []).find((m: any) => m.node === n.node);
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-3 text-[11px]">
|
||||
<span className="w-28 truncate text-[var(--dg-text-secondary)]" title={n.node}>{n.node}</span>
|
||||
<MiniBar val={Number(n.value || 0)} label={`${Number(n.value || 0).toFixed(1)}%`} />
|
||||
<MiniBar val={Number(mem?.value || 0)} label={`${Number(mem?.value || 0).toFixed(1)}%`} color="bg-blue-500" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 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 (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">节点</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">CPU</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">内存</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">磁盘</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">流入</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">流出</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(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 (
|
||||
<tr key={i} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2 whitespace-nowrap font-medium text-[var(--dg-text-primary)]">
|
||||
{n.nodename || instance}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`text-[10px] ${isUp ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'}`}>
|
||||
{isUp ? 'UP' : 'DOWN'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2"><MiniBar val={Number(cpu?.value || 0)} label={`${Number(cpu?.value || 0).toFixed(1)}%`} /></td>
|
||||
<td className="px-3 py-2"><MiniBar val={Number(mem?.value || 0)} label={`${Number(mem?.value || 0).toFixed(1)}%`} color="bg-blue-500" /></td>
|
||||
<td className="px-3 py-2"><MiniBar val={Number(disk?.value || 0)} label={`${Number(disk?.value || 0).toFixed(1)}%`} color="bg-amber-500" /></td>
|
||||
<td className="px-3 py-2 font-mono text-[var(--dg-text-secondary)]">{fmtBps(Number(rx?.value || 0))}</td>
|
||||
<td className="px-3 py-2 font-mono text-[var(--dg-text-secondary)]">{fmtBps(Number(tx?.value || 0))}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-xl font-bold text-emerald-400">{running}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Running</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-xl font-bold text-amber-400">{pending}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Pending</div></CardContent></Card>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] p-3">
|
||||
<div className="text-xs font-medium text-[var(--dg-text-primary)] mb-2">CPU Top 10</div>
|
||||
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
||||
{cpuItems.map((p: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[11px]">
|
||||
<span className="w-10 text-right text-[var(--dg-text-muted)]">{i + 1}</span>
|
||||
<span className="w-32 truncate text-[var(--dg-text-secondary)]" title={p.pod}>{p.pod}</span>
|
||||
<MiniBar val={Number(p.value || 0) * 100} label={`${Number(p.value || 0).toFixed(2)}c`} color="bg-purple-500" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] p-3">
|
||||
<div className="text-xs font-medium text-[var(--dg-text-primary)] mb-2">内存 Top 10</div>
|
||||
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
||||
{memItems.map((p: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[11px]">
|
||||
<span className="w-10 text-right text-[var(--dg-text-muted)]">{i + 1}</span>
|
||||
<span className="w-32 truncate text-[var(--dg-text-secondary)]" title={p.pod}>{p.pod}</span>
|
||||
<span className="ml-auto text-[var(--dg-text-primary)]">{fmtBytes(Number(p.value || 0))}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold text-[var(--dg-text-primary)]">{uptimeStr}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Uptime</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><MiniBar val={heap} label={`${heap.toFixed(1)}%`} color={heap > 80 ? 'bg-orange-500' : 'bg-purple-500'} /><div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Heap</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><MiniBar val={nonHeap} label={`${nonHeap.toFixed(1)}%`} color="bg-blue-500" /><div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Non-Heap</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold text-emerald-400">{threads}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Threads</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold text-amber-400">{Number(data.gcPause?.[0]?.value || 0).toFixed(3)}</div><div className="text-[10px] text-[var(--dg-text-muted)]">GC Pause (s)</div></CardContent></Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 主组件
|
||||
// ============================================
|
||||
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<any>({});
|
||||
const [nodeData, setNodeData] = useState<any>({});
|
||||
const [podData, setPodData] = useState<any>({});
|
||||
const [jvmData, setJvmData] = useState<any>({});
|
||||
|
||||
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 (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-purple-500/20">
|
||||
<CardHeader className="py-3 px-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-purple-400" />
|
||||
<CardTitle className="text-sm font-medium text-[var(--dg-text-primary)]">Prometheus 监控</CardTitle>
|
||||
{connected && <Badge className="text-[10px] bg-purple-500/10 text-purple-400 border-purple-500/30">{targets} targets</Badge>}
|
||||
{connected && <div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={refresh}><RefreshCw className="w-3 h-3 mr-1" />刷新</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[10px] text-red-400" onClick={() => { setConnected(false); localStorage.removeItem(LS_KEY); }}>
|
||||
<X className="w-3 h-3 mr-1" />断开
|
||||
</Button>
|
||||
</div>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3 pt-0">
|
||||
{loading ? (
|
||||
<div className="text-center py-12"><Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin text-purple-400" /><p className="text-xs text-[var(--dg-text-muted)]">连接中...</p></div>
|
||||
) : !connected ? (
|
||||
<div>
|
||||
{error && <div className="mb-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-xs text-red-400 flex items-start gap-2"><AlertTriangle className="h-4 w-4 mt-0.5" /><span>{error}</span></div>}
|
||||
{!showForm ? (
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)]">
|
||||
<Plug className="h-8 w-8 mx-auto mb-2 text-slate-600" />
|
||||
<p className="text-sm">接入 Prometheus 以显示 K8s/Node/JVM 监控</p>
|
||||
<Button size="sm" variant="outline" className="mt-4" onClick={() => setShowForm(true)}><Settings className="w-3 h-3 mr-1" />配置连接</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-md mx-auto p-4 space-y-3 rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between"><span className="text-sm font-medium">Prometheus 连接</span><Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => setShowForm(false)}><X className="w-3 h-3" /></Button></div>
|
||||
<div><Label className="text-xs">URL</Label><Input className="h-8 text-xs mt-1" placeholder="http://prometheus:9090" value={url} onChange={e => setUrl(e.target.value)} /></div>
|
||||
<Button size="sm" className="w-full" onClick={handleConnect}><CheckCircle2 className="w-3 h-3 mr-1" />连接</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList className="bg-[var(--dg-bg-tertiary)] mb-3">
|
||||
<TabsTrigger value="k8s" className="text-xs"><Container className="w-3 h-3 mr-1" />K8s 集群</TabsTrigger>
|
||||
<TabsTrigger value="node" className="text-xs"><Server className="w-3 h-3 mr-1" />节点</TabsTrigger>
|
||||
<TabsTrigger value="pod" className="text-xs"><Box className="w-3 h-3 mr-1" />Pod</TabsTrigger>
|
||||
<TabsTrigger value="jvm" className="text-xs"><Zap className="w-3 h-3 mr-1" />JVM</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="k8s"><K8sPanel data={k8sData} /></TabsContent>
|
||||
<TabsContent value="node"><NodePanel data={nodeData} /></TabsContent>
|
||||
<TabsContent value="pod"><PodPanel data={podData} /></TabsContent>
|
||||
<TabsContent value="jvm"><JvmPanel data={jvmData} /></TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// 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) => <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>;
|
||||
const Box = (p: any) => <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>;
|
||||
|
||||
const K8sPanel: React.FC<{ d: any }> = ({ d }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-2xl font-bold text-purple-400">{d.podCount?.[0]?.value || 0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Pods</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-2xl font-bold text-blue-400">{d.deployCount?.[0]?.value || 0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Deployments</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-2xl font-bold text-emerald-400">{d.nodeCount?.[0]?.value || 0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Nodes</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-2xl font-bold text-amber-400">{d.nsPods?.length || 0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Namespaces</div></CardContent></Card>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] p-3 max-h-60 overflow-y-auto">
|
||||
<div className="text-xs font-medium mb-2 flex items-center gap-1"><Cpu className="w-3 h-3 text-purple-400" />节点 CPU / 内存利用率</div>
|
||||
{(d.nodeCPU || []).map((n: any, i: number) => {
|
||||
const mem = (d.nodeMem || []).find((m: any) => m.node === n.node);
|
||||
return <div key={i} className="flex items-center gap-3 text-[11px] mb-1.5"><span className="w-28 truncate" title={n.node}>{n.node}</span><MiniBar val={Number(n.value||0)} label={`${Number(n.value||0).toFixed(1)}%`} /><MiniBar val={Number(mem?.value||0)} label={`${Number(mem?.value||0).toFixed(1)}%`} color="bg-blue-500" /></div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const NodePanel: React.FC<{ d: any }> = ({ d }) => (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)]"><tr><th className="px-3 py-2 text-left">节点</th><th className="px-3 py-2 text-left">状态</th><th className="px-3 py-2 text-left">CPU</th><th className="px-3 py-2 text-left">内存</th><th className="px-3 py-2 text-left">磁盘</th><th className="px-3 py-2 text-left">流入</th><th className="px-3 py-2 text-left">流出</th></tr></thead>
|
||||
<tbody>
|
||||
{(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 <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2 font-medium">{n.nodename||inst}</td>
|
||||
<td className="px-3 py-2"><Badge className={`text-[10px] ${up?.value===1?'bg-emerald-500/20 text-emerald-400':'bg-red-500/20 text-red-400'}`}>{up?.value===1?'UP':'DOWN'}</Badge></td>
|
||||
<td className="px-3 py-2"><MiniBar val={Number(cpu?.value||0)} label={`${Number(cpu?.value||0).toFixed(1)}%`} /></td>
|
||||
<td className="px-3 py-2"><MiniBar val={Number(mem?.value||0)} label={`${Number(mem?.value||0).toFixed(1)}%`} color="bg-blue-500" /></td>
|
||||
<td className="px-3 py-2"><MiniBar val={Number(disk?.value||0)} label={`${Number(disk?.value||0).toFixed(1)}%`} color="bg-amber-500" /></td>
|
||||
<td className="px-3 py-2 font-mono text-[var(--dg-text-secondary)]">{fmtBps(Number(rx?.value||0))}</td>
|
||||
<td className="px-3 py-2 font-mono text-[var(--dg-text-secondary)]">{fmtBps(Number(tx?.value||0))}</td>
|
||||
</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PodPanel: React.FC<{ d: any }> = ({ d }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-xl font-bold text-emerald-400">{d.running?.[0]?.value||0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Running</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-xl font-bold text-amber-400">{d.pending?.[0]?.value||0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Pending</div></CardContent></Card>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] p-3 max-h-40 overflow-y-auto"><div className="text-xs font-medium mb-2">CPU Top 10</div>{(d.cpuTop||[]).map((p:any,i:number)=><div key={i} className="flex items-center gap-2 text-[11px] mb-1"><span className="w-10 text-right text-[var(--dg-text-muted)]">{i+1}</span><span className="w-32 truncate" title={p.pod}>{p.pod}</span><MiniBar val={Number(p.value||0)*100} label={`${Number(p.value||0).toFixed(2)}c`} color="bg-purple-500" /></div>)}</div>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] p-3 max-h-40 overflow-y-auto"><div className="text-xs font-medium mb-2">内存 Top 10</div>{(d.memTop||[]).map((p:any,i:number)=><div key={i} className="flex items-center gap-2 text-[11px] mb-1"><span className="w-10 text-right text-[var(--dg-text-muted)]">{i+1}</span><span className="w-32 truncate" title={p.pod}>{p.pod}</span><span className="ml-auto">{fmtBytes(Number(p.value||0))}</span></div>)}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const JvmPanel: React.FC<{ d: any }> = ({ d }) => {
|
||||
const h = Number(d.heapUsed?.[0]?.value||0);
|
||||
const uptime = Number(d.uptime?.[0]?.value||0);
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold">{uptime>86400?`${(uptime/86400).toFixed(1)}d`:`${(uptime/3600).toFixed(1)}h`}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Uptime</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><MiniBar val={h} label={`${h.toFixed(1)}%`} color={h>80?'bg-orange-500':'bg-purple-500'} /><div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Heap</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><MiniBar val={Number(d.nonHeapUsed?.[0]?.value||0)} label={`${Number(d.nonHeapUsed?.[0]?.value||0).toFixed(1)}%`} color="bg-blue-500" /><div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Non-Heap</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold text-emerald-400">{d.threadCount?.[0]?.value||0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Threads</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold text-amber-400">{Number(d.gcPause?.[0]?.value||0).toFixed(3)}</div><div className="text-[10px] text-[var(--dg-text-muted)]">GC Pause (s)</div></CardContent></Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PrometheusDashboard: React.FC<Props> = ({ 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<any>({});
|
||||
const [nodeD, setNodeD] = useState<any>({});
|
||||
const [podD, setPodD] = useState<any>({});
|
||||
const [jvmD, setJvmD] = useState<any>({});
|
||||
|
||||
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 (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-purple-500/20">
|
||||
<CardHeader className="py-3 px-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-purple-400" />
|
||||
<CardTitle className="text-sm font-medium">Prometheus 监控</CardTitle>
|
||||
{connected && <Badge className="text-[10px] bg-purple-500/10 text-purple-400">{targets} targets</Badge>}
|
||||
{connected && <div className="ml-auto"><Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={refresh}><RefreshCw className="w-3 h-3 mr-1" />刷新</Button></div>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3 pt-0">
|
||||
{loading ? (<div className="text-center py-12"><Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin text-purple-400" /><p className="text-xs text-[var(--dg-text-muted)]">连接中…</p></div>)
|
||||
: error ? (<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-xs text-red-400"><AlertTriangle className="h-4 w-4 inline mr-1" />{error}</div>)
|
||||
: (
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList className="bg-[var(--dg-bg-tertiary)] mb-3">
|
||||
<TabsTrigger value="k8s" className="text-xs"><Container className="w-3 h-3 mr-1" />K8s</TabsTrigger>
|
||||
<TabsTrigger value="node" className="text-xs"><Server className="w-3 h-3 mr-1" />Node</TabsTrigger>
|
||||
<TabsTrigger value="pod" className="text-xs"><Box className="w-3 h-3 mr-1" />Pod</TabsTrigger>
|
||||
<TabsTrigger value="jvm" className="text-xs"><Zap className="w-3 h-3 mr-1" />JVM</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="k8s"><K8sPanel d={k8sD} /></TabsContent>
|
||||
<TabsContent value="node"><NodePanel d={nodeD} /></TabsContent>
|
||||
<TabsContent value="pod"><PodPanel d={podD} /></TabsContent>
|
||||
<TabsContent value="jvm"><JvmPanel d={jvmD} /></TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrometheusDashboard;
|
||||
@@ -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<Props> = ({ dsUrl, dsUsername, dsPassword }) => {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hosts, setHosts] = useState<ZabbixHost[]>([]);
|
||||
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<SortField>('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 <ArrowUpDown className="w-3 h-3 text-slate-500" />;
|
||||
const SI = ({ f }: { f: SortField }) => {
|
||||
if (sortField !== f) return <ArrowUpDown className="w-3 h-3 text-slate-500" />;
|
||||
return sortDir === 'asc' ? <ArrowUp className="w-3 h-3 text-blue-400" /> : <ArrowDown className="w-3 h-3 text-blue-400" />;
|
||||
};
|
||||
|
||||
const usageBar = (val: number, label: string) => {
|
||||
const pct = Math.min(val, 100);
|
||||
const isWarn = val >= 80;
|
||||
return (
|
||||
const bar = (v: number, label: string) => (
|
||||
<div className="flex items-center gap-2 min-w-[90px]">
|
||||
<div className="flex-1 h-2.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all duration-500 ${isWarn ? 'bg-orange-500' : 'bg-blue-500'}`}
|
||||
style={{ width: `${pct}%` }} />
|
||||
<div className={`h-full rounded-full transition-all duration-500 ${v >= 80 ? 'bg-orange-500' : 'bg-blue-500'}`} style={{ width: `${Math.min(v, 100)}%` }} />
|
||||
</div>
|
||||
<span className={`w-14 text-right text-xs font-mono tabular-nums ${isWarn ? 'text-orange-400' : ''}`}>{label}</span>
|
||||
<span className={`w-14 text-right text-xs font-mono tabular-nums ${v >= 80 ? 'text-orange-400' : ''}`}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
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'
|
||||
? <Badge className="text-[10px] bg-emerald-500/20 text-emerald-400 border-emerald-500/30"><Wifi className="w-3 h-3 mr-1" />可用</Badge>
|
||||
: <Badge className="text-[10px] bg-red-500/20 text-red-400 border-red-500/30"><WifiOff className="w-3 h-3 mr-1" />不可用</Badge>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-blue-500/20">
|
||||
<CardHeader className="py-3 px-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-blue-400" />
|
||||
<CardTitle className="text-sm font-medium text-[var(--dg-text-primary)]">Zabbix 主机监控</CardTitle>
|
||||
{version && <Badge className="text-[10px] bg-blue-500/10 text-blue-400 border-blue-500/30">v{version}</Badge>}
|
||||
{connected && (
|
||||
<>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)] ml-2">{hosts.length} 台主机</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={refreshData}><RefreshCw className="w-3 h-3 mr-1" />刷新</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[10px] text-red-400" onClick={handleDisconnect}><X className="w-3 h-3 mr-1" />断开</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<CardTitle className="text-sm font-medium">Zabbix 主机监控</CardTitle>
|
||||
{version && <Badge className="text-[10px] bg-blue-500/10 text-blue-400">v{version}</Badge>}
|
||||
{connected && <span className="text-[10px] text-[var(--dg-text-muted)] ml-2">{hosts.length} 台</span>}
|
||||
{connected && <div className="ml-auto flex gap-1"><Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={refresh}><RefreshCw className="w-3 h-3 mr-1" />刷新</Button></div>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3 pt-0">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin text-blue-400" />
|
||||
<p className="text-xs">正在连接 Zabbix...</p>
|
||||
</div>
|
||||
) : !connected ? (
|
||||
<div>
|
||||
{error && (
|
||||
<div className="mb-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-xs text-red-400 flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{!showForm ? (
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)]">
|
||||
<Plug className="h-8 w-8 mx-auto mb-2 text-slate-600" />
|
||||
<p className="text-sm">接入 Zabbix 数据源以显示主机监控</p>
|
||||
<p className="text-xs mt-1 mb-4">支持 Zabbix 5.0 / 6.0 / 7.0</p>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button size="sm" variant="outline" onClick={() => setShowForm(true)}>
|
||||
<Settings className="w-3 h-3 mr-1" />配置连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-md mx-auto p-4 space-y-3 rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">Zabbix 连接配置</span>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => setShowForm(false)}><X className="w-3 h-3" /></Button>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">API URL</Label>
|
||||
<Input className="h-8 text-xs mt-1" placeholder="http://zabbix.example.com/api_jsonrpc.php"
|
||||
value={url} onChange={e => setUrl(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">用户名</Label>
|
||||
<Input className="h-8 text-xs mt-1" placeholder="Admin" value={username}
|
||||
onChange={e => setUsername(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">密码</Label>
|
||||
<Input className="h-8 text-xs mt-1" type="password" placeholder="••••••••" value={password}
|
||||
onChange={e => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<Button size="sm" className="w-full" onClick={handleConnect}>
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />连接 Zabbix
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : hosts.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)]">
|
||||
<Server className="h-8 w-8 mx-auto mb-2 text-slate-600" />
|
||||
<p className="text-sm">未找到被监控的 Zabbix 主机</p>
|
||||
</div>
|
||||
) : (
|
||||
{loading ? (<div className="text-center py-12"><Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin text-blue-400" /><p className="text-xs text-[var(--dg-text-muted)]">连接中…</p></div>)
|
||||
: error ? (<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-xs text-red-400 flex items-start gap-2"><AlertTriangle className="h-4 w-4 mt-0.5" /><span>{error}</span></div>)
|
||||
: hosts.length === 0 ? (<div className="text-center py-8 text-[var(--dg-text-muted)]"><Server className="h-8 w-8 mx-auto mb-2 text-slate-600" /><p className="text-sm">未找到被监控的 Zabbix 主机</p></div>)
|
||||
: (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]"><tr>
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<th key={field}
|
||||
className={`px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] cursor-pointer hover:text-[var(--dg-text-primary)] select-none whitespace-nowrap ${w}`}
|
||||
onClick={() => toggleSort(field)}>
|
||||
<div className="flex items-center gap-1">{label}<SortIcon field={field} /></div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
{ 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 }) => (
|
||||
<th key={f} className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] cursor-pointer hover:text-[var(--dg-text-primary)] select-none whitespace-nowrap" onClick={() => toggleSort(f)}>
|
||||
<div className="flex items-center gap-1">{l}<SI f={f} /></div></th>
|
||||
))}</tr></thead>
|
||||
<tbody>
|
||||
{sortedHosts.map((host, i) => (
|
||||
<tr key={host.hostid || i} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50 transition-colors">
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getAvailBadge(host.available)}
|
||||
<span className="font-medium text-[var(--dg-text-primary)]">{host.name || host.hostname}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap font-mono text-[var(--dg-text-secondary)]">{host.ip || '—'}</td>
|
||||
<td className="px-3 py-2.5">{usageBar(Number(host.cpu_usage || 0), `${Number(host.cpu_usage || 0).toFixed(1)}%`)}</td>
|
||||
<td className="px-3 py-2.5">{usageBar(Number(host.memory_usage || 0), `${Number(host.memory_usage || 0).toFixed(1)}%`)}</td>
|
||||
<td className="px-3 py-2.5">{usageBar(Number(host.disk_usage || 0), `${Number(host.disk_usage || 0).toFixed(1)}%`)}</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap text-[var(--dg-text-secondary)]">{host.load5 > 0 ? host.load5.toFixed(1) : '—'}</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap font-mono text-[var(--dg-text-secondary)]">{fmtBps(host.net_in_bps)}</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap font-mono text-[var(--dg-text-secondary)]">{fmtBps(host.net_out_bps)}</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap text-center">
|
||||
<span className={`font-mono font-bold ${getPriorityColor(host.problem_count)}`}>
|
||||
{host.problem_count > 0
|
||||
? <span className="inline-flex items-center gap-1"><AlertTriangle className="w-3 h-3" />{host.problem_count}</span>
|
||||
: <span className="text-emerald-400">0</span>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap text-[var(--dg-text-secondary)]">
|
||||
{host.uptime_days > 0 ? `${host.uptime_days}d` : '—'}
|
||||
</td>
|
||||
{sorted.map((h, i) => (
|
||||
<tr key={h.hostid || i} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2.5 whitespace-nowrap"><Badge className={`text-[10px] ${h.available === '1' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'}`}><Wifi className="w-3 h-3 mr-1" />{h.available === '1' ? 'UP' : 'DOWN'}</Badge> <span className="font-medium text-[var(--dg-text-primary)] ml-1">{h.name || h.hostname}</span></td>
|
||||
<td className="px-3 py-2.5">{bar(Number(h.cpu_usage || 0), `${Number(h.cpu_usage || 0).toFixed(1)}%`)}</td>
|
||||
<td className="px-3 py-2.5">{bar(Number(h.memory_usage || 0), `${Number(h.memory_usage || 0).toFixed(1)}%`)}</td>
|
||||
<td className="px-3 py-2.5">{bar(Number(h.disk_usage || 0), `${Number(h.disk_usage || 0).toFixed(1)}%`)}</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)]">{h.load5 > 0 ? h.load5.toFixed(1) : '—'}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">{fmtBps(h.net_in_bps)}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">{fmtBps(h.net_out_bps)}</td>
|
||||
<td className="px-3 py-2.5 text-center"><span className={`font-mono font-bold ${h.problem_count > 0 ? (h.problem_count > 2 ? 'text-red-400' : 'text-amber-400') : 'text-emerald-400'}`}>{h.problem_count > 0 ? <><AlertTriangle className="w-3 h-3 inline mr-1" />{h.problem_count}</> : '0'}</span></td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)]">{h.uptime_days > 0 ? `${h.uptime_days}d` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -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<string, string>) => 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 ====================
|
||||
|
||||
Reference in new issue
Block a user