feat(phase4): list/set/zset editors, CLI, slow log, DB selector
- ListEditor: pagination, push, edit, remove elements - SetEditor: filter, add, remove members - ZsetEditor: filter, add with score, remove members - CliView: interactive terminal with command history - SlowLogView: slow log table with time/duration/command - DbSelector: dropdown to switch Redis databases (0-15) - StatusBar integrated with DB selector - MainArea: added CLI and Slow Log tabs - KeyDetail: dispatch to List/Set/Zset editors - RedisService: list/set/zset/slowlog/memory operations - Preload: all new IPC channels typed
This commit is contained in:
@@ -121,4 +121,62 @@ export function registerIpcHandlers(): void {
|
|||||||
ipcMain.handle('redis:dbSize', async (_e, id: string) => {
|
ipcMain.handle('redis:dbSize', async (_e, id: string) => {
|
||||||
return redisService.dbSize(id)
|
return redisService.dbSize(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// List
|
||||||
|
ipcMain.handle('redis:listRange', async (_e, id: string, key: string, start: number, stop: number) => {
|
||||||
|
return redisService.listRange(id, key, start, stop)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:listLength', async (_e, id: string, key: string) => {
|
||||||
|
return redisService.listLength(id, key)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:listPush', async (_e, id: string, key: string, values: string[]) => {
|
||||||
|
return redisService.listPush(id, key, ...values)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:listSet', async (_e, id: string, key: string, index: number, value: string) => {
|
||||||
|
await redisService.listSet(id, key, index, value)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:listRemove', async (_e, id: string, key: string, count: number, value: string) => {
|
||||||
|
return redisService.listRemove(id, key, count, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set
|
||||||
|
ipcMain.handle('redis:setMembers', async (_e, id: string, key: string) => {
|
||||||
|
return redisService.setMembers(id, key)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:setSize', async (_e, id: string, key: string) => {
|
||||||
|
return redisService.setSize(id, key)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:setAdd', async (_e, id: string, key: string, members: string[]) => {
|
||||||
|
return redisService.setAdd(id, key, ...members)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:setRemove', async (_e, id: string, key: string, members: string[]) => {
|
||||||
|
return redisService.setRemove(id, key, ...members)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sorted set
|
||||||
|
ipcMain.handle('redis:zsetRange', async (_e, id: string, key: string, start: number, stop: number, withScores: boolean) => {
|
||||||
|
return redisService.zsetRange(id, key, start, stop, withScores)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:zsetSize', async (_e, id: string, key: string) => {
|
||||||
|
return redisService.zsetSize(id, key)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:zsetAdd', async (_e, id: string, key: string, score: number, member: string) => {
|
||||||
|
return redisService.zsetAdd(id, key, score, member)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:zsetRemove', async (_e, id: string, key: string, members: string[]) => {
|
||||||
|
return redisService.zsetRemove(id, key, ...members)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Slow log
|
||||||
|
ipcMain.handle('redis:slowLogGet', async (_e, id: string, count: number) => {
|
||||||
|
return redisService.slowLogGet(id, count)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:slowLogLen', async (_e, id: string) => {
|
||||||
|
return redisService.slowLogLen(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
ipcMain.handle('redis:memoryUsage', async (_e, id: string, key: string) => {
|
||||||
|
return redisService.memoryUsage(id, key)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,3 +176,105 @@ export async function dbSize(id: string): Promise<number> {
|
|||||||
if (!client) throw new Error(`Client ${id} not found`)
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
return client.dbsize() as Promise<number>
|
return client.dbsize() as Promise<number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List operations
|
||||||
|
export async function listRange(id: string, key: string, start: number, stop: number): Promise<string[]> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.lrange(key, start, stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLength(id: string, key: string): Promise<number> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.llen(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPush(id: string, key: string, ...values: string[]): Promise<number> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.rpush(key, ...values)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSet(id: string, key: string, index: number, value: string): Promise<void> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
await client.lset(key, index, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRemove(id: string, key: string, count: number, value: string): Promise<number> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.lrem(key, count, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set operations
|
||||||
|
export async function setMembers(id: string, key: string): Promise<string[]> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.smembers(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSize(id: string, key: string): Promise<number> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.scard(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAdd(id: string, key: string, ...members: string[]): Promise<number> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.sadd(key, ...members)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRemove(id: string, key: string, ...members: string[]): Promise<number> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.srem(key, ...members)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorted set operations
|
||||||
|
export async function zsetRange(id: string, key: string, start: number, stop: number, withScores: boolean): Promise<string[]> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
if (withScores) return client.zrange(key, start, stop, 'WITHSCORES')
|
||||||
|
return client.zrange(key, start, stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zsetSize(id: string, key: string): Promise<number> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.zcard(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zsetAdd(id: string, key: string, score: number, member: string): Promise<number> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.zadd(key, score, member)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zsetRemove(id: string, key: string, ...members: string[]): Promise<number> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.zrem(key, ...members)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow log
|
||||||
|
export async function slowLogGet(id: string, count: number): Promise<any[]> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.slowlog('GET', count) as Promise<any[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function slowLogLen(id: string): Promise<number> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.slowlog('LEN') as Promise<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory info
|
||||||
|
export async function memoryUsage(id: string, key: string): Promise<number | null> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.memory('USAGE', key) as Promise<number | null>
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+16
@@ -70,6 +70,22 @@ export interface ElectronAPI {
|
|||||||
setTTL: (id: string, key: string, ttl: number) => Promise<void>
|
setTTL: (id: string, key: string, ttl: number) => Promise<void>
|
||||||
selectDb: (id: string, db: number) => Promise<void>
|
selectDb: (id: string, db: number) => Promise<void>
|
||||||
dbSize: (id: string) => Promise<number>
|
dbSize: (id: string) => Promise<number>
|
||||||
|
listRange: (id: string, key: string, start: number, stop: number) => Promise<string[]>
|
||||||
|
listLength: (id: string, key: string) => Promise<number>
|
||||||
|
listPush: (id: string, key: string, values: string[]) => Promise<number>
|
||||||
|
listSet: (id: string, key: string, index: number, value: string) => Promise<void>
|
||||||
|
listRemove: (id: string, key: string, count: number, value: string) => Promise<number>
|
||||||
|
setMembers: (id: string, key: string) => Promise<string[]>
|
||||||
|
setSize: (id: string, key: string) => Promise<number>
|
||||||
|
setAdd: (id: string, key: string, members: string[]) => Promise<number>
|
||||||
|
setRemove: (id: string, key: string, members: string[]) => Promise<number>
|
||||||
|
zsetRange: (id: string, key: string, start: number, stop: number, withScores: boolean) => Promise<string[]>
|
||||||
|
zsetSize: (id: string, key: string) => Promise<number>
|
||||||
|
zsetAdd: (id: string, key: string, score: number, member: string) => Promise<number>
|
||||||
|
zsetRemove: (id: string, key: string, members: string[]) => Promise<number>
|
||||||
|
slowLogGet: (id: string, count: number) => Promise<any[]>
|
||||||
|
slowLogLen: (id: string) => Promise<number>
|
||||||
|
memoryUsage: (id: string, key: string) => Promise<number | null>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,43 @@ const electronAPI = {
|
|||||||
selectDb: (id: string, db: number): Promise<void> =>
|
selectDb: (id: string, db: number): Promise<void> =>
|
||||||
ipcRenderer.invoke('redis:selectDb', id, db),
|
ipcRenderer.invoke('redis:selectDb', id, db),
|
||||||
dbSize: (id: string): Promise<number> => ipcRenderer.invoke('redis:dbSize', id),
|
dbSize: (id: string): Promise<number> => ipcRenderer.invoke('redis:dbSize', id),
|
||||||
|
// List
|
||||||
|
listRange: (id: string, key: string, start: number, stop: number): Promise<string[]> =>
|
||||||
|
ipcRenderer.invoke('redis:listRange', id, key, start, stop),
|
||||||
|
listLength: (id: string, key: string): Promise<number> =>
|
||||||
|
ipcRenderer.invoke('redis:listLength', id, key),
|
||||||
|
listPush: (id: string, key: string, values: string[]): Promise<number> =>
|
||||||
|
ipcRenderer.invoke('redis:listPush', id, key, values),
|
||||||
|
listSet: (id: string, key: string, index: number, value: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('redis:listSet', id, key, index, value),
|
||||||
|
listRemove: (id: string, key: string, count: number, value: string): Promise<number> =>
|
||||||
|
ipcRenderer.invoke('redis:listRemove', id, key, count, value),
|
||||||
|
// Set
|
||||||
|
setMembers: (id: string, key: string): Promise<string[]> =>
|
||||||
|
ipcRenderer.invoke('redis:setMembers', id, key),
|
||||||
|
setSize: (id: string, key: string): Promise<number> =>
|
||||||
|
ipcRenderer.invoke('redis:setSize', id, key),
|
||||||
|
setAdd: (id: string, key: string, members: string[]): Promise<number> =>
|
||||||
|
ipcRenderer.invoke('redis:setAdd', id, key, members),
|
||||||
|
setRemove: (id: string, key: string, members: string[]): Promise<number> =>
|
||||||
|
ipcRenderer.invoke('redis:setRemove', id, key, members),
|
||||||
|
// Sorted set
|
||||||
|
zsetRange: (id: string, key: string, start: number, stop: number, withScores: boolean): Promise<string[]> =>
|
||||||
|
ipcRenderer.invoke('redis:zsetRange', id, key, start, stop, withScores),
|
||||||
|
zsetSize: (id: string, key: string): Promise<number> =>
|
||||||
|
ipcRenderer.invoke('redis:zsetSize', id, key),
|
||||||
|
zsetAdd: (id: string, key: string, score: number, member: string): Promise<number> =>
|
||||||
|
ipcRenderer.invoke('redis:zsetAdd', id, key, score, member),
|
||||||
|
zsetRemove: (id: string, key: string, members: string[]): Promise<number> =>
|
||||||
|
ipcRenderer.invoke('redis:zsetRemove', id, key, members),
|
||||||
|
// Slow log
|
||||||
|
slowLogGet: (id: string, count: number): Promise<any[]> =>
|
||||||
|
ipcRenderer.invoke('redis:slowLogGet', id, count),
|
||||||
|
slowLogLen: (id: string): Promise<number> =>
|
||||||
|
ipcRenderer.invoke('redis:slowLogLen', id),
|
||||||
|
// Memory
|
||||||
|
memoryUsage: (id: string, key: string): Promise<number | null> =>
|
||||||
|
ipcRenderer.invoke('redis:memoryUsage', id, key),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// src/renderer/src/components/CliView.vue
|
||||||
|
import { ref, nextTick, watch } from 'vue'
|
||||||
|
import { useConnectionStore } from '@renderer/stores/connection'
|
||||||
|
|
||||||
|
const connStore = useConnectionStore()
|
||||||
|
|
||||||
|
const input = ref('')
|
||||||
|
const history = ref<{ cmd: string; result: string; error?: boolean }[]>([])
|
||||||
|
const historyIndex = ref(-1)
|
||||||
|
const outputRef = ref<HTMLDivElement>()
|
||||||
|
|
||||||
|
async function execute() {
|
||||||
|
if (!connStore.activeId || !input.value.trim()) return
|
||||||
|
|
||||||
|
const cmd = input.value.trim()
|
||||||
|
input.value = ''
|
||||||
|
historyIndex.value = -1
|
||||||
|
|
||||||
|
const parts = cmd.split(/\s+/)
|
||||||
|
const command = parts[0].toUpperCase()
|
||||||
|
const args = parts.slice(1)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.redis.execute(connStore.activeId, command, args)
|
||||||
|
let formatted = ''
|
||||||
|
|
||||||
|
if (result === null) {
|
||||||
|
formatted = '(nil)'
|
||||||
|
} else if (typeof result === 'number') {
|
||||||
|
formatted = `(integer) ${result}`
|
||||||
|
} else if (Array.isArray(result)) {
|
||||||
|
if (result.length === 0) {
|
||||||
|
formatted = '(empty array)'
|
||||||
|
} else {
|
||||||
|
formatted = result.map((item: any, i: number) => {
|
||||||
|
if (item === null) return `${i + 1}) (nil)`
|
||||||
|
if (typeof item === 'number') return `${i + 1}) (integer) ${item}`
|
||||||
|
return `${i + 1}) "${item}"`
|
||||||
|
}).join('\n')
|
||||||
|
}
|
||||||
|
} else if (typeof result === 'object') {
|
||||||
|
formatted = JSON.stringify(result, null, 2)
|
||||||
|
} else {
|
||||||
|
formatted = String(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
history.value.push({ cmd, result: formatted })
|
||||||
|
} catch (err: any) {
|
||||||
|
history.value.push({ cmd, result: `ERROR: ${err.message}`, error: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
if (outputRef.value) {
|
||||||
|
outputRef.value.scrollTop = outputRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
execute()
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (historyIndex.value < history.value.length - 1) {
|
||||||
|
historyIndex.value++
|
||||||
|
input.value = history.value[history.value.length - 1 - historyIndex.value].cmd
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (historyIndex.value > 0) {
|
||||||
|
historyIndex.value--
|
||||||
|
input.value = history.value[history.value.length - 1 - historyIndex.value].cmd
|
||||||
|
} else {
|
||||||
|
historyIndex.value = -1
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cli-view">
|
||||||
|
<div class="cli-output" ref="outputRef">
|
||||||
|
<div v-for="(item, index) in history" :key="index" class="cli-entry">
|
||||||
|
<div class="cli-cmd">
|
||||||
|
<span class="prompt">></span>
|
||||||
|
<span>{{ item.cmd }}</span>
|
||||||
|
</div>
|
||||||
|
<pre class="cli-result" :class="{ error: item.error }">{{ item.result }}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-if="history.length === 0" class="cli-welcome">
|
||||||
|
Redis CLI - type commands below
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cli-input-bar">
|
||||||
|
<span class="prompt">></span>
|
||||||
|
<input
|
||||||
|
v-model="input"
|
||||||
|
class="cli-input"
|
||||||
|
placeholder="Enter Redis command..."
|
||||||
|
@keydown="onKeydown"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cli-view {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-output {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-welcome {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-entry {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-cmd {
|
||||||
|
color: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt {
|
||||||
|
color: var(--color-green);
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-result {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 4px 0 0 18px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-result.error {
|
||||||
|
color: var(--color-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-input-bar {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// src/renderer/src/components/DbSelector.vue
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useConnectionStore } from '@renderer/stores/connection'
|
||||||
|
import { useKeyStore } from '@renderer/stores/key'
|
||||||
|
|
||||||
|
const connStore = useConnectionStore()
|
||||||
|
const keyStore = useKeyStore()
|
||||||
|
|
||||||
|
const selectedDb = ref(0)
|
||||||
|
const dbs = Array.from({ length: 16 }, (_, i) => i)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => connStore.isConnected,
|
||||||
|
(connected) => {
|
||||||
|
if (connected) {
|
||||||
|
selectedDb.value = 0
|
||||||
|
keyStore.currentDb = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async function switchDb(db: number) {
|
||||||
|
if (!connStore.activeId) return
|
||||||
|
selectedDb.value = db
|
||||||
|
await keyStore.selectDb(connStore.activeId, db)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="db-selector" v-if="connStore.isConnected">
|
||||||
|
<span class="db-label">DB</span>
|
||||||
|
<el-select
|
||||||
|
:model-value="selectedDb"
|
||||||
|
size="small"
|
||||||
|
@change="switchDb"
|
||||||
|
style="width: 70px"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="db in dbs"
|
||||||
|
:key="db"
|
||||||
|
:label="db"
|
||||||
|
:value="db"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.db-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,6 +6,9 @@ import { useConnectionStore } from '@renderer/stores/connection'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import StringEditor from './StringEditor.vue'
|
import StringEditor from './StringEditor.vue'
|
||||||
import HashEditor from './HashEditor.vue'
|
import HashEditor from './HashEditor.vue'
|
||||||
|
import ListEditor from './ListEditor.vue'
|
||||||
|
import SetEditor from './SetEditor.vue'
|
||||||
|
import ZsetEditor from './ZsetEditor.vue'
|
||||||
|
|
||||||
const keyStore = useKeyStore()
|
const keyStore = useKeyStore()
|
||||||
const connStore = useConnectionStore()
|
const connStore = useConnectionStore()
|
||||||
@@ -70,6 +73,9 @@ async function refreshKey() {
|
|||||||
<div class="detail-content">
|
<div class="detail-content">
|
||||||
<StringEditor v-if="keyStore.selectedType === 'string'" />
|
<StringEditor v-if="keyStore.selectedType === 'string'" />
|
||||||
<HashEditor v-else-if="keyStore.selectedType === 'hash'" />
|
<HashEditor v-else-if="keyStore.selectedType === 'hash'" />
|
||||||
|
<ListEditor v-else-if="keyStore.selectedType === 'list'" />
|
||||||
|
<SetEditor v-else-if="keyStore.selectedType === 'set'" />
|
||||||
|
<ZsetEditor v-else-if="keyStore.selectedType === 'zset'" />
|
||||||
<div v-else class="unsupported">
|
<div v-else class="unsupported">
|
||||||
<el-icon><Warning /></el-icon>
|
<el-icon><Warning /></el-icon>
|
||||||
<p>Type "{{ keyStore.selectedType }}" viewer not yet implemented</p>
|
<p>Type "{{ keyStore.selectedType }}" viewer not yet implemented</p>
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// src/renderer/src/components/ListEditor.vue
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useKeyStore } from '@renderer/stores/key'
|
||||||
|
import { useConnectionStore } from '@renderer/stores/connection'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
const keyStore = useKeyStore()
|
||||||
|
const connStore = useConnectionStore()
|
||||||
|
|
||||||
|
const items = ref<string[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const pageStart = ref(0)
|
||||||
|
const pageSize = 50
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const showAddDialog = ref(false)
|
||||||
|
const newItemValue = ref('')
|
||||||
|
const editingIndex = ref<number | null>(null)
|
||||||
|
const editingValue = ref('')
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [len, data] = await Promise.all([
|
||||||
|
window.electronAPI.redis.listLength(connStore.activeId, keyStore.selectedKey),
|
||||||
|
window.electronAPI.redis.listRange(connStore.activeId, keyStore.selectedKey, pageStart.value, pageStart.value + pageSize - 1),
|
||||||
|
])
|
||||||
|
totalCount.value = len
|
||||||
|
items.value = data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => keyStore.selectedKey,
|
||||||
|
() => { if (keyStore.selectedType === 'list') { pageStart.value = 0; loadList() } },
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
async function addItem() {
|
||||||
|
if (!connStore.activeId || !keyStore.selectedKey || !newItemValue.value) return
|
||||||
|
try {
|
||||||
|
await window.electronAPI.redis.listPush(connStore.activeId, keyStore.selectedKey, [newItemValue.value])
|
||||||
|
showAddDialog.value = false
|
||||||
|
newItemValue.value = ''
|
||||||
|
await loadList()
|
||||||
|
ElMessage.success('Added')
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(index: number) {
|
||||||
|
editingIndex.value = index
|
||||||
|
editingValue.value = items.value[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!connStore.activeId || !keyStore.selectedKey || editingIndex.value === null) return
|
||||||
|
try {
|
||||||
|
await window.electronAPI.redis.listSet(connStore.activeId, keyStore.selectedKey, editingIndex.value, editingValue.value)
|
||||||
|
items.value[editingIndex.value] = editingValue.value
|
||||||
|
editingIndex.value = null
|
||||||
|
ElMessage.success('Saved')
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeItem(item: string) {
|
||||||
|
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`Remove this element?`, 'Confirm')
|
||||||
|
await window.electronAPI.redis.listRemove(connStore.activeId, keyStore.selectedKey, 1, item)
|
||||||
|
await loadList()
|
||||||
|
ElMessage.success('Removed')
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
if (pageStart.value === 0) return
|
||||||
|
pageStart.value = Math.max(0, pageStart.value - pageSize)
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
if (pageStart.value + pageSize >= totalCount.value) return
|
||||||
|
pageStart.value += pageSize
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="list-editor">
|
||||||
|
<div class="editor-toolbar">
|
||||||
|
<span class="type-badge">LIST</span>
|
||||||
|
<span class="item-count">{{ totalCount }} elements</span>
|
||||||
|
<el-button size="small" type="primary" @click="showAddDialog = true">Push</el-button>
|
||||||
|
<el-button size="small" @click="loadList" :loading="loading">Refresh</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-content">
|
||||||
|
<div v-for="(item, index) in items" :key="index" class="list-item">
|
||||||
|
<span class="item-index">{{ pageStart + index }}</span>
|
||||||
|
<template v-if="editingIndex === index">
|
||||||
|
<el-input v-model="editingValue" size="small" class="item-input" />
|
||||||
|
<el-button size="small" type="primary" text @click="saveEdit">Save</el-button>
|
||||||
|
<el-button size="small" @click="editingIndex = null">Cancel</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="item-value">{{ item }}</span>
|
||||||
|
<div class="item-actions">
|
||||||
|
<el-button size="small" text @click="startEdit(index)">Edit</el-button>
|
||||||
|
<el-button size="small" type="danger" text @click="removeItem(item)">Del</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="items.length === 0 && !loading" class="empty">Empty list</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-pagination" v-if="totalCount > pageSize">
|
||||||
|
<el-button size="small" :disabled="pageStart === 0" @click="prevPage">Prev</el-button>
|
||||||
|
<span class="page-info">{{ pageStart + 1 }}-{{ Math.min(pageStart + pageSize, totalCount) }} / {{ totalCount }}</span>
|
||||||
|
<el-button size="small" :disabled="pageStart + pageSize >= totalCount" @click="nextPage">Next</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="showAddDialog" title="Push Element" width="400px">
|
||||||
|
<el-form-item label="Value">
|
||||||
|
<el-input v-model="newItemValue" type="textarea" :rows="4" />
|
||||||
|
</el-form-item>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showAddDialog = false">Cancel</el-button>
|
||||||
|
<el-button type="primary" @click="addItem">Push</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.list-editor {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-light);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-index {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 30px;
|
||||||
|
font-family: 'Cascadia Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-value {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'Cascadia Code', monospace;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-pagination {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,10 +4,12 @@ import { ref, watch } from 'vue'
|
|||||||
import { useConnectionStore } from '@renderer/stores/connection'
|
import { useConnectionStore } from '@renderer/stores/connection'
|
||||||
import StatusView from './StatusView.vue'
|
import StatusView from './StatusView.vue'
|
||||||
import KeyBrowser from './KeyBrowser.vue'
|
import KeyBrowser from './KeyBrowser.vue'
|
||||||
|
import CliView from './CliView.vue'
|
||||||
|
import SlowLogView from './SlowLogView.vue'
|
||||||
|
|
||||||
const connStore = useConnectionStore()
|
const connStore = useConnectionStore()
|
||||||
|
|
||||||
type Tab = 'status' | 'keys' | 'cli'
|
type Tab = 'status' | 'keys' | 'cli' | 'slowlog'
|
||||||
const activeTab = ref<Tab>('status')
|
const activeTab = ref<Tab>('status')
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -39,10 +41,28 @@ watch(
|
|||||||
<el-icon><Document /></el-icon>
|
<el-icon><Document /></el-icon>
|
||||||
Keys
|
Keys
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: activeTab === 'cli' }"
|
||||||
|
@click="activeTab = 'cli'"
|
||||||
|
>
|
||||||
|
<el-icon><Monitor /></el-icon>
|
||||||
|
CLI
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: activeTab === 'slowlog' }"
|
||||||
|
@click="activeTab = 'slowlog'"
|
||||||
|
>
|
||||||
|
<el-icon><Clock /></el-icon>
|
||||||
|
Slow Log
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<StatusView v-if="activeTab === 'status'" />
|
<StatusView v-if="activeTab === 'status'" />
|
||||||
<KeyBrowser v-else-if="activeTab === 'keys'" />
|
<KeyBrowser v-else-if="activeTab === 'keys'" />
|
||||||
|
<CliView v-else-if="activeTab === 'cli'" />
|
||||||
|
<SlowLogView v-else-if="activeTab === 'slowlog'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// src/renderer/src/components/SetEditor.vue
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useKeyStore } from '@renderer/stores/key'
|
||||||
|
import { useConnectionStore } from '@renderer/stores/connection'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
const keyStore = useKeyStore()
|
||||||
|
const connStore = useConnectionStore()
|
||||||
|
|
||||||
|
const members = ref<string[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const filterText = ref('')
|
||||||
|
const showAddDialog = ref(false)
|
||||||
|
const newMember = ref('')
|
||||||
|
|
||||||
|
async function loadMembers() {
|
||||||
|
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
members.value = await window.electronAPI.redis.setMembers(connStore.activeId, keyStore.selectedKey)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => keyStore.selectedKey,
|
||||||
|
() => { if (keyStore.selectedType === 'set') loadMembers() },
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
async function addMember() {
|
||||||
|
if (!connStore.activeId || !keyStore.selectedKey || !newMember.value) return
|
||||||
|
try {
|
||||||
|
const added = await window.electronAPI.redis.setAdd(connStore.activeId, keyStore.selectedKey, [newMember.value])
|
||||||
|
if (added > 0) {
|
||||||
|
members.value.push(newMember.value)
|
||||||
|
}
|
||||||
|
showAddDialog.value = false
|
||||||
|
newMember.value = ''
|
||||||
|
ElMessage.success('Added')
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMember(member: string) {
|
||||||
|
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`Remove member "${member}"?`, 'Confirm')
|
||||||
|
await window.electronAPI.redis.setRemove(connStore.activeId, keyStore.selectedKey, [member])
|
||||||
|
members.value = members.value.filter(m => m !== member)
|
||||||
|
ElMessage.success('Removed')
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredMembers = ref<string[]>([])
|
||||||
|
watch([members, filterText], () => {
|
||||||
|
filteredMembers.value = filterText.value
|
||||||
|
? members.value.filter(m => m.includes(filterText.value))
|
||||||
|
: members.value
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="set-editor">
|
||||||
|
<div class="editor-toolbar">
|
||||||
|
<span class="type-badge">SET</span>
|
||||||
|
<span class="item-count">{{ members.length }} members</span>
|
||||||
|
<el-input v-model="filterText" placeholder="Filter..." size="small" clearable style="width: 150px" />
|
||||||
|
<el-button size="small" type="primary" @click="showAddDialog = true">Add</el-button>
|
||||||
|
<el-button size="small" @click="loadMembers" :loading="loading">Refresh</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="set-content">
|
||||||
|
<div v-for="member in filteredMembers" :key="member" class="set-item">
|
||||||
|
<span class="item-value">{{ member }}</span>
|
||||||
|
<el-button size="small" type="danger" text @click="removeMember(member)">Del</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="filteredMembers.length === 0 && !loading" class="empty">Empty set</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="showAddDialog" title="Add Member" width="400px">
|
||||||
|
<el-form-item label="Member">
|
||||||
|
<el-input v-model="newMember" />
|
||||||
|
</el-form-item>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showAddDialog = false">Cancel</el-button>
|
||||||
|
<el-button type="primary" @click="addMember">Add</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.set-editor {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-light);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-value {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'Cascadia Code', monospace;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// src/renderer/src/components/SlowLogView.vue
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useConnectionStore } from '@renderer/stores/connection'
|
||||||
|
|
||||||
|
const connStore = useConnectionStore()
|
||||||
|
|
||||||
|
interface SlowLogEntry {
|
||||||
|
id: number
|
||||||
|
timestamp: number
|
||||||
|
duration: number
|
||||||
|
command: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = ref<SlowLogEntry[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (!connStore.activeId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const raw = await window.electronAPI.redis.slowLogGet(connStore.activeId, 50)
|
||||||
|
entries.value = raw.map((item: any) => ({
|
||||||
|
id: item[0],
|
||||||
|
timestamp: item[1],
|
||||||
|
duration: item[2],
|
||||||
|
command: Array.isArray(item[3]) ? item[3].join(' ') : String(item[3]),
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(us: number) {
|
||||||
|
if (us < 1000) return `${us}μs`
|
||||||
|
if (us < 1000000) return `${(us / 1000).toFixed(1)}ms`
|
||||||
|
return `${(us / 1000000).toFixed(2)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: number) {
|
||||||
|
return new Date(ts * 1000).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ refresh })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="slow-log">
|
||||||
|
<div class="toolbar">
|
||||||
|
<span class="title">Slow Log</span>
|
||||||
|
<el-button size="small" @click="refresh" :loading="loading">Refresh</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<el-table :data="entries" size="small" max-height="400" empty-text="No slow log entries">
|
||||||
|
<el-table-column label="Time" width="180">
|
||||||
|
<template #default="{ row }">{{ formatTime(row.timestamp) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Duration" width="100">
|
||||||
|
<template #default="{ row }">{{ formatDuration(row.duration) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Command" min-width="300">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cmd-text">{{ row.command }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.slow-log {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-text {
|
||||||
|
font-family: 'Cascadia Code', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,8 +2,11 @@
|
|||||||
// 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'
|
||||||
|
|
||||||
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...'
|
||||||
@@ -28,6 +31,9 @@ const statusClass = computed(() => {
|
|||||||
<span class="status-dot" :class="statusClass" />
|
<span class="status-dot" :class="statusClass" />
|
||||||
{{ statusText }}
|
{{ statusText }}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="statusbar-center">
|
||||||
|
<DbSelector v-if="connStore.isConnected" />
|
||||||
|
</span>
|
||||||
<span class="statusbar-right">JRedisDesktop v2.0</span>
|
<span class="statusbar-right">JRedisDesktop v2.0</span>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
@@ -53,6 +59,12 @@ const statusClass = computed(() => {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statusbar-center {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// src/renderer/src/components/ZsetEditor.vue
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useKeyStore } from '@renderer/stores/key'
|
||||||
|
import { useConnectionStore } from '@renderer/stores/connection'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
const keyStore = useKeyStore()
|
||||||
|
const connStore = useConnectionStore()
|
||||||
|
|
||||||
|
interface ZsetItem {
|
||||||
|
member: string
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = ref<ZsetItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const filterText = ref('')
|
||||||
|
const showAddDialog = ref(false)
|
||||||
|
const newMember = ref('')
|
||||||
|
const newScore = ref(0)
|
||||||
|
|
||||||
|
async function loadMembers() {
|
||||||
|
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await window.electronAPI.redis.zsetRange(connStore.activeId, keyStore.selectedKey, 0, -1, true)
|
||||||
|
const result: ZsetItem[] = []
|
||||||
|
for (let i = 0; i < data.length; i += 2) {
|
||||||
|
result.push({ member: data[i], score: Number(data[i + 1]) })
|
||||||
|
}
|
||||||
|
items.value = result
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => keyStore.selectedKey,
|
||||||
|
() => { if (keyStore.selectedType === 'zset') loadMembers() },
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
async function addMember() {
|
||||||
|
if (!connStore.activeId || !keyStore.selectedKey || !newMember.value) return
|
||||||
|
try {
|
||||||
|
await window.electronAPI.redis.zsetAdd(connStore.activeId, keyStore.selectedKey, newScore.value, newMember.value)
|
||||||
|
items.value.push({ member: newMember.value, score: newScore.value })
|
||||||
|
items.value.sort((a, b) => a.score - b.score)
|
||||||
|
showAddDialog.value = false
|
||||||
|
newMember.value = ''
|
||||||
|
newScore.value = 0
|
||||||
|
ElMessage.success('Added')
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMember(member: string) {
|
||||||
|
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`Remove member "${member}"?`, 'Confirm')
|
||||||
|
await window.electronAPI.redis.zsetRemove(connStore.activeId, keyStore.selectedKey, [member])
|
||||||
|
items.value = items.value.filter(i => i.member !== member)
|
||||||
|
ElMessage.success('Removed')
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredItems = ref<ZsetItem[]>([])
|
||||||
|
watch([items, filterText], () => {
|
||||||
|
filteredItems.value = filterText.value
|
||||||
|
? items.value.filter(i => i.member.includes(filterText.value))
|
||||||
|
: items.value
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="zset-editor">
|
||||||
|
<div class="editor-toolbar">
|
||||||
|
<span class="type-badge">ZSET</span>
|
||||||
|
<span class="item-count">{{ items.length }} members</span>
|
||||||
|
<el-input v-model="filterText" placeholder="Filter..." size="small" clearable style="width: 150px" />
|
||||||
|
<el-button size="small" type="primary" @click="showAddDialog = true">Add</el-button>
|
||||||
|
<el-button size="small" @click="loadMembers" :loading="loading">Refresh</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="zset-content">
|
||||||
|
<div v-for="item in filteredItems" :key="item.member" class="zset-item">
|
||||||
|
<span class="item-score">{{ item.score }}</span>
|
||||||
|
<span class="item-member">{{ item.member }}</span>
|
||||||
|
<el-button size="small" type="danger" text @click="removeMember(item.member)">Del</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="filteredItems.length === 0 && !loading" class="empty">Empty sorted set</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="showAddDialog" title="Add Member" width="400px">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="Member">
|
||||||
|
<el-input v-model="newMember" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Score">
|
||||||
|
<el-input-number v-model="newScore" :precision="2" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showAddDialog = false">Cancel</el-button>
|
||||||
|
<el-button type="primary" @click="addMember">Add</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.zset-editor {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-light);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zset-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zset-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zset-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-score {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'Cascadia Code', monospace;
|
||||||
|
color: var(--accent-light);
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-member {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'Cascadia Code', monospace;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -135,6 +135,21 @@ export const useKeyStore = defineStore('key', () => {
|
|||||||
keyData.value = result.fields
|
keyData.value = result.fields
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'list': {
|
||||||
|
const len = await window.electronAPI.redis.listLength(connId, key)
|
||||||
|
keyData.value = { length: len }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'set': {
|
||||||
|
const size = await window.electronAPI.redis.setSize(connId, key)
|
||||||
|
keyData.value = { size }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'zset': {
|
||||||
|
const size = await window.electronAPI.redis.zsetSize(connId, key)
|
||||||
|
keyData.value = { size }
|
||||||
|
break
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
keyData.value = null
|
keyData.value = null
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user