feat(phase7): pattern search, health check, keyboard shortcuts
- KeyList: pattern input for Redis MATCH, local filter, Go button - ConnectionStore: health check interval (5s ping), healthOk state - StatusBar: health indicator (unstable = yellow pulse) - RedisService: ping, isConnected methods - Sidebar: Delete key (Del), deselect (Esc), refresh (F5) - useKeyboard: expanded shortcut map
This commit is contained in:
@@ -207,4 +207,10 @@ export function registerIpcHandlers(): void {
|
|||||||
ipcMain.handle('redis:existsKey', async (_e, id: string, key: string) => {
|
ipcMain.handle('redis:existsKey', async (_e, id: string, key: string) => {
|
||||||
return redisService.existsKey(id, key)
|
return redisService.existsKey(id, key)
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('redis:ping', async (_e, id: string) => {
|
||||||
|
return redisService.ping(id)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:isConnected', (_e, id: string) => {
|
||||||
|
return redisService.isConnected(id)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -337,3 +337,14 @@ export async function existsKey(id: string, key: string): Promise<boolean> {
|
|||||||
if (!client) throw new Error(`Client ${id} not found`)
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
return (await client.exists(key)) === 1
|
return (await client.exists(key)) === 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function ping(id: string): Promise<string> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.ping()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isConnected(id: string): boolean {
|
||||||
|
const client = clients.get(id)
|
||||||
|
return client?.status === 'ready'
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -94,6 +94,8 @@ export interface ElectronAPI {
|
|||||||
streamDel: (id: string, key: string, ids: string[]) => Promise<number>
|
streamDel: (id: string, key: string, ids: string[]) => Promise<number>
|
||||||
renameKey: (id: string, oldKey: string, newKey: string) => Promise<void>
|
renameKey: (id: string, oldKey: string, newKey: string) => Promise<void>
|
||||||
existsKey: (id: string, key: string) => Promise<boolean>
|
existsKey: (id: string, key: string) => Promise<boolean>
|
||||||
|
ping: (id: string) => Promise<string>
|
||||||
|
isConnected: (id: string) => Promise<boolean>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ const electronAPI = {
|
|||||||
ipcRenderer.invoke('redis:renameKey', id, oldKey, newKey),
|
ipcRenderer.invoke('redis:renameKey', id, oldKey, newKey),
|
||||||
existsKey: (id: string, key: string): Promise<boolean> =>
|
existsKey: (id: string, key: string): Promise<boolean> =>
|
||||||
ipcRenderer.invoke('redis:existsKey', id, key),
|
ipcRenderer.invoke('redis:existsKey', id, key),
|
||||||
|
ping: (id: string): Promise<string> =>
|
||||||
|
ipcRenderer.invoke('redis:ping', id),
|
||||||
|
isConnected: (id: string): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke('redis:isConnected', id),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,18 @@ const keyStore = useKeyStore()
|
|||||||
const connStore = useConnectionStore()
|
const connStore = useConnectionStore()
|
||||||
|
|
||||||
const filterText = ref('')
|
const filterText = ref('')
|
||||||
|
const patternInput = ref('*')
|
||||||
const showTree = ref(false)
|
const showTree = ref(false)
|
||||||
const multiSelect = ref(false)
|
const multiSelect = ref(false)
|
||||||
const selectedKeys = ref<Set<string>>(new Set())
|
const selectedKeys = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
function applyPattern() {
|
||||||
|
keyStore.pattern = patternInput.value || '*'
|
||||||
|
if (connStore.activeId) {
|
||||||
|
keyStore.scanKeys(connStore.activeId, '0', true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (!connStore.activeId) return
|
if (!connStore.activeId) return
|
||||||
await keyStore.scanKeys(connStore.activeId, keyStore.scanCursor)
|
await keyStore.scanKeys(connStore.activeId, keyStore.scanCursor)
|
||||||
@@ -21,6 +29,7 @@ async function loadMore() {
|
|||||||
async function refreshKeys() {
|
async function refreshKeys() {
|
||||||
if (!connStore.activeId) return
|
if (!connStore.activeId) return
|
||||||
selectedKeys.value.clear()
|
selectedKeys.value.clear()
|
||||||
|
keyStore.pattern = patternInput.value || '*'
|
||||||
await keyStore.scanKeys(connStore.activeId, '0', true)
|
await keyStore.scanKeys(connStore.activeId, '0', true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +68,12 @@ function toggleMultiSelect() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deselectAll() {
|
||||||
|
selectedKeys.value.clear()
|
||||||
|
keyStore.selectedKey = null
|
||||||
|
keyStore.keyData = null
|
||||||
|
}
|
||||||
|
|
||||||
const filteredKeys = ref<string[]>([])
|
const filteredKeys = ref<string[]>([])
|
||||||
watch(
|
watch(
|
||||||
() => keyStore.keys,
|
() => keyStore.keys,
|
||||||
@@ -75,17 +90,34 @@ watch(filterText, (val) => {
|
|||||||
? keyStore.keys.filter(k => k.name.includes(val)).map(k => k.name)
|
? keyStore.keys.filter(k => k.name.includes(val)).map(k => k.name)
|
||||||
: keyStore.keys.map(k => k.name)
|
: keyStore.keys.map(k => k.name)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
defineExpose({ deselectAll })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="key-list">
|
<div class="key-list">
|
||||||
|
<div class="key-pattern-row">
|
||||||
|
<el-input
|
||||||
|
v-model="patternInput"
|
||||||
|
size="small"
|
||||||
|
placeholder="Key pattern (e.g. user:*)"
|
||||||
|
class="pattern-input"
|
||||||
|
@keyup.enter="applyPattern"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<span class="pattern-label">MATCH</span>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button size="small" type="primary" @click="applyPattern">Go</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="key-toolbar">
|
<div class="key-toolbar">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filterText"
|
v-model="filterText"
|
||||||
placeholder="Filter keys..."
|
placeholder="Local filter..."
|
||||||
size="small"
|
size="small"
|
||||||
clearable
|
clearable
|
||||||
prefix-icon="Search"
|
class="filter-input"
|
||||||
/>
|
/>
|
||||||
<el-button-group class="key-actions">
|
<el-button-group class="key-actions">
|
||||||
<el-button size="small" @click="refreshKeys" :disabled="!connStore.activeId">
|
<el-button size="small" @click="refreshKeys" :disabled="!connStore.activeId">
|
||||||
@@ -116,7 +148,6 @@ watch(filterText, (val) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="key-content">
|
<div class="key-content">
|
||||||
<!-- Flat list -->
|
|
||||||
<div v-if="!showTree" class="key-flat-list">
|
<div v-if="!showTree" class="key-flat-list">
|
||||||
<div
|
<div
|
||||||
v-for="keyName in filteredKeys"
|
v-for="keyName in filteredKeys"
|
||||||
@@ -140,7 +171,6 @@ watch(filterText, (val) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tree view -->
|
|
||||||
<div v-else class="key-tree">
|
<div v-else class="key-tree">
|
||||||
<div
|
<div
|
||||||
v-for="keyName in filteredKeys"
|
v-for="keyName in filteredKeys"
|
||||||
@@ -189,14 +219,35 @@ watch(filterText, (val) => {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.key-toolbar {
|
.key-pattern-row {
|
||||||
padding: 12px;
|
padding: 10px 12px 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pattern-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-toolbar {
|
||||||
|
padding: 6px 12px 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.key-actions {
|
.key-actions {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useConnectionStore } from '@renderer/stores/connection'
|
|||||||
import { useKeyStore } from '@renderer/stores/key'
|
import { useKeyStore } from '@renderer/stores/key'
|
||||||
import type { ConnectionConfig } from '@renderer/stores/connection'
|
import type { ConnectionConfig } from '@renderer/stores/connection'
|
||||||
import { useKeyboard } from '@renderer/composables/useKeyboard'
|
import { useKeyboard } from '@renderer/composables/useKeyboard'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
const connStore = useConnectionStore()
|
const connStore = useConnectionStore()
|
||||||
const keyStore = useKeyStore()
|
const keyStore = useKeyStore()
|
||||||
@@ -23,13 +24,35 @@ function handleNew() {
|
|||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
useKeyboard({
|
async function deleteSelectedKey() {
|
||||||
'ctrl+n': handleNew,
|
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||||
'ctrl+r': () => {
|
try {
|
||||||
|
await ElMessageBox.confirm(`Delete key "${keyStore.selectedKey}"?`, 'Confirm')
|
||||||
|
await window.electronAPI.redis.deleteKey(connStore.activeId, keyStore.selectedKey)
|
||||||
|
ElMessage.success('Deleted')
|
||||||
|
keyStore.selectedKey = null
|
||||||
|
keyStore.keyData = null
|
||||||
|
await keyStore.scanKeys(connStore.activeId, '0', true)
|
||||||
|
} catch { /* cancelled */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectKey() {
|
||||||
|
keyStore.selectedKey = null
|
||||||
|
keyStore.keyData = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAll() {
|
||||||
if (connStore.activeId) {
|
if (connStore.activeId) {
|
||||||
keyStore.scanKeys(connStore.activeId, '0', true)
|
keyStore.scanKeys(connStore.activeId, '0', true)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
|
useKeyboard({
|
||||||
|
'ctrl+n': handleNew,
|
||||||
|
'ctrl+r': refreshAll,
|
||||||
|
'f5': refreshAll,
|
||||||
|
'delete': deleteSelectedKey,
|
||||||
|
'escape': deselectKey,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,9 @@
|
|||||||
// src/renderer/src/components/StatusBar.vue
|
// src/renderer/src/components/StatusBar.vue
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useConnectionStore } from '@renderer/stores/connection'
|
import { useConnectionStore } from '@renderer/stores/connection'
|
||||||
import { useKeyStore } from '@renderer/stores/key'
|
|
||||||
import DbSelector from './DbSelector.vue'
|
import DbSelector from './DbSelector.vue'
|
||||||
|
|
||||||
const connStore = useConnectionStore()
|
const connStore = useConnectionStore()
|
||||||
const keyStore = useKeyStore()
|
|
||||||
|
|
||||||
const statusText = computed(() => {
|
const statusText = computed(() => {
|
||||||
if (connStore.connecting) return 'Connecting...'
|
if (connStore.connecting) return 'Connecting...'
|
||||||
@@ -20,7 +18,8 @@ const statusText = computed(() => {
|
|||||||
const statusClass = computed(() => {
|
const statusClass = computed(() => {
|
||||||
if (connStore.connecting) return 'status-connecting'
|
if (connStore.connecting) return 'status-connecting'
|
||||||
if (connStore.error) return 'status-error'
|
if (connStore.error) return 'status-error'
|
||||||
if (connStore.isConnected) return 'status-connected'
|
if (connStore.isConnected && connStore.healthOk) return 'status-connected'
|
||||||
|
if (connStore.isConnected && !connStore.healthOk) return 'status-unstable'
|
||||||
return 'status-disconnected'
|
return 'status-disconnected'
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -88,6 +87,12 @@ const statusClass = computed(() => {
|
|||||||
box-shadow: 0 0 6px var(--danger);
|
box-shadow: 0 0 6px var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-dot.status-unstable {
|
||||||
|
background: var(--warning);
|
||||||
|
box-shadow: 0 0 6px var(--warning);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.4; }
|
50% { opacity: 0.4; }
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export const useConnectionStore = defineStore('connection', () => {
|
|||||||
const connecting = ref(false)
|
const connecting = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const serverInfo = ref<string | null>(null)
|
const serverInfo = ref<string | null>(null)
|
||||||
|
const healthOk = ref(true)
|
||||||
|
let healthTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const activeConnection = computed(() =>
|
const activeConnection = computed(() =>
|
||||||
connections.value.find((c: ConnectionConfig) => c.id === activeId.value) ?? null
|
connections.value.find((c: ConnectionConfig) => c.id === activeId.value) ?? null
|
||||||
@@ -77,6 +79,7 @@ export const useConnectionStore = defineStore('connection', () => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
activeId.value = conn.id
|
activeId.value = conn.id
|
||||||
serverInfo.value = await window.electronAPI.redis.getInfo(conn.id)
|
serverInfo.value = await window.electronAPI.redis.getInfo(conn.id)
|
||||||
|
startHealthCheck(conn.id)
|
||||||
} else {
|
} else {
|
||||||
error.value = result.error || 'Connection failed'
|
error.value = result.error || 'Connection failed'
|
||||||
}
|
}
|
||||||
@@ -88,6 +91,7 @@ export const useConnectionStore = defineStore('connection', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function disconnect() {
|
async function disconnect() {
|
||||||
|
stopHealthCheck()
|
||||||
if (activeId.value) {
|
if (activeId.value) {
|
||||||
await window.electronAPI.redis.disconnect(activeId.value)
|
await window.electronAPI.redis.disconnect(activeId.value)
|
||||||
activeId.value = null
|
activeId.value = null
|
||||||
@@ -95,6 +99,30 @@ export const useConnectionStore = defineStore('connection', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startHealthCheck(connId: string) {
|
||||||
|
stopHealthCheck()
|
||||||
|
healthOk.value = true
|
||||||
|
healthTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const ok = await window.electronAPI.redis.isConnected(connId)
|
||||||
|
healthOk.value = ok
|
||||||
|
if (!ok && activeId.value === connId) {
|
||||||
|
// Connection lost
|
||||||
|
error.value = 'Connection lost'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
healthOk.value = false
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHealthCheck() {
|
||||||
|
if (healthTimer) {
|
||||||
|
clearInterval(healthTimer)
|
||||||
|
healthTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function testConnection(opts: {
|
async function testConnection(opts: {
|
||||||
host: string; port: number; auth?: string; username?: string; tls?: boolean
|
host: string; port: number; auth?: string; username?: string; tls?: boolean
|
||||||
}): Promise<{ success: boolean; error?: string }> {
|
}): Promise<{ success: boolean; error?: string }> {
|
||||||
@@ -137,7 +165,7 @@ export const useConnectionStore = defineStore('connection', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connections, activeId, connecting, error, serverInfo,
|
connections, activeId, connecting, error, serverInfo, healthOk,
|
||||||
activeConnection, isConnected,
|
activeConnection, isConnected,
|
||||||
loadConnections, saveConnection, deleteConnection, reorderConnections,
|
loadConnections, saveConnection, deleteConnection, reorderConnections,
|
||||||
connect, disconnect, testConnection, exportConnections, importConnections,
|
connect, disconnect, testConnection, exportConnections, importConnections,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user