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:
2026-07-04 18:07:33 +08:00
parent 272eb6b3af
commit ff09f5dc03
15 changed files with 1197 additions and 2 deletions
+58
View File
@@ -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)
})
} }
+102
View File
@@ -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>
}
+16
View File
@@ -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>
} }
} }
+37
View File
@@ -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),
}, },
} }
+177
View File
@@ -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">&gt;</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">&gt;</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>
+239
View File
@@ -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>
+21 -1
View File
@@ -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>
+162
View File
@@ -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>
+103
View File
@@ -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>
+12
View File
@@ -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;
+186
View File
@@ -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>
+15
View File
@@ -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