fix: patch all P1 bugs from project review
- pubsub: add sender.isDestroyed() checks + destroyed event auto-cleanup (P1#10)
- pubsub: add .catch() to monitor() promise + isDestroyed check (P1#11)
- connection: move server.on('error') before server.listen() (P1#12)
- preload: onSubscribeMessage/onMonitorMessage return cleanup functions (P1#9)
- CliView: save and call IPC listener cleanup in onUnmounted (P1#9)
- KeyDetail: move setInterval into onMounted, cleanup in onUnmounted (P1#8)
- key store: add loadKeyVersion guard to prevent race condition (P1#13)
- ListEditor: LSET sentinel + LREM for index-based deletion (P1#14)
- ipc-handlers: remove non-null assertion, add null guard in reorderConnections (P1#16)
- StreamEditor: add Confirm/Cancel buttons to Trim dialog (P1#17)
- KeyBrowser: remove { immediate: true } to prevent double scan (P1#18)
- P1#7 MULTI/EXEC: investigated, not a bug (Redis server handles queuing)
- docs: update PROJECT_REVIEW.md with P1 fix status
This commit is contained in:
@@ -95,10 +95,11 @@ export function registerIpcHandlers(): void {
|
||||
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)!
|
||||
const conn = connections.find((c: ConnectionConfig) => c.id === id)
|
||||
if (!conn) return null
|
||||
conn.order = i
|
||||
return conn
|
||||
})
|
||||
}).filter((c): c is ConnectionConfig => c !== null)
|
||||
store.set('connections', reordered)
|
||||
return reordered
|
||||
})
|
||||
|
||||
@@ -93,15 +93,15 @@ async function createSshTunnel(opts: ConnectionOptions): Promise<{ localPort: nu
|
||||
)
|
||||
})
|
||||
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const localPort = (server.address() as AddressInfo).port
|
||||
resolve({ localPort, sshClient, server })
|
||||
})
|
||||
|
||||
server.on('error', (err) => {
|
||||
sshClient.end()
|
||||
reject(new Error(`SSH tunnel server error: ${err.message}`))
|
||||
})
|
||||
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const localPort = (server.address() as AddressInfo).port
|
||||
resolve({ localPort, sshClient, server })
|
||||
})
|
||||
})
|
||||
|
||||
sshClient.on('error', (err: Error) => {
|
||||
|
||||
@@ -19,20 +19,22 @@ export function startSubscribe(
|
||||
const sub = new Redis(existing.options)
|
||||
subClients.set(id, sub)
|
||||
|
||||
sender.once('destroyed', () => stopSubscribe(id))
|
||||
|
||||
if (isPattern) {
|
||||
sub.psubscribe(...channels)
|
||||
sub.on('pmessage', (_pattern, channel, message) => {
|
||||
sender.send('redis:subscribeMessage', { channel, message, pattern: _pattern })
|
||||
if (!sender.isDestroyed()) sender.send('redis:subscribeMessage', { channel, message, pattern: _pattern })
|
||||
})
|
||||
} else {
|
||||
sub.subscribe(...channels)
|
||||
sub.on('message', (channel, message) => {
|
||||
sender.send('redis:subscribeMessage', { channel, message })
|
||||
if (!sender.isDestroyed()) sender.send('redis:subscribeMessage', { channel, message })
|
||||
})
|
||||
}
|
||||
|
||||
sub.on('error', (err) => {
|
||||
sender.send('redis:subscribeMessage', { error: err.message })
|
||||
if (!sender.isDestroyed()) sender.send('redis:subscribeMessage', { error: err.message })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,13 +56,17 @@ export function startMonitor(id: string, sender: WebContents): void {
|
||||
const monitor = new Redis(existing.options)
|
||||
monitorClients.set(id, monitor)
|
||||
|
||||
sender.once('destroyed', () => stopMonitor(id))
|
||||
|
||||
monitor.monitor().then((mon) => {
|
||||
mon.on('monitor', (time: string, args: string[], source: string, database: number) => {
|
||||
sender.send('redis:monitorMessage', { time, args, source, database })
|
||||
if (!sender.isDestroyed()) sender.send('redis:monitorMessage', { time, args, source, database })
|
||||
})
|
||||
mon.on('error', (err: Error) => {
|
||||
sender.send('redis:monitorMessage', { error: err.message })
|
||||
if (!sender.isDestroyed()) sender.send('redis:monitorMessage', { error: err.message })
|
||||
})
|
||||
}).catch(err => {
|
||||
if (!sender.isDestroyed()) sender.send('redis:monitorMessage', { error: err.message })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -125,11 +125,11 @@ export interface ElectronAPI {
|
||||
// Pub/Sub
|
||||
subscribe: (id: string, channels: string[], isPattern: boolean) => Promise<any>
|
||||
unsubscribe: (id: string) => Promise<void>
|
||||
onSubscribeMessage: (callback: (msg: any) => void) => void
|
||||
onSubscribeMessage: (callback: (msg: any) => void) => () => void
|
||||
// Monitor
|
||||
monitor: (id: string) => Promise<any>
|
||||
monitorStop: (id: string) => Promise<void>
|
||||
onMonitorMessage: (callback: (msg: any) => void) => void
|
||||
onMonitorMessage: (callback: (msg: any) => void) => () => void
|
||||
}
|
||||
updater: {
|
||||
check: () => Promise<any>
|
||||
|
||||
@@ -151,7 +151,9 @@ const electronAPI = {
|
||||
unsubscribe: (id: string): Promise<void> =>
|
||||
ipcRenderer.invoke('redis:unsubscribe', id),
|
||||
onSubscribeMessage: (callback: (msg: any) => void) => {
|
||||
ipcRenderer.on('redis:subscribeMessage', (_e, msg) => callback(msg))
|
||||
const handler = (_e: any, msg: any) => callback(msg)
|
||||
ipcRenderer.on('redis:subscribeMessage', handler)
|
||||
return () => ipcRenderer.removeListener('redis:subscribeMessage', handler)
|
||||
},
|
||||
// Monitor
|
||||
monitor: (id: string): Promise<any> =>
|
||||
@@ -159,7 +161,9 @@ const electronAPI = {
|
||||
monitorStop: (id: string): Promise<void> =>
|
||||
ipcRenderer.invoke('redis:monitorStop', id),
|
||||
onMonitorMessage: (callback: (msg: any) => void) => {
|
||||
ipcRenderer.on('redis:monitorMessage', (_e, msg) => callback(msg))
|
||||
const handler = (_e: any, msg: any) => callback(msg)
|
||||
ipcRenderer.on('redis:monitorMessage', handler)
|
||||
return () => ipcRenderer.removeListener('redis:monitorMessage', handler)
|
||||
},
|
||||
},
|
||||
updater: {
|
||||
|
||||
@@ -81,11 +81,14 @@ async function saveEdit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(item: string) {
|
||||
async function removeItem(index: number) {
|
||||
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||
try {
|
||||
await ElMessageBox.confirm(t('editor.removeElementConfirm'), t('common.confirm'))
|
||||
await window.electronAPI.redis.listRemove(connStore.activeId, keyStore.selectedKey, 1, item)
|
||||
// Use LSET + LREM sentinel pattern to delete by index instead of by value
|
||||
const sentinel = `\x00__JREDM_DEL_${Date.now()}_${Math.random()}__`
|
||||
await window.electronAPI.redis.listSet(connStore.activeId, keyStore.selectedKey, pageStart + index, sentinel)
|
||||
await window.electronAPI.redis.listRemove(connStore.activeId, keyStore.selectedKey, 1, sentinel)
|
||||
await loadList()
|
||||
ElMessage.success(t('editor.removed'))
|
||||
} catch (err: any) {
|
||||
@@ -127,7 +130,7 @@ function nextPage() {
|
||||
<span class="item-value">{{ item }}</span>
|
||||
<div class="item-actions">
|
||||
<el-button size="small" text @click="startEdit(index)">{{ t('editor.edit') }}</el-button>
|
||||
<el-button size="small" type="danger" text @click="removeItem(item)">{{ t('common.delete') }}</el-button>
|
||||
<el-button size="small" type="danger" text @click="removeItem(index)">{{ t('common.delete') }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -358,6 +358,10 @@ async function trimStream() {
|
||||
<el-form-item :label="t('editor.maxLength')">
|
||||
<el-input-number v-model="trimMaxLen" :min="1" />
|
||||
</el-form-item>
|
||||
<template #footer>
|
||||
<el-button @click="trimming = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="trimStream()">{{ t('common.confirm') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -16,8 +16,7 @@ watch(
|
||||
keyStore.keyData = null
|
||||
await keyStore.scanKeys(newId, '0', true)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onUnmounted } from 'vue'
|
||||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useKeyStore } from '@renderer/stores/key'
|
||||
import { useConnectionStore } from '@renderer/stores/connection'
|
||||
import { useI18n } from '@renderer/i18n'
|
||||
@@ -40,31 +40,38 @@ watch(() => keyStore.selectedTTL, (v) => {
|
||||
liveTTL.value = v
|
||||
})
|
||||
|
||||
let ttlTimer: ReturnType<typeof setInterval> | null = setInterval(async () => {
|
||||
if (liveTTL.value > 0) {
|
||||
liveTTL.value--
|
||||
if (liveTTL.value === 0) {
|
||||
const connId = connStore.activeId
|
||||
const key = keyStore.selectedKey
|
||||
if (!connId || !key) return
|
||||
const exists = await window.electronAPI.redis.existsKey(connId, key)
|
||||
// 用户可能已切换 key,跳过过期检查
|
||||
if (keyStore.selectedKey !== key) return
|
||||
if (!exists) {
|
||||
keyStore.selectedType = 'none'
|
||||
} else {
|
||||
// key 仍存在(TTL 可能被其他客户端刷新),同步实际 TTL
|
||||
const ttl = await window.electronAPI.redis.getKeyTTL(connId, key)
|
||||
let ttlTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
ttlTimer = setInterval(async () => {
|
||||
if (liveTTL.value > 0) {
|
||||
liveTTL.value--
|
||||
if (liveTTL.value === 0) {
|
||||
const connId = connStore.activeId
|
||||
const key = keyStore.selectedKey
|
||||
if (!connId || !key) return
|
||||
const exists = await window.electronAPI.redis.existsKey(connId, key)
|
||||
// 用户可能已切换 key,跳过过期检查
|
||||
if (keyStore.selectedKey !== key) return
|
||||
keyStore.selectedTTL = ttl
|
||||
liveTTL.value = ttl
|
||||
if (!exists) {
|
||||
keyStore.selectedType = 'none'
|
||||
} else {
|
||||
// key 仍存在(TTL 可能被其他客户端刷新),同步实际 TTL
|
||||
const ttl = await window.electronAPI.redis.getKeyTTL(connId, key)
|
||||
if (keyStore.selectedKey !== key) return
|
||||
keyStore.selectedTTL = ttl
|
||||
liveTTL.value = ttl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ttlTimer) clearInterval(ttlTimer)
|
||||
if (ttlTimer) {
|
||||
clearInterval(ttlTimer)
|
||||
ttlTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
const ttlDisplay = computed(() => {
|
||||
|
||||
@@ -377,9 +377,12 @@ function onClearCli() {
|
||||
history.value = []
|
||||
}
|
||||
|
||||
let unsubscribeMessageCleanup: (() => void) | null = null
|
||||
let monitorMessageCleanup: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
window.electronAPI.redis.onSubscribeMessage(onSubscribeMessage)
|
||||
window.electronAPI.redis.onMonitorMessage(onMonitorMessage)
|
||||
unsubscribeMessageCleanup = window.electronAPI.redis.onSubscribeMessage(onSubscribeMessage)
|
||||
monitorMessageCleanup = window.electronAPI.redis.onMonitorMessage(onMonitorMessage)
|
||||
window.addEventListener('clear-cli', onClearCli)
|
||||
})
|
||||
|
||||
@@ -387,6 +390,8 @@ onUnmounted(() => {
|
||||
if (connStore.activeId && streamingMode.value !== 'none') {
|
||||
stopStreaming()
|
||||
}
|
||||
unsubscribeMessageCleanup?.()
|
||||
monitorMessageCleanup?.()
|
||||
window.removeEventListener('clear-cli', onClearCli)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -132,10 +132,14 @@ export const useKeyStore = defineStore('key', () => {
|
||||
selectedTTL.value = ttl
|
||||
}
|
||||
|
||||
let loadKeyVersion = 0
|
||||
|
||||
async function loadKeyData(connId: string, key: string) {
|
||||
const currentVersion = ++loadKeyVersion
|
||||
loading.value = true
|
||||
try {
|
||||
await loadKeyTypeAndTTL(connId, key)
|
||||
if (currentVersion !== loadKeyVersion) return
|
||||
selectedKey.value = key
|
||||
|
||||
// Key no longer exists — show expired state and refresh list
|
||||
@@ -160,29 +164,35 @@ export const useKeyStore = defineStore('key', () => {
|
||||
break
|
||||
case 'hash': {
|
||||
const result = await window.electronAPI.redis.hashScan(connId, key, '0', 100)
|
||||
if (currentVersion !== loadKeyVersion) return
|
||||
keyData.value = result.fields
|
||||
break
|
||||
}
|
||||
case 'list': {
|
||||
const len = await window.electronAPI.redis.listLength(connId, key)
|
||||
if (currentVersion !== loadKeyVersion) return
|
||||
keyData.value = { length: len }
|
||||
break
|
||||
}
|
||||
case 'set': {
|
||||
const size = await window.electronAPI.redis.setSize(connId, key)
|
||||
if (currentVersion !== loadKeyVersion) return
|
||||
keyData.value = { size }
|
||||
break
|
||||
}
|
||||
case 'zset': {
|
||||
const size = await window.electronAPI.redis.zsetSize(connId, key)
|
||||
if (currentVersion !== loadKeyVersion) return
|
||||
keyData.value = { size }
|
||||
break
|
||||
}
|
||||
case 'stream': {
|
||||
try {
|
||||
const info = await window.electronAPI.redis.streamInfo(connId, key)
|
||||
if (currentVersion !== loadKeyVersion) return
|
||||
keyData.value = { length: info.length }
|
||||
} catch {
|
||||
if (currentVersion !== loadKeyVersion) return
|
||||
keyData.value = { length: 0 }
|
||||
}
|
||||
break
|
||||
@@ -195,6 +205,7 @@ export const useKeyStore = defineStore('key', () => {
|
||||
// command (e.g. key was overwritten). Re-fetch the type so the
|
||||
// correct editor is shown.
|
||||
await loadKeyTypeAndTTL(connId, key)
|
||||
if (currentVersion !== loadKeyVersion) return
|
||||
keyData.value = null
|
||||
}
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user