feat: Redis cluster support + version auto-detect + metrics API
This commit is contained in:
1 parent
8384006ae9
commit
3af65f2350
10 files changed
+481
-62
No files matched your search
@@ -67,9 +67,12 @@ func Login(c *gin.Context) {
|
|||||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
|
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// LDAP 认证成功,自动创建本地用户
|
// LDAP 认证成功,自动创建或恢复本地用户
|
||||||
isActive := true
|
isActive := true
|
||||||
defaultRole := "developer"
|
defaultRole := "developer"
|
||||||
|
if ldapInfo.DisplayName == "" {
|
||||||
|
ldapInfo.DisplayName = ldapInfo.Username
|
||||||
|
}
|
||||||
newUser := model.User{
|
newUser := model.User{
|
||||||
Username: ldapInfo.Username,
|
Username: ldapInfo.Username,
|
||||||
DisplayName: ldapInfo.DisplayName,
|
DisplayName: ldapInfo.DisplayName,
|
||||||
@@ -79,20 +82,25 @@ func Login(c *gin.Context) {
|
|||||||
Role: defaultRole,
|
Role: defaultRole,
|
||||||
IsActive: &isActive,
|
IsActive: &isActive,
|
||||||
}
|
}
|
||||||
if newUser.DisplayName == "" {
|
// FirstOrCreate: 不存在则创建,已存在则获取(避免 uniqueIndex 冲突)
|
||||||
newUser.DisplayName = newUser.Username
|
result := model.DB.Where("username = ?", ldapInfo.Username).FirstOrCreate(&newUser)
|
||||||
}
|
if result.Error != nil {
|
||||||
if createErr := model.DB.Create(&newUser).Error; createErr != nil {
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to create LDAP user: " + result.Error.Error()})
|
||||||
// 可能并发创建冲突,再查一次
|
|
||||||
model.DB.Where("username = ?", ldapInfo.Username).First(&user)
|
|
||||||
if user.ID == 0 {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to create LDAP user"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
// 更新已有用户的 display_name / email(LDAP 信息可能变更)
|
||||||
user = newUser
|
if newUser.ID != 0 {
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"display_name": ldapInfo.DisplayName,
|
||||||
|
"auth_source": "ldap",
|
||||||
|
"is_active": true,
|
||||||
}
|
}
|
||||||
// 记录 auth_source
|
if ldapInfo.Email != nil {
|
||||||
|
updates["email"] = *ldapInfo.Email
|
||||||
|
}
|
||||||
|
model.DB.Model(&newUser).Updates(updates)
|
||||||
|
}
|
||||||
|
user = newUser
|
||||||
} else {
|
} else {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Database error"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Database error"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ import (
|
|||||||
"sql-platform/internal/model"
|
"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.
|
// testDBConnection tests a real database connection based on db_type.
|
||||||
func testDBConnection(instance *model.Instance) (bool, string) {
|
func testDBConnection(instance *model.Instance) (bool, string) {
|
||||||
password, err := decryptPassword(instance.Password)
|
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)
|
return testPostgreSQL(ctx, instance.Host, instance.Port, instance.Username, password, dbname)
|
||||||
case "redis":
|
case "redis":
|
||||||
return testRedis(ctx, instance.Host, instance.Port, password)
|
ok, msg, _ := testRedis(ctx, instance.Host, instance.Port, password)
|
||||||
|
return ok, msg
|
||||||
default:
|
default:
|
||||||
return false, fmt.Sprintf("Unsupported database type: %s", instance.DBType)
|
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) {
|
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)
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/?timeout=10s&parseTime=true", user, pass, host, port)
|
||||||
db, err := sql.Open("mysql", dsn)
|
db, err := sql.Open("mysql", dsn)
|
||||||
@@ -60,7 +110,7 @@ func testPostgreSQL(ctx context.Context, host string, port int, user, pass, dbna
|
|||||||
if dbname == "" {
|
if dbname == "" {
|
||||||
dbname = "postgres"
|
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)
|
user, pass, host, port, dbname)
|
||||||
db, err := sql.Open("pgx", dsn)
|
db, err := sql.Open("pgx", dsn)
|
||||||
if err != nil {
|
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)
|
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)
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
rdb := redis.NewClient(&redis.Options{
|
rdb := redis.NewClient(&redis.Options{
|
||||||
Addr: addr,
|
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()
|
pong, err := rdb.Ping(ctx).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Sprintf("Connection failed: %v", err)
|
return false, fmt.Sprintf("Connection failed: %v", err), false
|
||||||
}
|
}
|
||||||
|
|
||||||
var version string
|
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") {
|
for _, line := range strings.Split(info, "\n") {
|
||||||
line = strings.TrimRight(line, "\r")
|
line = strings.TrimRight(line, "\r")
|
||||||
if strings.HasPrefix(line, "redis_version:") {
|
if strings.HasPrefix(line, "redis_version:") {
|
||||||
version = strings.TrimPrefix(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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mode := "standalone"
|
||||||
|
if isCluster {
|
||||||
|
mode = "cluster"
|
||||||
|
}
|
||||||
|
|
||||||
if version != "" {
|
if version != "" {
|
||||||
return true, fmt.Sprintf("Redis connection successful (PING=%s, version: %s)", pong, 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)", pong)
|
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
|
// getDB opens a real database connection and returns *sql.DB
|
||||||
func getDBConnection(instance *model.Instance, dbName string) (*sql.DB, error) {
|
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 == "" {
|
if dbName == "" {
|
||||||
dbName = "postgres"
|
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)
|
instance.Username, password, instance.Host, instance.Port, dbName)
|
||||||
return sql.Open("pgx", dsn)
|
return sql.Open("pgx", dsn)
|
||||||
default:
|
default:
|
||||||
@@ -154,7 +323,6 @@ func listDatabases(instance *model.Instance) ([]string, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var name string
|
var name string
|
||||||
rows.Scan(&name)
|
rows.Scan(&name)
|
||||||
// Filter out system databases
|
|
||||||
if name != "information_schema" && name != "performance_schema" &&
|
if name != "information_schema" && name != "performance_schema" &&
|
||||||
name != "mysql" && name != "sys" {
|
name != "mysql" && name != "sys" {
|
||||||
databases = append(databases, name)
|
databases = append(databases, name)
|
||||||
|
|||||||
@@ -68,6 +68,20 @@ func CreateInstance(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Auto-detect version and cluster_mode after creation
|
||||||
|
go func() {
|
||||||
|
result := testDBConnectionFull(&instance)
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if result.Success && result.Version != "" {
|
||||||
|
updates["version"] = result.Version
|
||||||
|
}
|
||||||
|
if result.ClusterMode {
|
||||||
|
updates["cluster_mode"] = true
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates)
|
||||||
|
}
|
||||||
|
}()
|
||||||
instance.Password = "******"
|
instance.Password = "******"
|
||||||
c.JSON(http.StatusCreated, instance)
|
c.JSON(http.StatusCreated, instance)
|
||||||
}
|
}
|
||||||
@@ -121,11 +135,22 @@ func TestConnection(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
success, message := testDBConnection(&instance)
|
result := testDBConnectionFull(&instance)
|
||||||
if success {
|
// Update version & cluster_mode in DB if detected
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": message})
|
updates := map[string]interface{}{}
|
||||||
|
if result.Success && result.Version != "" {
|
||||||
|
updates["version"] = result.Version
|
||||||
|
}
|
||||||
|
if result.ClusterMode {
|
||||||
|
updates["cluster_mode"] = true
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates)
|
||||||
|
}
|
||||||
|
if result.Success {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": result.Message, "version": result.Version, "cluster_mode": result.ClusterMode})
|
||||||
} else {
|
} else {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": message})
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": result.Message})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,3 +177,25 @@ func parseID(s string) (int, bool) {
|
|||||||
id, err := strconv.Atoi(s)
|
id, err := strconv.Atoi(s)
|
||||||
return id, err == nil
|
return id, err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/instances/:id/metrics — live metrics for an instance
|
||||||
|
func InstanceMetrics(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var instance model.Instance
|
||||||
|
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch instance.DBType {
|
||||||
|
case "redis":
|
||||||
|
metrics := getRedisMetrics(&instance)
|
||||||
|
c.JSON(http.StatusOK, metrics)
|
||||||
|
default:
|
||||||
|
// For MySQL/PG: return basic status
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "online",
|
||||||
|
"version": instance.Version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"time"
|
"time"
|
||||||
"context"
|
"context"
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
"sql-platform/internal/middleware"
|
"sql-platform/internal/middleware"
|
||||||
"strings"
|
"strings"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -665,14 +664,6 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim
|
|||||||
}
|
}
|
||||||
|
|
||||||
func executeRedisQuery(instance *model.Instance, dbSelect string, command string, limit int, offset int) (gin.H, error) {
|
func executeRedisQuery(instance *model.Instance, dbSelect string, command string, limit int, offset int) (gin.H, error) {
|
||||||
password := instance.Password
|
|
||||||
if strings.HasPrefix(password, "enc:") {
|
|
||||||
decrypted, err := decryptPassword(strings.TrimPrefix(password, "enc:"))
|
|
||||||
if err == nil {
|
|
||||||
password = decrypted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse Redis DB number from dbSelect (e.g. "0", "1")
|
// Parse Redis DB number from dbSelect (e.g. "0", "1")
|
||||||
redisDB := 0
|
redisDB := 0
|
||||||
if dbSelect != "" {
|
if dbSelect != "" {
|
||||||
@@ -681,17 +672,13 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client := redis.NewClient(&redis.Options{
|
client, err := getRedisClient(instance, redisDB)
|
||||||
Addr: fmt.Sprintf("%s:%d", instance.Host, instance.Port),
|
if err != nil {
|
||||||
Password: password,
|
return nil, err
|
||||||
DB: redisDB,
|
}
|
||||||
})
|
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if err := client.Ping(ctx).Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("Redis connection failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Fields(command)
|
parts := strings.Fields(command)
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ func AccessibleInstances(c *gin.Context) {
|
|||||||
// admin/dba see all instances, filter by access_mode if needed
|
// admin/dba see all instances, filter by access_mode if needed
|
||||||
query := model.DB.Order("id")
|
query := model.DB.Order("id")
|
||||||
if accessType == "readonly" {
|
if accessType == "readonly" {
|
||||||
query = query.Where("access_mode IN ?", []string{"readwrite", "readonly"})
|
query = query.Where("access_mode IN ? OR access_mode IS NULL", []string{"readwrite", "readonly"})
|
||||||
} else {
|
} else {
|
||||||
query = query.Where("access_mode IN ?", []string{"readwrite", "write"})
|
query = query.Where("access_mode IN ? OR access_mode IS NULL", []string{"readwrite", "write"})
|
||||||
}
|
}
|
||||||
query.Find(&instances)
|
query.Find(&instances)
|
||||||
} else {
|
} else {
|
||||||
@@ -117,10 +117,11 @@ func TestNewConnection(c *gin.Context) {
|
|||||||
instance.Database = &req.Database
|
instance.Database = &req.Database
|
||||||
}
|
}
|
||||||
|
|
||||||
success, message := testDBConnection(instance)
|
result := testDBConnectionFull(instance)
|
||||||
if success {
|
c.JSON(http.StatusOK, gin.H{
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": message})
|
"success": result.Success,
|
||||||
} else {
|
"message": result.Message,
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": message})
|
"version": result.Version,
|
||||||
}
|
"cluster_mode": result.ClusterMode,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -13,8 +13,186 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
// Helper: get Redis client from instance
|
// Helper: get Redis client from instance (auto-detect standalone vs cluster)
|
||||||
func getRedisClient(instance *model.Instance, db int) (*redis.Client, error) {
|
type redisClientWrapper struct {
|
||||||
|
Client *redis.Client
|
||||||
|
ClusterClient *redis.ClusterClient
|
||||||
|
isCluster bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) Close() {
|
||||||
|
if w.isCluster {
|
||||||
|
w.ClusterClient.Close()
|
||||||
|
} else {
|
||||||
|
w.Client.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping, Info, DBSize, Scan, Type, TTL, Get, LRange, SMembers, ZRangeWithScores, HGetAll,
|
||||||
|
// SlowLogGet, ClientList, Do, Set, RPush, SAdd, HSet, Expire, Del — unified interface via wrapper methods
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) Ping(ctx context.Context) *redis.StatusCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.Ping(ctx)
|
||||||
|
}
|
||||||
|
return w.Client.Ping(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) Info(ctx context.Context, sections ...string) *redis.StringCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.Info(ctx, sections...)
|
||||||
|
}
|
||||||
|
return w.Client.Info(ctx, sections...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) DBSize(ctx context.Context) *redis.IntCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.DBSize(ctx)
|
||||||
|
}
|
||||||
|
return w.Client.DBSize(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) Scan(ctx context.Context, cursor uint64, match string, count int64) *redis.ScanCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.Scan(ctx, cursor, match, count)
|
||||||
|
}
|
||||||
|
return w.Client.Scan(ctx, cursor, match, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) Type(ctx context.Context, key string) *redis.StatusCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.Type(ctx, key)
|
||||||
|
}
|
||||||
|
return w.Client.Type(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) TTL(ctx context.Context, key string) *redis.DurationCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.TTL(ctx, key)
|
||||||
|
}
|
||||||
|
return w.Client.TTL(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) Get(ctx context.Context, key string) *redis.StringCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.Get(ctx, key)
|
||||||
|
}
|
||||||
|
return w.Client.Get(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) LRange(ctx context.Context, key string, start, stop int64) *redis.StringSliceCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.LRange(ctx, key, start, stop)
|
||||||
|
}
|
||||||
|
return w.Client.LRange(ctx, key, start, stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) SMembers(ctx context.Context, key string) *redis.StringSliceCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.SMembers(ctx, key)
|
||||||
|
}
|
||||||
|
return w.Client.SMembers(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) ZRangeWithScores(ctx context.Context, key string, start, stop int64) *redis.ZSliceCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.ZRangeWithScores(ctx, key, start, stop)
|
||||||
|
}
|
||||||
|
return w.Client.ZRangeWithScores(ctx, key, start, stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) HGetAll(ctx context.Context, key string) *redis.MapStringStringCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.HGetAll(ctx, key)
|
||||||
|
}
|
||||||
|
return w.Client.HGetAll(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) SlowLogGet(ctx context.Context, count int64) *redis.SlowLogCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.SlowLogGet(ctx, count)
|
||||||
|
}
|
||||||
|
return w.Client.SlowLogGet(ctx, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) ClientList(ctx context.Context) *redis.StringCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.ClientList(ctx)
|
||||||
|
}
|
||||||
|
return w.Client.ClientList(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) Do(ctx context.Context, args ...interface{}) *redis.Cmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.Do(ctx, args...)
|
||||||
|
}
|
||||||
|
return w.Client.Do(ctx, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.Set(ctx, key, value, expiration)
|
||||||
|
}
|
||||||
|
return w.Client.Set(ctx, key, value, expiration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) RPush(ctx context.Context, key string, values ...interface{}) *redis.IntCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.RPush(ctx, key, values...)
|
||||||
|
}
|
||||||
|
return w.Client.RPush(ctx, key, values...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) SAdd(ctx context.Context, key string, members ...interface{}) *redis.IntCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.SAdd(ctx, key, members...)
|
||||||
|
}
|
||||||
|
return w.Client.SAdd(ctx, key, members...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) HSet(ctx context.Context, key string, values ...interface{}) *redis.IntCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.HSet(ctx, key, values...)
|
||||||
|
}
|
||||||
|
return w.Client.HSet(ctx, key, values...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) Expire(ctx context.Context, key string, expiration time.Duration) *redis.BoolCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.Expire(ctx, key, expiration)
|
||||||
|
}
|
||||||
|
return w.Client.Expire(ctx, key, expiration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) Del(ctx context.Context, keys ...string) *redis.IntCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.Del(ctx, keys...)
|
||||||
|
}
|
||||||
|
return w.Client.Del(ctx, keys...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) LLen(ctx context.Context, key string) *redis.IntCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.LLen(ctx, key)
|
||||||
|
}
|
||||||
|
return w.Client.LLen(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) SCard(ctx context.Context, key string) *redis.IntCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.SCard(ctx, key)
|
||||||
|
}
|
||||||
|
return w.Client.SCard(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisClientWrapper) SScan(ctx context.Context, key string, cursor uint64, match string, count int64) *redis.ScanCmd {
|
||||||
|
if w.isCluster {
|
||||||
|
return w.ClusterClient.SScan(ctx, key, cursor, match, count)
|
||||||
|
}
|
||||||
|
return w.Client.SScan(ctx, key, cursor, match, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, error) {
|
||||||
password := instance.Password
|
password := instance.Password
|
||||||
// Decrypt if encrypted
|
// Decrypt if encrypted
|
||||||
if strings.HasPrefix(password, "enc:") {
|
if strings.HasPrefix(password, "enc:") {
|
||||||
@@ -23,18 +201,36 @@ func getRedisClient(instance *model.Instance, db int) (*redis.Client, error) {
|
|||||||
password = decrypted
|
password = decrypted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
addr := fmt.Sprintf("%s:%d", instance.Host, instance.Port)
|
||||||
|
|
||||||
|
// Check cluster mode
|
||||||
|
isCluster := instance.ClusterMode != nil && *instance.ClusterMode
|
||||||
|
|
||||||
|
if isCluster {
|
||||||
|
clusterClient := redis.NewClusterClient(&redis.ClusterOptions{
|
||||||
|
Addrs: []string{addr},
|
||||||
|
Password: password,
|
||||||
|
})
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := clusterClient.Ping(ctx).Err(); err != nil {
|
||||||
|
clusterClient.Close()
|
||||||
|
return nil, fmt.Errorf("Redis Cluster connection failed: %v", err)
|
||||||
|
}
|
||||||
|
return &redisClientWrapper{ClusterClient: clusterClient, isCluster: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone mode
|
||||||
client := redis.NewClient(&redis.Options{
|
client := redis.NewClient(&redis.Options{
|
||||||
Addr: fmt.Sprintf("%s:%d", instance.Host, instance.Port),
|
Addr: addr,
|
||||||
Password: password,
|
Password: password,
|
||||||
DB: db,
|
DB: db,
|
||||||
})
|
})
|
||||||
// Test connection
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if err := client.Ping(ctx).Err(); err != nil {
|
if err := client.Ping(ctx).Err(); err != nil {
|
||||||
client.Close()
|
client.Close()
|
||||||
return nil, fmt.Errorf("Redis connection failed: %v", err)
|
return nil, fmt.Errorf("Redis connection failed: %v", err)
|
||||||
}
|
}
|
||||||
return client, nil
|
return &redisClientWrapper{Client: client, isCluster: false}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/v1/redis/:id/info
|
// GET /api/v1/redis/:id/info
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type Instance struct {
|
|||||||
Description *string `gorm:"type:text" json:"description"`
|
Description *string `gorm:"type:text" json:"description"`
|
||||||
Status *string `gorm:"size:20" json:"status"`
|
Status *string `gorm:"size:20" json:"status"`
|
||||||
Version *string `gorm:"size:50" json:"version"`
|
Version *string `gorm:"size:50" json:"version"`
|
||||||
|
ClusterMode *bool `gorm:"default:false" json:"cluster_mode"`
|
||||||
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
|
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
|
||||||
AccessMode *string `gorm:"size:20;default:readwrite" json:"access_mode"`
|
AccessMode *string `gorm:"size:20;default:readwrite" json:"access_mode"`
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func Setup(r *gin.Engine) {
|
|||||||
auth.GET("/instances/:id/databases/:db/tables", handler.ListTables)
|
auth.GET("/instances/:id/databases/:db/tables", handler.ListTables)
|
||||||
auth.POST("/instances/:id/test-connection", handler.TestConnection)
|
auth.POST("/instances/:id/test-connection", handler.TestConnection)
|
||||||
auth.POST("/instances/test-connection", handler.TestNewConnection)
|
auth.POST("/instances/test-connection", handler.TestNewConnection)
|
||||||
|
auth.GET("/instances/:id/metrics", handler.InstanceMetrics)
|
||||||
|
|
||||||
// Instance Detail (databases/tables/schema/processlist/status/users)
|
// Instance Detail (databases/tables/schema/processlist/status/users)
|
||||||
auth.GET("/instances/:id/detail/databases", handler.DetailListDatabases)
|
auth.GET("/instances/:id/detail/databases", handler.DetailListDatabases)
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ export const instanceApi = {
|
|||||||
testNewConnection: (data: any) => request.post<any, any>('/instances/test-connection', data),
|
testNewConnection: (data: any) => request.post<any, any>('/instances/test-connection', data),
|
||||||
listDatabases: (id: number) => request.get<any, string[]>(`/instances/${id}/databases`),
|
listDatabases: (id: number) => request.get<any, string[]>(`/instances/${id}/databases`),
|
||||||
listTables: (id: number, db: string) => request.get<any, any[]>(`/instances/${id}/databases/${db}/tables`),
|
listTables: (id: number, db: string) => request.get<any, any[]>(`/instances/${id}/databases/${db}/tables`),
|
||||||
|
metrics: (id: number) => request.get<any, any>(`/instances/${id}/metrics`),
|
||||||
};
|
};
|
||||||
@@ -31,6 +31,7 @@ interface InstanceItem {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
|
cluster_mode?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
connections?: number;
|
connections?: number;
|
||||||
qps?: number;
|
qps?: number;
|
||||||
@@ -728,6 +729,14 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
|
|||||||
}) {
|
}) {
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null);
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null);
|
||||||
|
const [metrics, setMetrics] = useState<{ connections: number; qps: number; latency: number; uptime: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
instanceApi.metrics(instance.id).then(res => {
|
||||||
|
setMetrics({ connections: res.connections ?? 0, qps: res.qps ?? 0, latency: res.latency ?? 0, uptime: res.uptime ?? '-' });
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [instance.id]);
|
||||||
|
|
||||||
const health = calcHealthScore(instance);
|
const health = calcHealthScore(instance);
|
||||||
const info = getStatusInfo(instance.status);
|
const info = getStatusInfo(instance.status);
|
||||||
const DetailIcon = config.icon;
|
const DetailIcon = config.icon;
|
||||||
@@ -768,7 +777,7 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
|
|||||||
{info.text}
|
{info.text}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)]">{instance.host}:{instance.port} · {instance.version || '版本未知'}</p>
|
<p className="text-sm text-[var(--dg-text-secondary)]">{instance.host}:{instance.port} · {instance.version || '版本未知'}{instance.cluster_mode ? ' · 集群' : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -881,10 +890,10 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
|
|||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
{[
|
{[
|
||||||
{ label: '连接数', value: instance.connections ?? 0, unit: '', icon: Users, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
{ label: '连接数', value: instance.connections ?? metrics?.connections ?? 0, unit: '', icon: Users, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||||
{ label: 'QPS', value: instance.qps ?? 0, unit: '/s', icon: Activity, color: 'text-green-400', bg: 'bg-green-400/10' },
|
{ label: 'QPS', value: instance.qps ?? metrics?.qps ?? 0, unit: '/s', icon: Activity, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||||
{ label: '延迟', value: instance.latency ?? 0, unit: 'ms', icon: Signal, color: 'text-orange-400', bg: 'bg-orange-400/10' },
|
{ label: '延迟', value: instance.latency ?? metrics?.latency ?? 0, unit: 'ms', icon: Signal, color: 'text-orange-400', bg: 'bg-orange-400/10' },
|
||||||
{ label: '运行时间', value: '-', unit: '', icon: HardDrive, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
{ label: '运行时间', value: metrics?.uptime ?? '-', unit: '', icon: HardDrive, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
||||||
].map(m => (
|
].map(m => (
|
||||||
<div key={m.label} className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-center">
|
<div key={m.label} className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-center">
|
||||||
<div className={`w-8 h-8 rounded-lg ${m.bg} flex items-center justify-center mx-auto mb-2`}>
|
<div className={`w-8 h-8 rounded-lg ${m.bg} flex items-center justify-center mx-auto mb-2`}>
|
||||||
@@ -911,7 +920,7 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
|
|||||||
{ label: '用户名', value: instance.username },
|
{ label: '用户名', value: instance.username },
|
||||||
{ label: '默认数据库', value: instance.database || '-' },
|
{ label: '默认数据库', value: instance.database || '-' },
|
||||||
{ label: '运行环境', value: envLabel[instance.env] || instance.env },
|
{ label: '运行环境', value: envLabel[instance.env] || instance.env },
|
||||||
{ label: '版本', value: instance.version || '-' },
|
{ label: '版本', value: `${instance.version || '-'}${instance.cluster_mode ? ' (集群)' : ''}` },
|
||||||
{ label: '创建时间', value: new Date(instance.created_at).toLocaleString() },
|
{ label: '创建时间', value: new Date(instance.created_at).toLocaleString() },
|
||||||
].map(item => (
|
].map(item => (
|
||||||
<div key={item.label} className="flex justify-between py-1.5 border-b border-[var(--dg-border-light)] last:border-0">
|
<div key={item.label} className="flex justify-between py-1.5 border-b border-[var(--dg-border-light)] last:border-0">
|
||||||
|
|||||||
Reference in new issue
Block a user