feat(monitor): F4 自定义监控仪表盘 — mock 桩改真实 CRUD + PromQL 查询渲染
后端: - 新增 model/custom_dashboard.go (monitor_custom_dashboards 表) - 新增 controller/custom_dashboard.go (CRUD + 设默认, 6 路由) - routes.go 注册 /custom-dashboards 路由组 - database.go AutoMigrate 注册 CustomDashboard 前端: - grafana.ts: dashboardApi 从 mock 返回空改为真实 HTTP 调用 - metrics.ts: prometheusProxyApi 新增 query/queryRange 方法 - GrafanaPanelContent.tsx: 从随机 mock 数据改为执行 PromQL 查询 + Recharts LineChart 渲染 graph 类型 + SVG gauge 渲染 gauge 类型 + 数值卡片渲染 stat 类型 + 表格渲染 table 类型 + 30s 自动刷新 + loading/error 状态 + 自动发现默认 Prometheus 数据源 - GrafanaDashboard.tsx: 传递 datasourceUrl 到 GrafanaPanelContent 验证: go build✅ npm build✅ 17/17 集成测试通过 E2E CRUD 全通过
This commit is contained in:
1 parent
7af10097c9
commit
f725ca7cac
11 files changed
+670
-53
No files matched your search
@@ -0,0 +1,258 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
ezops "ezops/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CustomDashboardHandler 自定义监控仪表盘 handler // Logan.Li
|
||||
type CustomDashboardHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewCustomDashboardHandler 创建 handler
|
||||
func NewCustomDashboardHandler(db *gorm.DB) *CustomDashboardHandler {
|
||||
return &CustomDashboardHandler{db: db}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DTO
|
||||
// ============================================
|
||||
|
||||
type CreateDashboardReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Datasource string `json:"datasource"`
|
||||
DatasourceURL string `json:"datasource_url"`
|
||||
Panels string `json:"panels"` // JSON string
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
type UpdateDashboardReq struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Datasource *string `json:"datasource"`
|
||||
DatasourceURL *string `json:"datasource_url"`
|
||||
Panels *string `json:"panels"`
|
||||
IsDefault *bool `json:"is_default"`
|
||||
IsFavorite *bool `json:"is_favorite"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CRUD
|
||||
// ============================================
|
||||
|
||||
// List 仪表盘列表
|
||||
// GET /api/v1/custom-dashboards
|
||||
func (h *CustomDashboardHandler) List(c *gin.Context) {
|
||||
var dashboards []ezops.CustomDashboard
|
||||
if err := h.db.Order("is_default DESC, updated_at DESC").Find(&dashboards).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ezops.Response{Code: 500, Message: "查询失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为前端期望的格式(id 转为 string,panels JSON 解析为数组)
|
||||
result := make([]gin.H, 0, len(dashboards))
|
||||
for _, d := range dashboards {
|
||||
item := gin.H{
|
||||
"id": strconv.Itoa(int(d.ID)),
|
||||
"name": d.Name,
|
||||
"description": d.Description,
|
||||
"datasource": d.Datasource,
|
||||
"datasourceUrl": d.DatasourceURL,
|
||||
"datasource_url": d.DatasourceURL,
|
||||
"createdAt": d.CreatedAt,
|
||||
"updatedAt": d.UpdatedAt,
|
||||
"createdBy": d.CreatedBy,
|
||||
"isDefault": d.IsDefault,
|
||||
"is_default": d.IsDefault,
|
||||
}
|
||||
// 解析 panels JSON
|
||||
var panels []interface{}
|
||||
if d.Panels != "" {
|
||||
if err := json.Unmarshal([]byte(d.Panels), &panels); err == nil {
|
||||
item["panels"] = panels
|
||||
} else {
|
||||
item["panels"] = []interface{}{}
|
||||
}
|
||||
} else {
|
||||
item["panels"] = []interface{}{}
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ezops.Response{Code: 200, Data: gin.H{
|
||||
"list": result,
|
||||
"total": len(result),
|
||||
}})
|
||||
}
|
||||
|
||||
// Get 获取单个仪表盘
|
||||
// GET /api/v1/custom-dashboards/:id
|
||||
func (h *CustomDashboardHandler) Get(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var d ezops.CustomDashboard
|
||||
if err := h.db.First(&d, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, ezops.Response{Code: 404, Message: "仪表盘不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var panels []interface{}
|
||||
if d.Panels != "" {
|
||||
json.Unmarshal([]byte(d.Panels), &panels)
|
||||
}
|
||||
if panels == nil {
|
||||
panels = []interface{}{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ezops.Response{Code: 200, Data: gin.H{
|
||||
"id": strconv.Itoa(int(d.ID)),
|
||||
"name": d.Name,
|
||||
"description": d.Description,
|
||||
"datasource": d.Datasource,
|
||||
"datasourceUrl": d.DatasourceURL,
|
||||
"datasource_url": d.DatasourceURL,
|
||||
"panels": panels,
|
||||
"createdAt": d.CreatedAt,
|
||||
"updatedAt": d.UpdatedAt,
|
||||
"createdBy": d.CreatedBy,
|
||||
"isDefault": d.IsDefault,
|
||||
"is_default": d.IsDefault,
|
||||
}})
|
||||
}
|
||||
|
||||
// Create 创建仪表盘
|
||||
// POST /api/v1/custom-dashboards
|
||||
func (h *CustomDashboardHandler) Create(c *gin.Context) {
|
||||
var req CreateDashboardReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ezops.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Datasource == "" {
|
||||
req.Datasource = "prometheus"
|
||||
}
|
||||
if req.Panels == "" {
|
||||
req.Panels = "[]"
|
||||
}
|
||||
|
||||
// 如果设为默认,先清除其他默认
|
||||
if req.IsDefault {
|
||||
h.db.Model(&ezops.CustomDashboard{}).Where("is_default = ?", true).Update("is_default", false)
|
||||
}
|
||||
|
||||
// 从 JWT 获取用户名
|
||||
username := "admin"
|
||||
if u, exists := c.Get("username"); exists {
|
||||
if s, ok := u.(string); ok {
|
||||
username = s
|
||||
}
|
||||
}
|
||||
|
||||
d := ezops.CustomDashboard{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Datasource: req.Datasource,
|
||||
DatasourceURL: req.DatasourceURL,
|
||||
Panels: req.Panels,
|
||||
CreatedBy: username,
|
||||
IsDefault: req.IsDefault,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&d).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ezops.Response{Code: 500, Message: "创建失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ezops.Response{Code: 200, Message: "创建成功", Data: gin.H{
|
||||
"success": true,
|
||||
"id": strconv.Itoa(int(d.ID)),
|
||||
}})
|
||||
}
|
||||
|
||||
// Update 更新仪表盘
|
||||
// PUT /api/v1/custom-dashboards/:id
|
||||
func (h *CustomDashboardHandler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var d ezops.CustomDashboard
|
||||
if err := h.db.First(&d, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, ezops.Response{Code: 404, Message: "仪表盘不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateDashboardReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ezops.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
updates["name"] = *req.Name
|
||||
}
|
||||
if req.Description != nil {
|
||||
updates["description"] = *req.Description
|
||||
}
|
||||
if req.Datasource != nil {
|
||||
updates["datasource"] = *req.Datasource
|
||||
}
|
||||
if req.DatasourceURL != nil {
|
||||
updates["datasource_url"] = *req.DatasourceURL
|
||||
}
|
||||
if req.Panels != nil {
|
||||
updates["panels"] = *req.Panels
|
||||
}
|
||||
if req.IsFavorite != nil {
|
||||
updates["is_favorite"] = *req.IsFavorite
|
||||
}
|
||||
if req.IsDefault != nil && *req.IsDefault {
|
||||
// 设为默认前先清除其他默认
|
||||
h.db.Model(&ezops.CustomDashboard{}).Where("is_default = ? AND id != ?", true, d.ID).Update("is_default", false)
|
||||
updates["is_default"] = true
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := h.db.Model(&d).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ezops.Response{Code: 500, Message: "更新失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ezops.Response{Code: 200, Message: "更新成功", Data: gin.H{"success": true}})
|
||||
}
|
||||
|
||||
// Delete 删除仪表盘
|
||||
// DELETE /api/v1/custom-dashboards/:id
|
||||
func (h *CustomDashboardHandler) Delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.db.Delete(&ezops.CustomDashboard{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ezops.Response{Code: 500, Message: "删除失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ezops.Response{Code: 200, Message: "删除成功", Data: gin.H{"success": true}})
|
||||
}
|
||||
|
||||
// SetDefault 设为默认仪表盘
|
||||
// POST /api/v1/custom-dashboards/:id/default
|
||||
func (h *CustomDashboardHandler) SetDefault(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var d ezops.CustomDashboard
|
||||
if err := h.db.First(&d, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, ezops.Response{Code: 404, Message: "仪表盘不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 清除其他默认
|
||||
h.db.Model(&ezops.CustomDashboard{}).Where("is_default = ? AND id != ?", true, d.ID).Update("is_default", false)
|
||||
// 设置当前为默认
|
||||
h.db.Model(&d).Update("is_default", true)
|
||||
|
||||
c.JSON(http.StatusOK, ezops.Response{Code: 200, Message: "设置成功", Data: gin.H{"success": true}})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CustomDashboard 自定义监控仪表盘(面板配置存 JSON) // Logan.Li
|
||||
type CustomDashboard struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
|
||||
Name string `gorm:"type:varchar(128);not null;comment:仪表盘名称" json:"name"`
|
||||
Description string `gorm:"type:varchar(512);comment:描述" json:"description"`
|
||||
Datasource string `gorm:"type:varchar(32);default:'prometheus';comment:数据源类型" json:"datasource"`
|
||||
DatasourceURL string `gorm:"type:varchar(512);comment:数据源URL(留空则用默认Prometheus)" json:"datasource_url"`
|
||||
// Panels 存储面板配置 JSON 数组: [{id,title,type,query,width,height,x,y,options}]
|
||||
Panels string `gorm:"type:longtext;comment:面板配置JSON" json:"panels"`
|
||||
// Tags 标签(JSON 数组字符串)
|
||||
Tags string `gorm:"type:varchar(512);comment:标签JSON" json:"tags,omitempty"`
|
||||
// 归属
|
||||
CreatedBy string `gorm:"type:varchar(64);comment:创建人" json:"created_by"`
|
||||
IsDefault bool `gorm:"default:false;index;comment:是否默认仪表盘" json:"is_default"`
|
||||
IsFavorite bool `gorm:"default:false;comment:是否收藏" json:"is_favorite"`
|
||||
// 时间戳
|
||||
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 (CustomDashboard) TableName() string {
|
||||
return "monitor_custom_dashboards"
|
||||
}
|
||||
@@ -156,6 +156,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&model.ApprovalWorkflowStep{}, // 审批流程步骤 // Logan.Li
|
||||
&model.AgentCLISession{}, // Agent CLI 任务会话 // Logan.Li
|
||||
&model.AgentCLIMessage{}, // Agent CLI 推理消息 // Logan.Li
|
||||
&model.CustomDashboard{}, // 自定义监控仪表盘 // Logan.Li
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
|
||||
@@ -966,6 +966,18 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
zabbixDSGroup.POST("/test", zabbixDSHandler.TestConnection)
|
||||
}
|
||||
|
||||
// 自定义监控仪表盘 — CRUD + 设默认 // Logan.Li
|
||||
customDashHandler := controller.NewCustomDashboardHandler(db)
|
||||
customDashGroup := authorized.Group("/custom-dashboards")
|
||||
{
|
||||
customDashGroup.GET("", customDashHandler.List)
|
||||
customDashGroup.GET("/:id", customDashHandler.Get)
|
||||
customDashGroup.POST("", customDashHandler.Create)
|
||||
customDashGroup.PUT("/:id", customDashHandler.Update)
|
||||
customDashGroup.DELETE("/:id", customDashHandler.Delete)
|
||||
customDashGroup.POST("/:id/default", customDashHandler.SetDefault)
|
||||
}
|
||||
|
||||
// 凭据管理 — SSH密钥/API Token/密码统一加密管理 // Logan.Li
|
||||
credentialHandlerV2 := controller.NewCredentialHandlerV2(svcs.Credential)
|
||||
credentialHandler := controller.NewCredentialHandler(db, cfg.JWT.Secret) // 仅用于 GetTags/PatchCredential/AssignCredential(待拆分)
|
||||
|
||||
Reference in new issue
Block a user