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
+283 -1
View File
@@ -9,6 +9,7 @@
"version": "2.0.0",
"hasInstallScript": true,
"dependencies": {
"electron-store": "^11.0.2",
"ioredis": "^5.7.0",
"pinia": "^3.0.4"
},
@@ -2298,6 +2299,45 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
@@ -2611,6 +2651,16 @@
"node": ">= 4.0.0"
}
},
"node_modules/atomically": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz",
"integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==",
"license": "MIT",
"dependencies": {
"stubborn-fs": "^2.0.0",
"when-exit": "^2.1.4"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -3271,6 +3321,75 @@
"dev": true,
"license": "MIT"
},
"node_modules/conf": {
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/conf/-/conf-15.1.0.tgz",
"integrity": "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==",
"license": "MIT",
"dependencies": {
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"atomically": "^2.0.3",
"debounce-fn": "^6.0.0",
"dot-prop": "^10.0.0",
"env-paths": "^3.0.0",
"json-schema-typed": "^8.0.1",
"semver": "^7.7.2",
"uint8array-extras": "^1.5.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/conf/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/conf/node_modules/env-paths": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/conf/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/conf/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/config-file-ts": {
"version": "0.2.8-rc1",
"resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz",
@@ -3451,6 +3570,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/debounce-fn": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz",
"integrity": "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==",
"license": "MIT",
"dependencies": {
"mimic-function": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3727,6 +3861,36 @@
"node": ">=8"
}
},
"node_modules/dot-prop": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz",
"integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==",
"license": "MIT",
"dependencies": {
"type-fest": "^5.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/dot-prop/node_modules/type-fest": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz",
"integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -3986,6 +4150,37 @@
"node": ">= 10.0.0"
}
},
"node_modules/electron-store": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/electron-store/-/electron-store-11.0.2.tgz",
"integrity": "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ==",
"license": "MIT",
"dependencies": {
"conf": "^15.0.2",
"type-fest": "^5.0.1"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/electron-store/node_modules/type-fest": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz",
"integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.387",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz",
@@ -4279,7 +4474,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
@@ -4289,6 +4483,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
@@ -5203,6 +5413,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-typed": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
@@ -5570,6 +5786,18 @@
"node": ">=6"
}
},
"node_modules/mimic-function": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mimic-response": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
@@ -6400,6 +6628,15 @@
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resedit": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
@@ -6909,6 +7146,21 @@
"node": ">=8"
}
},
"node_modules/stubborn-fs": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz",
"integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==",
"license": "MIT",
"dependencies": {
"stubborn-utils": "^1.0.1"
}
},
"node_modules/stubborn-utils": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz",
"integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==",
"license": "MIT"
},
"node_modules/sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
@@ -6947,6 +7199,18 @@
"node": ">=8"
}
},
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
@@ -7125,6 +7389,18 @@
"node": ">=14.17"
}
},
"node_modules/uint8array-extras": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -7368,6 +7644,12 @@
"defaults": "^1.0.3"
}
},
"node_modules/when-exit": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz",
"integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+1
View File
@@ -18,6 +18,7 @@
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.vue --fix"
},
"dependencies": {
"electron-store": "^11.0.2",
"ioredis": "^5.7.0",
"pinia": "^3.0.4"
},
+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 }
+55
View File
@@ -1,4 +1,29 @@
// src/preload/index.d.ts
export 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
}
export interface ElectronAPI {
window: {
minimize: () => void
@@ -14,6 +39,36 @@ export interface ElectronAPI {
openFile: (options: { title?: string; filters?: { name: string; extensions: string[] }[] }) =>
Promise<{ path: string; content: string } | null>
}
credential: {
encrypt: (text: string) => Promise<string>
decrypt: (encoded: string) => Promise<string>
}
storage: {
getConnections: () => Promise<ConnectionConfig[]>
saveConnection: (conn: ConnectionConfig) => Promise<ConnectionConfig[]>
deleteConnection: (id: string) => Promise<ConnectionConfig[]>
reorderConnections: (ids: string[]) => Promise<ConnectionConfig[]>
getSettings: () => Promise<any>
saveSettings: (settings: any) => Promise<void>
}
redis: {
connect: (id: string, opts: any) => Promise<{ success: boolean; error?: string }>
disconnect: (id: string) => Promise<void>
execute: (id: string, command: string, args: string[]) => Promise<any>
getInfo: (id: string) => Promise<string>
scanKeys: (id: string, cursor: string, pattern: string, count: number) =>
Promise<{ cursor: string; keys: string[] }>
getKeyType: (id: string, key: string) => Promise<string>
getKeyTTL: (id: string, key: string) => Promise<number>
getString: (id: string, key: string) => Promise<string | null>
setString: (id: string, key: string, value: string) => Promise<void>
deleteKey: (id: string, key: string) => Promise<void>
hashScan: (id: string, key: string, cursor: string, count: number) =>
Promise<{ cursor: string; fields: [string, string][] }>
hashSet: (id: string, key: string, field: string, value: string) => Promise<void>
hashDel: (id: string, key: string, field: string) => Promise<void>
setTTL: (id: string, key: string, ttl: number) => Promise<void>
}
}
declare global {
+36 -1
View File
@@ -11,13 +11,48 @@ const electronAPI = {
get: (): Promise<boolean> => ipcRenderer.invoke('theme:get'),
set: (theme: 'system' | 'dark' | 'light') => ipcRenderer.send('theme:set', theme),
onOsUpdated: (callback: (isDark: boolean) => void) => {
ipcRenderer.on('theme:os-updated', (_event, isDark: boolean) => callback(isDark))
ipcRenderer.on('theme:os-updated', (_e, isDark: boolean) => callback(isDark))
},
},
dialog: {
openFile: (options: { title?: string; filters?: { name: string; extensions: string[] }[] }) =>
ipcRenderer.invoke('dialog:openFile', options),
},
credential: {
encrypt: (text: string): Promise<string> => ipcRenderer.invoke('credential:encrypt', text),
decrypt: (encoded: string): Promise<string> => ipcRenderer.invoke('credential:decrypt', encoded),
},
storage: {
getConnections: (): Promise<any[]> => ipcRenderer.invoke('storage:getConnections'),
saveConnection: (conn: any): Promise<any[]> => ipcRenderer.invoke('storage:saveConnection', conn),
deleteConnection: (id: string): Promise<any[]> => ipcRenderer.invoke('storage:deleteConnection', id),
reorderConnections: (ids: string[]): Promise<any[]> => ipcRenderer.invoke('storage:reorderConnections', ids),
getSettings: (): Promise<any> => ipcRenderer.invoke('storage:getSettings'),
saveSettings: (settings: any): Promise<void> => ipcRenderer.invoke('storage:saveSettings', settings),
},
redis: {
connect: (id: string, opts: any): Promise<any> => ipcRenderer.invoke('redis:connect', id, opts),
disconnect: (id: string): Promise<void> => ipcRenderer.invoke('redis:disconnect', id),
execute: (id: string, command: string, args: string[]): Promise<any> =>
ipcRenderer.invoke('redis:execute', id, command, args),
getInfo: (id: string): Promise<string> => ipcRenderer.invoke('redis:getInfo', id),
scanKeys: (id: string, cursor: string, pattern: string, count: number): Promise<any> =>
ipcRenderer.invoke('redis:scanKeys', id, cursor, pattern, count),
getKeyType: (id: string, key: string): Promise<string> => ipcRenderer.invoke('redis:getKeyType', id, key),
getKeyTTL: (id: string, key: string): Promise<number> => ipcRenderer.invoke('redis:getKeyTTL', id, key),
getString: (id: string, key: string): Promise<string | null> => ipcRenderer.invoke('redis:getString', id, key),
setString: (id: string, key: string, value: string): Promise<void> =>
ipcRenderer.invoke('redis:setString', id, key, value),
deleteKey: (id: string, key: string): Promise<void> => ipcRenderer.invoke('redis:deleteKey', id, key),
hashScan: (id: string, key: string, cursor: string, count: number): Promise<any> =>
ipcRenderer.invoke('redis:hashScan', id, key, cursor, count),
hashSet: (id: string, key: string, field: string, value: string): Promise<void> =>
ipcRenderer.invoke('redis:hashSet', id, key, field, value),
hashDel: (id: string, key: string, field: string): Promise<void> =>
ipcRenderer.invoke('redis:hashDel', id, key, field),
setTTL: (id: string, key: string, ttl: number): Promise<void> =>
ipcRenderer.invoke('redis:setTTL', id, key, ttl),
},
}
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
+69 -8
View File
@@ -1,19 +1,38 @@
<script setup lang="ts">
// src/renderer/src/App.vue
import { onMounted } from 'vue'
import TitleBar from './components/TitleBar.vue'
import Sidebar from './components/Sidebar.vue'
import StatusBar from './components/StatusBar.vue'
import { useConnectionStore } from '@renderer/stores/connection'
const connStore = useConnectionStore()
onMounted(() => {
connStore.loadConnections()
})
</script>
<template>
<div class="app-shell">
<TitleBar />
<main class="app-main">
<div class="placeholder-content">
<div class="placeholder-icon">R</div>
<h2>JRedisDesktop</h2>
<p>A modern Redis desktop manager</p>
</div>
</main>
<div class="app-body">
<Sidebar />
<main class="app-main">
<div v-if="!connStore.isConnected" class="placeholder-content">
<div class="placeholder-icon">R</div>
<h2>JRedisDesktop</h2>
<p>A modern Redis desktop manager</p>
<p class="placeholder-hint">Select or create a connection to get started</p>
</div>
<div v-else class="connected-content">
<div class="info-card">
<h3>{{ connStore.activeConnection?.name }}</h3>
<pre class="info-text">{{ connStore.serverInfo }}</pre>
</div>
</div>
</main>
</div>
<StatusBar />
</div>
</template>
@@ -26,13 +45,19 @@ import StatusBar from './components/StatusBar.vue'
width: 100%;
}
.app-body {
flex: 1;
display: flex;
overflow: hidden;
}
.app-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
overflow: hidden;
overflow: auto;
}
.placeholder-content {
@@ -65,4 +90,40 @@ import StatusBar from './components/StatusBar.vue'
.placeholder-content p {
font-size: 13px;
}
.placeholder-hint {
margin-top: 8px;
color: var(--text-muted);
font-size: 12px !important;
}
.connected-content {
width: 100%;
height: 100%;
padding: 20px;
overflow: auto;
}
.info-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 20px;
box-shadow: var(--shadow-sm);
}
.info-card h3 {
font-size: 16px;
font-weight: 600;
color: var(--accent-light);
margin-bottom: 12px;
}
.info-text {
font-size: 12px;
color: var(--text-secondary);
white-space: pre-wrap;
font-family: 'Cascadia Code', 'Fira Code', monospace;
line-height: 1.6;
}
</style>
@@ -0,0 +1,138 @@
<script setup lang="ts">
// src/renderer/src/components/ConnectionCard.vue
import { computed } from 'vue'
import type { ConnectionConfig } from '@renderer/stores/connection'
import { useConnectionStore } from '@renderer/stores/connection'
const props = defineProps<{ connection: ConnectionConfig }>()
const emit = defineEmits<{ edit: [conn: ConnectionConfig]; delete: [id: string] }>()
const connStore = useConnectionStore()
const isActive = computed(() => connStore.activeId === props.connection.id)
const statusColor = computed(() => {
if (isActive.value) return 'var(--success)'
return 'var(--text-muted)'
})
function handleClick() {
if (isActive.value) {
connStore.disconnect()
} else {
connStore.connect(props.connection)
}
}
</script>
<template>
<div
class="connection-card"
:class="{ active: isActive, connecting: connStore.connecting && connStore.activeId === connection.id }"
@click="handleClick"
:style="{ '--status-color': statusColor }"
>
<div class="card-status" />
<div class="card-body">
<div class="card-name">{{ connection.name }}</div>
<div class="card-host">{{ connection.host }}:{{ connection.port }}</div>
</div>
<div class="card-actions" @click.stop>
<button class="card-btn" title="Edit" @click="emit('edit', connection)">&#x270E;</button>
<button class="card-btn card-btn-danger" title="Delete" @click="emit('delete', connection.id)">&#x2715;</button>
</div>
</div>
</template>
<style scoped>
.connection-card {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
box-shadow: var(--shadow-sm);
}
.connection-card:hover {
background: var(--bg-card-hover);
transform: perspective(600px) rotateY(-1deg);
}
.connection-card.active {
background: var(--bg-card-active);
border-color: var(--border-accent);
box-shadow: var(--shadow-glow);
}
.connection-card.connecting {
opacity: 0.7;
pointer-events: none;
}
.card-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--status-color);
box-shadow: 0 0 8px var(--status-color);
flex-shrink: 0;
}
.card-body {
flex: 1;
min-width: 0;
}
.card-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-host {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.card-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.connection-card:hover .card-actions {
opacity: 1;
}
.card-btn {
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-muted);
border-radius: 6px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.card-btn:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.card-btn-danger:hover {
background: rgba(239, 68, 68, 0.15);
color: var(--danger);
}
</style>
@@ -0,0 +1,133 @@
<script setup lang="ts">
// src/renderer/src/components/ConnectionList.vue
import { ref, computed } from 'vue'
import { ElMessageBox } from 'element-plus'
import ConnectionCard from './ConnectionCard.vue'
import { useConnectionStore } from '@renderer/stores/connection'
import type { ConnectionConfig } from '@renderer/stores/connection'
const connStore = useConnectionStore()
const searchQuery = ref('')
const emit = defineEmits<{ edit: [conn: ConnectionConfig | null] }>()
const filteredConnections = computed(() => {
const q = searchQuery.value.toLowerCase()
return connStore.connections
.filter(c => c.name.toLowerCase().includes(q) || c.host.includes(q))
.sort((a, b) => a.order - b.order)
})
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('Delete this connection?', 'Confirm', {
type: 'warning',
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
})
await connStore.deleteConnection(id)
} catch { /* cancelled */ }
}
function handleEdit(conn: ConnectionConfig) {
emit('edit', conn)
}
function handleNew() {
emit('edit', null)
}
</script>
<template>
<div class="connection-list">
<div class="list-search">
<input
v-model="searchQuery"
class="search-input"
type="text"
placeholder="Search connections..."
/>
</div>
<div class="list-items">
<ConnectionCard
v-for="conn in filteredConnections"
:key="conn.id"
:connection="conn"
@edit="handleEdit"
@delete="handleDelete"
/>
<div v-if="filteredConnections.length === 0" class="list-empty">
<p v-if="searchQuery">No connections match</p>
<p v-else>No connections yet</p>
</div>
</div>
<button class="list-add-btn" @click="handleNew">
+ New Connection
</button>
</div>
</template>
<style scoped>
.connection-list {
display: flex;
flex-direction: column;
height: 100%;
}
.list-search {
padding: 14px;
}
.search-input {
width: 100%;
padding: 10px 14px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.search-input::placeholder {
color: var(--text-muted);
}
.search-input:focus {
border-color: var(--border-accent);
}
.list-items {
flex: 1;
overflow-y: auto;
padding: 0 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.list-empty {
text-align: center;
color: var(--text-muted);
font-size: 13px;
padding: 40px 0;
}
.list-add-btn {
margin: 14px;
padding: 10px;
background: var(--bg-card);
border: 1px dashed var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.list-add-btn:hover {
border-color: var(--border-accent);
color: var(--accent-light);
background: var(--bg-card-hover);
}
</style>
@@ -0,0 +1,137 @@
<script setup lang="ts">
// src/renderer/src/components/NewConnectionDialog.vue
import { reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useConnectionStore } from '@renderer/stores/connection'
import type { ConnectionConfig } from '@renderer/stores/connection'
const props = defineProps<{ visible: boolean; connection: ConnectionConfig | null }>()
const emit = defineEmits<{ 'update:visible': [v: boolean] }>()
const connStore = useConnectionStore()
const form = reactive({
name: '',
host: '127.0.0.1',
port: 6379,
auth: '',
username: '',
tls: false,
separator: ':',
})
const saving = ref(false)
const isEdit = ref(false)
watch(() => props.visible, (v) => {
if (v) {
if (props.connection) {
isEdit.value = true
Object.assign(form, {
name: props.connection.name,
host: props.connection.host,
port: props.connection.port,
auth: '',
username: props.connection.username,
tls: props.connection.tls,
separator: props.connection.separator,
})
} else {
isEdit.value = false
Object.assign(form, {
name: '', host: '127.0.0.1', port: 6379,
auth: '', username: '', tls: false, separator: ':',
})
}
}
})
function close() {
emit('update:visible', false)
}
async function handleSave() {
if (!form.name.trim() || !form.host.trim()) {
ElMessage.warning('Name and host are required')
return
}
saving.value = true
try {
const conn: ConnectionConfig = {
id: props.connection?.id ?? `conn_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
name: form.name.trim(),
host: form.host.trim(),
port: Number(form.port) || 6379,
auth: form.auth,
username: form.username.trim(),
tls: form.tls,
tlsCaPath: '',
tlsCertPath: '',
tlsKeyPath: '',
sshEnabled: false,
sshHost: '',
sshPort: 22,
sshUsername: '',
sshPassword: '',
sshPrivateKeyPath: '',
cluster: false,
sentinelMasterName: '',
separator: form.separator || ':',
order: props.connection?.order ?? connStore.connections.length,
createdAt: props.connection?.createdAt ?? Date.now(),
}
await connStore.saveConnection(conn)
ElMessage.success(isEdit.value ? 'Connection updated' : 'Connection created')
close()
} catch (err: any) {
ElMessage.error(err.message || 'Failed to save connection')
} finally {
saving.value = false
}
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="isEdit ? 'Edit Connection' : 'New Connection'"
width="480px"
:close-on-click-modal="false"
@update:model-value="close"
>
<el-form label-position="top" size="small">
<el-form-item label="Name" required>
<el-input v-model="form.name" placeholder="My Redis" />
</el-form-item>
<el-row :gutter="12">
<el-col :span="16">
<el-form-item label="Host" required>
<el-input v-model="form.host" placeholder="127.0.0.1" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Port">
<el-input-number v-model="form.port" :min="1" :max="65535" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="Password">
<el-input v-model="form.auth" type="password" show-password placeholder="Redis password" />
</el-form-item>
<el-form-item label="Username (Redis 6+ ACL)">
<el-input v-model="form.username" placeholder="default" />
</el-form-item>
<el-form-item label="Key Separator">
<el-input v-model="form.separator" placeholder=":" />
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.tls">Enable TLS/SSL</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">Cancel</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ isEdit ? 'Save' : 'Create' }}
</el-button>
</template>
</el-dialog>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
// src/renderer/src/components/Sidebar.vue
import { ref } from 'vue'
import ConnectionList from './ConnectionList.vue'
import NewConnectionDialog from './NewConnectionDialog.vue'
import { useConnectionStore } from '@renderer/stores/connection'
import type { ConnectionConfig } from '@renderer/stores/connection'
const connStore = useConnectionStore()
const dialogVisible = ref(false)
const editingConnection = ref<ConnectionConfig | null>(null)
function handleEdit(conn: ConnectionConfig | null) {
editingConnection.value = conn
dialogVisible.value = true
}
</script>
<template>
<aside class="sidebar">
<ConnectionList @edit="handleEdit" />
<NewConnectionDialog
v-model:visible="dialogVisible"
:connection="editingConnection"
/>
</aside>
</template>
<style scoped>
.sidebar {
width: 260px;
flex-shrink: 0;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>
+58 -1
View File
@@ -1,10 +1,33 @@
<script setup lang="ts">
// src/renderer/src/components/StatusBar.vue
import { computed } from 'vue'
import { useConnectionStore } from '@renderer/stores/connection'
const connStore = useConnectionStore()
const statusText = computed(() => {
if (connStore.connecting) return 'Connecting...'
if (connStore.error) return `Error: ${connStore.error}`
if (connStore.activeConnection) {
return `${connStore.activeConnection.host}:${connStore.activeConnection.port}`
}
return 'No connection'
})
const statusClass = computed(() => {
if (connStore.connecting) return 'status-connecting'
if (connStore.error) return 'status-error'
if (connStore.isConnected) return 'status-connected'
return 'status-disconnected'
})
</script>
<template>
<footer class="statusbar">
<span class="statusbar-left">No connection</span>
<span class="statusbar-left">
<span class="status-dot" :class="statusClass" />
{{ statusText }}
</span>
<span class="statusbar-right">JRedisDesktop v2.0</span>
</footer>
</template>
@@ -23,4 +46,38 @@
flex-shrink: 0;
user-select: none;
}
.statusbar-left {
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-muted);
}
.status-dot.status-connected {
background: var(--success);
box-shadow: 0 0 6px var(--success);
}
.status-dot.status-connecting {
background: var(--warning);
box-shadow: 0 0 6px var(--warning);
animation: pulse 1s infinite;
}
.status-dot.status-error {
background: var(--danger);
box-shadow: 0 0 6px var(--danger);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>
+104
View File
@@ -0,0 +1,104 @@
// src/renderer/src/stores/connection.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export 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
}
export const useConnectionStore = defineStore('connection', () => {
const connections = ref<ConnectionConfig[]>([])
const activeId = ref<string | null>(null)
const connecting = ref(false)
const error = ref<string | null>(null)
const serverInfo = ref<string | null>(null)
const activeConnection = computed(() =>
connections.value.find((c: ConnectionConfig) => c.id === activeId.value) ?? null
)
const isConnected = computed(() => activeId.value !== null)
async function loadConnections() {
connections.value = await window.electronAPI.storage.getConnections()
}
async function saveConnection(conn: ConnectionConfig) {
if (conn.auth && !conn.auth.startsWith('enc:')) {
conn.auth = 'enc:' + await window.electronAPI.credential.encrypt(conn.auth)
}
connections.value = await window.electronAPI.storage.saveConnection(conn)
}
async function deleteConnection(id: string) {
if (activeId.value === id) await disconnect()
connections.value = await window.electronAPI.storage.deleteConnection(id)
}
async function reorderConnections(ids: string[]) {
connections.value = await window.electronAPI.storage.reorderConnections(ids)
}
async function connect(conn: ConnectionConfig) {
connecting.value = true
error.value = null
try {
let auth = conn.auth
if (auth.startsWith('enc:')) {
auth = await window.electronAPI.credential.decrypt(auth.slice(4))
}
const result = await window.electronAPI.redis.connect(conn.id, {
host: conn.host,
port: conn.port,
password: auth || undefined,
username: conn.username || undefined,
tls: conn.tls,
})
if (result.success) {
activeId.value = conn.id
serverInfo.value = await window.electronAPI.redis.getInfo(conn.id)
} else {
error.value = result.error || 'Connection failed'
}
} catch (err: any) {
error.value = err.message
} finally {
connecting.value = false
}
}
async function disconnect() {
if (activeId.value) {
await window.electronAPI.redis.disconnect(activeId.value)
activeId.value = null
serverInfo.value = null
}
}
return {
connections, activeId, connecting, error, serverInfo,
activeConnection, isConnected,
loadConnections, saveConnection, deleteConnection, reorderConnections,
connect, disconnect,
}
})
File diff suppressed because one or more lines are too long