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:
Generated
+283
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
Vendored
+55
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)">✎</button>
|
||||
<button class="card-btn card-btn-danger" title="Delete" @click="emit('delete', connection.id)">✕</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user