feat(phase2): connection management

- RedisService in main process (ioredis wrapper, execute, scan, hash ops)
- electron-store for connection/settings persistence
- safeStorage credential encrypt/decrypt
- IPC handlers for redis, storage, credential channels
- Preload API typed with all new channels
- useConnectionStore (Pinia) with connect/disconnect/save/delete
- ConnectionCard with 3D hover effect + status indicator
- ConnectionList with search filter
- NewConnectionDialog (create/edit connection form)
- Sidebar component
- StatusBar with live connection status + pulse animation
- App.vue updated with Sidebar layout
This commit is contained in:
2026-07-04 17:45:19 +08:00
parent 7e5d61c099
commit 4f9398c65d
16 changed files with 1387 additions and 24 deletions
+16
View File
@@ -0,0 +1,16 @@
// src/main/credential.ts
import { safeStorage } from 'electron'
export function encrypt(text: string): string {
if (!safeStorage.isEncryptionAvailable()) {
return Buffer.from(text).toString('base64')
}
return safeStorage.encryptString(text).toString('base64')
}
export function decrypt(encoded: string): string {
if (!safeStorage.isEncryptionAvailable()) {
return Buffer.from(encoded, 'base64').toString('utf-8')
}
return safeStorage.decryptString(Buffer.from(encoded, 'base64'))
}
+96 -13
View File
@@ -1,35 +1,118 @@
// src/main/ipc-handlers.ts
import { ipcMain, BrowserWindow, nativeTheme, dialog } from 'electron'
import store, { ConnectionConfig } from './store'
import { encrypt, decrypt } from './credential'
import * as redisService from './redis-service'
export function registerIpcHandlers(): void {
ipcMain.on('window:minimize', () => {
BrowserWindow.getFocusedWindow()?.minimize()
})
// Window
ipcMain.on('window:minimize', () => BrowserWindow.getFocusedWindow()?.minimize())
ipcMain.on('window:maximize', () => {
const win = BrowserWindow.getFocusedWindow()
if (win) win.isMaximized() ? win.unmaximize() : win.maximize()
})
ipcMain.on('window:close', () => BrowserWindow.getFocusedWindow()?.close())
ipcMain.on('window:close', () => {
BrowserWindow.getFocusedWindow()?.close()
})
// Theme
ipcMain.handle('theme:get', () => nativeTheme.shouldUseDarkColors)
ipcMain.on('theme:set', (_event, theme: 'system' | 'dark' | 'light') => {
ipcMain.on('theme:set', (_e, theme: 'system' | 'dark' | 'light') => {
nativeTheme.themeSource = theme
})
ipcMain.handle('dialog:openFile', async (_event, options: Electron.OpenDialogOptions) => {
// Dialog
ipcMain.handle('dialog:openFile', async (_e, options: Electron.OpenDialogOptions) => {
const win = BrowserWindow.getFocusedWindow()
if (!win) return null
const result = await dialog.showOpenDialog(win, options)
if (result.canceled || result.filePaths.length === 0) return null
const fs = await import('fs')
try {
const content = fs.readFileSync(result.filePaths[0], 'utf-8')
return { path: result.filePaths[0], content }
return { path: result.filePaths[0], content: fs.readFileSync(result.filePaths[0], 'utf-8') }
} catch { return null }
})
// Credential
ipcMain.handle('credential:encrypt', (_e, text: string) => encrypt(text))
ipcMain.handle('credential:decrypt', (_e, encoded: string) => decrypt(encoded))
// Storage - connections
ipcMain.handle('storage:getConnections', () => store.get('connections', []))
ipcMain.handle('storage:saveConnection', (_e, conn: ConnectionConfig) => {
const connections = store.get('connections', [])
const idx = connections.findIndex((c: ConnectionConfig) => c.id === conn.id)
if (idx >= 0) connections[idx] = conn
else connections.push(conn)
store.set('connections', connections)
return connections
})
ipcMain.handle('storage:deleteConnection', (_e, id: string) => {
const connections = store.get('connections', []).filter((c: ConnectionConfig) => c.id !== id)
store.set('connections', connections)
return connections
})
ipcMain.handle('storage:reorderConnections', (_e, ids: string[]) => {
const connections = store.get('connections', [])
const reordered = ids.map((id, i) => {
const conn = connections.find((c: ConnectionConfig) => c.id === id)!
conn.order = i
return conn
})
store.set('connections', reordered)
return reordered
})
// Storage - settings
ipcMain.handle('storage:getSettings', () => store.get('settings'))
ipcMain.handle('storage:saveSettings', (_e, settings) => {
store.set('settings', settings)
})
// Redis
ipcMain.handle('redis:connect', async (_e, id: string, opts) => {
try {
await redisService.connect(id, opts)
return { success: true }
} catch (err: any) {
return { success: false, error: err.message }
}
})
ipcMain.handle('redis:disconnect', async (_e, id: string) => {
await redisService.disconnect(id)
})
ipcMain.handle('redis:execute', async (_e, id: string, command: string, args: string[]) => {
return redisService.execute(id, command, args)
})
ipcMain.handle('redis:getInfo', async (_e, id: string) => {
return redisService.getServerInfo(id)
})
ipcMain.handle('redis:scanKeys', async (_e, id: string, cursor: string, pattern: string, count: number) => {
return redisService.scanKeys(id, cursor, pattern, count)
})
ipcMain.handle('redis:getKeyType', async (_e, id: string, key: string) => {
return redisService.getKeyType(id, key)
})
ipcMain.handle('redis:getKeyTTL', async (_e, id: string, key: string) => {
return redisService.getKeyTTL(id, key)
})
ipcMain.handle('redis:getString', async (_e, id: string, key: string) => {
return redisService.getStringValue(id, key)
})
ipcMain.handle('redis:setString', async (_e, id: string, key: string, value: string) => {
await redisService.setStringValue(id, key, value)
})
ipcMain.handle('redis:deleteKey', async (_e, id: string, key: string) => {
await redisService.deleteKey(id, key)
})
ipcMain.handle('redis:hashScan', async (_e, id: string, key: string, cursor: string, count: number) => {
return redisService.getHashFields(id, key, cursor, count)
})
ipcMain.handle('redis:hashSet', async (_e, id: string, key: string, field: string, value: string) => {
await redisService.setHashField(id, key, field, value)
})
ipcMain.handle('redis:hashDel', async (_e, id: string, key: string, field: string) => {
await redisService.deleteHashField(id, key, field)
})
ipcMain.handle('redis:setTTL', async (_e, id: string, key: string, ttl: number) => {
await redisService.setKeyTTL(id, key, ttl)
})
}
+166
View File
@@ -0,0 +1,166 @@
// src/main/redis-service.ts
import Redis, { RedisOptions, Cluster, ClusterNode } from 'ioredis'
export type RedisClient = Redis | Cluster
interface ConnectionOptions {
id: string
host: string
port: number
password?: string
username?: string
tls?: boolean
tlsCa?: string
tlsCert?: string
tlsKey?: string
}
const clients = new Map<string, RedisClient>()
function buildRedisOptions(opts: ConnectionOptions): RedisOptions {
const options: RedisOptions = {
host: opts.host,
port: opts.port,
username: opts.username || undefined,
password: opts.password || undefined,
stringNumbers: true,
enableReadyCheck: false,
family: 0,
connectTimeout: 30000,
retryStrategy(times: number) {
if (times > 3) return null
return Math.min(times * 200, 1000)
},
maxRetriesPerRequest: 3,
}
if (opts.tls) {
options.tls = {
rejectUnauthorized: false,
checkServerIdentity: () => undefined,
}
if (opts.tlsCa) options.tls.ca = opts.tlsCa
if (opts.tlsCert) options.tls.cert = opts.tlsCert
if (opts.tlsKey) options.tls.key = opts.tlsKey
}
return options
}
export async function connect(id: string, opts: ConnectionOptions): Promise<void> {
if (clients.has(id)) {
await disconnect(id)
}
const redis = new Redis(buildRedisOptions(opts))
clients.set(id, redis)
}
export async function disconnect(id: string): Promise<void> {
const client = clients.get(id)
if (client) {
client.disconnect()
clients.delete(id)
}
}
export function getClient(id: string): RedisClient | undefined {
return clients.get(id)
}
export function disconnectAll(): void {
for (const [id] of clients) {
disconnect(id)
}
}
export async function execute(id: string, command: string, args: string[]): Promise<unknown> {
const client = clients.get(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.call(command, ...args)
}
export async function getServerInfo(id: string): Promise<string> {
const client = clients.get(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.info() as Promise<string>
}
export async function scanKeys(
id: string,
cursor: string,
pattern: string,
count: number
): Promise<{ cursor: string; keys: string[] }> {
const client = clients.get(id)
if (!client) throw new Error(`Client ${id} not found`)
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', count)
return { cursor: nextCursor, keys }
}
export async function getKeyType(id: string, key: string): Promise<string> {
const client = clients.get(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.type(key)
}
export async function getKeyTTL(id: string, key: string): Promise<number> {
const client = clients.get(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.ttl(key)
}
export async function getStringValue(id: string, key: string): Promise<string | null> {
const client = clients.get(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.get(key)
}
export async function setStringValue(id: string, key: string, value: string): Promise<void> {
const client = clients.get(id)
if (!client) throw new Error(`Client ${id} not found`)
await client.set(key, value)
}
export async function deleteKey(id: string, key: string): Promise<void> {
const client = clients.get(id)
if (!client) throw new Error(`Client ${id} not found`)
await client.del(key)
}
export async function getHashFields(
id: string, key: string, cursor: string, count: number
): Promise<{ cursor: string; fields: [string, string][] }> {
const client = clients.get(id)
if (!client) throw new Error(`Client ${id} not found`)
const [nextCursor, raw] = await client.hscan(key, cursor, 'COUNT', count)
const fields: [string, string][] = []
for (let i = 0; i < raw.length; i += 2) {
fields.push([raw[i], raw[i + 1]])
}
return { cursor: nextCursor, fields }
}
export async function setHashField(
id: string, key: string, field: string, value: string
): Promise<void> {
const client = clients.get(id)
if (!client) throw new Error(`Client ${id} not found`)
await client.hset(key, field, value)
}
export async function deleteHashField(id: string, key: string, field: string): Promise<void> {
const client = clients.get(id)
if (!client) throw new Error(`Client ${id} not found`)
await client.hdel(key, field)
}
export async function setKeyTTL(id: string, key: string, ttl: number): Promise<void> {
const client = clients.get(id)
if (!client) throw new Error(`Client ${id} not found`)
if (ttl < 0) {
await client.persist(key)
} else {
await client.expire(key, ttl)
}
}
+55
View File
@@ -0,0 +1,55 @@
// src/main/store.ts
import Store from 'electron-store'
interface ConnectionConfig {
id: string
name: string
host: string
port: number
auth: string
username: string
tls: boolean
tlsCaPath: string
tlsCertPath: string
tlsKeyPath: string
sshEnabled: boolean
sshHost: string
sshPort: number
sshUsername: string
sshPassword: string
sshPrivateKeyPath: string
cluster: boolean
sentinelMasterName: string
separator: string
order: number
createdAt: number
color?: string
}
interface AppSettings {
theme: 'system' | 'dark' | 'light'
locale: string
fontSize: number
scanCount: number
}
interface AppStorage {
connections: ConnectionConfig[]
settings: AppSettings
}
const store = new Store<AppStorage>({
name: 'jrdm-config',
defaults: {
connections: [],
settings: {
theme: 'system',
locale: 'en',
fontSize: 13,
scanCount: 500,
},
},
})
export default store
export type { ConnectionConfig, AppSettings }