snapshot: 数据源管理+大屏增强前快照

This commit is contained in:
attacker committed 2026-06-18 14:01:22 +00:00
1 parent 2bf3e7c308
commit 1726ad65ac
19 files changed
+2540 -273

No files matched your search

+3 -2
View File
@@ -82,8 +82,9 @@ func main() {
// collectAndReport 执行单次采集并上报;各项采集独立进行,单项失败不影响整体上报 // collectAndReport 执行单次采集并上报;各项采集独立进行,单项失败不影响整体上报
collectAndReport := func() { collectAndReport := func() {
report := &model.Report{ report := &model.Report{
Hostname: hostname, Hostname: hostname,
Timestamp: time.Now().UTC().Format(time.RFC3339), Timestamp: time.Now().UTC().Format(time.RFC3339),
AgentVersion: version,
} }
if stat, err := cpuCollector.Collect(); err != nil { if stat, err := cpuCollector.Collect(); err != nil {
+9 -8
View File
@@ -6,14 +6,15 @@ package model
// Report 为单次采集周期的完整上报报文 // Report 为单次采集周期的完整上报报文
type Report struct { type Report struct {
Hostname string `json:"hostname"` // 主机名 Hostname string `json:"hostname"` // 主机名
Timestamp string `json:"timestamp"` // RFC3339 格式的 UTC 时间戳 Timestamp string `json:"timestamp"` // RFC3339 格式的 UTC 时间戳
CPU CPUStat `json:"cpu"` // CPU 指标 AgentVersion string `json:"agent_version"` // Agent 版本号
Memory MemoryStat `json:"memory"` // 内存指标 CPU CPUStat `json:"cpu"` // CPU 指标
Disks []DiskStat `json:"disks"` // 磁盘指标列表 Memory MemoryStat `json:"memory"` // 内存指标
Ports []PortStat `json:"ports"` // 监听端口列表 Disks []DiskStat `json:"disks"` // 磁盘指标列表
Network NetworkStat `json:"network"` // 网络指标 Ports []PortStat `json:"ports"` // 监听端口列表
DiskIO DiskIOStat `json:"disk_io"` // 磁盘 IO 指标 Network NetworkStat `json:"network"` // 网络指标
DiskIO DiskIOStat `json:"disk_io"` // 磁盘 IO 指标
} }
// CPUStat CPU 相关指标 // CPUStat CPU 相关指标
+37 -14
View File
@@ -87,13 +87,15 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
result := h.db.Model(&models.Server{}). result := h.db.Model(&models.Server{}).
Where("hostname = ? OR ip = ?", req.Hostname, req.Hostname). Where("hostname = ? OR ip = ?", req.Hostname, req.Hostname).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"cpu_usage": req.CPU.UsagePercent, "cpu_usage": req.CPU.UsagePercent,
"memory_usage": req.Memory.UsagePercent, "memory_usage": req.Memory.UsagePercent,
"disk_usage": diskAvg, "disk_usage": diskAvg,
"cpu": req.CPU.CoreCount, "cpu": req.CPU.CoreCount,
"memory": memMB, "memory": memMB,
"disk": int64(totalDiskGB), "disk": int64(totalDiskGB),
"status": "running", "status": "running",
"agent_status": "online",
"agent_version": req.AgentVersion,
}) })
// WebSocket 广播:向所有前端客户端推送该主机的最新指标 // Logan.Li // WebSocket 广播:向所有前端客户端推送该主机的最新指标 // Logan.Li
@@ -155,20 +157,41 @@ func (h *AgentHandler) writeHotMetric(c *gin.Context, key string, value float64,
// GetLatestReports 获取各主机最新上报 // GetLatestReports 获取各主机最新上报
// GET /api/v1/agent/latest // GET /api/v1/agent/latest
func (h *AgentHandler) GetLatestReports(c *gin.Context) { func (h *AgentHandler) GetLatestReports(c *gin.Context) {
// 子查询:每个 hostname 最新的一条记录 var results []models.AgentLatestResponse
var reports []models.AgentReport
h.db.Raw(` h.db.Raw(`
SELECT * FROM agent_reports SELECT
WHERE id IN ( a.hostname,
COALESCE(s.ip, '') as ip,
a.cpu_usage,
a.cpu_cores,
a.mem_usage,
a.mem_total,
a.mem_used,
COALESCE(s.disk_usage, 0) as disk_usage,
a.disk_info,
a.net_info,
a.report_time,
COALESCE(s.agent_version, '') as agent_version,
COALESCE(s.agent_status, 'offline') as agent_status,
COALESCE(s.os, '') as os,
COALESCE(r.name, '') as region
FROM agent_reports a
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 GROUP BY hostname
) )
ORDER BY report_time DESC ORDER BY a.report_time DESC
`).Scan(&reports) `).Scan(&results)
if results == nil {
results = []models.AgentLatestResponse{}
}
c.JSON(http.StatusOK, models.Response{ c.JSON(http.StatusOK, models.Response{
Code: 200, Code: 200,
Message: "success", Message: "success",
Data: reports, Data: results,
}) })
} }
@@ -0,0 +1,352 @@
package handlers
import (
"crypto/tls"
"encoding/base64"
"encoding/json"
"net/http"
"time"
"ezops/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// PromDSHandler Prometheus 数据源管理 handler // Logan.Li
type PromDSHandler struct {
db *gorm.DB
}
// NewPromDSHandler 创建 Prometheus 数据源 handler
func NewPromDSHandler(db *gorm.DB) *PromDSHandler {
return &PromDSHandler{db: db}
}
// ============================================
// CRUD
// ============================================
// List 数据源列表
// GET /api/v1/prometheus-datasources
func (h *PromDSHandler) List(c *gin.Context) {
var sources []models.PrometheusDataSource
if err := h.db.Order("is_default DESC, id ASC").Find(&sources).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败: " + err.Error()})
return
}
if sources == nil {
sources = []models.PrometheusDataSource{}
}
c.JSON(http.StatusOK, models.Response{Code: 200, Data: sources})
}
// Get 获取单个数据源
// GET /api/v1/prometheus-datasources/:id
func (h *PromDSHandler) Get(c *gin.Context) {
id := c.Param("id")
var ds models.PrometheusDataSource
if err := h.db.First(&ds, id).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "数据源不存在"})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Data: ds})
}
// Create 创建数据源
// POST /api/v1/prometheus-datasources
func (h *PromDSHandler) Create(c *gin.Context) {
var req models.CreatePromDSReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
return
}
// 如果设为默认,先清除其他默认
if req.IsDefault {
h.db.Model(&models.PrometheusDataSource{}).Where("is_default = ?", true).Update("is_default", false)
}
// 默认值
if req.Access == "" {
req.Access = "server"
}
if req.HTTPMethod == "" {
req.HTTPMethod = "POST"
}
if req.ScrapeInterval <= 0 {
req.ScrapeInterval = 15
}
if req.QueryTimeout <= 0 {
req.QueryTimeout = 60
}
enableAlerting := true
if req.EnableAlerting != nil {
enableAlerting = *req.EnableAlerting
}
ds := models.PrometheusDataSource{
Name: req.Name,
URL: req.URL,
Access: req.Access,
BasicAuth: req.BasicAuth,
BasicAuthUser: req.BasicAuthUser,
BasicAuthPass: req.BasicAuthPass,
WithCredentials: req.WithCredentials,
TLSClientAuth: req.TLSClientAuth,
WithCACert: req.WithCACert,
CACert: req.CACert,
SkipTLSVerify: req.SkipTLSVerify,
CustomHeaders: req.CustomHeaders,
EnableAlerting: enableAlerting,
AlertmanagerDS: req.AlertmanagerDS,
ScrapeInterval: req.ScrapeInterval,
QueryTimeout: req.QueryTimeout,
HTTPMethod: req.HTTPMethod,
DisableMetricsLookup: req.DisableMetricsLookup,
CustomQueryParams: req.CustomQueryParams,
IsDefault: req.IsDefault,
Status: 1,
}
if err := h.db.Create(&ds).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败: " + err.Error()})
return
}
h.recordAudit(c, "create", "prometheus_datasource", ds.Name)
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: ds})
}
// Update 更新数据源
// PUT /api/v1/prometheus-datasources/:id
func (h *PromDSHandler) Update(c *gin.Context) {
id := c.Param("id")
var ds models.PrometheusDataSource
if err := h.db.First(&ds, id).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "数据源不存在"})
return
}
var req models.UpdatePromDSReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
return
}
updates := map[string]interface{}{}
if req.Name != nil {
updates["name"] = *req.Name
}
if req.URL != nil {
updates["url"] = *req.URL
}
if req.Access != nil {
updates["access"] = *req.Access
}
if req.BasicAuth != nil {
updates["basic_auth"] = *req.BasicAuth
}
if req.BasicAuthUser != nil {
updates["basic_auth_user"] = *req.BasicAuthUser
}
if req.BasicAuthPass != nil {
updates["basic_auth_pass"] = *req.BasicAuthPass
}
if req.WithCredentials != nil {
updates["with_credentials"] = *req.WithCredentials
}
if req.TLSClientAuth != nil {
updates["tls_client_auth"] = *req.TLSClientAuth
}
if req.WithCACert != nil {
updates["with_ca_cert"] = *req.WithCACert
}
if req.CACert != nil {
updates["ca_cert"] = *req.CACert
}
if req.SkipTLSVerify != nil {
updates["skip_tls_verify"] = *req.SkipTLSVerify
}
if req.CustomHeaders != nil {
updates["custom_headers"] = *req.CustomHeaders
}
if req.EnableAlerting != nil {
updates["enable_alerting"] = *req.EnableAlerting
}
if req.AlertmanagerDS != nil {
updates["alertmanager_ds"] = *req.AlertmanagerDS
}
if req.ScrapeInterval != nil {
updates["scrape_interval"] = *req.ScrapeInterval
}
if req.QueryTimeout != nil {
updates["query_timeout"] = *req.QueryTimeout
}
if req.HTTPMethod != nil {
updates["http_method"] = *req.HTTPMethod
}
if req.DisableMetricsLookup != nil {
updates["disable_metrics_lookup"] = *req.DisableMetricsLookup
}
if req.CustomQueryParams != nil {
updates["custom_query_params"] = *req.CustomQueryParams
}
if req.IsDefault != nil {
// 如果设为默认,先清除其他默认
if *req.IsDefault {
h.db.Model(&models.PrometheusDataSource{}).Where("is_default = ?", true).Update("is_default", false)
}
updates["is_default"] = *req.IsDefault
}
if req.Status != nil {
updates["status"] = *req.Status
}
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无更新字段"})
return
}
if err := h.db.Model(&ds).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败: " + err.Error()})
return
}
// 重新查询返回
h.db.First(&ds, id)
h.recordAudit(c, "update", "prometheus_datasource", ds.Name)
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: ds})
}
// Delete 删除数据源
// DELETE /api/v1/prometheus-datasources/:id
func (h *PromDSHandler) Delete(c *gin.Context) {
id := c.Param("id")
var ds models.PrometheusDataSource
if err := h.db.First(&ds, id).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "数据源不存在"})
return
}
if err := h.db.Delete(&ds).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败: " + err.Error()})
return
}
h.recordAudit(c, "delete", "prometheus_datasource", ds.Name)
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"})
}
// SetDefault 设为默认数据源
// POST /api/v1/prometheus-datasources/:id/default
func (h *PromDSHandler) SetDefault(c *gin.Context) {
id := c.Param("id")
var ds models.PrometheusDataSource
if err := h.db.First(&ds, id).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "数据源不存在"})
return
}
// 清除所有默认
h.db.Model(&models.PrometheusDataSource{}).Where("is_default = ?", true).Update("is_default", false)
// 设置当前为默认
h.db.Model(&ds).Update("is_default", true)
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已设为默认"})
}
// ============================================
// Test Connection
// ============================================
// TestConnection 测试数据源连接
// POST /api/v1/prometheus-datasources/test
func (h *PromDSHandler) TestConnection(c *gin.Context) {
var req models.TestPromDSReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
return
}
client := &http.Client{Timeout: 15 * time.Second}
// TLS 配置
if req.SkipTLSVerify {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}
// 构造请求
apiURL := req.URL + "/api/v1/query?query=up&time=" + time.Now().UTC().Format(time.RFC3339)
httpReq, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
c.JSON(http.StatusOK, models.Response{Code: 400, Data: gin.H{
"success": false, "message": "URL 格式错误: " + err.Error(),
}})
return
}
// Basic Auth
if req.BasicAuth {
auth := base64.StdEncoding.EncodeToString([]byte(req.BasicAuthUser + ":" + req.BasicAuthPass))
httpReq.Header.Set("Authorization", "Basic "+auth)
}
resp, err := client.Do(httpReq)
if err != nil {
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{
"success": false, "message": "连接失败: " + err.Error(),
}})
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
// 尝试解析响应获取 target 数量
var result struct {
Data struct {
Result []interface{} `json:"result"`
} `json:"data"`
}
json.NewDecoder(resp.Body).Decode(&result)
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{
"success": true,
"message": "连接成功",
"status_code": resp.StatusCode,
"target_count": len(result.Data.Result),
}})
} else {
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{
"success": false,
"message": "连接返回异常状态码",
"status_code": resp.StatusCode,
}})
}
}
// ============================================
// 审计
// ============================================
func (h *PromDSHandler) recordAudit(c *gin.Context, action, resource, name string) {
userID, _ := c.Get("userID")
username, _ := c.Get("username")
var uid uint
var uname string
if v, ok := userID.(uint); ok {
uid = v
}
if v, ok := username.(string); ok {
uname = v
}
h.db.Create(&models.AuditLog{
UserID: &uid,
Username: uname,
IP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
Module: "prometheus_datasource",
Action: action,
Resource: resource,
ResourceID: name,
Status: 200,
})
}
@@ -0,0 +1,331 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"ezops/internal/models"
"github.com/gin-gonic/gin"
)
// PrometheusProxyHandler Prometheus API 代理 — 转发 PromQL 查询到 Prometheus 服务器
type PrometheusProxyHandler struct{}
func NewPrometheusProxyHandler() *PrometheusProxyHandler {
return &PrometheusProxyHandler{}
}
// PromConnectReq 连接 Prometheus 请求
type PromConnectReq struct {
URL string `json:"url" binding:"required"`
}
// PromConnect 连接 Prometheus 并返回概览数据
// POST /api/v1/prometheus/connect
func (h *PrometheusProxyHandler) Connect(c *gin.Context) {
var req PromConnectReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
return
}
client := &http.Client{Timeout: 15 * time.Second}
// 测试连接:查询 up 指标
status, targetCount := h.testConnect(client, req.URL)
if !status {
c.JSON(http.StatusBadGateway, models.Response{Code: 502, Message: "无法连接到 Prometheus,请检查 URL"})
return
}
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "success",
Data: gin.H{
"status": "connected",
"targets": targetCount,
},
})
}
// PromQuery 执行 PromQL 即时查询
// POST /api/v1/prometheus/query
func (h *PrometheusProxyHandler) Query(c *gin.Context) {
var req struct {
URL string `json:"url" binding:"required"`
Query string `json:"query" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"})
return
}
client := &http.Client{Timeout: 15 * time.Second}
result, err := h.execPromQL(client, req.URL, req.Query)
if err != nil {
c.JSON(http.StatusBadGateway, models.Response{Code: 502, Message: "查询失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: result})
}
// PromQueryRange 执行 PromQL 范围查询
// POST /api/v1/prometheus/query_range
func (h *PrometheusProxyHandler) QueryRange(c *gin.Context) {
var req struct {
URL string `json:"url" binding:"required"`
Query string `json:"query" binding:"required"`
Start string `json:"start"` // RFC3339
End string `json:"end"`
Step string `json:"step"` // e.g. "15s", "1m"
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"})
return
}
if req.Step == "" {
req.Step = "1m"
}
client := &http.Client{Timeout: 30 * time.Second}
u := fmt.Sprintf("%s/api/v1/query_range?query=%s", strings.TrimRight(req.URL, "/"),
url.QueryEscape(req.Query))
if req.Start != "" {
u += "&start=" + url.QueryEscape(req.Start)
}
if req.End != "" {
u += "&end=" + url.QueryEscape(req.End)
}
u += "&step=" + url.QueryEscape(req.Step)
resp, err := client.Get(u)
if err != nil {
c.JSON(http.StatusBadGateway, models.Response{Code: 502, Message: "查询失败: " + err.Error()})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var promResult map[string]interface{}
json.Unmarshal(body, &promResult)
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: promResult})
}
// PromDashboard 获取预定义仪表盘数据(K8s / Node / Pod / JVM)
// POST /api/v1/prometheus/dashboard
func (h *PrometheusProxyHandler) Dashboard(c *gin.Context) {
var req struct {
URL string `json:"url" binding:"required"`
Type string `json:"type" binding:"required"` // k8s, node, pod, jvm
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"})
return
}
client := &http.Client{Timeout: 30 * time.Second}
var result map[string]interface{}
switch req.Type {
case "node":
result = h.dashboardNode(client, req.URL)
case "pod":
result = h.dashboardPod(client, req.URL)
case "jvm":
result = h.dashboardJVM(client, req.URL)
default:
result = h.dashboardK8s(client, req.URL)
}
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: result})
}
// ============================================
// 内部方法
// ============================================
func (h *PrometheusProxyHandler) testConnect(client *http.Client, baseURL string) (bool, int) {
result, err := h.execPromQL(client, baseURL, "count(up)")
if err != nil {
return false, 0
}
targets := 0
if data, ok := result["data"].(map[string]interface{}); ok {
if res, ok := data["result"].([]interface{}); ok {
if len(res) > 0 {
if val, ok := res[0].(map[string]interface{})["value"].([]interface{}); ok && len(val) > 1 {
targets, _ = strconv.Atoi(fmt.Sprintf("%v", val[1]))
}
}
}
}
return true, targets
}
func (h *PrometheusProxyHandler) execPromQL(client *http.Client, baseURL, query string) (map[string]interface{}, error) {
u := fmt.Sprintf("%s/api/v1/query?query=%s", strings.TrimRight(baseURL, "/"), url.QueryEscape(query))
resp, err := client.Get(u)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
if status, ok := result["status"].(string); !ok || status != "success" {
return nil, fmt.Errorf("prometheus error: %v", result)
}
return result, nil
}
func parseVector(result map[string]interface{}) []map[string]interface{} {
var out []map[string]interface{}
data, _ := result["data"].(map[string]interface{})
res, _ := data["result"].([]interface{})
for _, r := range res {
rm, _ := r.(map[string]interface{})
metric, _ := rm["metric"].(map[string]interface{})
value, _ := rm["value"].([]interface{})
entry := map[string]interface{}{}
for k, v := range metric {
entry[k] = v
}
if len(value) > 1 {
if v, err := strconv.ParseFloat(fmt.Sprintf("%v", value[1]), 64); err == nil {
entry["value"] = math.Round(v*100) / 100
}
}
out = append(out, entry)
}
return out
}
// ============================================
// 预定义仪表盘查询
// ============================================
func (h *PrometheusProxyHandler) dashboardNode(client *http.Client, baseURL string) map[string]interface{} {
results := gin.H{}
// 节点概览表
results["nodes"] = parseVector(h.mustQuery(client, baseURL, `node_uname_info - 0`))
// CPU
results["cpuUsage"] = parseVector(h.mustQuery(client, baseURL,
`(1 - avg(irate(node_cpu_seconds_total{mode="idle"}[5m])) by (instance)) * 100`))
// 内存
results["memUsage"] = parseVector(h.mustQuery(client, baseURL,
`(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100`))
// 磁盘
results["diskUsage"] = parseVector(h.mustQuery(client, baseURL,
`(1 - sum(node_filesystem_avail_bytes{fstype=~"xfs|ext.*"}) by (instance) / sum(node_filesystem_size_bytes{fstype=~"xfs|ext.*"}) by (instance)) * 100`))
// Load
results["load5"] = parseVector(h.mustQuery(client, baseURL, `node_load5`))
// 网络
results["netRx"] = parseVector(h.mustQuery(client, baseURL,
`max(irate(node_network_receive_bytes_total[5m])) by (instance)`))
results["netTx"] = parseVector(h.mustQuery(client, baseURL,
`max(irate(node_network_transmit_bytes_total[5m])) by (instance)`))
results["up"] = parseVector(h.mustQuery(client, baseURL, `up`))
return results
}
func (h *PrometheusProxyHandler) dashboardPod(client *http.Client, baseURL string) map[string]interface{} {
results := gin.H{}
results["cpuTop"] = parseVector(h.mustQuery(client, baseURL,
`topk(10, sum(rate(container_cpu_usage_seconds_total{container!="",pod!=""}[5m])) by (namespace, pod))`))
results["memTop"] = parseVector(h.mustQuery(client, baseURL,
`topk(10, sum(container_memory_working_set_bytes{container!="",pod!=""}) by (namespace, pod))`))
results["netRxTop"] = parseVector(h.mustQuery(client, baseURL,
`topk(10, sum(rate(container_network_receive_bytes_total{pod!=""}[5m])) by (namespace, pod))`))
results["netTxTop"] = parseVector(h.mustQuery(client, baseURL,
`topk(10, sum(rate(container_network_transmit_bytes_total{pod!=""}[5m])) by (namespace, pod))`))
results["restartTop"] = parseVector(h.mustQuery(client, baseURL,
`topk(10, sum(kube_pod_container_status_restarts_total) by (namespace, pod))`))
results["running"] = parseVector(h.mustQuery(client, baseURL,
`sum(kube_pod_status_phase{phase="Running"})`))
results["pending"] = parseVector(h.mustQuery(client, baseURL,
`sum(kube_pod_status_phase{phase="Pending"}) or vector(0)`))
return results
}
func (h *PrometheusProxyHandler) dashboardJVM(client *http.Client, baseURL string) map[string]interface{} {
results := gin.H{}
results["uptime"] = parseVector(h.mustQuery(client, baseURL, `process_uptime_seconds`))
results["heapUsed"] = parseVector(h.mustQuery(client, baseURL,
`sum(jvm_memory_used_bytes{area="heap"}) / sum(jvm_memory_max_bytes{area="heap"}) * 100`))
results["nonHeapUsed"] = parseVector(h.mustQuery(client, baseURL,
`sum(jvm_memory_used_bytes{area="nonheap"}) / sum(jvm_memory_max_bytes{area="nonheap"}) * 100`))
results["threadCount"] = parseVector(h.mustQuery(client, baseURL,
`jvm_threads_live_threads`))
results["gcPause"] = parseVector(h.mustQuery(client, baseURL,
`rate(jvm_gc_pause_seconds_sum[5m])`))
results["cpuLoad"] = parseVector(h.mustQuery(client, baseURL,
`process_cpu_usage`))
return results
}
func (h *PrometheusProxyHandler) dashboardK8s(client *http.Client, baseURL string) map[string]interface{} {
results := gin.H{}
// 节点数
results["nodeCount"] = parseVector(h.mustQuery(client, baseURL, `count(kube_node_info)`))
// Pod 总数
results["podCount"] = parseVector(h.mustQuery(client, baseURL,
`count(kube_pod_info{created_by_kind!~"<none>|Job"})`))
// 节点 CPU 利用率
results["nodeCPU"] = parseVector(h.mustQuery(client, baseURL,
`sum(irate(container_cpu_usage_seconds_total{container!=""}[2m])) by (node) / sum(kube_pod_container_resource_requests{resource="cpu"}) by (node) * 100`))
// 节点内存利用率
results["nodeMem"] = parseVector(h.mustQuery(client, baseURL,
`sum(container_memory_working_set_bytes{container!=""}) by (node) / sum(kube_pod_container_resource_requests{resource="memory"}) by (node) * 100`))
// Namespace 资源统计
results["nsPods"] = parseVector(h.mustQuery(client, baseURL,
`count(kube_pod_info) by (namespace)`))
// 部署统计
results["deployCount"] = parseVector(h.mustQuery(client, baseURL,
`count(kube_deployment_metadata_generation)`))
return results
}
func (h *PrometheusProxyHandler) mustQuery(client *http.Client, baseURL, query string) map[string]interface{} {
r, err := h.execPromQL(client, baseURL, query)
if err != nil {
return map[string]interface{}{"data": map[string]interface{}{"result": []interface{}{}}}
}
return r
}
@@ -0,0 +1,284 @@
package handlers
import (
"context"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
"ezops/internal/models"
"ezops/pkg/zabbix"
"github.com/gin-gonic/gin"
)
// ZabbixProxyHandler Zabbix 代理处理器 — 将 Zabbix API 数据转为前端面板可用格式
type ZabbixProxyHandler struct{}
func NewZabbixProxyHandler() *ZabbixProxyHandler {
return &ZabbixProxyHandler{}
}
// ZabbixHostSummary Zabbix 主机汇总(前端面板数据)
type ZabbixHostSummary struct {
HostID string `json:"hostid"`
Hostname string `json:"hostname"`
Name string `json:"name"`
IP string `json:"ip"`
Available string `json:"available"` // 0=未知 1=可用 2=不可用
CPUUsage float64 `json:"cpu_usage"`
MemoryUsage float64 `json:"memory_usage"`
DiskUsage float64 `json:"disk_usage"`
Load5 float64 `json:"load5"`
NetInBPS float64 `json:"net_in_bps"`
NetOutBPS float64 `json:"net_out_bps"`
ProblemCount int `json:"problem_count"`
Uptime int64 `json:"uptime_days"` // 天
CPUCount int `json:"cpu_count"`
TotalMemory float64 `json:"total_memory_gb"`
}
// ZabbixConnectReq 连接 Zabbix 请求
type ZabbixConnectReq struct {
URL string `json:"url" binding:"required"`
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// ZabbixConnect 连接 Zabbix 并返回主机摘要
// POST /api/v1/zabbix/connect
func (h *ZabbixProxyHandler) Connect(c *gin.Context) {
var req ZabbixConnectReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
return
}
client := zabbix.NewClient(req.URL, req.Username, req.Password)
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
if err := client.Auth(ctx); err != nil {
c.JSON(http.StatusBadGateway, models.Response{Code: 502, Message: "Zabbix 认证失败: " + err.Error()})
return
}
// 获取 Zabbix 版本
version, _ := client.GetVersion(ctx)
// 获取主机列表(含接口和组信息)
hosts, err := client.GetHosts(ctx, &zabbix.GetHostsParams{
Status: 0, // 只获取被监控的主机
SelectInterfaces: []string{"interfaceid", "hostid", "main", "type", "useip", "ip", "dns", "port"},
SelectGroups: []string{"groupid", "name"},
Output: []string{"hostid", "host", "name", "status", "available", "description"},
})
if err != nil {
client.Logout(ctx)
c.JSON(http.StatusBadGateway, models.Response{Code: 502, Message: "获取 Zabbix 主机失败: " + err.Error()})
return
}
// 为每台主机查询监控项
var summaries []ZabbixHostSummary
itemKeys := []string{
"system.cpu.util[,idle]", // CPU 空闲率
"system.cpu.util", // CPU 使用率(备用)
"vm.memory.size[total]", // 总内存 bytes
"vm.memory.size[available]", // 可用内存 bytes
"vfs.fs.size[/,total]", // 总磁盘 bytes
"vfs.fs.size[/,used]", // 已用磁盘 bytes
"system.cpu.load[all,avg5]", // 5分钟负载
"net.if.in[ens18]", // 网络流入
"net.if.out[ens18]", // 网络流出
"system.cpu.num", // CPU 数量
"system.uptime", // 运行时间
}
for _, host := range hosts {
summary := ZabbixHostSummary{
HostID: host.HostID,
Hostname: host.Host,
Name: host.Name,
Available: host.Available,
}
// 获取 IP
for _, iface := range host.Interfaces {
if iface.Main == "1" && summary.IP == "" {
if iface.UseIP == "1" && iface.IP != "" {
summary.IP = iface.IP
} else if iface.DNS != "" {
summary.IP = iface.DNS
}
}
}
// 获取监控项
items, err := client.GetItems(ctx, &zabbix.GetItemsParams{
HostIDs: []string{host.HostID},
Filter: map[string]interface{}{"key_": itemKeys},
Output: []string{"itemid", "hostid", "name", "key_", "lastvalue", "units"},
SortField: []string{"name"},
})
if err != nil {
summaries = append(summaries, summary)
continue
}
var totalMemBytes, availMemBytes, totalDiskBytes, usedDiskBytes float64
for _, item := range items {
val, _ := strconv.ParseFloat(item.LastValue, 64)
switch item.Key {
case "system.cpu.util[,idle]":
summary.CPUUsage = math.Round((100-val)*100) / 100
case "system.cpu.util":
if summary.CPUUsage == 0 {
summary.CPUUsage = math.Round(val*100) / 100
}
case "vm.memory.size[total]":
totalMemBytes = val
case "vm.memory.size[available]":
availMemBytes = val
case "vfs.fs.size[/,total]":
totalDiskBytes = val
case "vfs.fs.size[/,used]":
usedDiskBytes = val
case "system.cpu.load[all,avg5]":
summary.Load5 = math.Round(val*100) / 100
case "net.if.in[ens18]":
summary.NetInBPS = math.Round(val*100) / 100
case "net.if.out[ens18]":
summary.NetOutBPS = math.Round(val*100) / 100
case "system.cpu.num":
summary.CPUCount = int(val)
case "system.uptime":
summary.Uptime = int64(val) / 86400 // seconds → days
}
}
// 计算内存使用率
if totalMemBytes > 0 {
summary.TotalMemory = math.Round(totalMemBytes/1024/1024/1024*100) / 100 // GB
if availMemBytes > 0 {
summary.MemoryUsage = math.Round((totalMemBytes-availMemBytes)/totalMemBytes*10000) / 100
}
}
// 计算磁盘使用率
if totalDiskBytes > 0 && usedDiskBytes > 0 {
summary.DiskUsage = math.Round(usedDiskBytes/totalDiskBytes*10000) / 100
}
// 获取问题触发器
triggers, _ := client.GetTriggersByHost(ctx, host.HostID, true)
summary.ProblemCount = len(triggers)
summaries = append(summaries, summary)
}
client.Logout(ctx)
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "success",
Data: gin.H{
"version": version,
"hosts": summaries,
"total": len(summaries),
},
})
}
// ZabbixHostDetail Zabbix 单主机详情
// GET /api/v1/zabbix/host/:hostid
func (h *ZabbixProxyHandler) GetHostDetail(c *gin.Context) {
hostID := c.Param("hostid")
if hostID == "" {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "缺少主机ID"})
return
}
// 从 query 参数获取连接信息
url := c.Query("url")
username := c.Query("username")
password := c.Query("password")
if url == "" || username == "" {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "缺少 Zabbix 连接信息"})
return
}
client := zabbix.NewClient(url, username, password)
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
if err := client.Auth(ctx); err != nil {
c.JSON(http.StatusBadGateway, models.Response{Code: 502, Message: "Zabbix 认证失败: " + err.Error()})
return
}
defer client.Logout(ctx)
// 获取主机详情
host, err := client.GetHostByID(ctx, hostID)
if err != nil {
c.JSON(http.StatusBadGateway, models.Response{Code: 502, Message: "获取主机失败: " + err.Error()})
return
}
// 获取所有监控项
items, err := client.GetItemsByHost(ctx, hostID)
if err != nil {
c.JSON(http.StatusBadGateway, models.Response{Code: 502, Message: "获取监控项失败: " + err.Error()})
return
}
// 获取问题触发器
triggers, _ := client.GetTriggersByHost(ctx, hostID, true)
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "success",
Data: gin.H{
"host": host,
"items": items,
"triggers": triggers,
"problems": len(triggers),
},
})
}
// formatBps 格式化带宽
func formatBps(bps float64) string {
if bps == 0 {
return "—"
}
units := []string{"B/s", "KB/s", "MB/s", "GB/s", "TB/s"}
unitIdx := 0
val := bps
for val >= 1024 && unitIdx < len(units)-1 {
val /= 1024
unitIdx++
}
return fmt.Sprintf("%.1f %s", val, units[unitIdx])
}
// parseZabbixItemSummary 从 items 中提取汇总信息的辅助函数(供内部使用)
func parseZabbixItemSummary(items []zabbix.Item) map[string]string {
result := make(map[string]string)
for _, item := range items {
key := item.Key
// 标准化 key 名称
if strings.Contains(item.Name, "CPU utilization") || key == "system.cpu.util" {
result["cpu"] = item.LastValue
}
if strings.Contains(item.Name, "Memory utilization") || key == "vm.memory.size[pavailable]" {
result["memory"] = item.LastValue
}
if strings.Contains(item.Name, "Space utilization") || key == "/Space utilization/" {
result["disk"] = item.LastValue
}
}
return result
}
+31
View File
@@ -490,6 +490,37 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
snmpGroup.GET("/stats", snmpHandler.GetSNMPStats) snmpGroup.GET("/stats", snmpHandler.GetSNMPStats)
} }
// Zabbix 代理 — 镜像 Zabbix API 为前端面板可用格式 // Logan.Li
zabbixHandler := handlers.NewZabbixProxyHandler()
zabbixGroup := authorized.Group("/zabbix")
{
zabbixGroup.POST("/connect", zabbixHandler.Connect)
zabbixGroup.GET("/host/:hostid", zabbixHandler.GetHostDetail)
}
// Prometheus 代理 — 转发 PromQL 查询到 Prometheus 服务器 // Logan.Li
promHandler := handlers.NewPrometheusProxyHandler()
promGroup := authorized.Group("/prometheus")
{
promGroup.POST("/connect", promHandler.Connect)
promGroup.POST("/query", promHandler.Query)
promGroup.POST("/query_range", promHandler.QueryRange)
promGroup.POST("/dashboard", promHandler.Dashboard)
}
// Prometheus 数据源管理 — CRUD + 连接测试 // Logan.Li
promDSHandler := handlers.NewPromDSHandler(db)
promDSGroup := authorized.Group("/prometheus-datasources")
{
promDSGroup.GET("", promDSHandler.List)
promDSGroup.GET("/:id", promDSHandler.Get)
promDSGroup.POST("", promDSHandler.Create)
promDSGroup.PUT("/:id", promDSHandler.Update)
promDSGroup.DELETE("/:id", promDSHandler.Delete)
promDSGroup.POST("/:id/default", promDSHandler.SetDefault)
promDSGroup.POST("/test", promDSHandler.TestConnection)
}
// 站点拨测 — HTTP/TCP/DNS/PING 主动监控 // Logan.Li // 站点拨测 — HTTP/TCP/DNS/PING 主动监控 // Logan.Li
siteHandler := handlers.NewSiteHandler(db) siteHandler := handlers.NewSiteHandler(db)
siteGroup := authorized.Group("/site") siteGroup := authorized.Group("/site")
+28 -8
View File
@@ -32,16 +32,36 @@ func (AgentReport) TableName() string {
return "agent_reports" return "agent_reports"
} }
// AgentLatestResponse Agent 最新状态(含服务器元信息)// Logan.Li
type AgentLatestResponse struct {
Hostname string `json:"hostname"`
IP string `json:"ip"`
CPUUsage float64 `json:"cpu_usage"`
CPUCores int `json:"cpu_cores"`
MemUsage float64 `json:"mem_usage"`
MemTotal float64 `json:"mem_total"`
MemUsed float64 `json:"mem_used"`
DiskUsage float64 `json:"disk_usage"`
DiskInfo string `json:"disk_info"`
NetInfo string `json:"net_info"`
ReportTime string `json:"report_time"`
AgentVersion string `json:"agent_version"`
AgentStatus string `json:"agent_status"`
OS string `json:"os"`
Region string `json:"region"`
}
// AgentReportRequest Agent 上报请求体 // Logan.Li // AgentReportRequest Agent 上报请求体 // Logan.Li
type AgentReportRequest struct { type AgentReportRequest struct {
Hostname string `json:"hostname" binding:"required"` Hostname string `json:"hostname" binding:"required"`
Timestamp string `json:"timestamp"` Timestamp string `json:"timestamp"`
CPU CPUMetric `json:"cpu"` AgentVersion string `json:"agent_version"` // Agent 版本号
Memory MemMetric `json:"memory"` CPU CPUMetric `json:"cpu"`
Disks []DiskMetric `json:"disks"` Memory MemMetric `json:"memory"`
Ports []PortMetric `json:"ports"` Disks []DiskMetric `json:"disks"`
Network NetworkStat `json:"network"` // 网络指标 Ports []PortMetric `json:"ports"`
DiskIO DiskIOStat `json:"disk_io"` // 磁盘 IO Network NetworkStat `json:"network"` // 网络指标
DiskIO DiskIOStat `json:"disk_io"` // 磁盘 IO
} }
type CPUMetric struct { type CPUMetric struct {
@@ -0,0 +1,117 @@
package models
import (
"time"
)
// PrometheusDataSource Prometheus 数据源配置(参考 Grafana 界面设计) // Logan.Li
type PrometheusDataSource struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;uniqueIndex;comment:数据源名称" json:"name"`
URL string `gorm:"type:varchar(512);not null;comment:Prometheus 服务地址" json:"url"`
Access string `gorm:"type:varchar(16);default:'server';comment:访问模式: server/proxy" json:"access"`
// Basic Auth
BasicAuth bool `gorm:"default:false;comment:启用基础认证" json:"basic_auth"`
BasicAuthUser string `gorm:"type:varchar(128);comment:认证用户名" json:"basic_auth_user,omitempty"`
BasicAuthPass string `gorm:"type:varchar(256);comment:认证密码" json:"basic_auth_pass,omitempty"`
WithCredentials bool `gorm:"default:false;comment:携带凭证" json:"with_credentials"`
// TLS Client Auth
TLSClientAuth bool `gorm:"default:false;comment:启用TLS客户端认证" json:"tls_client_auth"`
WithCACert bool `gorm:"default:false;comment:携带CA证书" json:"with_ca_cert"`
CACert string `gorm:"type:text;comment:CA证书内容(PEM)" json:"ca_cert,omitempty"`
SkipTLSVerify bool `gorm:"default:false;comment:跳过TLS验证" json:"skip_tls_verify"`
// Custom HTTP Headers (JSON array of {header, value})
CustomHeaders string `gorm:"type:text;comment:自定义HTTP头(JSON)" json:"custom_headers,omitempty"`
// Alerting
EnableAlerting bool `gorm:"default:true;comment:启用告警管理" json:"enable_alerting"`
AlertmanagerDS string `gorm:"type:varchar(128);comment:Alertmanager数据源" json:"alertmanager_ds,omitempty"`
// 采集与查询
ScrapeInterval int `gorm:"default:15;comment:采集间隔(秒)" json:"scrape_interval"`
QueryTimeout int `gorm:"default:60;comment:查询超时(秒)" json:"query_timeout"`
HTTPMethod string `gorm:"type:varchar(8);default:'POST';comment:HTTP方法" json:"http_method"`
// 杂项
DisableMetricsLookup bool `gorm:"default:false;comment:禁用指标查找" json:"disable_metrics_lookup"`
CustomQueryParams string `gorm:"type:text;comment:自定义查询参数(JSON)" json:"custom_query_params,omitempty"`
// 状态
IsDefault bool `gorm:"default:false;index;comment:是否默认数据源" json:"is_default"`
Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"`
// 时间戳
CreatedAt time.Time `gorm:"not null;index;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
// TableName 指定表名
func (PrometheusDataSource) TableName() string {
return "prometheus_datasources"
}
// ============================================
// 请求/响应 struct
// ============================================
// CreatePromDSReq 创建数据源请求
type CreatePromDSReq struct {
Name string `json:"name" binding:"required"`
URL string `json:"url" binding:"required"`
Access string `json:"access"`
BasicAuth bool `json:"basic_auth"`
BasicAuthUser string `json:"basic_auth_user"`
BasicAuthPass string `json:"basic_auth_pass"`
WithCredentials bool `json:"with_credentials"`
TLSClientAuth bool `json:"tls_client_auth"`
WithCACert bool `json:"with_ca_cert"`
CACert string `json:"ca_cert"`
SkipTLSVerify bool `json:"skip_tls_verify"`
CustomHeaders string `json:"custom_headers"`
EnableAlerting *bool `json:"enable_alerting"`
AlertmanagerDS string `json:"alertmanager_ds"`
ScrapeInterval int `json:"scrape_interval"`
QueryTimeout int `json:"query_timeout"`
HTTPMethod string `json:"http_method"`
DisableMetricsLookup bool `json:"disable_metrics_lookup"`
CustomQueryParams string `json:"custom_query_params"`
IsDefault bool `json:"is_default"`
}
// UpdatePromDSReq 更新数据源请求
type UpdatePromDSReq struct {
Name *string `json:"name"`
URL *string `json:"url"`
Access *string `json:"access"`
BasicAuth *bool `json:"basic_auth"`
BasicAuthUser *string `json:"basic_auth_user"`
BasicAuthPass *string `json:"basic_auth_pass"`
WithCredentials *bool `json:"with_credentials"`
TLSClientAuth *bool `json:"tls_client_auth"`
WithCACert *bool `json:"with_ca_cert"`
CACert *string `json:"ca_cert"`
SkipTLSVerify *bool `json:"skip_tls_verify"`
CustomHeaders *string `json:"custom_headers"`
EnableAlerting *bool `json:"enable_alerting"`
AlertmanagerDS *string `json:"alertmanager_ds"`
ScrapeInterval *int `json:"scrape_interval"`
QueryTimeout *int `json:"query_timeout"`
HTTPMethod *string `json:"http_method"`
DisableMetricsLookup *bool `json:"disable_metrics_lookup"`
CustomQueryParams *string `json:"custom_query_params"`
IsDefault *bool `json:"is_default"`
Status *int `json:"status"`
}
// TestPromDSReq 测试数据源连接请求
type TestPromDSReq struct {
URL string `json:"url" binding:"required"`
BasicAuth bool `json:"basic_auth"`
BasicAuthUser string `json:"basic_auth_user"`
BasicAuthPass string `json:"basic_auth_pass"`
SkipTLSVerify bool `json:"skip_tls_verify"`
}
+2 -1
View File
@@ -129,7 +129,8 @@ func AutoMigrate(db *gorm.DB) error {
&models.AlertUpgrade{}, // 告警升级 // Logan.Li &models.AlertUpgrade{}, // 告警升级 // Logan.Li
&models.AlertUpgradeRecord{},// 告警升级记录 // Logan.Li &models.AlertUpgradeRecord{},// 告警升级记录 // Logan.Li
&models.AlertSilence{}, // 告警静默 // Logan.Li &models.AlertSilence{}, // 告警静默 // Logan.Li
&models.AlertEventV2{}, // 告警事件V2 // Logan.Li &models.AlertEventV2{}, // 告警事件V2 // Logan.Li
&models.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li
} }
var lastErr error var lastErr error
+1
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strconv"
"sync" "sync"
"time" "time"
) )
+2 -2
View File
@@ -30,7 +30,7 @@ import { AIAlertAnalyzer } from '@/components/aiops/AIAlertAnalyzer';
import { AIPredictiveAnalytics } from '@/components/aiops/AIPredictiveAnalytics'; import { AIPredictiveAnalytics } from '@/components/aiops/AIPredictiveAnalytics';
import AlertCenter from '@/components/monitor/alert-center/AlertCenter'; import AlertCenter from '@/components/monitor/alert-center/AlertCenter';
import TraceManager from '@/components/monitor/trace/TraceManager'; import TraceManager from '@/components/monitor/trace/TraceManager';
import GrafanaDashboard from '@/components/monitor/dashboard/GrafanaDashboard'; import HostMonitorDashboard from '@/components/monitor/dashboard/HostMonitorDashboard';
import { MonitorCenter } from '@/pages/MonitorCenter'; import { MonitorCenter } from '@/pages/MonitorCenter';
import { MetricsExplorer } from '@/pages/MetricsExplorer'; import { MetricsExplorer } from '@/pages/MetricsExplorer';
@@ -73,7 +73,7 @@ function App() {
<Route path="metrics" element={<MetricsExplorer />} /> <Route path="metrics" element={<MetricsExplorer />} />
<Route path="alert-center" element={<AlertCenter />} /> <Route path="alert-center" element={<AlertCenter />} />
<Route path="trace" element={<TraceManager />} /> <Route path="trace" element={<TraceManager />} />
<Route path="dashboards" element={<GrafanaDashboard />} /> <Route path="dashboards" element={<HostMonitorDashboard />} />
<Route path="alerts" element={<AlertManager />} /> <Route path="alerts" element={<AlertManager />} />
<Route path="logs" element={<AlertManager />} /> <Route path="logs" element={<AlertManager />} />
<Route path="approval" element={<ApprovalFlowManager />} /> <Route path="approval" element={<ApprovalFlowManager />} />
+1 -1
View File
@@ -86,9 +86,9 @@ const menuItems: MenuItem[] = [
name: '监控中心', name: '监控中心',
icon: Bell, icon: Bell,
children: [ children: [
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/dashboards' },
{ id: 'monitor-center', name: '资源监控', icon: Activity, path: '/monitor-center' }, { id: 'monitor-center', name: '资源监控', icon: Activity, path: '/monitor-center' },
{ id: 'metrics', name: '指标监控', icon: Cpu, path: '/metrics' }, { id: 'metrics', name: '指标监控', icon: Cpu, path: '/metrics' },
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/dashboards' },
{ id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace' }, { 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' },
], ],
@@ -3,7 +3,8 @@ import {
Monitor, Settings, Plus, Edit, Trash2, Copy, Eye, Download, RefreshCw, Monitor, Settings, Plus, Edit, Trash2, Copy, Eye, Download, RefreshCw,
Maximize2, Minimize2, Save, X, ChevronDown, BarChart3, LineChart, PieChart, Maximize2, Minimize2, Save, X, ChevronDown, BarChart3, LineChart, PieChart,
Activity, Cpu, Database, Server, Globe, Zap, AlertTriangle, TrendingUp, HardDrive, Activity, Cpu, Database, Server, Globe, Zap, AlertTriangle, TrendingUp, HardDrive,
TrendingDown, Clock, Calendar, Filter, Search TrendingDown, Clock, Calendar, Filter, Search, ArrowUpDown, ArrowUp, ArrowDown,
Wifi, WifiOff
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -15,15 +16,16 @@ import {
import { import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import { dashboardApi } from '@/services/api'; import { dashboardApi } from '@/services/api';
import { GrafanaPanelContent } from './GrafanaPanelContent'; import { GrafanaPanelContent } from './GrafanaPanelContent';
import { ZabbixDashboard } from './ZabbixDashboard';
import { PrometheusDashboard } from './PrometheusDashboard';
import { http } from '@/services/http'; import { http } from '@/services/http';
import { asList } from '@/components/agent/agentUtils';
interface Dashboard { interface Dashboard {
id: string; id: string;
@@ -75,6 +77,27 @@ interface Datasource {
status: 'connected' | 'disconnected' | 'error'; 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;
}
type SortField = 'hostname' | 'agent_version' | 'agent_status' | 'cpu_usage' | 'mem_usage' | 'disk_usage' | 'region' | 'ip';
const GrafanaDashboard: React.FC = () => { const GrafanaDashboard: React.FC = () => {
const _confirm = useConfirm(); const _confirm = useConfirm();
const [dashboards, setDashboards] = useState<Dashboard[]>([]); const [dashboards, setDashboards] = useState<Dashboard[]>([]);
@@ -108,38 +131,79 @@ const GrafanaDashboard: React.FC = () => {
url: '' url: ''
}); });
// Agent 主机监控 — 默认面板 // ==================== Agent 主机监控 ====================
const [hostTop, setHostTop] = useState<any[]>([]); 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(() => { useEffect(() => {
(async () => { fetchHosts();
try { const timer = setInterval(fetchHosts, 30000);
const [cpu, mem, disk] = await Promise.all([
http.get('/metrics/top?metric=cpu&limit=10'),
http.get('/metrics/top?metric=memory&limit=10'),
http.get('/metrics/top?metric=disk&limit=10'),
]);
setHostTop([
{ name: 'CPU', data: Array.isArray(cpu) ? cpu : [], key: 'cpu_usage', unit: '%', icon: 'cpu' },
{ name: '内存', data: Array.isArray(mem) ? mem : [], key: 'memory_usage', unit: '%', icon: 'mem' },
{ name: '磁盘', data: Array.isArray(disk) ? disk : [], key: 'disk_usage', unit: '%', icon: 'disk' },
]);
} catch { /* silent */ }
})();
const timer = setInterval(async () => {
try {
const cpu = await http.get('/metrics/top?metric=cpu&limit=10');
const mem = await http.get('/metrics/top?metric=memory&limit=10');
const disk = await http.get('/metrics/top?metric=disk&limit=10');
setHostTop([
{ name: 'CPU', data: Array.isArray(cpu) ? cpu : [], key: 'cpu_usage', unit: '%', icon: 'cpu' },
{ name: '内存', data: Array.isArray(mem) ? mem : [], key: 'memory_usage', unit: '%', icon: 'mem' },
{ name: '磁盘', data: Array.isArray(disk) ? disk : [], key: 'disk_usage', unit: '%', icon: 'disk' },
]);
} catch { /* silent */ }
}, 30000);
return () => clearInterval(timer); 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 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>
);
};
// ==================== 仪表盘 CRUD ====================
useEffect(() => { useEffect(() => {
fetchDashboards(); fetchDashboards();
fetchDatasources(); fetchDatasources();
@@ -271,78 +335,10 @@ const GrafanaDashboard: React.FC = () => {
} }
}; };
const getDatasourceStatusBadge = (status: string) => {
switch (status) {
case 'connected':
return <Badge variant="default" className="bg-green-500">已连接</Badge>;
case 'disconnected':
return <Badge variant="secondary">已断开</Badge>;
case 'error':
return <Badge variant="destructive">错误</Badge>;
default:
return <Badge variant="outline">未知</Badge>;
}
};
return ( return (
<div className={`space-y-4 ${fullscreen ? 'fixed inset-0 z-50 bg-background p-4' : ''}`}> <div className={`space-y-4 ${fullscreen ? 'fixed inset-0 z-50 bg-background p-4' : ''}`}>
{/* ===== 默认:Agent 主机监控面板 ===== */}
<Card className="bg-[var(--dg-bg-secondary)] border-emerald-500/20">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-emerald-400" />
<CardTitle className="text-base text-[var(--dg-text-primary)]">Agent 主机监控</CardTitle>
<Badge variant="outline" className="text-xs bg-emerald-500/10 text-emerald-400 border-emerald-500/30">默认</Badge>
<span className="text-xs text-[var(--dg-text-muted)] ml-auto">每 30s 刷新</span>
</div>
</CardHeader>
<CardContent className="pt-0">
{hostTop.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">暂无 Agent 上报数据</p>
<p className="text-xs mt-1">请确保至少一台主机安装了 AIOps Agent 并正常上报</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{hostTop.map((group: any) => (
<div key={group.name} className="bg-[var(--dg-bg-primary)] rounded-lg p-3 border border-[var(--dg-border)]">
<div className="flex items-center gap-2 mb-2">
{group.icon === 'cpu' && <Cpu className="h-4 w-4 text-red-400" />}
{group.icon === 'mem' && <Server className="h-4 w-4 text-amber-400" />}
{group.icon === 'disk' && <HardDrive className="h-4 w-4 text-orange-400" />}
<span className="text-xs font-medium text-[var(--dg-text-primary)]">{group.name} Top 10</span>
</div>
<div className="space-y-1">
{group.data.slice(0, 10).map((h: any, i: number) => {
const val = Number(h[group.key] || 0);
const barPct = Math.min(val, 100);
const barColor = val > 85 ? 'bg-red-500' : val > 60 ? 'bg-amber-500' : 'bg-emerald-500';
const dotColor = i < 3 ? (i === 0 ? 'text-red-400' : i === 1 ? 'text-amber-400' : 'text-orange-400') : 'text-slate-500';
return (
<div key={i} className="flex items-center gap-1.5 text-[11px]">
<span className={`w-4 text-right font-mono font-bold ${dotColor}`}>
{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}`}
</span>
<span className="w-24 truncate text-[var(--dg-text-secondary)]" title={h.hostname}>
{h.hostname}
</span>
<div className="flex-1 h-1 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${barPct}%` }} />
</div>
<span className="w-12 text-right font-mono text-[var(--dg-text-primary)]">{val.toFixed(1)}{group.unit}</span>
</div>
);
})}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 顶部工具栏 */} {/* ===== 顶部工具栏 ===== */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Select <Select
@@ -407,7 +403,7 @@ const GrafanaDashboard: React.FC = () => {
</div> </div>
</div> </div>
{/* 仪表盘内容 */} {/* ===== 仪表盘内容 ===== */}
{activeDashboard ? ( {activeDashboard ? (
<div className="space-y-4"> <div className="space-y-4">
{/* 仪表盘信息 */} {/* 仪表盘信息 */}
@@ -424,6 +420,110 @@ const GrafanaDashboard: React.FC = () => {
</div> </div>
</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>
</th>
))}
</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>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Zabbix 数据源面板 */}
<ZabbixDashboard />
{/* Prometheus 数据源面板 */}
<PrometheusDashboard />
{/* 面板网格 */} {/* 面板网格 */}
<div className="grid grid-cols-12 gap-4"> <div className="grid grid-cols-12 gap-4">
{activeDashboard.panels.map((panel) => ( {activeDashboard.panels.map((panel) => (
@@ -438,7 +538,6 @@ const GrafanaDashboard: React.FC = () => {
{getPanelIcon(panel.type)} {getPanelIcon(panel.type)}
{panel.title} {panel.title}
</CardTitle> </CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="p-4 pt-0 flex-1"> <CardContent className="p-4 pt-0 flex-1">
@@ -508,20 +607,17 @@ const GrafanaDashboard: React.FC = () => {
</Select> </Select>
</div> </div>
<div> <div>
<Label>数据源地址</Label> <Label>数据源 URL</Label>
<Input <Input
value={newDashboard.datasourceUrl} value={newDashboard.datasourceUrl}
onChange={(e) => setNewDashboard({ ...newDashboard, datasourceUrl: e.target.value })} onChange={(e) => setNewDashboard({ ...newDashboard, datasourceUrl: e.target.value })}
placeholder="http://localhost:9090" placeholder="http://prometheus:9090"
/> />
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button> <Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
<Button onClick={handleCreateDashboard}> <Button onClick={handleCreateDashboard}>创建</Button>
<Save className="w-4 h-4 mr-2" />
创建
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -531,19 +627,19 @@ const GrafanaDashboard: React.FC = () => {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>添加面板</DialogTitle> <DialogTitle>添加面板</DialogTitle>
<DialogDescription>添加一个新的监控面板</DialogDescription> <DialogDescription>向当前仪表盘添加一个监控面板</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label>标题</Label> <Label>面板标题</Label>
<Input <Input
value={newPanel.title} value={newPanel.title}
onChange={(e) => setNewPanel({ ...newPanel, title: e.target.value })} onChange={(e) => setNewPanel({ ...newPanel, title: e.target.value })}
placeholder="面板标题" placeholder="CPU 使用率"
/> />
</div> </div>
<div> <div>
<Label>类型</Label> <Label>图表类型</Label>
<Select <Select
value={newPanel.type} value={newPanel.type}
onValueChange={(v) => setNewPanel({ ...newPanel, type: v as any })} onValueChange={(v) => setNewPanel({ ...newPanel, type: v as any })}
@@ -553,149 +649,101 @@ const GrafanaDashboard: React.FC = () => {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="graph">折线图</SelectItem> <SelectItem value="graph">折线图</SelectItem>
<SelectItem value="stat">统计值</SelectItem> <SelectItem value="stat">数值</SelectItem>
<SelectItem value="gauge">仪表盘</SelectItem> <SelectItem value="gauge">仪表盘</SelectItem>
<SelectItem value="pie">饼图</SelectItem> <SelectItem value="pie">饼图</SelectItem>
<SelectItem value="table">表格</SelectItem> <SelectItem value="table">表格</SelectItem>
<SelectItem value="heatmap">热力图</SelectItem> <SelectItem value="text">文本</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div> <div>
<Label>查询语句</Label> <Label>PromQL / 查询语句</Label>
<textarea <Input
value={newPanel.query} value={newPanel.query}
onChange={(e) => setNewPanel({ ...newPanel, query: e.target.value })} onChange={(e) => setNewPanel({ ...newPanel, query: e.target.value })}
placeholder={newPanel.type === 'graph' ? 'sum(rate(http_requests_total[5m]))' : 'up'} placeholder="rate(node_cpu_seconds_total[5m])"
className="w-full min-h-[100px] p-2 border rounded-md font-mono text-sm"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>宽度 (列)</Label>
<Select
value={newPanel.width.toString()}
onValueChange={(v) => setNewPanel({ ...newPanel, width: parseInt(v) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3列 (25%)</SelectItem>
<SelectItem value="4">4列 (33%)</SelectItem>
<SelectItem value="6">6列 (50%)</SelectItem>
<SelectItem value="8">8列 (66%)</SelectItem>
<SelectItem value="12">12列 (100%)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>高度 (行)</Label>
<Select
value={newPanel.height.toString()}
onValueChange={(v) => setNewPanel({ ...newPanel, height: parseInt(v) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3行 (短)</SelectItem>
<SelectItem value="4">4行 (中)</SelectItem>
<SelectItem value="6">6行 (长)</SelectItem>
<SelectItem value="8">8行 (超长)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setAddPanelOpen(false)}>取消</Button> <Button variant="outline" onClick={() => setAddPanelOpen(false)}>取消</Button>
<Button onClick={handleAddPanel}> <Button onClick={handleAddPanel}>添加</Button>
<Plus className="w-4 h-4 mr-2" />
添加
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 数据源管理对话框 */} {/* 数据源对话框 */}
<Dialog open={datasourceOpen} onOpenChange={setDatasourceOpen}> <Dialog open={datasourceOpen} onOpenChange={setDatasourceOpen}>
<DialogContent className="max-w-3xl"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>数据源管理</DialogTitle> <DialogTitle>管理数据源</DialogTitle>
<DialogDescription>管理监控数据源配置</DialogDescription> <DialogDescription>配置 Prometheus / Zabbix / SkyWalking 等外部数据源连接</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3">
<Tabs defaultValue="list"> {datasources.length === 0 ? (
<TabsList className="grid w-full grid-cols-2"> <p className="text-sm text-muted-foreground py-4 text-center">暂无数据源配置</p>
<TabsTrigger value="list">数据源列表</TabsTrigger> ) : (
<TabsTrigger value="add">添加数据源</TabsTrigger> datasources.map(ds => (
</TabsList> <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">
<TabsContent value="list" className="space-y-4"> {getDatasourceIcon(ds.type)}
<div className="grid grid-cols-1 gap-4"> <div>
{datasources.map((ds) => ( <p className="text-sm font-medium">{ds.name}</p>
<Card key={ds.id}> <p className="text-xs text-muted-foreground">{ds.url}</p>
<CardContent className="p-4"> </div>
<div className="flex items-center justify-between"> </div>
<div className="flex items-center gap-3"> <Badge
{getDatasourceIcon(ds.type)} variant={ds.status === 'connected' ? 'default' : 'secondary'}
<div> className={ds.status === 'connected' ? 'bg-green-500' : ds.status === 'error' ? 'bg-red-500' : ''}
<p className="font-medium">{ds.name}</p> >
<p className="text-sm text-muted-foreground">{ds.url}</p> {ds.status === 'connected' ? '已连接' : ds.status === 'error' ? '错误' : '已断开'}
</div> </Badge>
</div> </div>
<div className="flex items-center gap-2"> ))
{getDatasourceStatusBadge(ds.status)} )}
</div> </div>
</div> <Separator className="my-2" />
</CardContent> <div className="space-y-3">
</Card> <div>
))} <Label>数据源名称</Label>
</div> <Input
</TabsContent> value={newDatasource.name}
onChange={(e) => setNewDatasource({ ...newDatasource, name: e.target.value })}
<TabsContent value="add" className="space-y-4"> placeholder="My Prometheus"
<div> />
<Label>名称</Label> </div>
<Input <div>
value={newDatasource.name} <Label>类型</Label>
onChange={(e) => setNewDatasource({ ...newDatasource, name: e.target.value })} <Select
placeholder="数据源名称" value={newDatasource.type}
/> onValueChange={(v) => setNewDatasource({ ...newDatasource, type: v as any })}
</div> >
<div> <SelectTrigger>
<Label>类型</Label> <SelectValue />
<Select </SelectTrigger>
value={newDatasource.type} <SelectContent>
onValueChange={(v) => setNewDatasource({ ...newDatasource, type: v as any })} <SelectItem value="prometheus">Prometheus</SelectItem>
> <SelectItem value="zabbix">Zabbix</SelectItem>
<SelectTrigger> <SelectItem value="skywalking">SkyWalking</SelectItem>
<SelectValue /> <SelectItem value="influxdb">InfluxDB</SelectItem>
</SelectTrigger> <SelectItem value="elasticsearch">Elasticsearch</SelectItem>
<SelectContent> </SelectContent>
<SelectItem value="prometheus">Prometheus</SelectItem> </Select>
<SelectItem value="zabbix">Zabbix</SelectItem> </div>
<SelectItem value="skywalking">SkyWalking</SelectItem> <div>
<SelectItem value="influxdb">InfluxDB</SelectItem> <Label>URL</Label>
<SelectItem value="elasticsearch">Elasticsearch</SelectItem> <Input
</SelectContent> value={newDatasource.url}
</Select> onChange={(e) => setNewDatasource({ ...newDatasource, url: e.target.value })}
</div> placeholder="http://prometheus:9090"
<div> />
<Label>地址</Label> </div>
<Input </div>
value={newDatasource.url} <DialogFooter>
onChange={(e) => setNewDatasource({ ...newDatasource, url: e.target.value })} <Button variant="outline" onClick={() => setDatasourceOpen(false)}>关闭</Button>
placeholder="http://localhost:9090" <Button onClick={handleAddDatasource}>添加数据源</Button>
/> </DialogFooter>
</div>
<Button onClick={handleAddDatasource} className="w-full">
<Plus className="w-4 h-4 mr-2" />
添加数据源
</Button>
</TabsContent>
</Tabs>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
@@ -0,0 +1,366 @@
// 主机监控大屏 — 列表视图 + 可排序 + 操作栏 // Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Server, Cpu, HardDrive, Activity, Monitor, Bell,
ArrowUpDown, ArrowUp, ArrowDown, RefreshCw, Search,
ChevronLeft, ChevronRight
} 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 {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select';
import { serverApi } from '@/services/api';
type SortField = 'hostname' | 'ip' | 'agent_version' | 'agent_status' | 'cpu_usage' | 'memory_usage' | 'disk_usage' | 'region';
type SortDir = 'asc' | 'desc';
function getUsageBar(val: number) {
const v = val || 0;
const color = v > 85 ? 'bg-red-500' : v > 60 ? 'bg-amber-500' : 'bg-emerald-500';
return (
<div className="flex items-center gap-1.5 min-w-[80px]">
<div className="flex-1 h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${Math.min(v, 100)}%` }} />
</div>
<span className="text-xs font-mono w-10 text-right text-[var(--dg-text-primary)]">{v.toFixed(0)}%</span>
</div>
);
}
export default function HostMonitorDashboard() {
const navigate = useNavigate();
const [hosts, setHosts] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(15);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// 排序状态
const [sortField, setSortField] = useState<SortField>('hostname');
const [sortDir, setSortDir] = useState<SortDir>('asc');
// 统计
const [stats, setStats] = useState<{ online: number; offline: number; total: number }>({ online: 0, offline: 0, total: 0 });
const loadData = useCallback(async (isRefresh = false) => {
if (isRefresh) setRefreshing(true);
else setLoading(true);
try {
const params: any = { page, page_size: pageSize };
if (search.trim()) params.keyword = search.trim();
const res = await serverApi.getServers(params);
const list = (res as any)?.list || [];
setHosts(list);
setTotal((res as any)?.total || 0);
// 统计
const online = list.filter((h: any) => h.agent_status === 'online').length;
setStats({ online, offline: list.length - online, total: (res as any)?.total || 0 });
} catch {
setHosts([]);
setTotal(0);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [page, pageSize, search]);
useEffect(() => { loadData(); }, [loadData]);
// 排序后的数据
const sorted = [...hosts].sort((a: any, b: any) => {
let va: any, vb: any;
if (sortField === 'region') {
va = (a.region?.name || a.region?.Name || '').toLowerCase();
vb = (b.region?.name || b.region?.Name || '').toLowerCase();
} else if (sortField === 'agent_status') {
va = a.agent_status || 'offline';
vb = b.agent_status || 'offline';
} else if (sortField === 'agent_version') {
va = a.agent_version || '';
vb = b.agent_version || '';
} else {
va = a[sortField] || '';
vb = b[sortField] || '';
}
if (typeof va === 'number' && typeof vb === 'number') {
return sortDir === 'asc' ? va - vb : vb - va;
}
const sa = String(va).toLowerCase();
const sb = String(vb).toLowerCase();
if (sa < sb) return sortDir === 'asc' ? -1 : 1;
if (sa > sb) return sortDir === 'asc' ? 1 : -1;
return 0;
});
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDir('asc');
}
};
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-blue-400" />
: <ArrowDown className="w-3 h-3 text-blue-400" />;
};
const totalPages = Math.ceil(total / pageSize);
const handleSearch = () => {
setPage(1);
loadData();
};
// 状态标签颜色
const statusBadge = (status: string) => {
if (status === 'online') return <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-[11px]">在线</Badge>;
if (status === 'running') return <Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-[11px]">运行中</Badge>;
if (status === 'stopped') return <Badge className="bg-slate-500/20 text-slate-400 border-slate-500/30 text-[11px]">已停止</Badge>;
return <Badge variant="outline" className="text-[11px] text-slate-500">{status || '--'}</Badge>;
};
return (
<div className="space-y-4">
{/* 统计卡片 */}
<div className="grid grid-cols-3 gap-3">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-emerald-500/10"><Server className="h-5 w-5 text-emerald-400" /></div>
<div>
<div className="text-2xl font-bold text-[var(--dg-text-primary)]">{stats.online}</div>
<div className="text-xs text-[var(--dg-text-muted)]">Agent 在线</div>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-slate-500/10"><Server className="h-5 w-5 text-slate-400" /></div>
<div>
<div className="text-2xl font-bold text-[var(--dg-text-primary)]">{stats.offline}</div>
<div className="text-xs text-[var(--dg-text-muted)]">Agent 离线</div>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/10"><Monitor className="h-5 w-5 text-blue-400" /></div>
<div>
<div className="text-2xl font-bold text-[var(--dg-text-primary)]">{stats.total}</div>
<div className="text-xs text-[var(--dg-text-muted)]">主机总数</div>
</div>
</CardContent>
</Card>
</div>
{/* 表格卡片 */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-emerald-400" />
<CardTitle className="text-base text-[var(--dg-text-primary)]">主机监控列表</CardTitle>
<Badge variant="outline" className="text-xs bg-emerald-500/10 text-emerald-400 border-emerald-500/30">实时</Badge>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
className="w-[200px] pl-8 h-8 text-sm bg-[var(--dg-bg-primary)]"
placeholder="搜索主机名/IP..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<Button variant="outline" size="sm" onClick={() => loadData(true)} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 mr-1 ${refreshing ? 'animate-spin' : ''}`} />
刷新
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="py-16 text-center text-[var(--dg-text-muted)]">
<Activity className="h-8 w-8 mx-auto mb-2 animate-spin text-emerald-400" />
<p className="text-sm">加载中...</p>
</div>
) : sorted.length === 0 ? (
<div className="py-16 text-center text-[var(--dg-text-muted)]">
<Server className="h-10 w-10 mx-auto mb-2 text-slate-600" />
<p className="text-sm">暂无主机数据</p>
<p className="text-xs mt-1">请先添加服务器,或等待 Agent 上报数据</p>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-[var(--dg-border)]">
{[
{ key: 'hostname', label: '主机名/ID', w: '' },
{ key: 'agent_version', label: 'Agent 版本', w: 'w-[100px]' },
{ key: 'agent_status', label: 'Agent 状态', w: 'w-[90px]' },
{ key: 'cpu_usage', label: 'CPU', w: 'w-[130px]' },
{ key: 'memory_usage', label: '内存', w: 'w-[130px]' },
{ key: 'disk_usage', label: '磁盘', w: 'w-[130px]' },
{ key: 'region', label: '所在区域', w: 'w-[120px]' },
{ key: 'ip', label: 'IP 地址', w: 'w-[140px]' },
].map(col => (
<th
key={col.key}
className={`text-left px-3 py-2.5 text-xs font-medium text-[var(--dg-text-muted)] cursor-pointer select-none hover:text-[var(--dg-text-primary)] transition-colors ${col.w}`}
onClick={() => handleSort(col.key as SortField)}
>
<div className="flex items-center gap-1">
{col.label}
<SortIcon field={col.key as SortField} />
</div>
</th>
))}
<th className="text-left px-3 py-2.5 text-xs font-medium text-[var(--dg-text-muted)]">操作</th>
</tr>
</thead>
<tbody>
{sorted.map((h: any) => (
<tr key={h.id} className="border-b border-[var(--dg-border)]/50 hover:bg-[var(--dg-bg-tertiary)]/50 transition-colors">
{/* 主机名/ID */}
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${h.agent_status === 'online' ? 'bg-emerald-400' : h.status === 'running' ? 'bg-blue-400' : 'bg-slate-500'}`} />
<div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{h.hostname}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">ID: {h.id}</div>
</div>
</div>
</td>
{/* Agent 版本 */}
<td className="px-3 py-2">
<code className="text-xs text-blue-300">{h.agent_version || '--'}</code>
</td>
{/* Agent 状态 */}
<td className="px-3 py-2">
{statusBadge(h.agent_status)}
</td>
{/* CPU 使用率 */}
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<Cpu className="h-3 w-3 text-red-400 flex-shrink-0" />
{getUsageBar(h.cpu_usage)}
</div>
</td>
{/* 内存使用率 */}
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<Server className="h-3 w-3 text-amber-400 flex-shrink-0" />
{getUsageBar(h.memory_usage)}
</div>
</td>
{/* 磁盘使用率 */}
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<HardDrive className="h-3 w-3 text-orange-400 flex-shrink-0" />
{getUsageBar(h.disk_usage)}
</div>
</td>
{/* 所在区域 */}
<td className="px-3 py-2">
<span className="text-xs text-[var(--dg-text-secondary)]">
{h.region?.name || h.region?.Name || (h.region ? h.region : '--')}
</span>
</td>
{/* IP 地址 */}
<td className="px-3 py-2">
<code className="text-xs text-slate-300 font-mono">{h.ip || '--'}</code>
</td>
{/* 操作栏 */}
<td className="px-3 py-2">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10"
onClick={() => navigate(`/monitor-center?host=${encodeURIComponent(h.hostname)}`)}
title="查看监控图表"
>
<Monitor className="h-3 w-3 mr-1" />
监控
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-amber-400 hover:text-amber-300 hover:bg-amber-500/10"
onClick={() => navigate(`/alerts?hostname=${encodeURIComponent(h.hostname)}`)}
title="查看报警规则"
>
<Bell className="h-3 w-3 mr-1" />
告警
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--dg-border)]">
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
<span>每页</span>
<Select value={String(pageSize)} onValueChange={(v) => { setPageSize(Number(v)); setPage(1); }}>
<SelectTrigger className="h-7 w-[60px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="15">15</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
<span>条,共 {total} 条</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-xs text-[var(--dg-text-secondary)] px-2">
{page} / {Math.max(totalPages, 1)}
</span>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={page >= totalPages}
onClick={() => setPage(p => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,365 @@
// Prometheus 数据源监控面板 — K8s / Node / Pod / JVM // 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 { 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';
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>
<span className="w-14 text-right text-[11px] font-mono tabular-nums 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 '—';
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`;
};
// ============================================
// 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 || {}) };
if (r.value) m.value = r.value[1];
return m;
});
}
// 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>;
export default PrometheusDashboard;
@@ -0,0 +1,310 @@
// Zabbix 数据源监控面板 — 镜像 Grafana Dashboard 5456 + 5363 // 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
} 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;
}
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';
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 = () => {
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);
}
}, []);
const doConnect = async (u: string, un: string, pw: string) => {
setLoading(true);
setError('');
try {
const data: ZabbixData = 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);
}
};
const handleConnect = () => {
if (!url || !username || !password) {
toast.error('请填写完整连接信息');
return;
}
doConnect(url, username, password);
};
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;
}
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" />;
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 (
<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>
<span className={`w-14 text-right text-xs font-mono tabular-nums ${isWarn ? 'text-orange-400' : ''}`}>{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 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';
};
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>
</>
)}
</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>
) : (
<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' 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>
<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>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
};
export default ZabbixDashboard;
+8 -3
View File
@@ -1,5 +1,6 @@
// 主机监控 — Agent 采集指标展示 (CPU/内存/TCP/磁盘IO/趋势图) // Logan.Li // 主机监控 — Agent 采集指标展示 (CPU/内存/TCP/磁盘IO/趋势图) // Logan.Li
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { agentApi } from '@/services/api'; import { agentApi } from '@/services/api';
import { useUserStore } from '@/store'; import { useUserStore } from '@/store';
import { asList, isOnline, timeAgo } from '@/components/agent/agentUtils'; import { asList, isOnline, timeAgo } from '@/components/agent/agentUtils';
@@ -42,8 +43,10 @@ function fmtNum(n: number): string {
} }
export function HostMonitor() { export function HostMonitor() {
const [searchParams] = useSearchParams();
const urlHost = searchParams.get('host') || '';
const [hosts, setHosts] = useState<any[]>([]); const [hosts, setHosts] = useState<any[]>([]);
const [selectedHost, setSelectedHost] = useState(''); const [selectedHost, setSelectedHost] = useState(urlHost);
const [latest, setLatest] = useState<any>(null); const [latest, setLatest] = useState<any>(null);
const [cpuKlines, setCpuKlines] = useState<any[]>([]); const [cpuKlines, setCpuKlines] = useState<any[]>([]);
const [memKlines, setMemKlines] = useState<any[]>([]); const [memKlines, setMemKlines] = useState<any[]>([]);
@@ -80,11 +83,13 @@ export function HostMonitor() {
const list = asList(data); const list = asList(data);
setHosts(list); setHosts(list);
if (list.length > 0 && !selectedHost) { if (list.length > 0 && !selectedHost) {
setSelectedHost(list[0].hostname); // 优先匹配 URL 参数传入的主机
const matched = urlHost ? list.find((h: any) => h.hostname === urlHost) : null;
setSelectedHost(matched ? matched.hostname : list[0].hostname);
} }
} catch { /* 后端不可用时显示空状态 */ } } catch { /* 后端不可用时显示空状态 */ }
finally { setCardLoading(false); } finally { setCardLoading(false); }
}, [selectedHost]); }, [selectedHost, urlHost]);
useEffect(() => { loadHosts(); }, []); useEffect(() => { loadHosts(); }, []);
+11
View File
@@ -684,6 +684,17 @@ export const dashboardApi = {
createDatasource: async (_data: any) => ({ success: false }), createDatasource: async (_data: any) => ({ success: false }),
}; };
// ==================== Prometheus 数据源管理 API ==================== // Logan.Li
export const promDatasourceApi = {
list: () => http.get('/prometheus-datasources'),
get: (id: number | string) => http.get(`/prometheus-datasources/${id}`),
create: (data: any) => http.post('/prometheus-datasources', data),
update: (id: number | string, data: any) => http.put(`/prometheus-datasources/${id}`, data),
delete: (id: number | string) => http.delete(`/prometheus-datasources/${id}`),
setDefault: (id: number | string) => http.post(`/prometheus-datasources/${id}/default`),
testConnection: (data: any) => http.post('/prometheus-datasources/test', data),
};
// AI 智能助手 API(后端 LLM 配置 + 对话代理,OpenAI 兼容) // Logan.Li // AI 智能助手 API(后端 LLM 配置 + 对话代理,OpenAI 兼容) // Logan.Li
export interface LLMConfigData { export interface LLMConfigData {
configured?: boolean; configured?: boolean;