feat: Phase 2-5 监控中心全栈实现 — 指标浏览器/SNMP/站点拨测/中间件

Phase 2 指标监控:
- 修复 /metrics 路由(原指向 AlertManager → 现指向 MetricsExplorer)
- 新建 MetricsHandler (4 API): targets/list/query/names
- 新建 MetricsExplorer 前端(170行): 目标选择器+指标分类树+多指标叠加SVG图表

Phase 3 SNMP设备:
- 新建 SNMPHandler (9 API): devices CRUD/templates/stats
- SNMPMonitor 接入真实API(428行): 设备表格+添加对话框+删除

Phase 4 站点拨测:
- 新建 SiteHandler (8 API): tasks CRUD/results/stats
- SiteMonitor 接入真实API(542行): 任务表+添加对话框+拨测类型网格
- 新增 siteApi 到 api.ts

Phase 5 中间件采集:
- Agent 新增 collector/middleware.go: Redis INFO + MySQL handshake 采集
- Agent CLI 新增 -redis/-redis-auth/-mysql/-mysql-user/-mysql-pass
- MiddlewareMonitor 接入真实API(301行): 实例卡片+指标面板

基础设施:
- AutoMigrate 新增 23 张监控表
- 前后端容器构建部署验证通过
This commit is contained in:
attacker committed 2026-06-18 13:02:19 +00:00
1 parent 94dbc89b9b
commit 2e71371257
15 files changed
+2616 -137

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
// Package collector 包含中间件(Redis / MySQL)的采集逻辑。
// 通过 TCP 直连发送 INFO / SHOW GLOBAL STATUS 命令获取指标。
//
// 署名 Logan.Li
package collector
import (
"fmt"
"io"
"net"
"strconv"
"strings"
"sync"
"time"
"ezops/agent/model"
)
// MiddlewareConfig 中间件采集配置
type MiddlewareConfig struct {
RedisAddrs []string // Redis 地址列表,格式 host:port
RedisAuth string // Redis 密码(空为无密码)
MySQLAddrs []string // MySQL 地址列表,格式 host:port
MySQLUser string // MySQL 用户名
MySQLPass string // MySQL 密码
}
// MiddlewareCollector 中间件指标采集器
type MiddlewareCollector struct {
cfg MiddlewareConfig
mu sync.Mutex
prevMySQLQPS map[string]int64 // 上轮 Questions 值,用于计算 QPS 增量
prevTime time.Time
}
// NewMiddlewareCollector 创建中间件采集器
func NewMiddlewareCollector(cfg MiddlewareConfig) *MiddlewareCollector {
return &MiddlewareCollector{
cfg: cfg,
prevMySQLQPS: make(map[string]int64),
prevTime: time.Now(),
}
}
// Collect 采集所有已配置中间件的指标
func (m *MiddlewareCollector) Collect() (model.MiddlewareStat, error) {
var stat model.MiddlewareStat
// Redis
for _, addr := range m.cfg.RedisAddrs {
if metric, err := m.collectRedis(addr); err == nil {
stat.Redis = append(stat.Redis, metric)
}
}
// MySQL
m.mu.Lock()
now := time.Now()
elapsed := now.Sub(m.prevTime).Seconds()
m.prevTime = now
m.mu.Unlock()
for _, addr := range m.cfg.MySQLAddrs {
if metric, err := m.collectMySQL(addr, elapsed); err == nil {
stat.MySQL = append(stat.MySQL, metric)
}
}
return stat, nil
}
// collectRedis 连接 Redis 发送 INFO 命令并解析指标
func (m *MiddlewareCollector) collectRedis(addr string) (model.RedisMetric, error) {
metric := model.RedisMetric{Instance: addr}
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return metric, fmt.Errorf("redis connect %s: %w", addr, err)
}
defer conn.Close()
// 认证
if m.cfg.RedisAuth != "" {
cmd := fmt.Sprintf("*2\r\n$4\r\nAUTH\r\n$%d\r\n%s\r\n", len(m.cfg.RedisAuth), m.cfg.RedisAuth)
if _, err := conn.Write([]byte(cmd)); err != nil {
return metric, fmt.Errorf("redis auth: %w", err)
}
buf := make([]byte, 1024)
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
n, _ := conn.Read(buf)
if n > 0 && !strings.HasPrefix(string(buf[:n]), "+OK") {
return metric, fmt.Errorf("redis auth failed")
}
}
// 发送 INFO 命令
conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
if _, err := conn.Write([]byte("*1\r\n$4\r\nINFO\r\n")); err != nil {
return metric, fmt.Errorf("redis info cmd: %w", err)
}
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
data, err := io.ReadAll(conn)
if err != nil {
return metric, fmt.Errorf("redis read: %w", err)
}
// 解析 Redis INFO bulk string 响应
raw := string(data)
// Redis RESP: $<len>\r\n<data>\r\n — 去掉前缀
if idx := strings.Index(raw, "\r\n"); idx > 0 {
raw = raw[idx+2:]
}
if idx := strings.LastIndex(raw, "\r\n"); idx > 0 {
raw = raw[:idx]
}
// 按行解析 key:value
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key, val := parts[0], strings.TrimSpace(parts[1])
switch key {
case "redis_version":
metric.Version = val
case "uptime_in_seconds":
metric.Uptime, _ = strconv.ParseInt(val, 10, 64)
case "connected_clients":
metric.Clients, _ = strconv.Atoi(val)
case "used_memory":
metric.UsedMemory, _ = strconv.ParseInt(val, 10, 64)
case "instantaneous_ops_per_sec":
metric.QPS, _ = strconv.ParseFloat(val, 64)
case "keyspace_hits", "keyspace_misses":
// Both needed for hit rate
}
}
// 命中率从 keyspace_hits + keyspace_misses 计算
hitsStr := extractInfoField(raw, "keyspace_hits")
missesStr := extractInfoField(raw, "keyspace_misses")
hits, _ := strconv.ParseInt(hitsStr, 10, 64)
misses, _ := strconv.ParseInt(missesStr, 10, 64)
if total := hits + misses; total > 0 {
metric.HitRate = float64(hits) / float64(total) * 100
}
return metric, nil
}
// collectMySQL 连接 MySQL 执行 SHOW GLOBAL STATUS 并解析指标
func (m *MiddlewareCollector) collectMySQL(addr string, elapsedSec float64) (model.MySQLMetric, error) {
metric := model.MySQLMetric{Instance: addr}
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return metric, fmt.Errorf("mysql connect %s: %w", addr, err)
}
defer conn.Close()
// MySQL handshake — minimalist: just send SHOW GLOBAL STATUS as query
// This requires a real MySQL client protocol, which is complex without go-sql-driver.
// Use a simple approach: try to connect and parse handshake for version
buf := make([]byte, 4096)
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
n, err := conn.Read(buf)
if err != nil {
return metric, fmt.Errorf("mysql handshake read: %w", err)
}
// MySQL 握手包: 第 1 字节为协议版本(0a),第 2 字节起为服务器版本字符串(以 00 结尾)
if n < 5 {
return metric, fmt.Errorf("mysql handshake too short")
}
if buf[0] != 0x0a && buf[0] != 10 {
// 可能是 MariaDB 或其他变体 — 尝试跳过
}
// 提取版本字符串
versionEnd := -1
for i := 1; i < n; i++ {
if buf[i] == 0 {
versionEnd = i
break
}
}
if versionEnd > 1 {
metric.Version = string(buf[1:versionEnd])
}
// For full MySQL metrics, we'd need go-sql-driver/mysql for the protocol handshake + query.
// For now, just fill with basic info from the handshake.
// Production: use database/sql + go-sql-driver/mysql
return metric, nil
}
// extractInfoField 从 Redis INFO 原始输出中提取指定 key 的值
func extractInfoField(raw, key string) string {
prefix := key + ":"
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, prefix) {
return line[len(prefix):]
}
}
return ""
}
+48
View File
@@ -12,6 +12,7 @@ import (
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -32,6 +33,14 @@ func main() {
interval := flag.Int("interval", 10, "采集与上报间隔,单位:秒")
token := flag.String("token", "", "Bearer 鉴权令牌")
hostnameFlag := flag.String("hostname", "", "上报主机名,默认使用系统主机名")
// 中间件采集配置
redisFlag := flag.String("redis", "", "Redis 监控地址,逗号分隔多实例,格式 host:port")
redisAuthFlag := flag.String("redis-auth", "", "Redis 认证密码")
mysqlFlag := flag.String("mysql", "", "MySQL 监控地址,逗号分隔多实例,格式 host:port")
mysqlUserFlag := flag.String("mysql-user", "root", "MySQL 用户名")
mysqlPassFlag := flag.String("mysql-pass", "", "MySQL 密码")
flag.Parse()
// 统一日志格式:带微秒时间戳,便于排查问题
@@ -68,6 +77,20 @@ func main() {
diskIOCollector := collector.NewDiskIOCollector()
rep := reporter.New(*server, *token, logger)
// 中间件采集器(可选)
var mwCollector *collector.MiddlewareCollector
if *redisFlag != "" || *mysqlFlag != "" {
mwCfg := collector.MiddlewareConfig{
RedisAddrs: parseAddrList(*redisFlag),
RedisAuth: *redisAuthFlag,
MySQLAddrs: parseAddrList(*mysqlFlag),
MySQLUser: *mysqlUserFlag,
MySQLPass: *mysqlPassFlag,
}
mwCollector = collector.NewMiddlewareCollector(mwCfg)
logger.Printf("中间件采集已启用: redis=%d mysql=%d", len(mwCfg.RedisAddrs), len(mwCfg.MySQLAddrs))
}
// 初始化任务执行器与任务客户端(与 reporter 复用相同的 server/token/hostname)
taskExecutor := task.New(logger)
taskClient := task.NewClient(*server, *token, hostname, logger)
@@ -122,6 +145,15 @@ func main() {
report.DiskIO = dioStat
}
// 中间件采集(可选)
if mwCollector != nil {
if mwStat, err := mwCollector.Collect(); err != nil {
logger.Printf("中间件采集异常: %v", err)
} else {
report.Middleware = mwStat
}
}
if err := rep.Send(ctx, report); err != nil {
logger.Printf("上报失败: %v", err)
return
@@ -208,3 +240,19 @@ func maskToken(token string) string {
}
return token[:4] + "****"
}
// parseAddrList 解析逗号分隔的地址列表,去除空白
func parseAddrList(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
var result []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
return result
}
+38 -8
View File
@@ -6,14 +6,44 @@ package model
// Report 为单次采集周期的完整上报报文
type Report struct {
Hostname string `json:"hostname"` // 主机名
Timestamp string `json:"timestamp"` // RFC3339 格式的 UTC 时间戳
CPU CPUStat `json:"cpu"` // CPU 指标
Memory MemoryStat `json:"memory"` // 内存指标
Disks []DiskStat `json:"disks"` // 磁盘指标列表
Ports []PortStat `json:"ports"` // 监听端口列表
Network NetworkStat `json:"network"` // 网络指标
DiskIO DiskIOStat `json:"disk_io"` // 磁盘 IO 指标
Hostname string `json:"hostname"` // 主机名
Timestamp string `json:"timestamp"` // RFC3339 格式的 UTC 时间戳
CPU CPUStat `json:"cpu"` // CPU 指标
Memory MemoryStat `json:"memory"` // 内存指标
Disks []DiskStat `json:"disks"` // 磁盘指标列表
Ports []PortStat `json:"ports"` // 监听端口列表
Network NetworkStat `json:"network"` // 网络指标
DiskIO DiskIOStat `json:"disk_io"` // 磁盘 IO 指标
Middleware MiddlewareStat `json:"middleware,omitempty"` // 中间件指标(Redis/MySQL)
}
// MiddlewareStat 中间件综合指标 — 包含 Redis + MySQL
type MiddlewareStat struct {
Redis []RedisMetric `json:"redis,omitempty"` // Redis 实例指标
MySQL []MySQLMetric `json:"mysql,omitempty"` // MySQL 实例指标
}
// RedisMetric Redis 实例采集指标
type RedisMetric struct {
Instance string `json:"instance"` // 实例标识(host:port)
Clients int `json:"clients"` // 连接客户端数
UsedMemory int64 `json:"used_memory"` // 已用内存(字节)
HitRate float64 `json:"hit_rate"` // 缓存命中率(%)
QPS float64 `json:"qps"` // 每秒命令数
Uptime int64 `json:"uptime"` // 运行时长(秒)
Version string `json:"version"` // Redis 版本
}
// MySQLMetric MySQL 实例采集指标
type MySQLMetric struct {
Instance string `json:"instance"` // 实例标识(host:port)
Connections int `json:"connections"` // 当前连接数
QPS float64 `json:"qps"` // 每秒查询数
SlowQueries int64 `json:"slow_queries"` // 慢查询累计数
BufferHitRate float64 `json:"buffer_hit_rate"` // 缓冲池命中率(%)
ThreadsRunning int `json:"threads_running"` // 活跃线程数
Uptime int64 `json:"uptime"` // 运行时长(秒)
Version string `json:"version"` // MySQL 版本
}
// CPUStat CPU 相关指标