diff --git a/backend/internal/handler/connection_tester.go b/backend/internal/handler/connection_tester.go index 37abe03..6720870 100644 --- a/backend/internal/handler/connection_tester.go +++ b/backend/internal/handler/connection_tester.go @@ -42,7 +42,7 @@ func testDBConnection(instance *model.Instance) (bool, string) { } return testPostgreSQL(ctx, instance.Host, instance.Port, instance.Username, password, dbname) case "redis": - ok, msg, _ := testRedis(ctx, instance.Host, instance.Port, password) + ok, msg, _ := testRedis(ctx, instance.Host, instance.Port, instance.Username, password) return ok, msg default: return false, fmt.Sprintf("Unsupported database type: %s", instance.DBType) @@ -71,7 +71,7 @@ func testDBConnectionFull(instance *model.Instance) *ConnectionResult { 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) + ok, msg, isCluster := testRedis(ctx, instance.Host, instance.Port, instance.Username, 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)} @@ -132,10 +132,16 @@ 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, bool) { +func testRedis(ctx context.Context, host string, port int, username, pass string) (bool, string, bool) { addr := fmt.Sprintf("%s:%d", host, port) + // Redis 6.0+ ACL: empty username = traditional password-only auth + redisUsername := username + if redisUsername == "default" { + redisUsername = "" + } rdb := redis.NewClient(&redis.Options{ Addr: addr, + Username: redisUsername, Password: pass, DB: 0, DialTimeout: 10 * time.Second, @@ -199,11 +205,17 @@ func getRedisMetrics(instance *model.Instance) map[string]interface{} { "cluster_mode": isCluster, } + // Redis 6.0+ ACL support + redisUsername := instance.Username + if redisUsername == "default" { + redisUsername = "" + } var info string // For metrics collection, always use single-node connection even for clusters. // ClusterClient does topology discovery which can timeout if some nodes are unreachable. rdb := redis.NewClient(&redis.Options{ Addr: addr, + Username: redisUsername, Password: password, DB: 0, DialTimeout: 5 * time.Second, diff --git a/backend/internal/handler/instance.go b/backend/internal/handler/instance.go index af65dab..2f261a1 100644 --- a/backend/internal/handler/instance.go +++ b/backend/internal/handler/instance.go @@ -108,15 +108,16 @@ func UpdateInstance(c *gin.Context) { return } - // if password is masked, don't update it - if pw, ok := input["password"].(string); ok && pw == "******" { - delete(input, "password") - } - // Encrypt password if provided and not masked - if pw, ok := input["password"].(string); ok && pw != "" { - encPw, err := encryptPassword(pw) - if err == nil { - input["password"] = "enc:" + encPw + // if password is empty or masked placeholder, don't update it + if pw, ok := input["password"].(string); ok { + if pw == "" || pw == "******" { + delete(input, "password") + } else { + // Encrypt new password + encPw, err := encryptPassword(pw) + if err == nil { + input["password"] = "enc:" + encPw + } } } diff --git a/backend/internal/handler/redis.go b/backend/internal/handler/redis.go index 3678e4f..dbcdf94 100644 --- a/backend/internal/handler/redis.go +++ b/backend/internal/handler/redis.go @@ -156,12 +156,19 @@ func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, erro } addr := fmt.Sprintf("%s:%d", instance.Host, instance.Port) + // Redis 6.0+ ACL: username is optional (empty = use "default" traditional auth) + redisUsername := instance.Username + if redisUsername == "default" || redisUsername == "" { + redisUsername = "" // go-redis treats empty Username as no ACL auth + } + // Always use single-node connection for query execution. // ClusterClient does topology discovery (CLUSTER SLOTS) which fails when // internal cluster IPs are unreachable from the client (e.g., Docker networks). // Single-node mode works for all read-only commands on cluster nodes. client := redis.NewClient(&redis.Options{ Addr: addr, + Username: redisUsername, Password: password, DB: db, }) diff --git a/frontend/src/components/EditInstanceDialog.tsx b/frontend/src/components/EditInstanceDialog.tsx index 31b9442..867f4f8 100644 --- a/frontend/src/components/EditInstanceDialog.tsx +++ b/frontend/src/components/EditInstanceDialog.tsx @@ -172,12 +172,15 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
- - setUsername(e.target.value)} /> + + setUsername(e.target.value)} placeholder={instance.db_type === 'redis' ? '可选,留空则仅密码认证' : '用户名'} />
- setPassword(e.target.value)} placeholder="••••••" /> + setPassword(e.target.value)} placeholder={mode === 'edit' ? '不修改请留空' : '••••••'} />
diff --git a/frontend/src/pages/InstancePage.tsx b/frontend/src/pages/InstancePage.tsx index baf18f3..d4a8dfd 100644 --- a/frontend/src/pages/InstancePage.tsx +++ b/frontend/src/pages/InstancePage.tsx @@ -1008,7 +1008,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { const [deleteConfirm, setDeleteConfirm] = useState<{msg: string; onConfirm: () => void} | null>(null); const isRedis = dbType === 'redis'; - const needUsername = !isRedis; + const needUsername = !isRedis; // Redis username is optional (6.0+ ACL) const needDatabase = !isRedis; const finalEnv = env === '__custom__' ? customEnv.trim() : env; const canSubmit = testResult?.success === true; @@ -1017,7 +1017,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { setDbType(t); setPort(String(DB_DEFAULT_PORTS[t] || 3306)); setTestResult(null); - if (t === 'redis') { setUsername(''); setDatabase(''); } + if (t === 'redis') { setDatabase(''); } }, []); const handleSubmit = useCallback(async () => { @@ -1028,7 +1028,8 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { try { await instanceApi.create({ name, db_type: dbType, host, port: Number(port), - username: username || 'default', password: password || 'default', + username: isRedis ? (username || undefined) : (username || 'default'), + password: password || 'default', database: database || undefined, env: (finalEnv || 'development') as string, access_mode: accessMode, @@ -1054,7 +1055,8 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { try { const res = await instanceApi.testNewConnection({ name: name || '__test__', db_type: dbType, host, port: Number(port), - username: username || 'default', password: password || 'default', + username: isRedis ? (username || undefined) : (username || 'default'), + password: password || 'default', database: database || undefined, env: (finalEnv || 'development') as string, access_mode: accessMode, }); @@ -1207,10 +1209,10 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { {isRedis && ( -
-

+

+

- Redis 实例无需用户名,密码可选 + Redis 6.0+ ACL 支持用户名+密码认证;传统模式仅填密码即可

)} @@ -1223,7 +1225,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {

连接信息

-
+
- {!isRedis && ( -
- - { setUsername(e.target.value); setTestResult(null); }} - className={inputCls} - placeholder="root" - /> -
- )} +
+ + { setUsername(e.target.value); setTestResult(null); }} + className={inputCls} + placeholder={isRedis ? 'ACL 用户名,留空则仅密码认证' : 'root'} + /> +