snapshot: 数据源管理+大屏增强前快照
This commit is contained in:
1 parent
2bf3e7c308
commit
1726ad65ac
19 files changed
+2478
-211
No files matched your search
@@ -84,6 +84,7 @@ func main() {
|
||||
report := &model.Report{
|
||||
Hostname: hostname,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
AgentVersion: version,
|
||||
}
|
||||
|
||||
if stat, err := cpuCollector.Collect(); err != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ package model
|
||||
type Report struct {
|
||||
Hostname string `json:"hostname"` // 主机名
|
||||
Timestamp string `json:"timestamp"` // RFC3339 格式的 UTC 时间戳
|
||||
AgentVersion string `json:"agent_version"` // Agent 版本号
|
||||
CPU CPUStat `json:"cpu"` // CPU 指标
|
||||
Memory MemoryStat `json:"memory"` // 内存指标
|
||||
Disks []DiskStat `json:"disks"` // 磁盘指标列表
|
||||
|
||||
@@ -94,6 +94,8 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
|
||||
"memory": memMB,
|
||||
"disk": int64(totalDiskGB),
|
||||
"status": "running",
|
||||
"agent_status": "online",
|
||||
"agent_version": req.AgentVersion,
|
||||
})
|
||||
|
||||
// WebSocket 广播:向所有前端客户端推送该主机的最新指标 // Logan.Li
|
||||
@@ -155,20 +157,41 @@ func (h *AgentHandler) writeHotMetric(c *gin.Context, key string, value float64,
|
||||
// GetLatestReports 获取各主机最新上报
|
||||
// GET /api/v1/agent/latest
|
||||
func (h *AgentHandler) GetLatestReports(c *gin.Context) {
|
||||
// 子查询:每个 hostname 最新的一条记录
|
||||
var reports []models.AgentReport
|
||||
var results []models.AgentLatestResponse
|
||||
h.db.Raw(`
|
||||
SELECT * FROM agent_reports
|
||||
WHERE id IN (
|
||||
SELECT
|
||||
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
|
||||
)
|
||||
ORDER BY report_time DESC
|
||||
`).Scan(&reports)
|
||||
ORDER BY a.report_time DESC
|
||||
`).Scan(&results)
|
||||
|
||||
if results == nil {
|
||||
results = []models.AgentLatestResponse{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{
|
||||
Code: 200,
|
||||
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
|
||||
}
|
||||
@@ -490,6 +490,37 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
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
|
||||
siteHandler := handlers.NewSiteHandler(db)
|
||||
siteGroup := authorized.Group("/site")
|
||||
|
||||
@@ -32,10 +32,30 @@ func (AgentReport) TableName() string {
|
||||
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
|
||||
type AgentReportRequest struct {
|
||||
Hostname string `json:"hostname" binding:"required"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
AgentVersion string `json:"agent_version"` // Agent 版本号
|
||||
CPU CPUMetric `json:"cpu"`
|
||||
Memory MemMetric `json:"memory"`
|
||||
Disks []DiskMetric `json:"disks"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -130,6 +130,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&models.AlertUpgradeRecord{},// 告警升级记录 // Logan.Li
|
||||
&models.AlertSilence{}, // 告警静默 // Logan.Li
|
||||
&models.AlertEventV2{}, // 告警事件V2 // Logan.Li
|
||||
&models.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -30,7 +30,7 @@ import { AIAlertAnalyzer } from '@/components/aiops/AIAlertAnalyzer';
|
||||
import { AIPredictiveAnalytics } from '@/components/aiops/AIPredictiveAnalytics';
|
||||
import AlertCenter from '@/components/monitor/alert-center/AlertCenter';
|
||||
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 { MetricsExplorer } from '@/pages/MetricsExplorer';
|
||||
|
||||
@@ -73,7 +73,7 @@ function App() {
|
||||
<Route path="metrics" element={<MetricsExplorer />} />
|
||||
<Route path="alert-center" element={<AlertCenter />} />
|
||||
<Route path="trace" element={<TraceManager />} />
|
||||
<Route path="dashboards" element={<GrafanaDashboard />} />
|
||||
<Route path="dashboards" element={<HostMonitorDashboard />} />
|
||||
<Route path="alerts" element={<AlertManager />} />
|
||||
<Route path="logs" element={<AlertManager />} />
|
||||
<Route path="approval" element={<ApprovalFlowManager />} />
|
||||
|
||||
@@ -86,9 +86,9 @@ const menuItems: MenuItem[] = [
|
||||
name: '监控中心',
|
||||
icon: Bell,
|
||||
children: [
|
||||
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/dashboards' },
|
||||
{ id: 'monitor-center', name: '资源监控', icon: Activity, path: '/monitor-center' },
|
||||
{ id: 'metrics', name: '指标监控', icon: Cpu, path: '/metrics' },
|
||||
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/dashboards' },
|
||||
{ id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace' },
|
||||
{ id: 'alert-center', name: '告警服务', icon: Bell, path: '/alert-center' },
|
||||
],
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
Monitor, Settings, Plus, Edit, Trash2, Copy, Eye, Download, RefreshCw,
|
||||
Maximize2, Minimize2, Save, X, ChevronDown, BarChart3, LineChart, PieChart,
|
||||
Activity, Cpu, Database, Server, Globe, Zap, AlertTriangle, TrendingUp, HardDrive,
|
||||
TrendingDown, Clock, Calendar, Filter, Search
|
||||
TrendingDown, Clock, Calendar, Filter, Search, ArrowUpDown, ArrowUp, ArrowDown,
|
||||
Wifi, WifiOff
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -15,15 +16,16 @@ import {
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { dashboardApi } from '@/services/api';
|
||||
import { GrafanaPanelContent } from './GrafanaPanelContent';
|
||||
import { ZabbixDashboard } from './ZabbixDashboard';
|
||||
import { PrometheusDashboard } from './PrometheusDashboard';
|
||||
import { http } from '@/services/http';
|
||||
import { asList } from '@/components/agent/agentUtils';
|
||||
|
||||
interface Dashboard {
|
||||
id: string;
|
||||
@@ -75,6 +77,27 @@ interface Datasource {
|
||||
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 _confirm = useConfirm();
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
@@ -108,38 +131,79 @@ const GrafanaDashboard: React.FC = () => {
|
||||
url: ''
|
||||
});
|
||||
|
||||
// Agent 主机监控 — 默认面板
|
||||
const [hostTop, setHostTop] = useState<any[]>([]);
|
||||
// ==================== Agent 主机监控 ====================
|
||||
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(() => {
|
||||
(async () => {
|
||||
try {
|
||||
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);
|
||||
fetchHosts();
|
||||
const timer = setInterval(fetchHosts, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// 排序
|
||||
const toggleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDir('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedHosts = [...hosts].sort((a, b) => {
|
||||
let va: any, vb: any;
|
||||
if (sortField === 'agent_status') {
|
||||
va = a.agent_status || '';
|
||||
vb = b.agent_status || '';
|
||||
} else {
|
||||
va = (a as any)[sortField] ?? '';
|
||||
vb = (b as any)[sortField] ?? '';
|
||||
}
|
||||
if (typeof va === 'number' && typeof vb === 'number') {
|
||||
return sortDir === 'asc' ? va - vb : vb - va;
|
||||
}
|
||||
return sortDir === 'asc'
|
||||
? String(va).localeCompare(String(vb))
|
||||
: String(vb).localeCompare(String(va));
|
||||
});
|
||||
|
||||
const 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(() => {
|
||||
fetchDashboards();
|
||||
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 (
|
||||
<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 gap-4">
|
||||
<Select
|
||||
@@ -407,7 +403,7 @@ const GrafanaDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 仪表盘内容 */}
|
||||
{/* ===== 仪表盘内容 ===== */}
|
||||
{activeDashboard ? (
|
||||
<div className="space-y-4">
|
||||
{/* 仪表盘信息 */}
|
||||
@@ -424,6 +420,110 @@ const GrafanaDashboard: React.FC = () => {
|
||||
</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">
|
||||
{activeDashboard.panels.map((panel) => (
|
||||
@@ -438,7 +538,6 @@ const GrafanaDashboard: React.FC = () => {
|
||||
{getPanelIcon(panel.type)}
|
||||
{panel.title}
|
||||
</CardTitle>
|
||||
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 flex-1">
|
||||
@@ -508,20 +607,17 @@ const GrafanaDashboard: React.FC = () => {
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>数据源地址</Label>
|
||||
<Label>数据源 URL</Label>
|
||||
<Input
|
||||
value={newDashboard.datasourceUrl}
|
||||
onChange={(e) => setNewDashboard({ ...newDashboard, datasourceUrl: e.target.value })}
|
||||
placeholder="http://localhost:9090"
|
||||
placeholder="http://prometheus:9090"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreateDashboard}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
创建
|
||||
</Button>
|
||||
<Button onClick={handleCreateDashboard}>创建</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -531,19 +627,19 @@ const GrafanaDashboard: React.FC = () => {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加面板</DialogTitle>
|
||||
<DialogDescription>添加一个新的监控面板</DialogDescription>
|
||||
<DialogDescription>向当前仪表盘添加一个监控面板</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>标题</Label>
|
||||
<Label>面板标题</Label>
|
||||
<Input
|
||||
value={newPanel.title}
|
||||
onChange={(e) => setNewPanel({ ...newPanel, title: e.target.value })}
|
||||
placeholder="面板标题"
|
||||
placeholder="CPU 使用率"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>类型</Label>
|
||||
<Label>图表类型</Label>
|
||||
<Select
|
||||
value={newPanel.type}
|
||||
onValueChange={(v) => setNewPanel({ ...newPanel, type: v as any })}
|
||||
@@ -553,115 +649,68 @@ const GrafanaDashboard: React.FC = () => {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="graph">折线图</SelectItem>
|
||||
<SelectItem value="stat">统计值</SelectItem>
|
||||
<SelectItem value="stat">数值</SelectItem>
|
||||
<SelectItem value="gauge">仪表盘</SelectItem>
|
||||
<SelectItem value="pie">饼图</SelectItem>
|
||||
<SelectItem value="table">表格</SelectItem>
|
||||
<SelectItem value="heatmap">热力图</SelectItem>
|
||||
<SelectItem value="text">文本</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>查询语句</Label>
|
||||
<textarea
|
||||
<Label>PromQL / 查询语句</Label>
|
||||
<Input
|
||||
value={newPanel.query}
|
||||
onChange={(e) => setNewPanel({ ...newPanel, query: e.target.value })}
|
||||
placeholder={newPanel.type === 'graph' ? 'sum(rate(http_requests_total[5m]))' : 'up'}
|
||||
className="w-full min-h-[100px] p-2 border rounded-md font-mono text-sm"
|
||||
placeholder="rate(node_cpu_seconds_total[5m])"
|
||||
/>
|
||||
</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>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddPanelOpen(false)}>取消</Button>
|
||||
<Button onClick={handleAddPanel}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加
|
||||
</Button>
|
||||
<Button onClick={handleAddPanel}>添加</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 数据源管理对话框 */}
|
||||
{/* 数据源对话框 */}
|
||||
<Dialog open={datasourceOpen} onOpenChange={setDatasourceOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>数据源管理</DialogTitle>
|
||||
<DialogDescription>管理监控数据源配置</DialogDescription>
|
||||
<DialogTitle>管理数据源</DialogTitle>
|
||||
<DialogDescription>配置 Prometheus / Zabbix / SkyWalking 等外部数据源连接</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="list">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="list">数据源列表</TabsTrigger>
|
||||
<TabsTrigger value="add">添加数据源</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="list" className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{datasources.map((ds) => (
|
||||
<Card key={ds.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="space-y-3">
|
||||
{datasources.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">暂无数据源配置</p>
|
||||
) : (
|
||||
datasources.map(ds => (
|
||||
<div key={ds.id} className="flex items-center justify-between p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2">
|
||||
{getDatasourceIcon(ds.type)}
|
||||
<div>
|
||||
<p className="font-medium">{ds.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{ds.url}</p>
|
||||
<p className="text-sm font-medium">{ds.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{ds.url}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getDatasourceStatusBadge(ds.status)}
|
||||
<Badge
|
||||
variant={ds.status === 'connected' ? 'default' : 'secondary'}
|
||||
className={ds.status === 'connected' ? 'bg-green-500' : ds.status === 'error' ? 'bg-red-500' : ''}
|
||||
>
|
||||
{ds.status === 'connected' ? '已连接' : ds.status === 'error' ? '错误' : '已断开'}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="add" className="space-y-4">
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>名称</Label>
|
||||
<Label>数据源名称</Label>
|
||||
<Input
|
||||
value={newDatasource.name}
|
||||
onChange={(e) => setNewDatasource({ ...newDatasource, name: e.target.value })}
|
||||
placeholder="数据源名称"
|
||||
placeholder="My Prometheus"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -683,19 +732,18 @@ const GrafanaDashboard: React.FC = () => {
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>地址</Label>
|
||||
<Label>URL</Label>
|
||||
<Input
|
||||
value={newDatasource.url}
|
||||
onChange={(e) => setNewDatasource({ ...newDatasource, url: e.target.value })}
|
||||
placeholder="http://localhost:9090"
|
||||
placeholder="http://prometheus:9090"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleAddDatasource} className="w-full">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加数据源
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDatasourceOpen(false)}>关闭</Button>
|
||||
<Button onClick={handleAddDatasource}>添加数据源</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</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;
|
||||
@@ -1,5 +1,6 @@
|
||||
// 主机监控 — Agent 采集指标展示 (CPU/内存/TCP/磁盘IO/趋势图) // Logan.Li
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { agentApi } from '@/services/api';
|
||||
import { useUserStore } from '@/store';
|
||||
import { asList, isOnline, timeAgo } from '@/components/agent/agentUtils';
|
||||
@@ -42,8 +43,10 @@ function fmtNum(n: number): string {
|
||||
}
|
||||
|
||||
export function HostMonitor() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const urlHost = searchParams.get('host') || '';
|
||||
const [hosts, setHosts] = useState<any[]>([]);
|
||||
const [selectedHost, setSelectedHost] = useState('');
|
||||
const [selectedHost, setSelectedHost] = useState(urlHost);
|
||||
const [latest, setLatest] = useState<any>(null);
|
||||
const [cpuKlines, setCpuKlines] = useState<any[]>([]);
|
||||
const [memKlines, setMemKlines] = useState<any[]>([]);
|
||||
@@ -80,11 +83,13 @@ export function HostMonitor() {
|
||||
const list = asList(data);
|
||||
setHosts(list);
|
||||
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 { /* 后端不可用时显示空状态 */ }
|
||||
finally { setCardLoading(false); }
|
||||
}, [selectedHost]);
|
||||
}, [selectedHost, urlHost]);
|
||||
|
||||
useEffect(() => { loadHosts(); }, []);
|
||||
|
||||
|
||||
@@ -684,6 +684,17 @@ export const dashboardApi = {
|
||||
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
|
||||
export interface LLMConfigData {
|
||||
configured?: boolean;
|
||||
|
||||
Reference in new issue
Block a user