feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145
This commit is contained in:
Syngnat
2026-03-03 09:42:49 +08:00
parent c02e7c12e8
commit b904c0b107
7 changed files with 587 additions and 90 deletions

View File

@@ -106,6 +106,7 @@ const ConnectionModal: React.FC<{
const mysqlTopology = Form.useWatch('mysqlTopology', form) || 'single';
const mongoTopology = Form.useWatch('mongoTopology', form) || 'single';
const mongoSrv = Form.useWatch('mongoSrv', form) || false;
const redisTopology = Form.useWatch('redisTopology', form) || 'single';
const getSectionBg = (darkHex: string) => {
if (!darkMode) {
@@ -449,6 +450,35 @@ const ConnectionModal: React.FC<{
return { host: normalizeFileDbPath(safeDecode(rawPath)) };
}
if (type === 'redis') {
const parsed = parseMultiHostUri(trimmedUri, 'redis');
if (!parsed) {
return null;
}
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
return null;
}
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const hostList = normalizeAddressList(parsed.hosts, 6379);
if (!hostList.length) {
return null;
}
const primary = parseHostPort(hostList[0] || 'localhost:6379', 6379);
const topologyParam = String(parsed.params.get('topology') || '').toLowerCase();
const dbText = String(parsed.database || '').trim().replace(/^\//, '');
const dbIndex = Number(dbText);
return {
host: primary?.host || 'localhost',
port: primary?.port || 6379,
password: parsed.password || '',
redisTopology: hostList.length > 1 || topologyParam === 'cluster' ? 'cluster' : 'single',
redisHosts: hostList.slice(1),
redisDB: Number.isFinite(dbIndex) && dbIndex >= 0 && dbIndex <= 15 ? Math.trunc(dbIndex) : 0,
};
}
if (type === 'mongodb') {
const parsed = parseMultiHostUri(trimmedUri, 'mongodb') || parseMultiHostUri(trimmedUri, 'mongodb+srv');
if (!parsed) {
@@ -547,6 +577,9 @@ const ConnectionModal: React.FC<{
if (dbType === 'clickhouse') {
return 'clickhouse://default:pass@127.0.0.1:9000/default';
}
if (dbType === 'redis') {
return 'redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster';
}
if (dbType === 'oracle') {
return 'oracle://user:pass@127.0.0.1:1521/ORCLPDB1';
}
@@ -585,6 +618,26 @@ const ConnectionModal: React.FC<{
return `${scheme}://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`;
}
if (type === 'redis') {
const primary = toAddress(host, port, 6379);
const clusterHosts = values.redisTopology === 'cluster'
? normalizeAddressList(values.redisHosts, 6379)
: [];
const hosts = normalizeAddressList([primary, ...clusterHosts], 6379);
const params = new URLSearchParams();
if (hosts.length > 1 || values.redisTopology === 'cluster') {
params.set('topology', 'cluster');
}
const redisPassword = String(values.password || '');
const redisAuth = redisPassword ? `:${encodeURIComponent(redisPassword)}@` : '';
const redisDB = Number.isFinite(Number(values.redisDB))
? Math.max(0, Math.min(15, Math.trunc(Number(values.redisDB))))
: 0;
const dbPath = `/${redisDB}`;
const query = params.toString();
return `redis://${redisAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`;
}
if (isFileDatabaseType(type)) {
const pathText = normalizeFileDbPath(String(values.host || '').trim());
if (!pathText) {
@@ -770,8 +823,10 @@ const ConnectionModal: React.FC<{
: (primaryAddress?.port || Number(config.port || defaultPort));
const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'diros' || configType === 'sphinx') ? normalizedHosts.slice(1) : [];
const mongoHosts = configType === 'mongodb' ? normalizedHosts.slice(1) : [];
const redisHosts = configType === 'redis' ? normalizedHosts.slice(1) : [];
const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0;
const mongoIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mongoHosts.length > 0 || !!config.replicaSet;
const redisIsCluster = String(config.topology || '').toLowerCase() === 'cluster' || redisHosts.length > 0;
form.setFieldsValue({
type: configType,
name: initialValues.name,
@@ -804,12 +859,15 @@ const ConnectionModal: React.FC<{
mysqlReplicaPassword: config.mysqlReplicaPassword || '',
mongoTopology: mongoIsReplica ? 'replica' : 'single',
mongoHosts: mongoHosts,
redisTopology: redisIsCluster ? 'cluster' : 'single',
redisHosts: redisHosts,
mongoSrv: !!config.mongoSrv,
mongoReplicaSet: config.replicaSet || '',
mongoAuthSource: config.authSource || '',
mongoReadPreference: config.readPreference || 'primary',
mongoAuthMechanism: config.mongoAuthMechanism || '',
savePassword: config.savePassword !== false,
redisDB: Number.isFinite(Number(config.redisDB)) ? Number(config.redisDB) : 0,
mongoReplicaUser: config.mongoReplicaUser || '',
mongoReplicaPassword: config.mongoReplicaPassword || ''
});
@@ -924,7 +982,6 @@ const ConnectionModal: React.FC<{
if (res.success) {
setTestResult({ type: 'success', message: res.message });
if (isRedisType) {
// Redis: generate database list 0-15
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
} else {
// Other databases: fetch database list
@@ -1033,7 +1090,7 @@ const ConnectionModal: React.FC<{
}
let hosts: string[] = [];
let topology: 'single' | 'replica' | undefined;
let topology: 'single' | 'replica' | 'cluster' | undefined;
let replicaSet = '';
let authSource = '';
let readPreference = '';
@@ -1087,6 +1144,22 @@ const ConnectionModal: React.FC<{
mongoAuthMechanism = String(mergedValues.mongoAuthMechanism || '').trim().toUpperCase();
}
if (type === 'redis') {
const clusterNodes = mergedValues.redisTopology === 'cluster'
? normalizeAddressList(mergedValues.redisHosts, defaultPort)
: [];
const allHosts = normalizeAddressList([`${primaryHost}:${primaryPort}`, ...clusterNodes], defaultPort);
if (mergedValues.redisTopology === 'cluster' || allHosts.length > 1) {
hosts = allHosts;
topology = 'cluster';
} else {
topology = 'single';
}
mergedValues.redisDB = Number.isFinite(Number(mergedValues.redisDB))
? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB))))
: 0;
}
const sshConfig = mergedValues.useSSH ? {
host: mergedValues.sshHost,
port: Number(mergedValues.sshPort),
@@ -1128,6 +1201,9 @@ const ConnectionModal: React.FC<{
driver: mergedValues.driver,
dsn: mergedValues.dsn,
timeout: Number(mergedValues.timeout || 30),
redisDB: Number.isFinite(Number(mergedValues.redisDB))
? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB))))
: 0,
uri: String(mergedValues.uri || '').trim(),
hosts: hosts,
topology: topology,
@@ -1178,6 +1254,7 @@ const ConnectionModal: React.FC<{
proxyUser: '',
proxyPassword: '',
mysqlTopology: 'single',
redisTopology: 'single',
mongoTopology: 'single',
mongoSrv: false,
mongoReadPreference: 'primary',
@@ -1186,11 +1263,13 @@ const ConnectionModal: React.FC<{
mongoAuthMechanism: '',
savePassword: true,
mysqlReplicaHosts: [],
redisHosts: [],
mongoHosts: [],
mysqlReplicaUser: '',
mysqlReplicaPassword: '',
mongoReplicaUser: '',
mongoReplicaPassword: '',
redisDB: 0,
});
} else if (type !== 'custom') {
const defaultUser = type === 'clickhouse' ? 'default' : 'root';
@@ -1199,6 +1278,7 @@ const ConnectionModal: React.FC<{
database: '',
port: defaultPort,
mysqlTopology: 'single',
redisTopology: 'single',
mongoTopology: 'single',
mongoSrv: false,
mongoReadPreference: 'primary',
@@ -1207,11 +1287,13 @@ const ConnectionModal: React.FC<{
mongoAuthMechanism: '',
savePassword: true,
mysqlReplicaHosts: [],
redisHosts: [],
mongoHosts: [],
mysqlReplicaUser: '',
mysqlReplicaPassword: '',
mongoReplicaUser: '',
mongoReplicaPassword: '',
redisDB: 0,
});
}
@@ -1346,17 +1428,20 @@ const ConnectionModal: React.FC<{
timeout: 30,
uri: '',
mysqlTopology: 'single',
redisTopology: 'single',
mongoTopology: 'single',
mongoSrv: false,
mongoReadPreference: 'primary',
mongoAuthMechanism: '',
savePassword: true,
mysqlReplicaHosts: [],
redisHosts: [],
mongoHosts: [],
mysqlReplicaUser: '',
mysqlReplicaPassword: '',
mongoReplicaUser: '',
mongoReplicaPassword: '',
redisDB: 0,
}}
onValuesChange={(changed) => {
if (testResult) {
@@ -1384,6 +1469,17 @@ const ConnectionModal: React.FC<{
}
// Type change handled by step 1, but keep sync if select changes (hidden now)
if (changed.type !== undefined) setDbType(changed.type);
if (changed.redisTopology !== undefined) {
const supportedDbs = Array.from({ length: 16 }, (_, i) => i);
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);
}
if (
changed.type !== undefined
|| changed.host !== undefined
@@ -1657,11 +1753,36 @@ const ConnectionModal: React.FC<{
{/* Redis specific: password only, no username */}
{isRedis && (
<>
<Form.Item name="redisTopology" label="连接模式">
<Select
options={[
{ value: 'single', label: '单机模式' },
{ value: 'cluster', label: '集群模式Redis Cluster' },
]}
/>
</Form.Item>
{redisTopology === 'cluster' && (
<Form.Item
name="redisHosts"
label="集群附加节点地址"
help="主节点使用上方主机地址这里填写其他种子节点格式host:port"
>
<Select mode="tags" placeholder="例如10.10.0.12:6379、10.10.0.13:6379" tokenSeparators={[',', ';', ' ']} />
</Form.Item>
)}
<Form.Item name="password" label="密码 (可选)">
<Input.Password placeholder="Redis 密码(如果设置了 requirepass" />
</Form.Item>
<Form.Item name="includeRedisDatabases" label="显示数据库 (留空显示全部)" help="连接测试成功后可选择">
<Select mode="multiple" placeholder="选择显示的数据库 (0-15)" allowClear>
<Form.Item
name="includeRedisDatabases"
label="显示数据库 (留空显示全部)"
help="连接测试成功后可选择"
>
<Select
mode="multiple"
placeholder="选择显示的数据库 (0-15)"
allowClear
>
{redisDbList.map(db => <Select.Option key={db} value={db}>db{db}</Select.Option>)}
</Select>
</Form.Item>

View File

@@ -199,7 +199,7 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
proxy,
uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH),
hosts: sanitizeAddressList(raw.hosts),
topology: raw.topology === 'replica' ? 'replica' : 'single',
topology: raw.topology === 'replica' ? 'replica' : (raw.topology === 'cluster' ? 'cluster' : 'single'),
mysqlReplicaUser: toTrimmedString(raw.mysqlReplicaUser),
mysqlReplicaPassword: savePassword ? toTrimmedString(raw.mysqlReplicaPassword) : '',
replicaSet: toTrimmedString(raw.replicaSet),

View File

@@ -32,7 +32,7 @@ export interface ConnectionConfig {
redisDB?: number; // Redis database index (0-15)
uri?: string; // Connection URI for copy/paste
hosts?: string[]; // Multi-host addresses: host:port
topology?: 'single' | 'replica';
topology?: 'single' | 'replica' | 'cluster';
mysqlReplicaUser?: string;
mysqlReplicaPassword?: string;
replicaSet?: string;