feat: CLI SUBSCRIBE/MONITOR streaming mode
- New pubsub.ts: startSubscribe/stopSubscribe/startMonitor/stopMonitor - IPC: redis:subscribe, redis:unsubscribe, redis:monitor, redis:monitorStop - Streaming messages pushed via webContents.send - Preload: onSubscribeMessage/onMonitorMessage callbacks - CliView: detect SUBSCRIBE/PSUBSCRIBE/MONITOR, switch to streaming mode - Stop button with pulsing indicator (green=subscribe, yellow=monitor) - Real-time message display with timestamps - Auto-cleanup on unmount - i18n keys (zh-CN + en)
This commit is contained in:
+141
-27
@@ -1,16 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
// src/renderer/src/components/CliView.vue
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { ref, computed, nextTick, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useConnectionStore } from '@renderer/stores/connection'
|
||||
import { useI18n } from '@renderer/i18n'
|
||||
import { redisCommands } from '@renderer/data/commands'
|
||||
|
||||
const connStore = useConnectionStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const input = ref('')
|
||||
const history = ref<{ cmd: string; result: string; error?: boolean }[]>([])
|
||||
const historyIndex = ref(-1)
|
||||
const outputRef = ref<HTMLDivElement>()
|
||||
const suggestionIndex = ref(-1)
|
||||
const inputRef = ref<HTMLInputElement>()
|
||||
const streamingMode = ref<'none' | 'subscribe' | 'monitor'>('none')
|
||||
|
||||
const showSuggestions = computed(() => {
|
||||
return suggestions.value.length > 0 && !input.value.includes(' ')
|
||||
@@ -44,6 +48,36 @@ async function execute() {
|
||||
const args = parts.slice(1)
|
||||
|
||||
try {
|
||||
// Handle SUBSCRIBE / PSUBSCRIBE
|
||||
if (command === 'SUBSCRIBE' || command === 'PSUBSCRIBE') {
|
||||
const channels = args.filter(a => !a.startsWith('"'))
|
||||
if (channels.length === 0) {
|
||||
history.value.push({ cmd, result: 'ERROR: No channel specified', error: true })
|
||||
return
|
||||
}
|
||||
const isPattern = command === 'PSUBSCRIBE'
|
||||
const result = await window.electronAPI.redis.subscribe(connStore.activeId, channels, isPattern)
|
||||
if (result.success) {
|
||||
streamingMode.value = 'subscribe'
|
||||
history.value.push({ cmd, result: t('cli.subscribedTo', { channels: channels.join(', ') }) })
|
||||
} else {
|
||||
history.value.push({ cmd, result: `ERROR: ${result.error}`, error: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle MONITOR
|
||||
if (command === 'MONITOR') {
|
||||
const result = await window.electronAPI.redis.monitor(connStore.activeId)
|
||||
if (result.success) {
|
||||
streamingMode.value = 'monitor'
|
||||
history.value.push({ cmd, result: t('cli.monitoring') })
|
||||
} else {
|
||||
history.value.push({ cmd, result: `ERROR: ${result.error}`, error: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.redis.execute(connStore.activeId, command, args)
|
||||
let formatted = ''
|
||||
|
||||
@@ -124,8 +158,6 @@ function onKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
const inputRef = ref<HTMLInputElement>()
|
||||
|
||||
function categoryColor(category: string): string {
|
||||
switch (category) {
|
||||
case 'admin': return 'var(--color-red)'
|
||||
@@ -136,6 +168,52 @@ function categoryColor(category: string): string {
|
||||
default: return 'var(--text-primary)'
|
||||
}
|
||||
}
|
||||
|
||||
async function stopStreaming() {
|
||||
if (!connStore.activeId) return
|
||||
if (streamingMode.value === 'subscribe') {
|
||||
await window.electronAPI.redis.unsubscribe(connStore.activeId)
|
||||
history.value.push({ cmd: 'UNSUBSCRIBE', result: 'OK' })
|
||||
} else if (streamingMode.value === 'monitor') {
|
||||
await window.electronAPI.redis.monitorStop(connStore.activeId)
|
||||
history.value.push({ cmd: 'MONITOR STOP', result: 'OK' })
|
||||
}
|
||||
streamingMode.value = 'none'
|
||||
await nextTick()
|
||||
if (outputRef.value) outputRef.value.scrollTop = outputRef.value.scrollHeight
|
||||
}
|
||||
|
||||
// IPC listeners for streaming messages
|
||||
function onSubscribeMessage(msg: any) {
|
||||
if (msg.error) {
|
||||
history.value.push({ cmd: '', result: `SUBSCRIBE ERROR: ${msg.error}`, error: true })
|
||||
} else {
|
||||
const ts = new Date().toLocaleTimeString()
|
||||
history.value.push({ cmd: '', result: `[${ts}] ${msg.channel}: ${msg.message}` })
|
||||
}
|
||||
nextTick(() => { if (outputRef.value) outputRef.value.scrollTop = outputRef.value.scrollHeight })
|
||||
}
|
||||
|
||||
function onMonitorMessage(msg: any) {
|
||||
if (msg.error) {
|
||||
history.value.push({ cmd: '', result: `MONITOR ERROR: ${msg.error}`, error: true })
|
||||
} else {
|
||||
const ts = msg.time || new Date().toLocaleTimeString()
|
||||
history.value.push({ cmd: '', result: `[${ts}] ${msg.args.join(' ')}` })
|
||||
}
|
||||
nextTick(() => { if (outputRef.value) outputRef.value.scrollTop = outputRef.value.scrollHeight })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.electronAPI.redis.onSubscribeMessage(onSubscribeMessage)
|
||||
window.electronAPI.redis.onMonitorMessage(onMonitorMessage)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (connStore.activeId && streamingMode.value !== 'none') {
|
||||
stopStreaming()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -149,35 +227,44 @@ function categoryColor(category: string): string {
|
||||
<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
|
||||
{{ t('cli.welcome') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cli-input-bar">
|
||||
<span class="prompt">></span>
|
||||
<div class="cli-input-wrapper">
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="input"
|
||||
class="cli-input"
|
||||
placeholder="Enter Redis command..."
|
||||
@keydown="onKeydown"
|
||||
autofocus
|
||||
/>
|
||||
<div v-if="showSuggestions" class="cli-suggestions">
|
||||
<div
|
||||
v-for="(cmd, index) in suggestions"
|
||||
:key="cmd.name"
|
||||
class="cli-suggestion-item"
|
||||
:class="{ active: index === suggestionIndex }"
|
||||
@click="selectSuggestion(cmd.name)"
|
||||
@mouseenter="suggestionIndex = index"
|
||||
>
|
||||
<span class="suggestion-name" :style="{ color: categoryColor(cmd.category) }">{{ cmd.name }}</span>
|
||||
<span class="suggestion-syntax">{{ cmd.syntax }}</span>
|
||||
<span class="suggestion-desc">{{ cmd.description }}</span>
|
||||
<template v-if="streamingMode === 'none'">
|
||||
<span class="prompt">></span>
|
||||
<div class="cli-input-wrapper">
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="input"
|
||||
class="cli-input"
|
||||
:placeholder="t('cli.placeholder')"
|
||||
@keydown="onKeydown"
|
||||
autofocus
|
||||
/>
|
||||
<div v-if="showSuggestions" class="cli-suggestions">
|
||||
<div
|
||||
v-for="(cmd, index) in suggestions"
|
||||
:key="cmd.name"
|
||||
class="cli-suggestion-item"
|
||||
:class="{ active: index === suggestionIndex }"
|
||||
@click="selectSuggestion(cmd.name)"
|
||||
@mouseenter="suggestionIndex = index"
|
||||
>
|
||||
<span class="suggestion-name" :style="{ color: categoryColor(cmd.category) }">{{ cmd.name }}</span>
|
||||
<span class="suggestion-syntax">{{ cmd.syntax }}</span>
|
||||
<span class="suggestion-desc">{{ cmd.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="stream-indicator" :class="streamingMode" />
|
||||
<span class="stream-text">{{ streamingMode === 'subscribe' ? t('cli.subscribed') : t('cli.monitoring') }}</span>
|
||||
<el-button size="small" type="danger" @click="stopStreaming">
|
||||
{{ streamingMode === 'subscribe' ? t('cli.stopSubscribe') : t('cli.stopMonitor') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -137,6 +137,11 @@ export default {
|
||||
title: 'CLI',
|
||||
welcome: 'Redis CLI - 在下方输入命令',
|
||||
placeholder: '输入 Redis 命令...',
|
||||
stopSubscribe: '停止订阅',
|
||||
stopMonitor: '停止监控',
|
||||
subscribed: '已订阅,等待消息...',
|
||||
monitoring: '监控中,等待命令...',
|
||||
subscribedTo: '已订阅频道: {channels}',
|
||||
},
|
||||
slowlog: {
|
||||
title: '慢日志',
|
||||
|
||||
Reference in New Issue
Block a user