diff --git a/ROADMAP.md b/ROADMAP.md index 618c961..4803c62 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ ## P1 — 核心功能缺失 - [x] **内存分析** — `MEMORY USAGE` 扫描所有 key,虚拟化列表,按大小排序,暂停/恢复,大小过滤 -- [ ] **命令日志** — 记录每次命令的耗时/连接/时间,最多 5000 条,只写模式过滤 +- [x] **命令日志** — 记录每次命令的耗时/连接/时间,最多 5000 条,只写模式过滤 - [ ] **自动更新** — electron-updater 集成,启动检查,下载进度,发行说明 - [ ] **多标签页** — 单连接支持同时打开 Status/Keys/CLI/SlowLog/内存分析/批量删除 多个标签 - [ ] **多连接并行** — 同时打开多个连接,各自独立标签页 diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 84cc8e7..3e376b9 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -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() }) } diff --git a/electron/main/redis/commandLogger.ts b/electron/main/redis/commandLogger.ts new file mode 100644 index 0000000..fe05d2c --- /dev/null +++ b/electron/main/redis/commandLogger.ts @@ -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 +} diff --git a/electron/main/redis/index.ts b/electron/main/redis/index.ts index bba73d7..9557b82 100644 --- a/electron/main/redis/index.ts +++ b/electron/main/redis/index.ts @@ -9,3 +9,4 @@ export * from './set' export * from './zset' export * from './stream' export * from './server' +export * from './commandLogger' diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 704f820..8c5f7e1 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -102,6 +102,8 @@ export interface ElectronAPI { existsKey: (id: string, key: string) => Promise ping: (id: string) => Promise isConnected: (id: string) => Promise + getCommandLog: () => Promise + clearCommandLog: () => Promise } } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 271baa1..16fee0d 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -114,6 +114,11 @@ const electronAPI = { ipcRenderer.invoke('redis:ping', id), isConnected: (id: string): Promise => ipcRenderer.invoke('redis:isConnected', id), + // Command log + getCommandLog: (): Promise => + ipcRenderer.invoke('redis:getCommandLog'), + clearCommandLog: (): Promise => + ipcRenderer.invoke('redis:clearCommandLog'), }, } diff --git a/src/components/CommandLog.vue b/src/components/CommandLog.vue new file mode 100644 index 0000000..8371635 --- /dev/null +++ b/src/components/CommandLog.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/src/components/Sidebar.vue b/src/components/Sidebar.vue index 7d4e366..cc55fb8 100644 --- a/src/components/Sidebar.vue +++ b/src/components/Sidebar.vue @@ -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(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" /> + diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 66a8bf5..64e5620 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 65f6d3d..5e42056 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -156,6 +156,16 @@ export default { sortDesc: '降序', scanned: '已扫描 {n} 个', }, + commandLog: { + title: '命令日志', + time: '时间', + command: '命令', + duration: '耗时', + connection: '连接', + onlyWrite: '仅写操作', + clear: '清空', + empty: '暂无命令记录', + }, status: { title: '状态', database: '数据库',