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) => {
|
||||
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`)
|
||||
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>
|
||||
renameKey: (id: string, oldKey: string, newKey: string) => Promise<void>
|
||||
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),
|
||||
existsKey: (id: string, key: string): Promise<boolean> =>
|
||||
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 filterText = ref('')
|
||||
const patternInput = ref('*')
|
||||
const showTree = ref(false)
|
||||
const multiSelect = ref(false)
|
||||
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() {
|
||||
if (!connStore.activeId) return
|
||||
await keyStore.scanKeys(connStore.activeId, keyStore.scanCursor)
|
||||
@@ -21,6 +29,7 @@ async function loadMore() {
|
||||
async function refreshKeys() {
|
||||
if (!connStore.activeId) return
|
||||
selectedKeys.value.clear()
|
||||
keyStore.pattern = patternInput.value || '*'
|
||||
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[]>([])
|
||||
watch(
|
||||
() => keyStore.keys,
|
||||
@@ -75,17 +90,34 @@ watch(filterText, (val) => {
|
||||
? keyStore.keys.filter(k => k.name.includes(val)).map(k => k.name)
|
||||
: keyStore.keys.map(k => k.name)
|
||||
})
|
||||
|
||||
defineExpose({ deselectAll })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<el-input
|
||||
v-model="filterText"
|
||||
placeholder="Filter keys..."
|
||||
placeholder="Local filter..."
|
||||
size="small"
|
||||
clearable
|
||||
prefix-icon="Search"
|
||||
class="filter-input"
|
||||
/>
|
||||
<el-button-group class="key-actions">
|
||||
<el-button size="small" @click="refreshKeys" :disabled="!connStore.activeId">
|
||||
@@ -116,7 +148,6 @@ watch(filterText, (val) => {
|
||||
</div>
|
||||
|
||||
<div class="key-content">
|
||||
<!-- Flat list -->
|
||||
<div v-if="!showTree" class="key-flat-list">
|
||||
<div
|
||||
v-for="keyName in filteredKeys"
|
||||
@@ -140,7 +171,6 @@ watch(filterText, (val) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tree view -->
|
||||
<div v-else class="key-tree">
|
||||
<div
|
||||
v-for="keyName in filteredKeys"
|
||||
@@ -189,14 +219,35 @@ watch(filterText, (val) => {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.key-toolbar {
|
||||
padding: 12px;
|
||||
.key-pattern-row {
|
||||
padding: 10px 12px 6px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useConnectionStore } from '@renderer/stores/connection'
|
||||
import { useKeyStore } from '@renderer/stores/key'
|
||||
import type { ConnectionConfig } from '@renderer/stores/connection'
|
||||
import { useKeyboard } from '@renderer/composables/useKeyboard'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const connStore = useConnectionStore()
|
||||
const keyStore = useKeyStore()
|
||||
@@ -23,13 +24,35 @@ function handleNew() {
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function deleteSelectedKey() {
|
||||
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||
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) {
|
||||
keyStore.scanKeys(connStore.activeId, '0', true)
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard({
|
||||
'ctrl+n': handleNew,
|
||||
'ctrl+r': () => {
|
||||
if (connStore.activeId) {
|
||||
keyStore.scanKeys(connStore.activeId, '0', true)
|
||||
}
|
||||
},
|
||||
'ctrl+r': refreshAll,
|
||||
'f5': refreshAll,
|
||||
'delete': deleteSelectedKey,
|
||||
'escape': deselectKey,
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
// src/renderer/src/components/StatusBar.vue
|
||||
import { computed } from 'vue'
|
||||
import { useConnectionStore } from '@renderer/stores/connection'
|
||||
import { useKeyStore } from '@renderer/stores/key'
|
||||
import DbSelector from './DbSelector.vue'
|
||||
|
||||
const connStore = useConnectionStore()
|
||||
const keyStore = useKeyStore()
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (connStore.connecting) return 'Connecting...'
|
||||
@@ -20,7 +18,8 @@ const statusText = computed(() => {
|
||||
const statusClass = computed(() => {
|
||||
if (connStore.connecting) return 'status-connecting'
|
||||
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'
|
||||
})
|
||||
</script>
|
||||
@@ -88,6 +87,12 @@ const statusClass = computed(() => {
|
||||
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 {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
|
||||
@@ -33,6 +33,8 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
const connecting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const serverInfo = ref<string | null>(null)
|
||||
const healthOk = ref(true)
|
||||
let healthTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const activeConnection = computed(() =>
|
||||
connections.value.find((c: ConnectionConfig) => c.id === activeId.value) ?? null
|
||||
@@ -77,6 +79,7 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
if (result.success) {
|
||||
activeId.value = conn.id
|
||||
serverInfo.value = await window.electronAPI.redis.getInfo(conn.id)
|
||||
startHealthCheck(conn.id)
|
||||
} else {
|
||||
error.value = result.error || 'Connection failed'
|
||||
}
|
||||
@@ -88,6 +91,7 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
stopHealthCheck()
|
||||
if (activeId.value) {
|
||||
await window.electronAPI.redis.disconnect(activeId.value)
|
||||
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: {
|
||||
host: string; port: number; auth?: string; username?: string; tls?: boolean
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
@@ -137,7 +165,7 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
connections, activeId, connecting, error, serverInfo,
|
||||
connections, activeId, connecting, error, serverInfo, healthOk,
|
||||
activeConnection, isConnected,
|
||||
loadConnections, saveConnection, deleteConnection, reorderConnections,
|
||||
connect, disconnect, testConnection, exportConnections, importConnections,
|
||||
|
||||
Reference in New Issue
Block a user