diff --git a/ROADMAP.md b/ROADMAP.md index 48d293c..df92e1d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,7 +20,7 @@ - [x] **多连接并行** — 同时打开多个连接,各自独立标签页 - [x] **Monaco 编辑器** — 替换 StringEditor 的 textarea,支持 JSON 折叠/展开/语法高亮 - [x] **Redis 命令自动补全** — CLI 输入时智能提示(基于 commands.js 命令数据库) -- [ ] **CLI SUBSCRIBE/MONITOR** — 实时消息流 + MONITOR 命令流 + 停止按钮 +- [x] **CLI SUBSCRIBE/MONITOR** — 实时消息流 + MONITOR 命令流 + 停止按钮 - [ ] **CLI MULTI/EXEC** — 事务模式支持 - [ ] **Stream 消费者组** — XINFO GROUPS / XINFO CONSUMERS 展开表格 diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 3e376b9..f5b2ea0 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -239,4 +239,30 @@ export function registerIpcHandlers(): void { ipcMain.handle('redis:clearCommandLog', () => { redis.clearLog() }) + + // Pub/Sub + ipcMain.handle('redis:subscribe', async (_e, id: string, channels: string[], isPattern: boolean) => { + try { + redis.startSubscribe(id, channels, isPattern, _e.sender) + return { success: true } + } catch (err: any) { + return { success: false, error: err.message } + } + }) + ipcMain.handle('redis:unsubscribe', async (_e, id: string) => { + redis.stopSubscribe(id) + }) + + // Monitor + ipcMain.handle('redis:monitor', async (_e, id: string) => { + try { + redis.startMonitor(id, _e.sender) + return { success: true } + } catch (err: any) { + return { success: false, error: err.message } + } + }) + ipcMain.handle('redis:monitorStop', async (_e, id: string) => { + redis.stopMonitor(id) + }) } diff --git a/electron/main/redis/index.ts b/electron/main/redis/index.ts index 9557b82..75bdd85 100644 --- a/electron/main/redis/index.ts +++ b/electron/main/redis/index.ts @@ -10,3 +10,4 @@ export * from './zset' export * from './stream' export * from './server' export * from './commandLogger' +export * from './pubsub' diff --git a/electron/main/redis/pubsub.ts b/electron/main/redis/pubsub.ts new file mode 100644 index 0000000..400d901 --- /dev/null +++ b/electron/main/redis/pubsub.ts @@ -0,0 +1,79 @@ +// electron/main/redis/pubsub.ts +// 发布订阅 + MONITOR 流式接口 +import Redis from 'ioredis' +import { getClient } from './connection' +import type { WebContents } from 'electron' + +const subClients = new Map() +const monitorClients = new Map() + +export function startSubscribe( + id: string, + channels: string[], + isPattern: boolean, + sender: WebContents +): void { + stopSubscribe(id) + const existing = getClient(id) + if (!existing) throw new Error('No active connection') + + const sub = new Redis(existing.options) + subClients.set(id, sub) + + if (isPattern) { + sub.psubscribe(...channels) + sub.on('pmessage', (_pattern, channel, message) => { + sender.send('redis:subscribeMessage', { channel, message, pattern: _pattern }) + }) + } else { + sub.subscribe(...channels) + sub.on('message', (channel, message) => { + sender.send('redis:subscribeMessage', { channel, message }) + }) + } + + sub.on('error', (err) => { + sender.send('redis:subscribeMessage', { error: err.message }) + }) +} + +export function stopSubscribe(id: string): void { + const sub = subClients.get(id) + if (sub) { + sub.unsubscribe() + sub.punsubscribe() + sub.disconnect() + subClients.delete(id) + } +} + +export function startMonitor(id: string, sender: WebContents): void { + stopMonitor(id) + const existing = getClient(id) + if (!existing) throw new Error('No active connection') + + const monitor = new Redis(existing.options) + monitorClients.set(id, monitor) + + monitor.monitor().then((mon) => { + mon.on('monitor', (time: string, args: string[], source: string, database: number) => { + sender.send('redis:monitorMessage', { time, args, source, database }) + }) + mon.on('error', (err: Error) => { + sender.send('redis:monitorMessage', { error: err.message }) + }) + }) +} + +export function stopMonitor(id: string): void { + const mon = monitorClients.get(id) + if (mon) { + mon.disconnect() + monitorClients.delete(id) + } +} + +export function stopAllPubSub(id: string): void { + stopSubscribe(id) + stopMonitor(id) +} diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 5f694b6..098b9b1 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -104,6 +104,12 @@ export interface ElectronAPI { isConnected: (id: string) => Promise getCommandLog: () => Promise clearCommandLog: () => Promise + subscribe: (id: string, channels: string[], isPattern: boolean) => Promise<{ success: boolean; error?: string }> + unsubscribe: (id: string) => Promise + onSubscribeMessage: (callback: (msg: any) => void) => void + monitor: (id: string) => Promise<{ success: boolean; error?: string }> + monitorStop: (id: string) => Promise + onMonitorMessage: (callback: (msg: any) => void) => void } updater: { check: () => Promise<{ available: boolean; version?: string }> diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 963737b..3205785 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -119,6 +119,22 @@ const electronAPI = { ipcRenderer.invoke('redis:getCommandLog'), clearCommandLog: (): Promise => ipcRenderer.invoke('redis:clearCommandLog'), + // Pub/Sub + subscribe: (id: string, channels: string[], isPattern: boolean): Promise => + ipcRenderer.invoke('redis:subscribe', id, channels, isPattern), + unsubscribe: (id: string): Promise => + ipcRenderer.invoke('redis:unsubscribe', id), + onSubscribeMessage: (callback: (msg: any) => void) => { + ipcRenderer.on('redis:subscribeMessage', (_e, msg) => callback(msg)) + }, + // Monitor + monitor: (id: string): Promise => + ipcRenderer.invoke('redis:monitor', id), + monitorStop: (id: string): Promise => + ipcRenderer.invoke('redis:monitorStop', id), + onMonitorMessage: (callback: (msg: any) => void) => { + ipcRenderer.on('redis:monitorMessage', (_e, msg) => callback(msg)) + }, }, updater: { check: (): Promise => ipcRenderer.invoke('updater:check'), diff --git a/src/components/CliView.vue b/src/components/CliView.vue index e961dff..a6e6281 100644 --- a/src/components/CliView.vue +++ b/src/components/CliView.vue @@ -1,16 +1,20 @@ @@ -308,4 +395,31 @@ function categoryColor(category: string): string { font-size: 11px; color: var(--text-secondary); } + +.stream-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 8px var(--success); + animation: pulse 1.5s infinite; +} + +.stream-indicator.monitor { + background: var(--warning); + box-shadow: 0 0 8px var(--warning); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.stream-text { + flex: 1; + font-size: 12px; + color: var(--text-secondary); + font-family: 'Cascadia Code', 'Fira Code', monospace; + margin-left: 6px; +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 09e8347..93863eb 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -137,6 +137,11 @@ export default { title: 'CLI', welcome: 'Redis CLI - type commands below', placeholder: 'Enter Redis command...', + stopSubscribe: 'Stop Subscribe', + stopMonitor: 'Stop Monitor', + subscribed: 'Subscribed, waiting for messages...', + monitoring: 'Monitoring, waiting for commands...', + subscribedTo: 'Subscribed to: {channels}', }, slowlog: { title: 'Slow Log', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 49952ab..9feb092 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -137,6 +137,11 @@ export default { title: 'CLI', welcome: 'Redis CLI - 在下方输入命令', placeholder: '输入 Redis 命令...', + stopSubscribe: '停止订阅', + stopMonitor: '停止监控', + subscribed: '已订阅,等待消息...', + monitoring: '监控中,等待命令...', + subscribedTo: '已订阅频道: {channels}', }, slowlog: { title: '慢日志',