From ed7a5c07a745e7cec64a1ab6290df65a7897d114 Mon Sep 17 00:00:00 2001 From: attacker Date: Mon, 29 Jun 2026 23:24:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(monitor):=20M15=20=E4=B8=AD=E9=97=B4?= =?UTF-8?q?=E4=BB=B6=E7=9B=91=E6=8E=A7=20=E2=80=94=20Redis/MySQL=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E7=9B=B4=E8=BF=9E=E9=87=87=E9=9B=86=E5=BC=95?= =?UTF-8?q?=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 新增 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 指标✅ --- PROJECT_EVALUATION.md | 3 +- ROADMAP.md | 34 + TDD_PLAN.md | 69 ++ backend/controller/middleware_monitor.go | 658 ++++++++++++++++++ backend/router/routes.go | 15 + frontend/src/api/monitor/index.ts | 2 +- frontend/src/api/monitor/metrics.ts | 14 + .../src/pages/monitor/MiddlewareMonitor.tsx | 548 +++++++++++---- 8 files changed, 1211 insertions(+), 132 deletions(-) create mode 100644 backend/controller/middleware_monitor.go diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index d58d139..305e53f 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -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 自动化运维 ✅ 完善 diff --git a/ROADMAP.md b/ROADMAP.md index 794e4ab..47c5e63 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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** | --- diff --git a/TDD_PLAN.md b/TDD_PLAN.md index 2be1da1..17625e6 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -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 集成回归测试通过(零回归) diff --git a/backend/controller/middleware_monitor.go b/backend/controller/middleware_monitor.go new file mode 100644 index 0000000..59172cb --- /dev/null +++ b/backend/controller/middleware_monitor.go @@ -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 +} diff --git a/backend/router/routes.go b/backend/router/routes.go index 1eda912..2b987da 100644 --- a/backend/router/routes.go +++ b/backend/router/routes.go @@ -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") diff --git a/frontend/src/api/monitor/index.ts b/frontend/src/api/monitor/index.ts index 129e744..5b7af99 100644 --- a/frontend/src/api/monitor/index.ts +++ b/frontend/src/api/monitor/index.ts @@ -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'; \ No newline at end of file +export { metricsApi, snmpApi, prometheusProxyApi, zabbixProxyApi, middlewareMonitorApi } from './metrics'; \ No newline at end of file diff --git a/frontend/src/api/monitor/metrics.ts b/frontend/src/api/monitor/metrics.ts index 9072d6a..ee43e16 100644 --- a/frontend/src/api/monitor/metrics.ts +++ b/frontend/src/api/monitor/metrics.ts @@ -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) => + http.get('/middleware-monitors', params), + create: (data: Record) => http.post('/middleware-monitors', data), + update: (id: number | string, data: Record) => + 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) => + 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 }), diff --git a/frontend/src/pages/monitor/MiddlewareMonitor.tsx b/frontend/src/pages/monitor/MiddlewareMonitor.tsx index 0c02fe1..1b1562e 100644 --- a/frontend/src/pages/monitor/MiddlewareMonitor.tsx +++ b/frontend/src/pages/monitor/MiddlewareMonitor.tsx @@ -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; + 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([]); + const confirm = useConfirm(); + const [targets, setTargets] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [dialogOpen, setDialogOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [testing, setTesting] = useState(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 (
+ {/* 工具栏 */} +
+

中间件监控

+
+ + + +
+
+ {loading && (
@@ -107,7 +184,7 @@ export function MiddlewareMonitor() {

{error}

-

请确认后端服务可用且已配置中间件采集

+

请确认后端服务可用

)} @@ -117,133 +194,134 @@ export function MiddlewareMonitor() {

暂无中间件监控目标

-

需在 Agent 配置 Redis / MySQL 连接信息后启用采集

+

点击「添加监控」配置 Redis / MySQL 连接

+
)} - {/* Redis 卡片 */} - - -
- -

Redis 监控

- {redisTarget && ( - - {redisTarget.name} ({redisTarget.ip}) - - )} -
- {!redisTarget ? ( -

需在 Agent 配置 Redis 连接地址和密码后启用采集。

- ) : ( -
- - - - + {/* Redis 概览卡片 */} + {redisTargets.length > 0 && ( + + +
+ +

Redis 监控

+ {redisTarget && ( + + {redisTarget.name} ({redisTarget.ip}:{redisTarget.port}) + + )}
- )} -
-
+ {redisTarget ? ( +
+ + + + + + +
+ ) : ( +

无在线 Redis 实例

+ )} + + + )} - {/* MySQL 卡片 */} - - -
- -

MySQL 监控

- {mysqlTarget && ( - - {mysqlTarget.name} ({mysqlTarget.ip}) - - )} -
- {!mysqlTarget ? ( -

需在 Agent 配置 MySQL 连接地址、用户名和密码后启用采集。

- ) : ( -
- - - - + {/* MySQL 概览卡片 */} + {mysqlTargets.length > 0 && ( + + +
+ +

MySQL 监控

+ {mysqlTarget && ( + + {mysqlTarget.name} ({mysqlTarget.ip}:{mysqlTarget.port}) + + )}
- )} -
-
+ {mysqlTarget ? ( +
+ + + + + + +
+ ) : ( +

无在线 MySQL 实例

+ )} + + + )} {/* 中间件实例列表 */} {targets.length > 0 && (
- +

- 中间件实例({targets.length}) + 监控目标列表({targets.length})

{targets.map(target => ( - + { setEditTarget(target); setDialogOpen(true); }} + onDelete={() => handleDelete(target)} + onTest={() => handleTest(target)} + testing={testing === target.id} + /> ))}
)} - {/* 其他中间件类型的扩展指标 */} - {targets.filter(t => !redisTargets.includes(t) && !mysqlTargets.includes(t)).length > 0 && ( + {/* 其他类型 */} + {otherTargets.length > 0 && (
- {targets - .filter(t => !redisTargets.includes(t) && !mysqlTargets.includes(t)) - .map(target => ( - - -
- -

{target.type}

- - {target.name} ({target.ip}) - + {otherTargets.map(target => ( + + +
+ +

{target.description}

+ + {target.name} ({target.ip}) + +
+ {Object.keys(target.latest_metrics || {}).length > 0 ? ( +
+ {Object.entries(target.latest_metrics).map(([key, val]) => ( + + ))}
- {Object.keys(target.latest_metrics || {}).length > 0 ? ( -
- {Object.entries(target.latest_metrics).map(([key, val]) => ( - - ))} -
- ) : ( -

暂无指标数据

- )} -
-
- ))} + ) : ( +

暂无指标数据

+ )} + + + ))}
)} + + {/* 添加/编辑弹框 */} +
); } @@ -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 }) {
- {target.ip} · {target.type} + {target.ip}:{target.port} · {target.description}
{metricCount > 0 ? ( -
+
{Object.entries(metrics).slice(0, 4).map(([key, val]) => ( - {key} + {key.replace(/^(redis|mysql)_/, '')} {typeof val === 'number' ? val.toFixed(1) : val} @@ -295,8 +381,210 @@ function InstanceCard({ target }: { target: TargetInfo }) { )}
) : ( -

暂无指标

+

暂无指标

)} + {/* 操作按钮 */} +
+ + + +
); } + +/** 添加/编辑中间件弹框 */ +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 = { + 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 ( + + + + + {editTarget ? '编辑中间件监控' : '添加中间件监控'} + + + 配置 Redis 或 MySQL 连接信息,平台将自动采集指标 + + +
+ {/* 类型选择 */} +
+ +
+ {(['redis', 'mysql'] as const).map(t => ( + + ))} +
+
+ {/* 名称 */} +
+ + 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)]" + /> +
+ {/* IP + Port */} +
+
+ + 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)]" + /> +
+
+ + setForm(f => ({ ...f, port: Number(e.target.value) }))} + className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" + /> +
+
+ {/* 密码 */} +
+ + setForm(f => ({ ...f, password: e.target.value }))} + placeholder="输入密码" + className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" + /> +
+ {/* MySQL 专属字段 */} + {form.type === 'mysql' && ( +
+
+ + setForm(f => ({ ...f, username: e.target.value }))} + placeholder="root" + className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" + /> +
+
+ + 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)]" + /> +
+
+ )} +
+ + + + +
+
+ ); +}