feat: Command Log dialog

- New commandLogger.ts: in-memory log (max 5000 entries)
- withLog() wrapper in ipc-handlers.ts for all write commands
- CommandLog.vue: table with time/command/duration, write-only filter
- IPC: redis:getCommandLog, redis:clearCommandLog
- Ctrl+G shortcut from Sidebar
- i18n keys (zh-CN + en)
This commit is contained in:
2026-07-05 21:48:33 +08:00
parent 15869943e8
commit d131a2732d
10 changed files with 270 additions and 38 deletions
+1 -1
View File
@@ -14,7 +14,7 @@
## P1 — 核心功能缺失
- [x] **内存分析**`MEMORY USAGE` 扫描所有 key,虚拟化列表,按大小排序,暂停/恢复,大小过滤
- [ ] **命令日志** — 记录每次命令的耗时/连接/时间,最多 5000 条,只写模式过滤
- [x] **命令日志** — 记录每次命令的耗时/连接/时间,最多 5000 条,只写模式过滤
- [ ] **自动更新** — electron-updater 集成,启动检查,下载进度,发行说明
- [ ] **多标签页** — 单连接支持同时打开 Status/Keys/CLI/SlowLog/内存分析/批量删除 多个标签
- [ ] **多连接并行** — 同时打开多个连接,各自独立标签页
+60 -37
View File
@@ -5,6 +5,21 @@ import store, { ConnectionConfig } from './store'
import { encrypt, decrypt } from './credential'
import * as redis from './redis'
// Helper: wrap handler with command logging
function withLog(cmd: string, fn: (...args: any[]) => any) {
return async (...args: any[]) => {
const start = Date.now()
try {
const result = await fn(...args)
redis.log('current', cmd, Date.now() - start)
return result
} catch (err) {
redis.log('current', cmd, Date.now() - start)
throw err
}
}
}
export function registerIpcHandlers(): void {
// Window
ipcMain.on('window:minimize', () => BrowserWindow.getFocusedWindow()?.minimize())
@@ -88,15 +103,15 @@ export function registerIpcHandlers(): void {
})
// Redis - 服务器
ipcMain.handle('redis:execute', async (_e, id: string, command: string, args: string[]) => {
ipcMain.handle('redis:execute', withLog('EXEC', async (_e, id: string, command: string, args: string[]) => {
return redis.execute(id, command, args)
})
}))
ipcMain.handle('redis:getInfo', async (_e, id: string) => {
return redis.getServerInfo(id)
})
ipcMain.handle('redis:selectDb', async (_e, id: string, db: number) => {
ipcMain.handle('redis:selectDb', withLog('SELECT', async (_e, id: string, db: number) => {
await redis.selectDb(id, db)
})
}))
ipcMain.handle('redis:dbSize', async (_e, id: string) => {
return redis.dbSize(id)
})
@@ -117,21 +132,21 @@ export function registerIpcHandlers(): void {
ipcMain.handle('redis:getKeyTTL', async (_e, id: string, key: string) => {
return redis.getKeyTTL(id, key)
})
ipcMain.handle('redis:setTTL', async (_e, id: string, key: string, ttl: number) => {
ipcMain.handle('redis:setTTL', withLog('EXPIRE', async (_e, id: string, key: string, ttl: number) => {
await redis.setKeyTTL(id, key, ttl)
})
ipcMain.handle('redis:deleteKey', async (_e, id: string, key: string) => {
}))
ipcMain.handle('redis:deleteKey', withLog('DEL', async (_e, id: string, key: string) => {
await redis.deleteKey(id, key)
})
ipcMain.handle('redis:renameKey', async (_e, id: string, oldKey: string, newKey: string) => {
}))
ipcMain.handle('redis:renameKey', withLog('RENAME', async (_e, id: string, oldKey: string, newKey: string) => {
await redis.renameKey(id, oldKey, newKey)
})
}))
ipcMain.handle('redis:existsKey', async (_e, id: string, key: string) => {
return redis.existsKey(id, key)
})
ipcMain.handle('redis:batchDelete', async (_e, id: string, keys: string[]) => {
ipcMain.handle('redis:batchDelete', withLog('DEL batch', async (_e, id: string, keys: string[]) => {
return redis.batchDelete(id, keys)
})
}))
ipcMain.handle('redis:memoryUsage', async (_e, id: string, key: string) => {
return redis.memoryUsage(id, key)
})
@@ -140,20 +155,20 @@ export function registerIpcHandlers(): void {
ipcMain.handle('redis:getString', async (_e, id: string, key: string) => {
return redis.getStringValue(id, key)
})
ipcMain.handle('redis:setString', async (_e, id: string, key: string, value: string) => {
ipcMain.handle('redis:setString', withLog('SET', async (_e, id: string, key: string, value: string) => {
await redis.setStringValue(id, key, value)
})
}))
// Redis - Hash
ipcMain.handle('redis:hashScan', async (_e, id: string, key: string, cursor: string, count: number) => {
return redis.getHashFields(id, key, cursor, count)
})
ipcMain.handle('redis:hashSet', async (_e, id: string, key: string, field: string, value: string) => {
ipcMain.handle('redis:hashSet', withLog('HSET', async (_e, id: string, key: string, field: string, value: string) => {
await redis.setHashField(id, key, field, value)
})
ipcMain.handle('redis:hashDel', async (_e, id: string, key: string, field: string) => {
}))
ipcMain.handle('redis:hashDel', withLog('HDEL', async (_e, id: string, key: string, field: string) => {
await redis.deleteHashField(id, key, field)
})
}))
// Redis - List
ipcMain.handle('redis:listRange', async (_e, id: string, key: string, start: number, stop: number) => {
@@ -162,15 +177,15 @@ export function registerIpcHandlers(): void {
ipcMain.handle('redis:listLength', async (_e, id: string, key: string) => {
return redis.listLength(id, key)
})
ipcMain.handle('redis:listPush', async (_e, id: string, key: string, values: string[]) => {
ipcMain.handle('redis:listPush', withLog('RPUSH', async (_e, id: string, key: string, values: string[]) => {
return redis.listPush(id, key, ...values)
})
ipcMain.handle('redis:listSet', async (_e, id: string, key: string, index: number, value: string) => {
}))
ipcMain.handle('redis:listSet', withLog('LSET', async (_e, id: string, key: string, index: number, value: string) => {
await redis.listSet(id, key, index, value)
})
ipcMain.handle('redis:listRemove', async (_e, id: string, key: string, count: number, value: string) => {
}))
ipcMain.handle('redis:listRemove', withLog('LREM', async (_e, id: string, key: string, count: number, value: string) => {
return redis.listRemove(id, key, count, value)
})
}))
// Redis - Set
ipcMain.handle('redis:setMembers', async (_e, id: string, key: string) => {
@@ -179,12 +194,12 @@ export function registerIpcHandlers(): void {
ipcMain.handle('redis:setSize', async (_e, id: string, key: string) => {
return redis.setSize(id, key)
})
ipcMain.handle('redis:setAdd', async (_e, id: string, key: string, members: string[]) => {
ipcMain.handle('redis:setAdd', withLog('SADD', async (_e, id: string, key: string, members: string[]) => {
return redis.setAdd(id, key, ...members)
})
ipcMain.handle('redis:setRemove', async (_e, id: string, key: string, members: string[]) => {
}))
ipcMain.handle('redis:setRemove', withLog('SREM', async (_e, id: string, key: string, members: string[]) => {
return redis.setRemove(id, key, ...members)
})
}))
// Redis - Zset
ipcMain.handle('redis:zsetRange', async (_e, id: string, key: string, start: number, stop: number, withScores: boolean) => {
@@ -193,12 +208,12 @@ export function registerIpcHandlers(): void {
ipcMain.handle('redis:zsetSize', async (_e, id: string, key: string) => {
return redis.zsetSize(id, key)
})
ipcMain.handle('redis:zsetAdd', async (_e, id: string, key: string, score: number, member: string) => {
ipcMain.handle('redis:zsetAdd', withLog('ZADD', async (_e, id: string, key: string, score: number, member: string) => {
return redis.zsetAdd(id, key, score, member)
})
ipcMain.handle('redis:zsetRemove', async (_e, id: string, key: string, members: string[]) => {
}))
ipcMain.handle('redis:zsetRemove', withLog('ZREM', async (_e, id: string, key: string, members: string[]) => {
return redis.zsetRemove(id, key, ...members)
})
}))
// Redis - Stream
ipcMain.handle('redis:streamInfo', async (_e, id: string, key: string) => {
@@ -207,13 +222,21 @@ export function registerIpcHandlers(): void {
ipcMain.handle('redis:streamRange', async (_e, id: string, key: string, start: string, end: string, count: number) => {
return redis.streamRange(id, key, start, end, count)
})
ipcMain.handle('redis:streamAdd', async (_e, id: string, key: string, streamId: string, fieldValues: string[]) => {
ipcMain.handle('redis:streamAdd', withLog('XADD', async (_e, id: string, key: string, streamId: string, fieldValues: string[]) => {
return redis.streamAdd(id, key, streamId, fieldValues)
})
ipcMain.handle('redis:streamTrim', async (_e, id: string, key: string, maxLen: number) => {
}))
ipcMain.handle('redis:streamTrim', withLog('XTRIM', async (_e, id: string, key: string, maxLen: number) => {
return redis.streamTrim(id, key, maxLen)
})
ipcMain.handle('redis:streamDel', async (_e, id: string, key: string, ids: string[]) => {
}))
ipcMain.handle('redis:streamDel', withLog('XDEL', async (_e, id: string, key: string, ids: string[]) => {
return redis.streamDel(id, key, ...ids)
}))
// Command Log
ipcMain.handle('redis:getCommandLog', () => {
return redis.getLog()
})
ipcMain.handle('redis:clearCommandLog', () => {
redis.clearLog()
})
}
+49
View File
@@ -0,0 +1,49 @@
// electron/main/redis/commandLogger.ts
// 命令日志记录器
export interface CommandEntry {
id: number
time: string
connection: string
command: string
duration: number
isWrite: boolean
}
const MAX_ENTRIES = 5000
let nextId = 1
const entries: CommandEntry[] = []
const WRITE_COMMANDS = new Set([
'SET', 'DEL', 'HSET', 'HDEL', 'RPUSH', 'LPUSH', 'LSET', 'LREM',
'SADD', 'SREM', 'ZADD', 'ZREM', 'XADD', 'XDEL', 'XTRIM',
'RENAME', 'EXPIRE', 'PERSIST', 'FLUSHDB', 'SELECT',
'SETEX', 'INCR', 'DECR', 'INCRBY', 'DECRBY', 'APPEND',
'HMSET', 'HINCRBY', 'HINCRBYFLOAT',
'LPOP', 'RPOP', 'BLPOP', 'BRPOP',
'ZINCRBY', 'ZREMRANGEBYRANK', 'ZREMRANGEBYSCORE',
'UNLINK', 'MOVE', 'RENAMENX',
])
export function log(connection: string, command: string, duration: number): void {
const isWrite = WRITE_COMMANDS.has(command.split(' ')[0].toUpperCase())
entries.push({
id: nextId++,
time: new Date().toISOString(),
connection,
command,
duration,
isWrite,
})
if (entries.length > MAX_ENTRIES) {
entries.splice(0, entries.length - MAX_ENTRIES)
}
}
export function getLog(): CommandEntry[] {
return entries
}
export function clearLog(): void {
entries.length = 0
}
+1
View File
@@ -9,3 +9,4 @@ export * from './set'
export * from './zset'
export * from './stream'
export * from './server'
export * from './commandLogger'
+2
View File
@@ -102,6 +102,8 @@ export interface ElectronAPI {
existsKey: (id: string, key: string) => Promise<boolean>
ping: (id: string) => Promise<string>
isConnected: (id: string) => Promise<boolean>
getCommandLog: () => Promise<any[]>
clearCommandLog: () => Promise<void>
}
}
+5
View File
@@ -114,6 +114,11 @@ const electronAPI = {
ipcRenderer.invoke('redis:ping', id),
isConnected: (id: string): Promise<boolean> =>
ipcRenderer.invoke('redis:isConnected', id),
// Command log
getCommandLog: (): Promise<any[]> =>
ipcRenderer.invoke('redis:getCommandLog'),
clearCommandLog: (): Promise<void> =>
ipcRenderer.invoke('redis:clearCommandLog'),
},
}
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
// src/components/CommandLog.vue
import { ref, onMounted } from 'vue'
import { useI18n } from '@renderer/i18n'
const { t } = useI18n()
interface CommandEntry {
id: number
time: string
connection: string
command: string
duration: number
isWrite: boolean
}
const entries = ref<CommandEntry[]>([])
const onlyWrite = ref(false)
const visible = defineModel<boolean>('visible', { default: false })
const filteredEntries = () => {
if (onlyWrite.value) {
return entries.value.filter(e => e.isWrite)
}
return entries.value
}
function formatTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString()
}
function formatDuration(ms: number): string {
if (ms < 1) return '<1ms'
if (ms < 1000) return ms + 'ms'
return (ms / 1000).toFixed(2) + 's'
}
async function refresh() {
try {
entries.value = await window.electronAPI.redis.getCommandLog()
} catch {
// ignore
}
}
async function clear() {
try {
await window.electronAPI.redis.clearCommandLog()
entries.value = []
} catch {
// ignore
}
}
defineExpose({ refresh })
</script>
<template>
<el-dialog
:model-value="visible"
:title="t('commandLog.title')"
width="700px"
top="5vh"
@update:model-value="visible = $event"
@opened="refresh"
>
<div class="cmdlog-toolbar">
<el-checkbox v-model="onlyWrite" size="small">{{ t('commandLog.onlyWrite') }}</el-checkbox>
<div class="cmdlog-actions">
<el-button size="small" @click="refresh">{{ t('common.refresh') }}</el-button>
<el-button size="small" type="danger" @click="clear">{{ t('commandLog.clear') }}</el-button>
</div>
</div>
<div class="cmdlog-table-wrap">
<el-table
:data="filteredEntries()"
size="small"
stripe
height="420"
highlight-current-row
>
<el-table-column prop="id" label="#" width="50" />
<el-table-column :label="t('commandLog.time')" width="90">
<template #default="{ row }">{{ formatTime(row.time) }}</template>
</el-table-column>
<el-table-column prop="command" :label="t('commandLog.command')" min-width="200" show-overflow-tooltip />
<el-table-column :label="t('commandLog.duration')" width="80" align="right">
<template #default="{ row }">
<span :style="{ color: row.duration > 100 ? 'var(--warning)' : 'var(--success)' }">
{{ formatDuration(row.duration) }}
</span>
</template>
</el-table-column>
</el-table>
<div v-if="entries.length === 0" class="cmdlog-empty">
{{ t('commandLog.empty') }}
</div>
</div>
</el-dialog>
</template>
<style scoped>
.cmdlog-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.cmdlog-actions {
display: flex;
gap: 8px;
}
.cmdlog-table-wrap {
min-height: 200px;
}
.cmdlog-empty {
display: flex;
align-items: center;
justify-content: center;
height: 150px;
color: var(--text-muted);
font-size: 13px;
}
</style>
+4
View File
@@ -3,6 +3,7 @@
import { ref } from 'vue'
import ConnectionList from './ConnectionList.vue'
import NewConnectionDialog from './NewConnectionDialog.vue'
import CommandLog from './CommandLog.vue'
import { useConnectionStore } from '@renderer/stores/connection'
import { useKeyStore } from '@renderer/stores/key'
import { useI18n } from '@renderer/i18n'
@@ -16,6 +17,7 @@ const { t } = useI18n()
const dialogVisible = ref(false)
const editingConnection = ref<ConnectionConfig | null>(null)
const collapsed = ref(false)
const commandLogVisible = ref(false)
function handleEdit(conn: ConnectionConfig | null) {
editingConnection.value = conn
@@ -53,6 +55,7 @@ function refreshAll() {
useKeyboard({
'ctrl+n': handleNew,
'ctrl+r': refreshAll,
'ctrl+g': () => { commandLogVisible.value = true },
'f5': refreshAll,
'delete': deleteSelectedKey,
'escape': deselectKey,
@@ -73,6 +76,7 @@ useKeyboard({
v-model:visible="dialogVisible"
:connection="editingConnection"
/>
<CommandLog v-model:visible="commandLogVisible" />
</aside>
</template>
+10
View File
@@ -156,6 +156,16 @@ export default {
sortDesc: 'Descending',
scanned: '{n} keys scanned',
},
commandLog: {
title: 'Command Log',
time: 'Time',
command: 'Command',
duration: 'Duration',
connection: 'Connection',
onlyWrite: 'Write Only',
clear: 'Clear',
empty: 'No commands recorded',
},
status: {
title: 'Status',
database: 'Database',
+10
View File
@@ -156,6 +156,16 @@ export default {
sortDesc: '降序',
scanned: '已扫描 {n} 个',
},
commandLog: {
title: '命令日志',
time: '时间',
command: '命令',
duration: '耗时',
connection: '连接',
onlyWrite: '仅写操作',
clear: '清空',
empty: '暂无命令记录',
},
status: {
title: '状态',
database: '数据库',