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:
+1
-1
@@ -14,7 +14,7 @@
|
||||
## P1 — 核心功能缺失
|
||||
|
||||
- [x] **内存分析** — `MEMORY USAGE` 扫描所有 key,虚拟化列表,按大小排序,暂停/恢复,大小过滤
|
||||
- [ ] **命令日志** — 记录每次命令的耗时/连接/时间,最多 5000 条,只写模式过滤
|
||||
- [x] **命令日志** — 记录每次命令的耗时/连接/时间,最多 5000 条,只写模式过滤
|
||||
- [ ] **自动更新** — electron-updater 集成,启动检查,下载进度,发行说明
|
||||
- [ ] **多标签页** — 单连接支持同时打开 Status/Keys/CLI/SlowLog/内存分析/批量删除 多个标签
|
||||
- [ ] **多连接并行** — 同时打开多个连接,各自独立标签页
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -9,3 +9,4 @@ export * from './set'
|
||||
export * from './zset'
|
||||
export * from './stream'
|
||||
export * from './server'
|
||||
export * from './commandLogger'
|
||||
|
||||
Vendored
+2
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -156,6 +156,16 @@ export default {
|
||||
sortDesc: '降序',
|
||||
scanned: '已扫描 {n} 个',
|
||||
},
|
||||
commandLog: {
|
||||
title: '命令日志',
|
||||
time: '时间',
|
||||
command: '命令',
|
||||
duration: '耗时',
|
||||
connection: '连接',
|
||||
onlyWrite: '仅写操作',
|
||||
clear: '清空',
|
||||
empty: '暂无命令记录',
|
||||
},
|
||||
status: {
|
||||
title: '状态',
|
||||
database: '数据库',
|
||||
|
||||
Reference in New Issue
Block a user