Files
dbx/apps/desktop/src/components/redis/RedisPubSubPanel.vue
T

273 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, nextTick, onBeforeUnmount } from "vue";
import { useI18n } from "vue-i18n";
import * as api from "@/lib/backend/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 {
const socket = await api.redisPubSubConnect(props.connectionId);
ws = socket;
socket.onopen = () => {
if (ws !== socket) return;
connected.value = true;
connecting.value = false;
// Re-subscribe existing channels
if (channels.value.length > 0) {
socket.send(JSON.stringify({ type: "subscribe", channels: channels.value }));
}
if (patterns.value.length > 0) {
socket.send(JSON.stringify({ type: "psubscribe", patterns: patterns.value }));
}
};
socket.onmessage = (event) => {
if (ws !== socket) return;
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
}
};
socket.onclose = () => {
if (ws !== socket) return;
connected.value = false;
connecting.value = false;
ws = null;
};
socket.onerror = () => {
if (ws !== socket) return;
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() {
const socket = ws;
ws = null;
connected.value = false;
connecting.value = false;
if (socket) socket.close();
}
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);
publishMessage.value = "";
} catch (e) {
toast(t("redis.pubsubPublishFailed", { error: e instanceof Error ? e.message : String(e) }), 3000);
}
}
function clearMessages() {
messages.value = [];
}
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 min-h-0 shrink-0">
<span class="text-xs font-medium whitespace-nowrap">{{ t("redis.pubsub") }}</span>
<span class="flex-1"></span>
<span class="inline-flex items-center gap-1 text-xs whitespace-nowrap" :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 border border-transparent 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" :class="{ 'opacity-50 pointer-events-none': !connected }">
<!-- 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" :disabled="!connected" 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 :disabled="!connected" class="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" @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" :disabled="!connected" 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 :disabled="!connected" class="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" @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>