feat: Redis cluster support + version auto-detect + metrics API

This commit is contained in:
logan committed 2026-05-30 01:44:58 +08:00
1 parent 8384006ae9
commit 3af65f2350
10 files changed
+485 -66

No files matched your search

+179 -11
View File
@@ -13,6 +13,14 @@ import (
"sql-platform/internal/model"
)
// ConnectionResult holds test connection results
type ConnectionResult struct {
Success bool `json:"success"`
Message string `json:"message"`
Version string `json:"version,omitempty"`
ClusterMode bool `json:"cluster_mode"`
}
// testDBConnection tests a real database connection based on db_type.
func testDBConnection(instance *model.Instance) (bool, string) {
password, err := decryptPassword(instance.Password)
@@ -33,12 +41,54 @@ func testDBConnection(instance *model.Instance) (bool, string) {
}
return testPostgreSQL(ctx, instance.Host, instance.Port, instance.Username, password, dbname)
case "redis":
return testRedis(ctx, instance.Host, instance.Port, password)
ok, msg, _ := testRedis(ctx, instance.Host, instance.Port, password)
return ok, msg
default:
return false, fmt.Sprintf("Unsupported database type: %s", instance.DBType)
}
}
// testDBConnectionFull returns detailed result including version
func testDBConnectionFull(instance *model.Instance) *ConnectionResult {
password, err := decryptPassword(instance.Password)
if err != nil {
return &ConnectionResult{Success: false, Message: fmt.Sprintf("Password decryption failed: %v", err)}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
switch instance.DBType {
case "mysql":
ok, msg := testMySQL(ctx, instance.Host, instance.Port, instance.Username, password)
return &ConnectionResult{Success: ok, Message: msg, Version: extractVersion(msg, "version: ")}
case "postgresql", "postgres", "pg":
dbname := ""
if instance.Database != nil {
dbname = *instance.Database
}
ok, msg := testPostgreSQL(ctx, instance.Host, instance.Port, instance.Username, password, dbname)
return &ConnectionResult{Success: ok, Message: msg, Version: extractVersion(msg, "version: ")}
case "redis":
ok, msg, isCluster := testRedis(ctx, instance.Host, instance.Port, password)
return &ConnectionResult{Success: ok, Message: msg, Version: extractVersion(msg, "version: "), ClusterMode: isCluster}
default:
return &ConnectionResult{Success: false, Message: fmt.Sprintf("Unsupported database type: %s", instance.DBType)}
}
}
func extractVersion(msg, prefix string) string {
idx := strings.Index(msg, prefix)
if idx < 0 {
return ""
}
ver := msg[idx+len(prefix):]
if end := strings.Index(ver, ")"); end > 0 {
ver = ver[:end]
}
return strings.TrimSpace(ver)
}
func testMySQL(ctx context.Context, host string, port int, user, pass string) (bool, string) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/?timeout=10s&parseTime=true", user, pass, host, port)
db, err := sql.Open("mysql", dsn)
@@ -60,7 +110,7 @@ func testPostgreSQL(ctx context.Context, host string, port int, user, pass, dbna
if dbname == "" {
dbname = "postgres"
}
dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable&connect_timeout=10",
dsn := fmt.Sprintf("postgres://%s:***@%s:%d/%s?sslmode=disable&connect_timeout=10",
user, pass, host, port, dbname)
db, err := sql.Open("pgx", dsn)
if err != nil {
@@ -77,7 +127,7 @@ func testPostgreSQL(ctx context.Context, host string, port int, user, pass, dbna
return true, fmt.Sprintf("PostgreSQL connection successful (version: %s)", ver)
}
func testRedis(ctx context.Context, host string, port int, pass string) (bool, string) {
func testRedis(ctx context.Context, host string, port int, pass string) (bool, string, bool) {
addr := fmt.Sprintf("%s:%d", host, port)
rdb := redis.NewClient(&redis.Options{
Addr: addr,
@@ -90,26 +140,145 @@ func testRedis(ctx context.Context, host string, port int, pass string) (bool, s
pong, err := rdb.Ping(ctx).Result()
if err != nil {
return false, fmt.Sprintf("Connection failed: %v", err)
return false, fmt.Sprintf("Connection failed: %v", err), false
}
var version string
if info, err := rdb.Info(ctx, "server").Result(); err == nil {
var isCluster bool
if info, err := rdb.Info(ctx, "server", "cluster").Result(); err == nil {
for _, line := range strings.Split(info, "\n") {
line = strings.TrimRight(line, "\r")
if strings.HasPrefix(line, "redis_version:") {
version = strings.TrimPrefix(line, "redis_version:")
break
}
if strings.HasPrefix(line, "cluster_enabled:") {
val := strings.TrimPrefix(line, "cluster_enabled:")
isCluster = strings.TrimSpace(val) == "1"
}
}
}
if version != "" {
return true, fmt.Sprintf("Redis connection successful (PING=%s, version: %s)", pong, version)
mode := "standalone"
if isCluster {
mode = "cluster"
}
return true, fmt.Sprintf("Redis connection successful (PING=%s)", pong)
if version != "" {
return true, fmt.Sprintf("Redis connection successful (PING=%s, version: %s, mode: %s)", pong, version, mode), isCluster
}
return true, fmt.Sprintf("Redis connection successful (PING=%s, mode: %s)", pong, mode), isCluster
}
// getRedisMetrics fetches live Redis metrics for instance overview
func getRedisMetrics(instance *model.Instance) map[string]interface{} {
password := instance.Password
if strings.HasPrefix(password, "enc:") {
decrypted, err := decryptPassword(strings.TrimPrefix(password, "enc:"))
if err == nil {
password = decrypted
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
addr := fmt.Sprintf("%s:%d", instance.Host, instance.Port)
isCluster := instance.ClusterMode != nil && *instance.ClusterMode
metrics := map[string]interface{}{
"connections": 0,
"qps": 0,
"latency": 0,
"uptime": "-",
"version": "",
"cluster_mode": isCluster,
}
var info string
if isCluster {
rdb := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{addr},
Password: password,
DialTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
})
defer rdb.Close()
_, err := rdb.Ping(ctx).Result()
if err != nil {
metrics["status"] = "offline"
return metrics
}
metrics["status"] = "online"
info, _ = rdb.Info(ctx, "server", "clients", "stats").Result()
// Latency from PING
start := time.Now()
rdb.Ping(ctx)
metrics["latency"] = time.Since(start).Milliseconds()
} else {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: 0,
DialTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
})
defer rdb.Close()
_, err := rdb.Ping(ctx).Result()
if err != nil {
metrics["status"] = "offline"
return metrics
}
metrics["status"] = "online"
info, _ = rdb.Info(ctx, "server", "clients", "stats").Result()
// Latency from PING
start := time.Now()
rdb.Ping(ctx)
metrics["latency"] = time.Since(start).Milliseconds()
}
if info == "" {
return metrics
}
infoMap := make(map[string]string)
for _, line := range strings.Split(info, "\n") {
line = strings.TrimSpace(strings.TrimRight(line, "\r"))
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
if v, ok := infoMap["redis_version"]; ok {
metrics["version"] = v
}
if v, ok := infoMap["connected_clients"]; ok {
metrics["connections"] = v
}
if v, ok := infoMap["instantaneous_ops_per_sec"]; ok {
metrics["qps"] = v
}
if v, ok := infoMap["uptime_in_days"]; ok {
metrics["uptime"] = v + " 天"
} else if v, ok := infoMap["uptime_in_seconds"]; ok {
secs := 0
fmt.Sscanf(v, "%d", &secs)
if secs > 86400 {
metrics["uptime"] = fmt.Sprintf("%d 天", secs/86400)
} else if secs > 3600 {
metrics["uptime"] = fmt.Sprintf("%d 小时", secs/3600)
} else {
metrics["uptime"] = fmt.Sprintf("%d 分钟", secs/60)
}
}
return metrics
}
// getDB opens a real database connection and returns *sql.DB
func getDBConnection(instance *model.Instance, dbName string) (*sql.DB, error) {
@@ -127,7 +296,7 @@ func getDBConnection(instance *model.Instance, dbName string) (*sql.DB, error) {
if dbName == "" {
dbName = "postgres"
}
dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable&connect_timeout=10",
dsn := fmt.Sprintf("postgres://%s:***@%s:%d/%s?sslmode=disable&connect_timeout=10",
instance.Username, password, instance.Host, instance.Port, dbName)
return sql.Open("pgx", dsn)
default:
@@ -154,7 +323,6 @@ func listDatabases(instance *model.Instance) ([]string, error) {
for rows.Next() {
var name string
rows.Scan(&name)
// Filter out system databases
if name != "information_schema" && name != "performance_schema" &&
name != "mysql" && name != "sys" {
databases = append(databases, name)