diff --git a/ROADMAP.md b/ROADMAP.md index 0778905..ba602fd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ - [x] **SSH 隧道** — 类型已定义,NewConnectionDialog 需添加 host/port/user/password/privateKey/passphrase 表单 - [x] **SSL/TLS 证书路径** — CA / Cert / Key 文件路径 + SNI servername,当前仅 TLS 复选框 -- [ ] **Sentinel 模式** — masterName + nodePassword 表单 +- [x] **Sentinel 模式** — masterName + nodePassword 表单 - [ ] **Cluster 开关** — UI 暴露 cluster 模式切换 + FAQ 说明 ## P1 — 核心功能缺失 diff --git a/electron/main/redis/connection.ts b/electron/main/redis/connection.ts index 33f976f..0dc9d1b 100644 --- a/electron/main/redis/connection.ts +++ b/electron/main/redis/connection.ts @@ -17,6 +17,11 @@ export interface ConnectionOptions { tlsCertPath?: string tlsKeyPath?: string tlsRejectUnauthorized?: boolean + sentinelEnabled?: boolean + sentinelHost?: string + sentinelPort?: number + sentinelMasterName?: string + sentinelNodePassword?: string sshEnabled?: boolean sshHost?: string sshPort?: number @@ -163,6 +168,48 @@ export async function connect(id: string, opts: ConnectionOptions): Promise | undefined try { + if (opts.sentinelEnabled && opts.sentinelMasterName) { + // Sentinel mode: connect via sentinel + const sentinelOptions: RedisOptions = { + sentinels: [{ host: opts.sentinelHost || opts.host, port: opts.sentinelPort || 26379 }], + name: opts.sentinelMasterName, + sentinelPassword: opts.sentinelNodePassword || undefined, + username: opts.username || undefined, + password: opts.password || undefined, + stringNumbers: true, + enableReadyCheck: true, + connectTimeout: 10000, + retryStrategy(times: number) { + if (times > 3) return null + return Math.min(times * 200, 1000) + }, + maxRetriesPerRequest: 3, + } + if (opts.tls) { + sentinelOptions.tls = { + rejectUnauthorized: opts.tlsRejectUnauthorized || false, + checkServerIdentity: () => undefined, + } + } + const redis = new Redis(sentinelOptions) + + redis.once('ready', () => { + clients.set(id, { client: redis }) + resolve() + }) + redis.once('error', (err: Error) => { + redis.disconnect() + reject(err) + }) + setTimeout(() => { + if (!clients.has(id)) { + redis.disconnect() + reject(new Error('连接超时,请检查主机和端口')) + } + }, 12000) + return + } + if (opts.sshEnabled) { const tunnel = await createSshTunnel(opts) sshClient = tunnel.sshClient diff --git a/electron/main/store.ts b/electron/main/store.ts index fe6da43..7899e03 100644 --- a/electron/main/store.ts +++ b/electron/main/store.ts @@ -21,7 +21,11 @@ interface ConnectionConfig { sshPrivateKeyPath: string sshPassphrase: string cluster: boolean + sentinelEnabled: boolean + sentinelHost: string + sentinelPort: number sentinelMasterName: string + sentinelNodePassword: string separator: string order: number createdAt: number diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 308ba48..704f820 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -19,7 +19,11 @@ export interface ConnectionConfig { sshPrivateKeyPath: string sshPassphrase: string cluster: boolean + sentinelEnabled: boolean + sentinelHost: string + sentinelPort: number sentinelMasterName: string + sentinelNodePassword: string separator: string order: number createdAt: number diff --git a/src/components/NewConnectionDialog.vue b/src/components/NewConnectionDialog.vue index 3e567c6..a38d0ea 100644 --- a/src/components/NewConnectionDialog.vue +++ b/src/components/NewConnectionDialog.vue @@ -22,6 +22,11 @@ const form = reactive({ tlsCertPath: '', tlsKeyPath: '', tlsRejectUnauthorized: false, + sentinelEnabled: false, + sentinelHost: '', + sentinelPort: 26379, + sentinelMasterName: '', + sentinelNodePassword: '', separator: ':', sshEnabled: false, sshHost: '', @@ -51,6 +56,12 @@ watch(() => props.visible, async (v) => { tlsCaPath: props.connection.tlsCaPath || '', tlsCertPath: props.connection.tlsCertPath || '', tlsKeyPath: props.connection.tlsKeyPath || '', + tlsRejectUnauthorized: props.connection.tlsRejectUnauthorized || false, + sentinelEnabled: props.connection.sentinelEnabled || false, + sentinelHost: props.connection.sentinelHost || '', + sentinelPort: props.connection.sentinelPort || 26379, + sentinelMasterName: props.connection.sentinelMasterName || '', + sentinelNodePassword: props.connection.sentinelNodePassword || '', separator: props.connection.separator, sshEnabled: props.connection.sshEnabled || false, sshHost: props.connection.sshHost || '', @@ -65,6 +76,9 @@ watch(() => props.visible, async (v) => { Object.assign(form, { name: '', host: '127.0.0.1', port: 6379, auth: '', username: '', tls: false, tlsCaPath: '', tlsCertPath: '', tlsKeyPath: '', + tlsRejectUnauthorized: false, + sentinelEnabled: false, sentinelHost: '', sentinelPort: 26379, + sentinelMasterName: '', sentinelNodePassword: '', separator: ':', sshEnabled: false, sshHost: '', sshPort: 22, sshUsername: '', sshPassword: '', sshPrivateKeyPath: '', sshPassphrase: '', @@ -128,6 +142,11 @@ async function handleTest() { tlsCertPath: form.tlsCertPath, tlsKeyPath: form.tlsKeyPath, tlsRejectUnauthorized: form.tlsRejectUnauthorized, + sentinelEnabled: form.sentinelEnabled, + sentinelHost: form.sentinelHost.trim(), + sentinelPort: Number(form.sentinelPort) || 26379, + sentinelMasterName: form.sentinelMasterName.trim(), + sentinelNodePassword: form.sentinelNodePassword, sshEnabled: form.sshEnabled, sshHost: form.sshHost.trim(), sshPort: Number(form.sshPort) || 22, @@ -165,6 +184,11 @@ async function handleSave() { tlsCertPath: form.tlsCertPath, tlsKeyPath: form.tlsKeyPath, tlsRejectUnauthorized: form.tlsRejectUnauthorized, + sentinelEnabled: form.sentinelEnabled, + sentinelHost: form.sentinelHost.trim(), + sentinelPort: Number(form.sentinelPort) || 26379, + sentinelMasterName: form.sentinelMasterName.trim(), + sentinelNodePassword: form.sentinelNodePassword, sshEnabled: form.sshEnabled, sshHost: form.sshHost.trim(), sshPort: Number(form.sshPort) || 22, @@ -249,6 +273,32 @@ async function handleSave() { + + + + {{ t('connection.sentinel') }} + + + diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index f9386bb..0132937 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -34,6 +34,11 @@ export default { tlsCertPath: 'Client Certificate', tlsKeyPath: 'Client Private Key', tlsRejectUnauthorized: 'Verify Server Certificate', + sentinel: 'Sentinel Mode', + sentinelHost: 'Sentinel Host', + sentinelPort: 'Sentinel Port', + sentinelMasterName: 'Master Name', + sentinelNodePassword: 'Sentinel Password', ssh: 'SSH Tunnel', sshHost: 'SSH Host', sshPort: 'SSH Port', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 9e616dc..2a78ba4 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -34,6 +34,11 @@ export default { tlsCertPath: '客户端证书', tlsKeyPath: '客户端私钥', tlsRejectUnauthorized: '验证服务器证书', + sentinel: 'Sentinel 哨兵模式', + sentinelHost: '哨兵主机', + sentinelPort: '哨兵端口', + sentinelMasterName: 'Master 名称', + sentinelNodePassword: '哨兵密码', ssh: 'SSH 隧道', sshHost: 'SSH 主机', sshPort: 'SSH 端口', diff --git a/src/stores/connection.ts b/src/stores/connection.ts index 1aeaf29..a4497f9 100644 --- a/src/stores/connection.ts +++ b/src/stores/connection.ts @@ -22,7 +22,11 @@ export interface ConnectionConfig { sshPrivateKeyPath: string sshPassphrase: string cluster: boolean + sentinelEnabled: boolean + sentinelHost: string + sentinelPort: number sentinelMasterName: string + sentinelNodePassword: string separator: string order: number createdAt: number @@ -88,6 +92,11 @@ export const useConnectionStore = defineStore('connection', () => { tlsCertPath: conn.tlsCertPath, tlsKeyPath: conn.tlsKeyPath, tlsRejectUnauthorized: conn.tlsRejectUnauthorized, + sentinelEnabled: conn.sentinelEnabled, + sentinelHost: conn.sentinelHost, + sentinelPort: conn.sentinelPort, + sentinelMasterName: conn.sentinelMasterName, + sentinelNodePassword: conn.sentinelNodePassword, sshEnabled: conn.sshEnabled, sshHost: conn.sshHost, sshPort: conn.sshPort, @@ -156,7 +165,9 @@ export const useConnectionStore = defineStore('connection', () => { async function testConnection(opts: { host: string; port: number; auth?: string; username?: string; tls?: boolean - tlsCaPath?: string; tlsCertPath?: string; tlsKeyPath?: string + tlsCaPath?: string; tlsCertPath?: string; tlsKeyPath?: string; tlsRejectUnauthorized?: boolean + sentinelEnabled?: boolean; sentinelHost?: string; sentinelPort?: number + sentinelMasterName?: string; sentinelNodePassword?: string sshEnabled?: boolean; sshHost?: string; sshPort?: number; sshUsername?: string sshPassword?: string; sshPrivateKeyPath?: string; sshPassphrase?: string }): Promise<{ success: boolean; error?: string }> { @@ -175,6 +186,11 @@ export const useConnectionStore = defineStore('connection', () => { tlsCertPath: opts.tlsCertPath, tlsKeyPath: opts.tlsKeyPath, tlsRejectUnauthorized: (opts as any).tlsRejectUnauthorized, + sentinelEnabled: opts.sentinelEnabled, + sentinelHost: opts.sentinelHost, + sentinelPort: opts.sentinelPort, + sentinelMasterName: opts.sentinelMasterName, + sentinelNodePassword: opts.sentinelNodePassword, sshEnabled: opts.sshEnabled, sshHost: opts.sshHost, sshPort: opts.sshPort,