mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 02:19:58 +08:00
🐛 fix(redis): 修复超过16个数据库无法展示
- 后端改为通过 CONFIG GET databases 动态获取 Redis 数据库数量 - 放宽单机和 Sentinel 模式的 RedisDB 索引限制,支持 db16 及以上 - 前端连接配置和持久化不再裁剪高编号 Redis 数据库 - 连接测试成功后按服务端返回的真实数据库列表展示可选 DB - 增加 Redis db31 展示、切换、保存和 URI 解析回归测试 Refs #558
This commit is contained in:
@@ -117,6 +117,7 @@ import {
|
||||
MongoDiscoverMembers,
|
||||
TestConnection,
|
||||
RedisConnect,
|
||||
RedisGetDatabases,
|
||||
SelectDatabaseFile,
|
||||
SelectCertificateFile,
|
||||
SelectSSHKeyFile,
|
||||
@@ -141,6 +142,7 @@ const MAX_URI_HOSTS = 32;
|
||||
const MAX_TIMEOUT_SECONDS = 3600;
|
||||
const CONNECTION_MODAL_WIDTH = 960;
|
||||
const CONNECTION_MODAL_BODY_HEIGHT = 620;
|
||||
const REDIS_DEFAULT_DATABASE_COUNT = 16;
|
||||
const STEP1_SIDEBAR_DIVIDER_DARK = "rgba(255, 255, 255, 0.16)";
|
||||
const STEP1_SIDEBAR_DIVIDER_LIGHT = "rgba(0, 0, 0, 0.08)";
|
||||
const CLICKHOUSE_PROTOCOL_OPTIONS: Array<{
|
||||
@@ -158,6 +160,62 @@ const OCEANBASE_PROTOCOL_OPTIONS: Array<{
|
||||
{ value: "mysql", label: "MySQL" },
|
||||
{ value: "oracle", label: "Oracle" },
|
||||
];
|
||||
|
||||
const normalizeRedisDatabaseIndex = (value: unknown): number | null => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return null;
|
||||
return Math.trunc(parsed);
|
||||
};
|
||||
|
||||
const buildRedisDatabaseList = (...values: unknown[]): number[] => {
|
||||
const indexes = new Set<number>();
|
||||
for (let i = 0; i < REDIS_DEFAULT_DATABASE_COUNT; i += 1) {
|
||||
indexes.add(i);
|
||||
}
|
||||
const collect = (value: unknown) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(collect);
|
||||
return;
|
||||
}
|
||||
const index = normalizeRedisDatabaseIndex(value);
|
||||
if (index !== null) {
|
||||
indexes.add(index);
|
||||
}
|
||||
};
|
||||
values.forEach(collect);
|
||||
return Array.from(indexes).sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
const extractRedisDatabaseList = (value: unknown): number[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const indexes = new Set<number>();
|
||||
value.forEach((row: any) => {
|
||||
const index = normalizeRedisDatabaseIndex(row?.index ?? row?.Index);
|
||||
if (index !== null) {
|
||||
indexes.add(index);
|
||||
}
|
||||
});
|
||||
const result = Array.from(indexes).sort((a, b) => a - b);
|
||||
return result.length > 0 ? result : buildRedisDatabaseList();
|
||||
};
|
||||
|
||||
const normalizeRedisDatabaseSelection = (
|
||||
value: unknown,
|
||||
supportedDbs: number[],
|
||||
): number[] | undefined => {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const supported = new Set(supportedDbs);
|
||||
const selected = Array.from(
|
||||
new Set(
|
||||
value
|
||||
.map(normalizeRedisDatabaseIndex)
|
||||
.filter((index): index is number => index !== null)
|
||||
.filter((index) => supported.size === 0 || supported.has(index)),
|
||||
),
|
||||
).sort((a, b) => a - b);
|
||||
return selected.length > 0 ? selected : undefined;
|
||||
};
|
||||
|
||||
const normalizeClickHouseProtocolValue = (
|
||||
value: unknown,
|
||||
): ClickHouseProtocolChoice => {
|
||||
@@ -290,7 +348,7 @@ const ConnectionModal: React.FC<{
|
||||
} | null>(null);
|
||||
const [testErrorLogOpen, setTestErrorLogOpen] = useState(false);
|
||||
const [dbList, setDbList] = useState<string[]>([]);
|
||||
const [redisDbList, setRedisDbList] = useState<number[]>([]); // Redis databases 0-15
|
||||
const [redisDbList, setRedisDbList] = useState<number[]>([]);
|
||||
const [mongoMembers, setMongoMembers] = useState<MongoMemberInfo[]>([]);
|
||||
const [discoveringMembers, setDiscoveringMembers] = useState(false);
|
||||
const [uriFeedback, setUriFeedback] = useState<{
|
||||
@@ -728,19 +786,17 @@ const ConnectionModal: React.FC<{
|
||||
) {
|
||||
form.setFieldValue("port", 6379);
|
||||
}
|
||||
const supportedDbs = Array.from({ length: 16 }, (_, i) => i);
|
||||
const supportedDbs = buildRedisDatabaseList(
|
||||
form.getFieldValue("redisDB"),
|
||||
form.getFieldValue("includeRedisDatabases"),
|
||||
);
|
||||
setRedisDbList(supportedDbs);
|
||||
const selectedDbsRaw = form.getFieldValue("includeRedisDatabases");
|
||||
const selectedDbs = Array.isArray(selectedDbsRaw)
|
||||
? selectedDbsRaw.map((entry: any) => Number(entry))
|
||||
: [];
|
||||
const validDbs = selectedDbs
|
||||
.filter((entry: number) => Number.isFinite(entry))
|
||||
.map((entry: number) => Math.trunc(entry))
|
||||
.filter((entry: number) => supportedDbs.includes(entry));
|
||||
form.setFieldValue(
|
||||
"includeRedisDatabases",
|
||||
validDbs.length > 0 ? validDbs : undefined,
|
||||
normalizeRedisDatabaseSelection(
|
||||
form.getFieldValue("includeRedisDatabases"),
|
||||
supportedDbs,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (fieldName === "proxyType") {
|
||||
@@ -2472,7 +2528,12 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
// 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表
|
||||
if (configType === "redis") {
|
||||
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
|
||||
setRedisDbList(
|
||||
buildRedisDatabaseList(
|
||||
config.redisDB,
|
||||
initialValues.includeRedisDatabases,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Create mode: Start at step 1
|
||||
@@ -2878,7 +2939,32 @@ const ConnectionModal: React.FC<{
|
||||
void message.destroy("connection-test-failure");
|
||||
setTestResult({ type: "success", message: res.message });
|
||||
if (isRedisType) {
|
||||
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
|
||||
const dbRes = await withClientTimeout(
|
||||
RedisGetDatabases(config as any),
|
||||
rpcTimeoutMs,
|
||||
`连接成功但拉取 Redis 数据库列表超时(>${timeoutSeconds} 秒)`,
|
||||
);
|
||||
if (dbRes.success) {
|
||||
const supportedDbs = extractRedisDatabaseList(dbRes.data);
|
||||
setRedisDbList(supportedDbs);
|
||||
form.setFieldValue(
|
||||
"includeRedisDatabases",
|
||||
normalizeRedisDatabaseSelection(
|
||||
form.getFieldValue("includeRedisDatabases"),
|
||||
supportedDbs,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setRedisDbList(
|
||||
buildRedisDatabaseList(
|
||||
config.redisDB,
|
||||
form.getFieldValue("includeRedisDatabases"),
|
||||
),
|
||||
);
|
||||
message.warning(
|
||||
`连接成功,但获取 Redis 数据库列表失败:${normalizeConnectionSecretErrorMessage(dbRes.message, "未知错误")}`,
|
||||
);
|
||||
}
|
||||
} else if (!isJVMType) {
|
||||
// Other databases: fetch database list
|
||||
const dbRes = await withClientTimeout(
|
||||
@@ -3419,7 +3505,7 @@ const ConnectionModal: React.FC<{
|
||||
connectionParams: normalizedConnectionParams,
|
||||
timeout: Number(mergedValues.timeout || 30),
|
||||
redisDB: Number.isFinite(Number(mergedValues.redisDB))
|
||||
? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB))))
|
||||
? Math.max(0, Math.trunc(Number(mergedValues.redisDB)))
|
||||
: 0,
|
||||
redisSentinelMaster: redisSentinelMaster,
|
||||
redisSentinelUser: redisSentinelUser,
|
||||
@@ -5957,19 +6043,17 @@ const ConnectionModal: React.FC<{
|
||||
) {
|
||||
form.setFieldValue("port", 6379);
|
||||
}
|
||||
const supportedDbs = Array.from({ length: 16 }, (_, i) => i);
|
||||
const supportedDbs = buildRedisDatabaseList(
|
||||
form.getFieldValue("redisDB"),
|
||||
form.getFieldValue("includeRedisDatabases"),
|
||||
);
|
||||
setRedisDbList(supportedDbs);
|
||||
const selectedDbsRaw = form.getFieldValue("includeRedisDatabases");
|
||||
const selectedDbs = Array.isArray(selectedDbsRaw)
|
||||
? selectedDbsRaw.map((entry: any) => Number(entry))
|
||||
: [];
|
||||
const validDbs = selectedDbs
|
||||
.filter((entry: number) => Number.isFinite(entry))
|
||||
.map((entry: number) => Math.trunc(entry))
|
||||
.filter((entry: number) => supportedDbs.includes(entry));
|
||||
form.setFieldValue(
|
||||
"includeRedisDatabases",
|
||||
validDbs.length > 0 ? validDbs : undefined,
|
||||
normalizeRedisDatabaseSelection(
|
||||
form.getFieldValue("includeRedisDatabases"),
|
||||
supportedDbs,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -224,7 +224,7 @@ const ConnectionModalRedisSections: React.FC<ConnectionModalRedisSectionsProps>
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择显示的数据库 (0-15)"
|
||||
placeholder="选择显示的数据库"
|
||||
allowClear
|
||||
>
|
||||
{redisDbList.map((db) => (
|
||||
|
||||
@@ -398,6 +398,30 @@ describe('store appearance persistence', () => {
|
||||
expect(config?.port).toBe(9030);
|
||||
});
|
||||
|
||||
it('preserves Redis database indexes above the default 16 databases', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().replaceConnections([
|
||||
{
|
||||
id: 'redis-32',
|
||||
name: 'Redis 32 DBs',
|
||||
includeRedisDatabases: [0, 15, 16, 31, -1, 31],
|
||||
config: {
|
||||
id: 'redis-32',
|
||||
type: 'redis',
|
||||
host: 'redis.local',
|
||||
port: 6379,
|
||||
user: '',
|
||||
redisDB: 31,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const saved = useStore.getState().connections[0];
|
||||
expect(saved?.config.redisDB).toBe(31);
|
||||
expect(saved?.includeRedisDatabases).toEqual([0, 15, 16, 31]);
|
||||
});
|
||||
|
||||
it('keeps InterSystems IRIS saved connections as independent datasource type', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ const MAX_PERSISTED_SQL_LOG_LENGTH = 100 * 1024;
|
||||
const MAX_PERSISTED_SQL_LOG_MESSAGE_LENGTH = 2 * 1024;
|
||||
const DEFAULT_CONNECTION_TYPE = "mysql";
|
||||
const DEFAULT_JVM_PORT = 9010;
|
||||
const MAX_REDIS_DATABASE_INDEX = Number.MAX_SAFE_INTEGER;
|
||||
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
|
||||
enabled: false,
|
||||
type: "socks5",
|
||||
@@ -781,7 +782,12 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
};
|
||||
|
||||
if (type === "redis") {
|
||||
safeConfig.redisDB = normalizeIntegerInRange(raw.redisDB, 0, 0, 15);
|
||||
safeConfig.redisDB = normalizeIntegerInRange(
|
||||
raw.redisDB,
|
||||
0,
|
||||
0,
|
||||
MAX_REDIS_DATABASE_INDEX,
|
||||
);
|
||||
safeConfig.redisSentinelMaster = toTrimmedString(raw.redisSentinelMaster);
|
||||
safeConfig.redisSentinelUser = toTrimmedString(raw.redisSentinelUser);
|
||||
safeConfig.redisSentinelPassword = savePassword
|
||||
@@ -857,7 +863,7 @@ const sanitizeSavedConnection = (
|
||||
const includeRedisDatabases = sanitizeNumberArray(
|
||||
raw.includeRedisDatabases,
|
||||
0,
|
||||
15,
|
||||
MAX_REDIS_DATABASE_INDEX,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -297,7 +297,7 @@ export interface ConnectionConfig {
|
||||
dsn?: string;
|
||||
connectionParams?: string;
|
||||
timeout?: number;
|
||||
redisDB?: number; // Redis database index (0-15)
|
||||
redisDB?: number; // Redis database index
|
||||
uri?: string; // Connection URI for copy/paste
|
||||
clickHouseProtocol?: "auto" | "http" | "native"; // ClickHouse connection protocol override
|
||||
oceanBaseProtocol?: "mysql" | "oracle"; // OceanBase tenant compatibility protocol
|
||||
@@ -342,7 +342,7 @@ export interface SavedConnection {
|
||||
hasOpaqueURI?: boolean;
|
||||
hasOpaqueDSN?: boolean;
|
||||
includeDatabases?: string[];
|
||||
includeRedisDatabases?: number[]; // Redis databases to show (0-15)
|
||||
includeRedisDatabases?: number[]; // Redis databases to show
|
||||
iconType?: string; // 自定义图标类型(如 'mysql','postgres'),不填则取 config.type
|
||||
iconColor?: string; // 自定义图标颜色(十六进制),不填则取类型默认色
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('redisConnectionUri', () => {
|
||||
redisSentinelMaster: 'mymaster',
|
||||
redisSentinelUser: 'ops',
|
||||
redisSentinelPassword: 'sentinel-pass',
|
||||
redisDB: 0,
|
||||
redisDB: 99,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,9 +250,8 @@ const appendRedisSSLPathParamsForUri = (
|
||||
|
||||
const normalizeRedisDB = (value: unknown): number => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 15
|
||||
? Math.trunc(parsed)
|
||||
: 0;
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
||||
return Math.trunc(parsed);
|
||||
};
|
||||
|
||||
const normalizeRedisTopology = (value: unknown): RedisTopology => {
|
||||
|
||||
@@ -28,7 +28,7 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
|
||||
runConfig.Database = name
|
||||
case "redis":
|
||||
runConfig.Database = name
|
||||
if idx, err := strconv.Atoi(name); err == nil && idx >= 0 && idx <= 15 {
|
||||
if idx, err := strconv.Atoi(name); err == nil && idx >= 0 {
|
||||
runConfig.RedisDB = idx
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -229,6 +229,19 @@ func TestNormalizeRunConfig_IRISUsesNamespaceFromTree(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRunConfig_RedisAllowsDatabaseIndexAboveDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runConfig := normalizeRunConfig(connection.ConnectionConfig{
|
||||
Type: "redis",
|
||||
RedisDB: 0,
|
||||
}, "31")
|
||||
|
||||
if runConfig.Database != "31" || runConfig.RedisDB != 31 {
|
||||
t.Fatalf("expected Redis db31 from tree, got database=%q redisDB=%d", runConfig.Database, runConfig.RedisDB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSchemaAndTable_IRISDoesNotTreatNamespaceAsSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ type RedisValue struct {
|
||||
|
||||
// RedisDBInfo represents information about a Redis database
|
||||
type RedisDBInfo struct {
|
||||
Index int `json:"index"` // Database index (single: 0-15, cluster: logical 0-15)
|
||||
Index int `json:"index"` // Database index (single/sentinel: server configured range, cluster: logical 0-15)
|
||||
Keys int64 `json:"keys"` // Number of keys in this database
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ type RedisClientImpl struct {
|
||||
}
|
||||
|
||||
const (
|
||||
redisDefaultDatabaseCount = 16
|
||||
redisClusterLogicalDBCount = 16
|
||||
redisScanDefaultTargetCount int64 = 2000
|
||||
redisScanMaxTargetCount int64 = 10000
|
||||
redisScanMinStepCount int64 = 200
|
||||
@@ -263,10 +265,9 @@ func (r *RedisClientImpl) Connect(config connection.ConnectionConfig) error {
|
||||
config.Password = sanitizeRedisPassword(config.Password)
|
||||
config.RedisSentinelPassword = sanitizeRedisPassword(config.RedisSentinelPassword)
|
||||
r.config = config
|
||||
if r.config.RedisDB < 0 || r.config.RedisDB > 15 {
|
||||
if r.config.RedisDB < 0 {
|
||||
r.config.RedisDB = 0
|
||||
}
|
||||
r.currentDB = r.config.RedisDB
|
||||
r.forwarder = nil
|
||||
r.client = nil
|
||||
r.singleClient = nil
|
||||
@@ -282,6 +283,10 @@ func (r *RedisClientImpl) Connect(config connection.ConnectionConfig) error {
|
||||
topology := strings.ToLower(strings.TrimSpace(config.Topology))
|
||||
isSentinel := topology == "sentinel"
|
||||
r.isCluster = !isSentinel && (topology == "cluster" || len(seedAddrs) > 1)
|
||||
if r.isCluster && r.config.RedisDB >= redisClusterLogicalDBCount {
|
||||
r.config.RedisDB = 0
|
||||
}
|
||||
r.currentDB = r.config.RedisDB
|
||||
|
||||
if (r.isCluster || isSentinel) && config.UseSSH {
|
||||
return fmt.Errorf("Redis %s模式暂不支持 SSH 隧道,请关闭 SSH 后重试", redisTopologyDisplayName(topology))
|
||||
@@ -1349,8 +1354,8 @@ func (r *RedisClientImpl) ExecuteCommand(args []string) (interface{}, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无效数据库索引: %s", args[1])
|
||||
}
|
||||
if index < 0 || index > 15 {
|
||||
return nil, fmt.Errorf("数据库索引必须在 0-15 之间")
|
||||
if index < 0 || index >= redisClusterLogicalDBCount {
|
||||
return nil, fmt.Errorf("数据库索引必须在 0-%d 之间", redisClusterLogicalDBCount-1)
|
||||
}
|
||||
r.currentDB = index
|
||||
r.config.RedisDB = index
|
||||
@@ -1496,6 +1501,67 @@ func (r *RedisClientImpl) GetServerInfo() (map[string]string, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseRedisKeyspaceDatabaseKeys(info string) map[int]int64 {
|
||||
dbMap := make(map[int]int64)
|
||||
lines := strings.Split(info, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "db") {
|
||||
// Format: db0:keys=123,expires=0,avg_ttl=0
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
dbIndex, err := strconv.Atoi(strings.TrimPrefix(parts[0], "db"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
kvPairs := strings.Split(parts[1], ",")
|
||||
for _, kv := range kvPairs {
|
||||
if strings.HasPrefix(kv, "keys=") {
|
||||
keys, _ := strconv.ParseInt(strings.TrimPrefix(kv, "keys="), 10, 64)
|
||||
dbMap[dbIndex] = keys
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return dbMap
|
||||
}
|
||||
|
||||
func parseRedisConfiguredDatabaseCount(config map[string]string) (int, bool) {
|
||||
for key, value := range config {
|
||||
if !strings.EqualFold(strings.TrimSpace(key), "databases") {
|
||||
continue
|
||||
}
|
||||
count, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err == nil && count > 0 {
|
||||
return count, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (r *RedisClientImpl) resolveRedisDatabaseCount(ctx context.Context, dbMap map[int]int64) int {
|
||||
count := redisDefaultDatabaseCount
|
||||
if r.currentDB >= count {
|
||||
count = r.currentDB + 1
|
||||
}
|
||||
for index := range dbMap {
|
||||
if index >= count {
|
||||
count = index + 1
|
||||
}
|
||||
}
|
||||
config, err := r.client.ConfigGet(ctx, "databases").Result()
|
||||
if err != nil {
|
||||
return count
|
||||
}
|
||||
if configured, ok := parseRedisConfiguredDatabaseCount(config); ok && configured > count {
|
||||
count = configured
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// GetDatabases returns information about all databases
|
||||
func (r *RedisClientImpl) GetDatabases() ([]RedisDBInfo, error) {
|
||||
if r.client == nil {
|
||||
@@ -1521,8 +1587,8 @@ func (r *RedisClientImpl) GetDatabases() ([]RedisDBInfo, error) {
|
||||
logger.Warnf("Redis 集群获取 key 数量失败,回退为 0: %v", err)
|
||||
totalKeys = 0
|
||||
}
|
||||
result := make([]RedisDBInfo, 16)
|
||||
for i := 0; i < 16; i++ {
|
||||
result := make([]RedisDBInfo, redisClusterLogicalDBCount)
|
||||
for i := 0; i < redisClusterLogicalDBCount; i++ {
|
||||
result[i] = RedisDBInfo{Index: i, Keys: 0}
|
||||
}
|
||||
result[0].Keys = totalKeys
|
||||
@@ -1535,36 +1601,10 @@ func (r *RedisClientImpl) GetDatabases() ([]RedisDBInfo, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse keyspace info
|
||||
dbMap := make(map[int]int64)
|
||||
lines := strings.Split(info, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "db") {
|
||||
// Format: db0:keys=123,expires=0,avg_ttl=0
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
dbIndex, err := strconv.Atoi(strings.TrimPrefix(parts[0], "db"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Parse keys count
|
||||
kvPairs := strings.Split(parts[1], ",")
|
||||
for _, kv := range kvPairs {
|
||||
if strings.HasPrefix(kv, "keys=") {
|
||||
keys, _ := strconv.ParseInt(strings.TrimPrefix(kv, "keys="), 10, 64)
|
||||
dbMap[dbIndex] = keys
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return all 16 databases (0-15)
|
||||
result := make([]RedisDBInfo, 16)
|
||||
for i := 0; i < 16; i++ {
|
||||
dbMap := parseRedisKeyspaceDatabaseKeys(info)
|
||||
databaseCount := r.resolveRedisDatabaseCount(ctx, dbMap)
|
||||
result := make([]RedisDBInfo, databaseCount)
|
||||
for i := 0; i < databaseCount; i++ {
|
||||
result[i] = RedisDBInfo{
|
||||
Index: i,
|
||||
Keys: dbMap[i], // Will be 0 if not in map
|
||||
@@ -1581,16 +1621,16 @@ func (r *RedisClientImpl) SelectDB(index int) error {
|
||||
}
|
||||
|
||||
if r.isCluster {
|
||||
if index < 0 || index > 15 {
|
||||
return fmt.Errorf("数据库索引必须在 0-15 之间")
|
||||
if index < 0 || index >= redisClusterLogicalDBCount {
|
||||
return fmt.Errorf("数据库索引必须在 0-%d 之间", redisClusterLogicalDBCount-1)
|
||||
}
|
||||
r.currentDB = index
|
||||
r.config.RedisDB = index
|
||||
return nil
|
||||
}
|
||||
|
||||
if index < 0 || index > 15 {
|
||||
return fmt.Errorf("数据库索引必须在 0-15 之间")
|
||||
if index < 0 {
|
||||
return fmt.Errorf("数据库索引必须大于等于 0")
|
||||
}
|
||||
|
||||
nextConfig := r.config
|
||||
|
||||
@@ -2,17 +2,101 @@ package redis
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/big"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func startRedisProtocolTestServer(t *testing.T, handler func([]string) string) string {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen redis test server failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
})
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
reader := bufio.NewReader(conn)
|
||||
for {
|
||||
args, err := readRedisProtocolArray(reader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
response := handler(args)
|
||||
if response == "" {
|
||||
response = "+OK\r\n"
|
||||
}
|
||||
if _, err := conn.Write([]byte(response)); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
func readRedisProtocolArray(reader *bufio.Reader) ([]string, error) {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "*") {
|
||||
return nil, fmt.Errorf("expected array, got %q", line)
|
||||
}
|
||||
count, err := strconv.Atoi(strings.TrimPrefix(line, "*"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := make([]string, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
bulkHeader, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bulkHeader = strings.TrimSpace(bulkHeader)
|
||||
if !strings.HasPrefix(bulkHeader, "$") {
|
||||
return nil, fmt.Errorf("expected bulk string, got %q", bulkHeader)
|
||||
}
|
||||
size, err := strconv.Atoi(strings.TrimPrefix(bulkHeader, "$"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf := make([]byte, size+2)
|
||||
if _, err := io.ReadFull(reader, buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, string(buf[:size]))
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func redisBulkString(value string) string {
|
||||
return fmt.Sprintf("$%d\r\n%s\r\n", len(value), value)
|
||||
}
|
||||
|
||||
// 回归保护:HGETALL 在 RESP3 下返回 map[interface{}]interface{}(go-redis v9 默认 RESP3),
|
||||
// 这种类型 encoding/json 无法序列化,原值穿透到 Wails RPC 会让 Windows 进程退出(用户感知闪退)。
|
||||
// formatCommandResult 必须把 map 平展成交错 [k1, v1, k2, v2, ...] array,前端按 array 渲染。
|
||||
@@ -277,6 +361,47 @@ func TestRedisClusterKeepsSSHValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisGetDatabasesUsesConfiguredDatabaseCountAboveDefault(t *testing.T) {
|
||||
keyspaceInfo := "# Keyspace\r\ndb0:keys=1,expires=0,avg_ttl=0\r\ndb31:keys=2,expires=0,avg_ttl=0\r\n"
|
||||
addr := startRedisProtocolTestServer(t, func(args []string) string {
|
||||
command := strings.ToUpper(strings.TrimSpace(args[0]))
|
||||
switch command {
|
||||
case "HELLO":
|
||||
return "-ERR unknown command 'HELLO'\r\n"
|
||||
case "CLIENT":
|
||||
return "-ERR unknown subcommand\r\n"
|
||||
case "CONFIG":
|
||||
if len(args) >= 3 && strings.EqualFold(args[1], "GET") && strings.EqualFold(args[2], "databases") {
|
||||
return "*2\r\n$9\r\ndatabases\r\n$2\r\n32\r\n"
|
||||
}
|
||||
case "INFO":
|
||||
return redisBulkString(keyspaceInfo)
|
||||
}
|
||||
return "+OK\r\n"
|
||||
})
|
||||
|
||||
rawClient := goredis.NewClient(&goredis.Options{
|
||||
Addr: addr,
|
||||
Protocol: 2,
|
||||
})
|
||||
client := &RedisClientImpl{
|
||||
client: rawClient,
|
||||
singleClient: rawClient,
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
dbs, err := client.GetDatabases()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDatabases returned error: %v", err)
|
||||
}
|
||||
if len(dbs) != 32 {
|
||||
t.Fatalf("expected 32 redis databases, got %d (%#v)", len(dbs), dbs)
|
||||
}
|
||||
if dbs[31].Index != 31 || dbs[31].Keys != 2 {
|
||||
t.Fatalf("expected db31 with 2 keys, got %#v", dbs[31])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisSelectDBReconnectsWithSentinelConfig(t *testing.T) {
|
||||
oldConnect := redisDBSwitchConnect
|
||||
defer func() {
|
||||
@@ -317,12 +442,12 @@ func TestRedisSelectDBReconnectsWithSentinelConfig(t *testing.T) {
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := client.SelectDB(6); err != nil {
|
||||
if err := client.SelectDB(31); err != nil {
|
||||
t.Fatalf("SelectDB returned error: %v", err)
|
||||
}
|
||||
|
||||
if captured.RedisDB != 6 || client.currentDB != 6 {
|
||||
t.Fatalf("expected RedisDB/currentDB=6, captured=%d current=%d", captured.RedisDB, client.currentDB)
|
||||
if captured.RedisDB != 31 || client.currentDB != 31 {
|
||||
t.Fatalf("expected RedisDB/currentDB=31, captured=%d current=%d", captured.RedisDB, client.currentDB)
|
||||
}
|
||||
if captured.Topology != "sentinel" {
|
||||
t.Fatalf("expected sentinel topology to be preserved, got %q", captured.Topology)
|
||||
|
||||
Reference in New Issue
Block a user