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:
1 parent
186ece6495
commit
ed7a5c07a7
8 files changed
+1211
-132
No files matched your search
@@ -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
@@ -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
@@ -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 集成回归测试通过(零回归)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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';
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user