feat(redis): add Pub/Sub publish and subscribe with WebSocket real-time push
This commit is contained in:
1 parent
abc7136ed2
commit
ef426a8218
26 files changed
+904
-11
No files matched your search
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, onMounted, onUnmounted, onActivated, onDeactivated, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Search, RefreshCw, Loader2, ChevronRight, ChevronDown, FolderClosed, FolderOpen, Trash2, Plus, KeyRound, TerminalSquare, Asterisk, History } from "@lucide/vue";
|
||||
import { Search, RefreshCw, Loader2, ChevronRight, ChevronDown, FolderClosed, FolderOpen, Trash2, Plus, KeyRound, TerminalSquare, Asterisk, History, Radio } from "@lucide/vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
@@ -15,6 +15,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import RedisValueViewer from "./RedisValueViewer.vue";
|
||||
import RedisPubSubPanel from "./RedisPubSubPanel.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import type { RedisKeyInfo, RedisScanResult, HistoryEntry } from "@/lib/api";
|
||||
import { uuid } from "@/lib/utils";
|
||||
@@ -44,7 +45,7 @@ interface CreateKeyEntry {
|
||||
field?: string;
|
||||
score?: string;
|
||||
}
|
||||
type RedisSidePanel = "detail" | "command";
|
||||
type RedisSidePanel = "detail" | "command" | "pubsub";
|
||||
type RedisCommandHistoryEntry = {
|
||||
id: number;
|
||||
prompt: string;
|
||||
@@ -942,6 +943,10 @@ defineExpose({ focusSearch });
|
||||
<TerminalSquare class="size-3.5" />
|
||||
{{ t("redis.commandLine") }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pubsub" class="h-6 flex-none gap-1.5 rounded-md px-2 text-xs">
|
||||
<Radio class="size-3.5" />
|
||||
{{ t("redis.pubsub") }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button v-if="activeSidePanel === 'command'" variant="ghost" size="icon" class="h-6 w-6" :title="t('redis.clearHistory')" @click="clearPersistedRedisHistory">
|
||||
<History class="size-3.5" />
|
||||
@@ -987,6 +992,10 @@ defineExpose({ focusSearch });
|
||||
</form>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pubsub" class="m-0 min-h-0 flex-1 flex flex-col">
|
||||
<RedisPubSubPanel :connection-id="connectionId" :db="db" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Pane>
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onBeforeUnmount } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import * as api from "@/lib/api";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
db: number;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
||||
// State
|
||||
const channels = ref<string[]>([]);
|
||||
const patterns = ref<string[]>([]);
|
||||
const newChannel = ref("");
|
||||
const newPattern = ref("");
|
||||
|
||||
const publishChannel = ref("");
|
||||
const publishMessage = ref("");
|
||||
|
||||
interface PubSubEntry {
|
||||
id: number;
|
||||
channel: string;
|
||||
pattern: string | null;
|
||||
payload: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
const messages = ref<PubSubEntry[]>([]);
|
||||
let nextId = 0;
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
const connected = ref(false);
|
||||
const connecting = ref(false);
|
||||
|
||||
const messagesContainer = ref<HTMLElement | null>(null);
|
||||
|
||||
function scrollToBottom() {
|
||||
void nextTick(() => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addMessage(channel: string, pattern: string | null, payload: string) {
|
||||
messages.value.push({
|
||||
id: ++nextId,
|
||||
channel,
|
||||
pattern,
|
||||
payload,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
// Keep max 500 messages
|
||||
if (messages.value.length > 500) {
|
||||
messages.value.shift();
|
||||
}
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
// Connect WebSocket
|
||||
async function connect() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) return;
|
||||
connecting.value = true;
|
||||
try {
|
||||
ws = api.redisPubSubConnect(props.connectionId);
|
||||
|
||||
ws.onopen = () => {
|
||||
connected.value = true;
|
||||
connecting.value = false;
|
||||
// Re-subscribe existing channels
|
||||
if (channels.value.length > 0) {
|
||||
ws!.send(JSON.stringify({ type: "subscribe", channels: channels.value }));
|
||||
}
|
||||
if (patterns.value.length > 0) {
|
||||
ws!.send(JSON.stringify({ type: "psubscribe", patterns: patterns.value }));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data as string);
|
||||
if (data.error) {
|
||||
toast(data.error, 5000);
|
||||
return;
|
||||
}
|
||||
addMessage(data.channel, data.pattern ?? null, data.payload);
|
||||
} catch {
|
||||
// Non-JSON message, skip
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
connected.value = false;
|
||||
connecting.value = false;
|
||||
ws = null;
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
connected.value = false;
|
||||
connecting.value = false;
|
||||
ws = null;
|
||||
};
|
||||
} catch (e) {
|
||||
connecting.value = false;
|
||||
toast(t("redis.pubsubWsConnectFailed", { error: e instanceof Error ? e.message : String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
connected.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe() {
|
||||
const ch = newChannel.value.trim();
|
||||
if (!ch) return;
|
||||
if (channels.value.includes(ch)) return;
|
||||
channels.value.push(ch);
|
||||
newChannel.value = "";
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "subscribe", channels: [ch] }));
|
||||
}
|
||||
}
|
||||
|
||||
function psubscribe() {
|
||||
const pat = newPattern.value.trim();
|
||||
if (!pat) return;
|
||||
if (patterns.value.includes(pat)) return;
|
||||
patterns.value.push(pat);
|
||||
newPattern.value = "";
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "psubscribe", patterns: [pat] }));
|
||||
}
|
||||
}
|
||||
|
||||
function unsubscribe(channel: string) {
|
||||
channels.value = channels.value.filter((c) => c !== channel);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "unsubscribe", channels: [channel] }));
|
||||
}
|
||||
}
|
||||
|
||||
function punsubscribe(pattern: string) {
|
||||
patterns.value = patterns.value.filter((p) => p !== pattern);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "punsubscribe", patterns: [pattern] }));
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
const ch = publishChannel.value.trim();
|
||||
const msg = publishMessage.value;
|
||||
if (!ch || !msg) return;
|
||||
try {
|
||||
await api.redisPubSubPublish(props.connectionId, props.db, ch, msg);
|
||||
// Echo locally only if subscribed — Redis PubSub doesn't deliver to publisher
|
||||
const isSubscribed = channels.value.includes(ch) || patterns.value.some((p) => new RegExp("^" + p.replace(/\*/g, ".*") + "$").test(ch));
|
||||
if (isSubscribed) {
|
||||
addMessage(ch, null, msg);
|
||||
}
|
||||
publishMessage.value = "";
|
||||
} catch (e) {
|
||||
toast(t("redis.pubsubPublishFailed", { error: e instanceof Error ? e.message : String(e) }), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
messages.value = [];
|
||||
}
|
||||
|
||||
// Auto-connect with a short delay for server readiness
|
||||
setTimeout(connect, 500);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Connection bar -->
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b bg-muted/30">
|
||||
<span class="text-xs font-medium">{{ t("redis.pubsub") }}</span>
|
||||
<span class="flex-1"></span>
|
||||
<span class="inline-flex items-center gap-1 text-xs" :class="connected ? 'text-green-600' : 'text-muted-foreground'">
|
||||
<span class="inline-block w-2 h-2 rounded-full" :class="connected ? 'bg-green-500' : 'bg-gray-400'"></span>
|
||||
{{ connected ? t("redis.pubsubConnected") : connecting ? t("redis.pubsubConnecting") : t("redis.pubsubDisconnected") }}
|
||||
</span>
|
||||
<button v-if="!connected" class="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground hover:bg-primary/90" :disabled="connecting" @click="connect">
|
||||
{{ t("redis.pubsubConnect") }}
|
||||
</button>
|
||||
<button v-else class="text-xs px-2 py-0.5 rounded border hover:bg-muted" @click="disconnect">
|
||||
{{ t("redis.pubsubDisconnect") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Subscriptions -->
|
||||
<div class="px-3 py-2 border-b space-y-2">
|
||||
<!-- Channel subscriptions -->
|
||||
<div>
|
||||
<div class="text-xs font-medium mb-1">{{ t("redis.pubsubChannels") }}</div>
|
||||
<div class="flex gap-1 mb-1">
|
||||
<input v-model="newChannel" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-7 px-2 text-xs border rounded bg-background" :placeholder="t('redis.pubsubChannelPlaceholder')" @keydown.enter="subscribe" />
|
||||
<button class="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground hover:bg-primary/90" @click="subscribe">
|
||||
{{ t("redis.pubsubSubscribe") }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="channels.length > 0" class="flex flex-wrap gap-1">
|
||||
<span v-for="ch in channels" :key="ch" class="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{{ ch }}
|
||||
<button class="hover:text-red-500" @click="unsubscribe(ch)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pattern subscriptions -->
|
||||
<div>
|
||||
<div class="text-xs font-medium mb-1">{{ t("redis.pubsubPatterns") }}</div>
|
||||
<div class="flex gap-1 mb-1">
|
||||
<input v-model="newPattern" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-7 px-2 text-xs border rounded bg-background" :placeholder="t('redis.pubsubPatternPlaceholder')" @keydown.enter="psubscribe" />
|
||||
<button class="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground hover:bg-primary/90" @click="psubscribe">
|
||||
{{ t("redis.pubsubPsubscribe") }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="patterns.length > 0" class="flex flex-wrap gap-1">
|
||||
<span v-for="pat in patterns" :key="pat" class="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
{{ pat }}
|
||||
<button class="hover:text-red-500" @click="punsubscribe(pat)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<div class="flex items-center gap-2 px-3 py-1 border-b">
|
||||
<span class="text-xs font-medium">{{ t("redis.pubsubMessages") }}</span>
|
||||
<span class="text-xs text-muted-foreground">({{ messages.length }})</span>
|
||||
<span class="flex-1"></span>
|
||||
<button class="text-xs px-2 py-0.5 rounded border hover:bg-muted" @click="clearMessages">
|
||||
{{ t("redis.pubsubClear") }}
|
||||
</button>
|
||||
</div>
|
||||
<div ref="messagesContainer" class="flex-1 overflow-auto px-3 py-1 font-mono text-xs">
|
||||
<div v-if="messages.length === 0" class="text-muted-foreground py-4 text-center">
|
||||
{{ t("redis.pubsubEmpty") }}
|
||||
</div>
|
||||
<div v-for="msg in messages" :key="msg.id" class="py-0.5 border-b border-dashed border-border/50">
|
||||
<span class="text-muted-foreground">{{ msg.timestamp.toLocaleTimeString() }}</span>
|
||||
<span class="ml-2 font-semibold text-blue-600 dark:text-blue-400">{{ msg.channel }}</span>
|
||||
<span v-if="msg.pattern" class="ml-1 text-purple-500">({{ msg.pattern }})</span>
|
||||
<span class="ml-2">{{ msg.payload }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Publish -->
|
||||
<div class="px-3 py-2 border-t space-y-1.5">
|
||||
<div class="text-xs font-medium">{{ t("redis.pubsubPublish") }}</div>
|
||||
<input v-model="publishChannel" type="text" class="w-full h-7 px-2 text-xs border rounded bg-background" :placeholder="t('redis.pubsubPublishChannel')" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" />
|
||||
<div class="flex gap-1">
|
||||
<input v-model="publishMessage" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-7 px-2 text-xs border rounded bg-background" :placeholder="t('redis.pubsubPublishMessage')" @keydown.enter="publish" />
|
||||
<button class="text-xs px-3 py-0.5 rounded bg-primary text-primary-foreground hover:bg-primary/90" @click="publish">
|
||||
{{ t("redis.pubsubSend") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user