feat(monitor): M15 中间件监控 — Redis/MySQL 后端直连采集引擎

后端:
- 新增 controller/middleware_monitor.go (500+ 行) — CRUD + 测试连接 + 采集引擎
- 后台 goroutine 每 30s 自动连接 Redis/MySQL 采集指标
- Redis: go-redis v9 INFO+DBSIZE 命令采集 12 项指标
- MySQL: database/sql SHOW GLOBAL STATUS 采集 12 项指标
- 指标写入 MetricHistory 表,7 天自动清理
- 8 个 RESTful 路由注册到 routes.go

前端:
- MiddlewareMonitor.tsx 从只读卡片升级为完整管理页面
- 添加监控弹框(Redis/MySQL 类型选择+连接配置表单)
- 实例卡片操作栏(测试/编辑/删除) + 工具栏(立即采集/刷新)
- middlewareMonitorApi 8 个方法

验证: go build✅ npm build✅ 17/17 回归通过 Redis 12 指标✅ MySQL 12 指标✅
This commit is contained in:
attacker committed 2026-06-29 23:24:05 +08:00
1 parent 186ece6495
commit ed7a5c07a7
8 files changed
+1211 -132

No files matched your search

+2 -1
View File
@@ -66,7 +66,7 @@
| 发布配置/凭证/工具 | ✅ credential.go(400L) | ✅ CredentialManager(822L) | ✅ |
| 发布权限 RBAC | ✅ | ✅ | ✅ |
### 2.4 监控告警 ⚠️ 部分缺口
### 2.4 监控告警 ✅ 完善
| 功能 | 后端 | 前端 | 状态 |
|------|:----:|:----:|:----:|
@@ -79,6 +79,7 @@
| 告警静默规则 | ✅ alert_silence.go + engine 集成 | ✅ SilenceRuleTab | ✅ |
| 监控大屏(自定义仪表盘) | ✅ custom_dashboard.go CRUD | ✅ GrafanaDashboard 自定义 Tab + GrafanaPanelContent PromQL 渲染 | ✅ F4 done |
| 链路追踪 | ✅ trace_proxy.go Jaeger/Tempo 代理 | ✅ TraceManager (587L) — traceApi 对接真实后端 + demo 数据兜底 | ✅ F5 done |
| **中间件监控** | ✅ middleware_monitor.go 后端直连采集 Redis/MySQL | ✅ MiddlewareMonitor 完整管理页面 (CRUD + 测试 + 概览) | ✅ M15 done |
### 2.5 自动化运维 ✅ 完善
+34
View File
@@ -391,6 +391,39 @@ services/api.ts (1324L)
---
### M15: 中间件监控 (Redis/MySQL) [done]
**问题**: 前端 `MiddlewareMonitor.tsx` 页面已存在但无后端支持——无 CRUD API、无指标采集、无配置入口。用户无法监控 Redis/MySQL 中间件实例。
**已完成 (2026-06-29)**:
- ✅ **后端 CRUD 控制器** — `controller/middleware_monitor.go` (500+ 行): Create/List/Get/Update/Delete + TestConnection + CollectNow + GetMetrics,共 8 个 RESTful 路由
- ✅ **后端直连采集引擎** — 后台 goroutine 每 30s 自动连接所有启用的 Redis/MySQL 实例采集指标,无需 Agent
- **Redis**: go-redis v9 INFO + DBSIZE 命令,采集 12 项指标(连接数/内存/命中率/QPS/Key总数/阻塞客户端/驱逐Key等)
- **MySQL**: database/sql SHOW GLOBAL STATUS,采集 12 项指标(连接数/活跃线程/QPS/慢查询/缓冲池命中率/收发流量等)
- ✅ **指标历史存储** — 采集结果写入 MetricHistory 表,7 天自动清理
- ✅ **连接配置安全存储** — Redis/MySQL 密码存储在 MonitorTarget.Config JSON 字段,不暴露在列表 API 中
- ✅ **前端增强** — MiddlewareMonitor.tsx 从只读卡片升级为完整管理页面:
- 「添加监控」弹框(Redis/MySQL 类型选择 + IP/端口/密码/用户名/数据库表单)
- 实例卡片操作栏(测试连接/编辑/删除按钮)
- 工具栏(立即采集/刷新/添加监控)
- Redis 概览卡片(6 项核心指标网格)+ MySQL 概览卡片(6 项核心指标网格)
- ✅ **API 层对接** — `middlewareMonitorApi` 8 个方法 (list/create/update/delete/test/collectNow/getMetrics)
- ✅ **FK 约束修复** — monitor_targets.host_group_id FK 约束改为 NULL(中间件目标不属于主机组)
**验收标准**:
- [x] 可创建/查看/编辑/删除 Redis/MySQL 中间件监控目标
- [x] 可测试连接(返回成功/失败 + 采集到的指标数)
- [x] 后台每 30s 自动采集指标(Redis 12 项 + MySQL 12 项)
- [x] 采集状态自动更新(online/offline)
- [x] 指标历史可查询(支持按指标名/时间范围筛选)
- [x] `go build` 零错误
- [x] `npm run build` 零错误(✓ built in 19.08s)
- [x] 17/17 集成回归测试通过(零回归)
- [x] Redis 连接成功(99% 命中率,12 项指标)
- [x] MySQL 连接成功(100% 缓冲池命中率,12 项指标)
---
## 模块状态追踪
| ID | 模块 | 优先级 | 状态 |
@@ -411,6 +444,7 @@ services/api.ts (1324L)
| U4 | 镜像优化 | P3 | **done** |
| F5 | 链路追踪后端对接 | P1 | **done** |
| F6 | 日志告警引擎 | P1 | **done** |
| M15 | 中间件监控 (Redis/MySQL) | P1 | **done** |
---
+69
View File
@@ -967,3 +967,72 @@ python3 tests/integration_test.py --host 127.0.0.1 --port 8080 | tail -3
- [x] U6: 17/17 集成回归测试通过
- [x] 引擎无 panic(无效 datasource_id 优雅跳过)
- [x] 日志数据源支持 none/basic/bearer/custom 四种认证方式(round-trip 验证)
---
## M15: 中间件监控 (Redis/MySQL)
### 验证用例
```bash
TOKEN=$(python3 -c "
import urllib.request, json
login_data = json.dumps({'username': 'admin', 'password': '123456'}).encode()
req = urllib.request.Request('http://127.0.0.1:8080/api/v1/auth/login', data=login_data, method='POST')
req.add_header('Content-Type', 'application/json')
resp = urllib.request.urlopen(req)
print(json.loads(resp.read())['data']['token'])
")
# U1: 创建 Redis 监控目标
curl -s -X POST http://127.0.0.1:8080/api/v1/middleware-monitors \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"平台Redis","type":"redis","ip":"aiops-redis","port":6379}' | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['code']==200; print('✅ create redis')"
# 期望: code=200
# U2: 创建 MySQL 监控目标
curl -s -X POST http://127.0.0.1:8080/api/v1/middleware-monitors \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"平台MySQL","type":"mysql","ip":"aiops-mysql","port":3306,"username":"root","password":"xxx","database":"aiops"}' | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['code']==200; print('✅ create mysql')"
# 期望: code=200
# U3: 列表
curl -s http://127.0.0.1:8080/api/v1/middleware-monitors -H "Authorization: Bearer $TOKEN" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['data']['total']>=2; print(f'✅ list: {d[\"data\"][\"total\"]} targets')"
# 期望: total >= 2
# U4: 测试 Redis 连接(采集指标)
curl -s -X POST http://127.0.0.1:8080/api/v1/middleware-monitors/REDIS_ID/test -H "Authorization: Bearer $TOKEN" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['data']['success']==True; print(f'✅ redis test: {len(d[\"data\"][\"metrics\"])} metrics')"
# 期望: success=true, 12 metrics
# U5: 立即采集
curl -s -X POST http://127.0.0.1:8080/api/v1/middleware-monitors/collect -H "Authorization: Bearer $TOKEN" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['data']['collected']>=1; print(f'✅ collect: {d[\"data\"][\"collected\"]} targets')"
# 期望: collected >= 1
# U6: 指标历史
curl -s "http://127.0.0.1:8080/api/v1/middleware-monitors/REDIS_ID/metrics" -H "Authorization: Bearer $TOKEN" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['data']['total']>0; print(f'✅ history: {d[\"data\"][\"total\"]} records')"
# 期望: records > 0
# U7: 前端 middlewareMonitorApi 8 个方法
grep -c 'http\.\(get\|post\|put\|delete\)' /srv/aiops/frontend/src/api/monitor/metrics.ts
# 期望: >= 8
# U8: go build
cd /srv/aiops/backend && go build ./...
# 期望: 零错误
# U9: npm build
cd /srv/aiops/frontend && npm run build
# 期望: 零错误
```
### 通过标准
- [x] U1: 创建 Redis 监控目标返回 200
- [x] U2: 创建 MySQL 监控目标返回 200
- [x] U3: 列表返回 >= 2 个目标
- [x] U4: Redis 连接测试成功(12 项指标:连接数/内存/命中率/QPS/Key总数等)
- [x] U5: 立即采集成功(collected >= 1)
- [x] U6: 指标历史有记录
- [x] U7: 前端 API 屄 8 个方法
- [x] U8: `go build` 零错误
- [x] U9: `npm run build` 零错误(✓ built in 19.08s)
- [x] 17/17 集成回归测试通过(零回归)
+658
View File
@@ -0,0 +1,658 @@
package controller
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"ezops/model"
_ "github.com/go-sql-driver/mysql"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// ============================================
// 中间件监控 (Redis / MySQL)
// 后端直连采集模式 — 无需 Agent,平台直接连接 Redis/MySQL 采集指标
// ============================================
// MiddlewareConfig 存储在 MonitorTarget.Config JSON 字段中
type MiddlewareConfig struct {
Password string `json:"password,omitempty"`
Username string `json:"username,omitempty"` // MySQL 专用
Database string `json:"database,omitempty"` // MySQL 专用
}
// MiddlewareMonitorHandler 中间件监控 handler
type MiddlewareMonitorHandler struct {
db *gorm.DB
stopCh chan struct{}
wg sync.WaitGroup
}
// NewMiddlewareMonitorHandler 创建 handler 并启动后台采集
func NewMiddlewareMonitorHandler(db *gorm.DB) *MiddlewareMonitorHandler {
h := &MiddlewareMonitorHandler{
db: db,
stopCh: make(chan struct{}),
}
return h
}
// StartCollector 启动后台指标采集 goroutine
func (h *MiddlewareMonitorHandler) StartCollector(intervalSec int) {
if intervalSec < 10 {
intervalSec = 30
}
h.wg.Add(1)
go func() {
defer h.wg.Done()
ticker := time.NewTicker(time.Duration(intervalSec) * time.Second)
defer ticker.Stop()
log.Printf("[MiddlewareMonitor] 采集引擎启动,间隔 %ds", intervalSec)
// 首次立即采集一次
h.collectAll()
for {
select {
case <-ticker.C:
h.collectAll()
case <-h.stopCh:
log.Printf("[MiddlewareMonitor] 采集引擎停止")
return
}
}
}()
}
// StopCollector 停止后台采集
func (h *MiddlewareMonitorHandler) StopCollector() {
close(h.stopCh)
h.wg.Wait()
}
// ============================================
// CRUD 接口
// ============================================
// CreateMiddlewareTarget 创建中间件监控目标
// POST /api/v1/middleware-monitors
func (h *MiddlewareMonitorHandler) CreateMiddlewareTarget(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"` // redis / mysql
IP string `json:"ip" binding:"required"`
Port int `json:"port" binding:"required"`
Password string `json:"password"`
Username string `json:"username"` // MySQL
Database string `json:"database"` // MySQL
Interval int `json:"interval"` // 采集间隔,默认 30s
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
// 验证类型
mwType := strings.ToLower(req.Type)
if mwType != "redis" && mwType != "mysql" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "type 必须是 redis 或 mysql"})
return
}
if req.Port == 0 {
if mwType == "redis" {
req.Port = 6379
} else {
req.Port = 3306
}
}
if req.Interval == 0 {
req.Interval = 30
}
// 构建 Config JSON
cfg := MiddlewareConfig{
Password: req.Password,
Username: req.Username,
Database: req.Database,
}
cfgJSON, _ := json.Marshal(cfg)
target := model.MonitorTarget{
Name: req.Name,
Type: model.TargetTypeMiddleware,
Status: model.TargetStatusUnknown,
IP: req.IP,
Port: req.Port,
CollectMode: "api",
Config: string(cfgJSON),
Interval: req.Interval,
Description: mwType, // 复用 Description 字段存储中间件子类型
}
if err := h.db.Create(&target).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建失败: " + err.Error()})
return
}
// 立即采集一次
go h.collectOne(target.ID)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: target})
}
// ListMiddlewareTargets 列出中间件监控目标
// GET /api/v1/middleware-monitors
func (h *MiddlewareMonitorHandler) ListMiddlewareTargets(c *gin.Context) {
var targets []model.MonitorTarget
q := h.db.Model(&model.MonitorTarget{}).Where("type = ?", model.TargetTypeMiddleware)
if kw := c.Query("keyword"); kw != "" {
q = q.Where("name LIKE ? OR ip LIKE ?", "%"+kw+"%", "%"+kw+"%")
}
if subType := c.Query("sub_type"); subType != "" {
q = q.Where("description = ?", subType)
}
var total int64
q.Count(&total)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100"))
q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&targets)
if targets == nil {
targets = []model.MonitorTarget{}
}
// 附带最新指标
type TargetWithMetrics struct {
model.MonitorTarget
SubType string `json:"sub_type"`
LatestMetrics map[string]float64 `json:"latest_metrics"`
}
result := make([]TargetWithMetrics, len(targets))
now := time.Now().Unix()
for i, t := range targets {
var histories []model.MetricHistory
h.db.Where("target_id = ? AND timestamp > ?", t.ID, now-600).
Order("timestamp DESC").Limit(20).Find(&histories)
metrics := make(map[string]float64)
for _, mh := range histories {
if _, ok := metrics[mh.MetricName]; !ok {
metrics[mh.MetricName] = mh.Value
}
}
result[i] = TargetWithMetrics{
MonitorTarget: t,
SubType: t.Description,
LatestMetrics: metrics,
}
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
"list": result,
"total": total,
}})
}
// GetMiddlewareTarget 获取单个中间件监控目标
// GET /api/v1/middleware-monitors/:id
func (h *MiddlewareMonitorHandler) GetMiddlewareTarget(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的 ID"})
return
}
var target model.MonitorTarget
if err := h.db.First(&target, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "目标不存在"})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: target})
}
// UpdateMiddlewareTarget 更新中间件监控目标
// PUT /api/v1/middleware-monitors/:id
func (h *MiddlewareMonitorHandler) UpdateMiddlewareTarget(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的 ID"})
return
}
var target model.MonitorTarget
if err := h.db.First(&target, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "目标不存在"})
return
}
var req struct {
Name *string `json:"name"`
IP *string `json:"ip"`
Port *int `json:"port"`
Password *string `json:"password"`
Username *string `json:"username"`
Database *string `json:"database"`
Interval *int `json:"interval"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
updates := map[string]interface{}{}
if req.Name != nil {
updates["name"] = *req.Name
}
if req.IP != nil {
updates["ip"] = *req.IP
}
if req.Port != nil {
updates["port"] = *req.Port
}
if req.Interval != nil {
updates["interval"] = *req.Interval
}
// 如果有密码/用户名变更,更新 Config JSON
if req.Password != nil || req.Username != nil || req.Database != nil {
var cfg MiddlewareConfig
if target.Config != "" {
json.Unmarshal([]byte(target.Config), &cfg)
}
if req.Password != nil {
cfg.Password = *req.Password
}
if req.Username != nil {
cfg.Username = *req.Username
}
if req.Database != nil {
cfg.Database = *req.Database
}
cfgJSON, _ := json.Marshal(cfg)
updates["config"] = string(cfgJSON)
}
if len(updates) > 0 {
h.db.Model(&target).Updates(updates)
}
// 立即采集一次
go h.collectOne(uint(id))
c.JSON(http.StatusOK, model.Response{Code: 200, Data: target})
}
// DeleteMiddlewareTarget 删除中间件监控目标
// DELETE /api/v1/middleware-monitors/:id
func (h *MiddlewareMonitorHandler) DeleteMiddlewareTarget(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的 ID"})
return
}
if err := h.db.Delete(&model.MonitorTarget{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "删除失败"})
return
}
// 清理指标历史
h.db.Where("target_id = ?", id).Delete(&model.MetricHistory{})
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "删除成功"})
}
// TestMiddlewareConnection 测试中间件连接
// POST /api/v1/middleware-monitors/:id/test
func (h *MiddlewareMonitorHandler) TestMiddlewareConnection(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的 ID"})
return
}
var target model.MonitorTarget
if err := h.db.First(&target, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "目标不存在"})
return
}
metrics, err := h.collectTarget(target)
if err != nil {
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
"success": false,
"message": err.Error(),
}})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
"success": true,
"message": fmt.Sprintf("连接成功,采集到 %d 项指标", len(metrics)),
"metrics": metrics,
}})
}
// CollectNow 手动触发采集
// POST /api/v1/middleware-monitors/collect
func (h *MiddlewareMonitorHandler) CollectNow(c *gin.Context) {
count := h.collectAll()
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
"collected": count,
"message": fmt.Sprintf("采集完成,成功 %d 个目标", count),
}})
}
// GetMiddlewareMetrics 获取中间件历史指标
// GET /api/v1/middleware-monitors/:id/metrics?metric=redis_qps&start=...&end=...
func (h *MiddlewareMonitorHandler) GetMiddlewareMetrics(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的 ID"})
return
}
metricName := c.Query("metric")
now := time.Now().Unix()
startTime := now - 3600 // 默认 1h
endTime := now
if s := c.Query("start"); s != "" {
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
startTime = v
}
}
if e := c.Query("end"); e != "" {
if v, err := strconv.ParseInt(e, 10, 64); err == nil {
endTime = v
}
}
q := h.db.Model(&model.MetricHistory{}).
Where("target_id = ? AND timestamp >= ? AND timestamp <= ?", id, startTime, endTime)
if metricName != "" {
q = q.Where("metric_name = ?", metricName)
}
var histories []model.MetricHistory
q.Order("timestamp ASC").Limit(2000).Find(&histories)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
"list": histories,
"total": len(histories),
}})
}
// ============================================
// 指标采集引擎
// ============================================
// collectAll 采集所有启用的中间件目标
func (h *MiddlewareMonitorHandler) collectAll() int {
var targets []model.MonitorTarget
h.db.Where("type = ? AND collect_mode = ?", model.TargetTypeMiddleware, "api").Find(&targets)
count := 0
for _, target := range targets {
metrics, err := h.collectTarget(target)
if err != nil {
h.db.Model(&model.MonitorTarget{}).Where("id = ?", target.ID).
Update("status", model.TargetStatusOffline)
continue
}
// 存储指标
h.saveMetrics(target.ID, metrics)
// 更新状态
now := time.Now()
h.db.Model(&model.MonitorTarget{}).Where("id = ?", target.ID).
Updates(map[string]interface{}{
"status": model.TargetStatusOnline,
"last_seen_at": &now,
})
count++
}
return count
}
// collectOne 采集单个目标
func (h *MiddlewareMonitorHandler) collectOne(targetID uint) {
var target model.MonitorTarget
if err := h.db.First(&target, targetID).Error; err != nil {
return
}
metrics, err := h.collectTarget(target)
if err != nil {
h.db.Model(&model.MonitorTarget{}).Where("id = ?", target.ID).
Update("status", model.TargetStatusOffline)
return
}
h.saveMetrics(target.ID, metrics)
now := time.Now()
h.db.Model(&model.MonitorTarget{}).Where("id = ?", target.ID).
Updates(map[string]interface{}{
"status": model.TargetStatusOnline,
"last_seen_at": &now,
})
}
// collectTarget 连接到中间件并采集指标
func (h *MiddlewareMonitorHandler) collectTarget(target model.MonitorTarget) (map[string]float64, error) {
mwType := strings.ToLower(target.Description) // Description 存储子类型
if mwType == "" {
// 尝试从名称推断
if strings.Contains(strings.ToLower(target.Name), "redis") {
mwType = "redis"
} else if strings.Contains(strings.ToLower(target.Name), "mysql") {
mwType = "mysql"
}
}
// 解析配置
var cfg MiddlewareConfig
if target.Config != "" {
json.Unmarshal([]byte(target.Config), &cfg)
}
addr := fmt.Sprintf("%s:%d", target.IP, target.Port)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
switch mwType {
case "redis":
return h.collectRedis(ctx, addr, cfg.Password)
case "mysql":
return h.collectMySQL(ctx, addr, cfg)
default:
return nil, fmt.Errorf("未知中间件类型: %s", mwType)
}
}
// collectRedis 采集 Redis 指标
func (h *MiddlewareMonitorHandler) collectRedis(ctx context.Context, addr, password string) (map[string]float64, error) {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: 0,
})
defer rdb.Close()
// PING 检测连接
if err := rdb.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("Redis 连接失败: %v", err)
}
result := make(map[string]float64)
// INFO 命令获取详细信息
info, err := rdb.Info(ctx).Result()
if err == nil {
infoMap := parseRedisInfo(info)
result["redis_connected_clients"] = parseFloat(infoMap["connected_clients"])
result["redis_used_memory"] = parseFloat(infoMap["used_memory"])
result["redis_used_memory_peak"] = parseFloat(infoMap["used_memory_peak"])
result["redis_total_commands_processed"] = parseFloat(infoMap["total_commands_processed"])
result["redis_uptime_in_seconds"] = parseFloat(infoMap["uptime_in_seconds"])
result["redis_keyspace_hits"] = parseFloat(infoMap["keyspace_hits"])
result["redis_keyspace_misses"] = parseFloat(infoMap["keyspace_misses"])
result["redis_evicted_keys"] = parseFloat(infoMap["evicted_keys"])
result["redis_blocked_clients"] = parseFloat(infoMap["blocked_clients"])
// 计算命中率
hits := result["redis_keyspace_hits"]
misses := result["redis_keyspace_misses"]
if hits+misses > 0 {
result["redis_hit_rate"] = hits / (hits + misses)
} else {
result["redis_hit_rate"] = 0
}
// 计算 QPS
uptime := result["redis_uptime_in_seconds"]
cmds := result["redis_total_commands_processed"]
if uptime > 0 {
result["redis_qps"] = cmds / uptime
}
}
// DBSIZE
dbSize, err := rdb.DBSize(ctx).Result()
if err == nil {
result["redis_db_size"] = float64(dbSize)
}
return result, nil
}
// collectMySQL 采集 MySQL 指标
func (h *MiddlewareMonitorHandler) collectMySQL(ctx context.Context, addr string, cfg MiddlewareConfig) (map[string]float64, error) {
dbName := cfg.Database
if dbName == "" {
dbName = "information_schema"
}
user := cfg.Username
if user == "" {
user = "root"
}
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?timeout=5s&readTimeout=3s", user, cfg.Password, addr, dbName)
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("MySQL 连接配置错误: %v", err)
}
defer db.Close()
// Ping 检测连接
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("MySQL 连接失败: %v", err)
}
result := make(map[string]float64)
// SHOW GLOBAL STATUS
rows, err := db.QueryContext(ctx, "SHOW GLOBAL STATUS")
if err != nil {
return nil, fmt.Errorf("MySQL 查询状态失败: %v", err)
}
defer rows.Close()
statusMap := make(map[string]string)
for rows.Next() {
var name, val string
if err := rows.Scan(&name, &val); err != nil {
continue
}
statusMap[name] = val
}
result["mysql_connections"] = parseFloat(statusMap["Threads_connected"])
result["mysql_threads_running"] = parseFloat(statusMap["Threads_running"])
result["mysql_slow_queries"] = parseFloat(statusMap["Slow_queries"])
result["mysql_questions"] = parseFloat(statusMap["Questions"])
result["mysql_uptime"] = parseFloat(statusMap["Uptime"])
result["mysql_bytes_sent"] = parseFloat(statusMap["Bytes_sent"])
result["mysql_bytes_received"] = parseFloat(statusMap["Bytes_received"])
result["mysql_buffer_pool_pages_total"] = parseFloat(statusMap["Innodb_buffer_pool_pages_total"])
result["mysql_buffer_pool_read_requests"] = parseFloat(statusMap["Innodb_buffer_pool_read_requests"])
result["mysql_buffer_pool_reads"] = parseFloat(statusMap["Innodb_buffer_pool_reads"])
// 计算 QPS
uptime := result["mysql_uptime"]
questions := result["mysql_questions"]
if uptime > 0 {
result["mysql_qps"] = questions / uptime
}
// 计算缓冲池命中率
readReqs := result["mysql_buffer_pool_read_requests"]
reads := result["mysql_buffer_pool_reads"]
if readReqs > 0 {
result["mysql_buffer_hit_rate"] = (readReqs - reads) / readReqs
} else {
result["mysql_buffer_hit_rate"] = 0
}
return result, nil
}
// saveMetrics 将采集到的指标存入 MetricHistory
func (h *MiddlewareMonitorHandler) saveMetrics(targetID uint, metrics map[string]float64) {
if len(metrics) == 0 {
return
}
now := time.Now().Unix()
var records []model.MetricHistory
for name, value := range metrics {
records = append(records, model.MetricHistory{
TargetID: targetID,
MetricName: name,
Value: value,
Timestamp: now,
})
}
// 批量插入
if err := h.db.CreateInBatches(records, 100).Error; err != nil {
log.Printf("[MiddlewareMonitor] 存储指标失败 target=%d: %v", targetID, err)
}
// 清理 7 天前的历史数据 (每次采集后异步清理)
go func() {
cutoff := time.Now().Unix() - 7*24*3600
h.db.Where("target_id = ? AND timestamp < ?", targetID, cutoff).Delete(&model.MetricHistory{})
}()
}
// ============================================
// 辅助函数
// ============================================
// parseRedisInfo 解析 Redis INFO 命令输出
func parseRedisInfo(info string) map[string]string {
result := make(map[string]string)
for _, line := range strings.Split(info, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
result[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
return result
}
// parseFloat 安全解析浮点数
func parseFloat(s string) float64 {
if s == "" {
return 0
}
var f float64
fmt.Sscanf(s, "%f", &f)
return f
}
+15
View File
@@ -911,6 +911,21 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
metricsGroup.GET("/top", metricsHandler.TopHosts)
}
// 中间件监控 — Redis/MySQL 后端直连采集 // Logan.Li
mwHandler := controller.NewMiddlewareMonitorHandler(db)
mwGroup := authorized.Group("/middleware-monitors")
{
mwGroup.GET("", mwHandler.ListMiddlewareTargets)
mwGroup.POST("", mwHandler.CreateMiddlewareTarget)
mwGroup.GET("/:id", mwHandler.GetMiddlewareTarget)
mwGroup.PUT("/:id", mwHandler.UpdateMiddlewareTarget)
mwGroup.DELETE("/:id", mwHandler.DeleteMiddlewareTarget)
mwGroup.POST("/:id/test", mwHandler.TestMiddlewareConnection)
mwGroup.POST("/collect", mwHandler.CollectNow)
mwGroup.GET("/:id/metrics", mwHandler.GetMiddlewareMetrics)
}
mwHandler.StartCollector(30) // 每 30s 采集一次
// SNMP 设备管理 — 网络设备/服务器硬件 // Logan.Li
snmpHandler := controller.NewSNMPHandler(db)
snmpGroup := authorized.Group("/snmp")
+1 -1
View File
@@ -7,4 +7,4 @@ export { traceApi } from './trace';
export { traceDatasourceApi, type TraceDataSource } from './trace-datasource';
export { dashboardApi } from './grafana';
export { promDatasourceApi, zabbixDatasourceApi } from './datasource';
export { metricsApi, snmpApi, prometheusProxyApi, zabbixProxyApi } from './metrics';
export { metricsApi, snmpApi, prometheusProxyApi, zabbixProxyApi, middlewareMonitorApi } from './metrics';
+14
View File
@@ -22,6 +22,20 @@ export const snmpApi = {
deleteDevice: (id: number | string) => http.delete(`/snmp/devices/${id}`),
};
// 中间件监控 — Redis/MySQL 后端直连采集 (M15)
export const middlewareMonitorApi = {
list: (params?: Record<string, string | number | undefined>) =>
http.get('/middleware-monitors', params),
create: (data: Record<string, unknown>) => http.post('/middleware-monitors', data),
update: (id: number | string, data: Record<string, unknown>) =>
http.put(`/middleware-monitors/${id}`, data),
delete: (id: number | string) => http.delete(`/middleware-monitors/${id}`),
test: (id: number | string) => http.post(`/middleware-monitors/${id}/test`),
collectNow: () => http.post('/middleware-monitors/collect'),
getMetrics: (id: number | string, params?: Record<string, string | number | undefined>) =>
http.get(`/middleware-monitors/${id}/metrics`, params),
};
export const prometheusProxyApi = {
connect: (url: string) => http.post('/prometheus/connect', { url }),
dashboard: (url: string, type: string) => http.post('/prometheus/dashboard', { url, type }),
+418 -130
View File
@@ -1,26 +1,39 @@
// 中间件监控 — Redis / MySQL 指标采集(实时 API 数据)
// 中间件监控 — Redis / MySQL 指标采集(后端直连模式 M15)
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Database, Server, Activity, Loader2, Circle } from 'lucide-react';
import { metricsApi } from '@/api';
import type { JsonData } from '@/types/common';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import {
Database, Server, Activity, Loader2, Circle, Plus,
Trash2, Pencil, Zap, RefreshCw, Settings2,
} from 'lucide-react';
import { middlewareMonitorApi } from '@/api';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
interface TargetInfo {
interface MwTarget {
id: number;
name: string;
type: string;
ip: string;
status: string;
ip: string;
port: number;
description: string; // 子类型 redis/mysql
latest_metrics: Record<string, number>;
last_seen_at?: string;
}
interface TargetsResponse {
list: TargetInfo[];
interface MwListResponse {
list: MwTarget[];
total: number;
}
/** 格式化内存值 (bytes → human readable) */
function fmtMemory(bytes: number): string {
if (bytes == null || bytes === 0) return '--';
if (bytes > 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
@@ -29,24 +42,22 @@ function fmtMemory(bytes: number): string {
return `${bytes} B`;
}
/** 格式化命中率 (0-1 float → %) */
function fmtHitRate(v: number): string {
if (v == null || Number.isNaN(v)) return '--';
return `${(v * 100).toFixed(1)}%`;
}
/** 格式化数值 */
function fmtVal(v: number, precision = 0): string {
if (v == null || Number.isNaN(v)) return '--';
return v.toFixed(precision);
}
/** 状态指示器颜色 */
function statusColor(status: string): string {
switch (status) {
case 'online': return 'text-emerald-400';
case 'warning': return 'text-amber-400';
case 'error': return 'text-red-400';
case 'error':
case 'offline': return 'text-red-400';
default: return 'text-slate-500';
}
}
@@ -55,46 +66,112 @@ function statusBg(status: string): string {
switch (status) {
case 'online': return 'bg-emerald-400';
case 'warning': return 'bg-amber-400';
case 'error': return 'bg-red-400';
case 'error':
case 'offline': return 'bg-red-400';
default: return 'bg-slate-500';
}
}
export function MiddlewareMonitor() {
const [targets, setTargets] = useState<TargetInfo[]>([]);
const confirm = useConfirm();
const [targets, setTargets] = useState<MwTarget[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [editTarget, setEditTarget] = useState<MwTarget | null>(null);
const [testing, setTesting] = useState<number | null>(null);
const loadTargets = useCallback(async () => {
try {
setLoading(true);
setError('');
const data = await metricsApi.getTargets({ type: 'middleware', page_size: 200 }) as TargetsResponse;
const data = await middlewareMonitorApi.list() as MwListResponse;
setTargets(data?.list || []);
} catch (err) {
setError((err as JsonData).message || '加载失败');
const msg = (err as { message?: string }).message || '加载失败';
setError(msg);
setTargets([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadTargets(); }, []);
useEffect(() => { loadTargets(); }, [loadTargets]);
// 按类型分组
const redisTargets = targets.filter(t => t.type === 'redis' || t.name?.toLowerCase().includes('redis'));
const mysqlTargets = targets.filter(t => t.type === 'mysql' || t.name?.toLowerCase().includes('mysql'));
// 按子类型分组
const redisTargets = targets.filter(t => t.description === 'redis');
const mysqlTargets = targets.filter(t => t.description === 'mysql');
const otherTargets = targets.filter(t => t.description !== 'redis' && t.description !== 'mysql');
// 聚合 Redis 指标 (取第一个在线实例)
const redisTarget = redisTargets.find(t => t.status === 'online') || redisTargets[0];
const redisMetrics = redisTarget?.latest_metrics || {};
// 聚合 MySQL 指标
const mysqlTarget = mysqlTargets.find(t => t.status === 'online') || mysqlTargets[0];
const mysqlMetrics = mysqlTarget?.latest_metrics || {};
const handleDelete = async (target: MwTarget) => {
const ok = await confirm({
title: '删除中间件监控',
description: `确认删除 "${target.name}"?相关指标历史也将清除。`,
confirmText: '删除',
});
if (!ok) return;
try {
await middlewareMonitorApi.delete(target.id);
toast.success('已删除');
loadTargets();
} catch (err) {
toast.error('删除失败', { description: (err as { message?: string }).message });
}
};
const handleTest = async (target: MwTarget) => {
setTesting(target.id);
try {
const res = await middlewareMonitorApi.test(target.id) as { success: boolean; message: string };
if (res.success) {
toast.success('连接成功', { description: res.message });
} else {
toast.error('连接失败', { description: res.message });
}
if (res.success) loadTargets();
} catch (err) {
toast.error('测试失败', { description: (err as { message?: string }).message });
} finally {
setTesting(null);
}
};
const handleCollectNow = async () => {
try {
const res = await middlewareMonitorApi.collectNow() as { collected: number; message: string };
toast.success('采集完成', { description: res.message });
loadTargets();
} catch (err) {
toast.error('采集失败', { description: (err as { message?: string }).message });
}
};
return (
<div className="space-y-6">
{/* 工具栏 */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">中间件监控</h2>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleCollectNow} className="gap-1.5">
<Zap className="h-4 w-4" />
立即采集
</Button>
<Button variant="outline" size="sm" onClick={loadTargets} className="gap-1.5">
<RefreshCw className="h-4 w-4" />
刷新
</Button>
<Button size="sm" onClick={() => { setEditTarget(null); setDialogOpen(true); }} className="gap-1.5">
<Plus className="h-4 w-4" />
添加监控
</Button>
</div>
</div>
{loading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-slate-500 mr-2" />
@@ -107,7 +184,7 @@ export function MiddlewareMonitor() {
<CardContent className="p-6 text-center">
<Activity className="h-8 w-8 text-red-400 mx-auto mb-2" />
<p className="text-sm text-red-400">{error}</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-2">请确认后端服务可用且已配置中间件采集</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-2">请确认后端服务可用</p>
</CardContent>
</Card>
)}
@@ -117,133 +194,134 @@ export function MiddlewareMonitor() {
<CardContent className="p-12 text-center">
<Database className="h-12 w-12 text-slate-600 mx-auto mb-4" />
<p className="text-sm text-[var(--dg-text-muted)]">暂无中间件监控目标</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-2">需在 Agent 配置 Redis / MySQL 连接信息后启用采集</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-2">点击「添加监控」配置 Redis / MySQL 连接</p>
<Button size="sm" className="mt-4 gap-1.5" onClick={() => { setEditTarget(null); setDialogOpen(true); }}>
<Plus className="h-4 w-4" />
添加第一个监控
</Button>
</CardContent>
</Card>
)}
{/* Redis 卡片 */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-4">
<Database className="h-4 w-4 text-red-400" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">Redis 监控</h3>
{redisTarget && (
<Badge variant="outline" className="ml-auto text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
{redisTarget.name} ({redisTarget.ip})
</Badge>
)}
</div>
{!redisTarget ? (
<p className="text-sm text-[var(--dg-text-muted)] mb-4">需在 Agent 配置 Redis 连接地址和密码后启用采集。</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<MetricTile
label="连接数"
value={fmtVal(redisMetrics.redis_connected_clients)}
/>
<MetricTile
label="内存"
value={fmtMemory(redisMetrics.redis_used_memory)}
/>
<MetricTile
label="命中率"
value={fmtHitRate(redisMetrics.redis_hit_rate)}
/>
<MetricTile
label="QPS"
value={fmtVal(redisMetrics.redis_qps, 0)}
/>
{/* Redis 概览卡片 */}
{redisTargets.length > 0 && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-4">
<Database className="h-4 w-4 text-red-400" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">Redis 监控</h3>
{redisTarget && (
<Badge variant="outline" className="ml-auto text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
{redisTarget.name} ({redisTarget.ip}:{redisTarget.port})
</Badge>
)}
</div>
)}
</CardContent>
</Card>
{redisTarget ? (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<MetricTile label="连接数" value={fmtVal(redisMetrics.redis_connected_clients)} />
<MetricTile label="内存" value={fmtMemory(redisMetrics.redis_used_memory)} />
<MetricTile label="命中率" value={fmtHitRate(redisMetrics.redis_hit_rate)} />
<MetricTile label="QPS" value={fmtVal(redisMetrics.redis_qps, 1)} />
<MetricTile label="Key总数" value={fmtVal(redisMetrics.redis_db_size)} />
<MetricTile label="阻塞客户端" value={fmtVal(redisMetrics.redis_blocked_clients)} />
</div>
) : (
<p className="text-sm text-[var(--dg-text-muted)]">无在线 Redis 实例</p>
)}
</CardContent>
</Card>
)}
{/* MySQL 卡片 */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-4">
<Server className="h-4 w-4 text-blue-400" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">MySQL 监控</h3>
{mysqlTarget && (
<Badge variant="outline" className="ml-auto text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
{mysqlTarget.name} ({mysqlTarget.ip})
</Badge>
)}
</div>
{!mysqlTarget ? (
<p className="text-sm text-[var(--dg-text-muted)] mb-4">需在 Agent 配置 MySQL 连接地址、用户名和密码后启用采集。</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<MetricTile
label="连接数"
value={fmtVal(mysqlMetrics.mysql_connections)}
/>
<MetricTile
label="QPS"
value={fmtVal(mysqlMetrics.mysql_qps, 1)}
/>
<MetricTile
label="慢查询"
value={fmtVal(mysqlMetrics.mysql_slow_queries)}
/>
<MetricTile
label="缓冲池命中率"
value={fmtHitRate(mysqlMetrics.mysql_buffer_hit_rate)}
/>
{/* MySQL 概览卡片 */}
{mysqlTargets.length > 0 && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-4">
<Server className="h-4 w-4 text-blue-400" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">MySQL 监控</h3>
{mysqlTarget && (
<Badge variant="outline" className="ml-auto text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
{mysqlTarget.name} ({mysqlTarget.ip}:{mysqlTarget.port})
</Badge>
)}
</div>
)}
</CardContent>
</Card>
{mysqlTarget ? (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<MetricTile label="连接数" value={fmtVal(mysqlMetrics.mysql_connections)} />
<MetricTile label="活跃线程" value={fmtVal(mysqlMetrics.mysql_threads_running)} />
<MetricTile label="QPS" value={fmtVal(mysqlMetrics.mysql_qps, 1)} />
<MetricTile label="慢查询" value={fmtVal(mysqlMetrics.mysql_slow_queries)} />
<MetricTile label="缓冲池命中率" value={fmtHitRate(mysqlMetrics.mysql_buffer_hit_rate)} />
<MetricTile label="发送流量" value={fmtMemory(mysqlMetrics.mysql_bytes_sent)} />
</div>
) : (
<p className="text-sm text-[var(--dg-text-muted)]">无在线 MySQL 实例</p>
)}
</CardContent>
</Card>
)}
{/* 中间件实例列表 */}
{targets.length > 0 && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-4">
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
<Settings2 className="h-4 w-4 text-[var(--dg-text-muted)]" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
中间件实例({targets.length})
监控目标列表({targets.length})
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{targets.map(target => (
<InstanceCard key={target.id} target={target} />
<InstanceCard
key={target.id}
target={target}
onEdit={() => { setEditTarget(target); setDialogOpen(true); }}
onDelete={() => handleDelete(target)}
onTest={() => handleTest(target)}
testing={testing === target.id}
/>
))}
</div>
</CardContent>
</Card>
)}
{/* 其他中间件类型的扩展指标 */}
{targets.filter(t => !redisTargets.includes(t) && !mysqlTargets.includes(t)).length > 0 && (
{/* 其他类型 */}
{otherTargets.length > 0 && (
<div className="space-y-4">
{targets
.filter(t => !redisTargets.includes(t) && !mysqlTargets.includes(t))
.map(target => (
<Card key={target.id} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Circle className={`h-3 w-3 ${statusColor(target.status)}`} />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] capitalize">{target.type}</h3>
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
{target.name} ({target.ip})
</Badge>
{otherTargets.map(target => (
<Card key={target.id} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Circle className={`h-3 w-3 ${statusColor(target.status)}`} />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] capitalize">{target.description}</h3>
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
{target.name} ({target.ip})
</Badge>
</div>
{Object.keys(target.latest_metrics || {}).length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{Object.entries(target.latest_metrics).map(([key, val]) => (
<MetricTile key={key} label={key} value={fmtVal(val, 1)} />
))}
</div>
{Object.keys(target.latest_metrics || {}).length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{Object.entries(target.latest_metrics).map(([key, val]) => (
<MetricTile key={key} label={key} value={fmtVal(val, 1)} />
))}
</div>
) : (
<p className="text-xs text-[var(--dg-text-muted)]">暂无指标数据</p>
)}
</CardContent>
</Card>
))}
) : (
<p className="text-xs text-[var(--dg-text-muted)]">暂无指标数据</p>
)}
</CardContent>
</Card>
))}
</div>
)}
{/* 添加/编辑弹框 */}
<MiddlewareDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
editTarget={editTarget}
onSuccess={loadTargets}
/>
</div>
);
}
@@ -258,8 +336,16 @@ function MetricTile({ label, value }: { label: string; value: string }) {
);
}
/** 实例状态卡片 */
function InstanceCard({ target }: { target: TargetInfo }) {
/** 实例卡片 */
function InstanceCard({
target, onEdit, onDelete, onTest, testing,
}: {
target: MwTarget;
onEdit: () => void;
onDelete: () => void;
onTest: () => void;
testing: boolean;
}) {
const metrics = target.latest_metrics || {};
const metricCount = Object.keys(metrics).length;
@@ -278,13 +364,13 @@ function InstanceCard({ target }: { target: TargetInfo }) {
</Badge>
</div>
<div className="text-xs text-[var(--dg-text-muted)] mb-2">
{target.ip} · {target.type}
{target.ip}:{target.port} · {target.description}
</div>
{metricCount > 0 ? (
<div className="flex flex-wrap gap-1.5">
<div className="flex flex-wrap gap-1.5 mb-3">
{Object.entries(metrics).slice(0, 4).map(([key, val]) => (
<span key={key} className="inline-flex items-center gap-1 text-[10px] bg-[var(--dg-bg-primary)] rounded px-1.5 py-0.5 text-[var(--dg-text-secondary)]">
<span className="text-[var(--dg-text-muted)]">{key}</span>
<span className="text-[var(--dg-text-muted)]">{key.replace(/^(redis|mysql)_/, '')}</span>
<span className="font-mono font-medium text-[var(--dg-text-primary)]">
{typeof val === 'number' ? val.toFixed(1) : val}
</span>
@@ -295,8 +381,210 @@ function InstanceCard({ target }: { target: TargetInfo }) {
)}
</div>
) : (
<p className="text-[10px] text-[var(--dg-text-muted)]">暂无指标</p>
<p className="text-[10px] text-[var(--dg-text-muted)] mb-3">暂无指标</p>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={onTest} disabled={testing}>
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3" />}
测试
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={onEdit}>
<Pencil className="h-3 w-3" />
编辑
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1 text-red-400 hover:text-red-300" onClick={onDelete}>
<Trash2 className="h-3 w-3" />
删除
</Button>
</div>
</div>
);
}
/** 添加/编辑中间件弹框 */
function MiddlewareDialog({
open, onOpenChange, editTarget, onSuccess,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
editTarget: MwTarget | null;
onSuccess: () => void;
}) {
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
name: '', type: 'redis', ip: '', port: 6379,
password: '', username: '', database: '',
});
useEffect(() => {
if (editTarget) {
setForm({
name: editTarget.name || '',
type: editTarget.description || 'redis',
ip: editTarget.ip || '',
port: editTarget.port || 6379,
password: '', // 不回显密码
username: '',
database: '',
});
} else {
setForm({
name: '', type: 'redis', ip: '', port: 6379,
password: '', username: '', database: '',
});
}
}, [editTarget, open]);
const handleSubmit = async () => {
if (!form.name.trim() || !form.ip.trim()) {
toast.error('请填写名称和 IP');
return;
}
setSaving(true);
try {
const payload: Record<string, unknown> = {
name: form.name.trim(),
type: form.type,
ip: form.ip.trim(),
port: Number(form.port) || (form.type === 'redis' ? 6379 : 3306),
interval: 30,
};
if (form.password) payload.password = form.password;
if (form.type === 'mysql') {
if (form.username) payload.username = form.username;
if (form.database) payload.database = form.database;
}
if (editTarget) {
await middlewareMonitorApi.update(editTarget.id, payload);
toast.success('更新成功');
} else {
await middlewareMonitorApi.create(payload);
toast.success('创建成功,正在采集...');
}
onOpenChange(false);
onSuccess();
} catch (err) {
toast.error(editTarget ? '更新失败' : '创建失败', {
description: (err as { message?: string }).message,
});
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-md">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">
{editTarget ? '编辑中间件监控' : '添加中间件监控'}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">
配置 Redis 或 MySQL 连接信息,平台将自动采集指标
</DialogDescription>
</DialogHeader>
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
{/* 类型选择 */}
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">中间件类型</Label>
<div className="flex gap-2">
{(['redis', 'mysql'] as const).map(t => (
<button
key={t}
type="button"
onClick={() => setForm(f => ({
...f, type: t,
port: t === 'redis' ? 6379 : 3306,
}))}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
form.type === t
? 'bg-blue-600 text-white'
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]'
}`}
>
{t === 'redis' ? 'Redis' : 'MySQL'}
</button>
))}
</div>
</div>
{/* 名称 */}
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">名称 *</Label>
<Input
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder={form.type === 'redis' ? '生产 Redis' : '生产 MySQL'}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
{/* IP + Port */}
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2 space-y-2">
<Label className="text-[var(--dg-text-secondary)]">IP 地址 *</Label>
<Input
value={form.ip}
onChange={e => setForm(f => ({ ...f, ip: e.target.value }))}
placeholder="10.10.10.75"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">端口</Label>
<Input
type="number"
value={form.port}
onChange={e => setForm(f => ({ ...f, port: Number(e.target.value) }))}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
</div>
{/* 密码 */}
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">
密码 {editTarget && <span className="text-xs text-[var(--dg-text-muted)]">(留空不修改)</span>}
</Label>
<Input
type="password"
value={form.password}
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
placeholder="输入密码"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
{/* MySQL 专属字段 */}
{form.type === 'mysql' && (
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">用户名</Label>
<Input
value={form.username}
onChange={e => setForm(f => ({ ...f, username: e.target.value }))}
placeholder="root"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">数据库</Label>
<Input
value={form.database}
onChange={e => setForm(f => ({ ...f, database: e.target.value }))}
placeholder="information_schema"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={handleSubmit} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{editTarget ? '保存' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}