feat(redis): add Pub/Sub publish and subscribe with WebSocket real-time push

This commit is contained in:
t8y2 2026-06-15 01:40:06 +08:00
parent abc7136ed2
commit ef426a8218
26 changed files with 904 additions and 11 deletions

33
Cargo.lock generated
View File

@ -646,6 +646,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@ -665,8 +666,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1 0.10.6",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@ -1844,6 +1847,7 @@ name = "dbx"
version = "0.5.32"
dependencies = [
"anyhow",
"axum",
"base64 0.22.1",
"calamine",
"chrono",
@ -1947,6 +1951,7 @@ dependencies = [
"futures",
"log",
"pbkdf2 0.12.2",
"redis",
"reqwest 0.12.28",
"rustls 0.23.40",
"serde",
@ -8539,6 +8544,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@ -8832,6 +8849,22 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.4",
"sha1 0.10.6",
"thiserror 2.0.18",
]
[[package]]
name = "twox-hash"
version = "2.1.2"

View File

@ -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>

View File

@ -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>

View File

@ -1513,6 +1513,27 @@
clearHistory: "Clear command history",
historyCleared: "Redis command history cleared",
blockedCommand: "Command {command} is blocked for safety. Disable the shield icon in the toolbar to allow.",
pubsub: "Pub/Sub",
pubsubConnected: "Connected",
pubsubConnecting: "Connecting...",
pubsubDisconnected: "Disconnected",
pubsubConnect: "Connect",
pubsubDisconnect: "Disconnect",
pubsubChannels: "Channels",
pubsubChannelPlaceholder: "Channel name",
pubsubSubscribe: "Subscribe",
pubsubPatterns: "Patterns",
pubsubPatternPlaceholder: "Pattern (e.g. news:*)",
pubsubPsubscribe: "PSubscribe",
pubsubMessages: "Messages",
pubsubClear: "Clear",
pubsubEmpty: "No messages yet. Subscribe to a channel to receive messages.",
pubsubPublish: "Publish",
pubsubPublishChannel: "Channel",
pubsubPublishMessage: "Message",
pubsubSend: "Send",
pubsubPublishFailed: "Publish failed: {error}",
pubsubWsConnectFailed: "WebSocket connection failed: {error}",
},
mongo: {
documents: "{count} documents",

View File

@ -1263,6 +1263,27 @@
clearHistory: "Borrar historial de comandos",
historyCleared: "Historial de comandos Redis borrado",
blockedCommand: "El comando {command} está bloqueado por seguridad. Desactiva el icono de escudo en la barra de herramientas para permitirlo.",
pubsub: "Pub/Sub",
pubsubConnected: "Conectado",
pubsubConnecting: "Conectando...",
pubsubDisconnected: "Desconectado",
pubsubConnect: "Conectar",
pubsubDisconnect: "Desconectar",
pubsubChannels: "Canales",
pubsubChannelPlaceholder: "Nombre del canal",
pubsubSubscribe: "Suscribirse",
pubsubPatterns: "Patrones",
pubsubPatternPlaceholder: "Patrón (ej. news:*)",
pubsubPsubscribe: "PSuscribirse",
pubsubMessages: "Mensajes",
pubsubClear: "Limpiar",
pubsubEmpty: "Sin mensajes. Suscríbete a un canal para recibir mensajes.",
pubsubPublish: "Publicar",
pubsubPublishChannel: "Canal",
pubsubPublishMessage: "Mensaje",
pubsubSend: "Enviar",
pubsubPublishFailed: "Publicación fallida: {error}",
pubsubWsConnectFailed: "Conexión WebSocket fallida: {error}",
},
mongo: {
documents: "{count} documentos",

View File

@ -1375,6 +1375,27 @@
clearHistory: "Cancella cronologia comandi",
historyCleared: "Cronologia comandi Redis cancellata",
blockedCommand: "Il comando {command} è bloccato per sicurezza. Disattiva l'icona scudo nella barra degli strumenti per consentirlo.",
pubsub: "Pub/Sub",
pubsubConnected: "Connesso",
pubsubConnecting: "Connessione in corso...",
pubsubDisconnected: "Disconnesso",
pubsubConnect: "Connetti",
pubsubDisconnect: "Disconnetti",
pubsubChannels: "Canali",
pubsubChannelPlaceholder: "Nome canale",
pubsubSubscribe: "Sottoscrivi",
pubsubPatterns: "Pattern",
pubsubPatternPlaceholder: "Pattern (es. news:*)",
pubsubPsubscribe: "PSottoscrivi",
pubsubMessages: "Messaggi",
pubsubClear: "Cancella",
pubsubEmpty: "Nessun messaggio. Sottoscrivi un canale per ricevere messaggi.",
pubsubPublish: "Pubblica",
pubsubPublishChannel: "Canale",
pubsubPublishMessage: "Messaggio",
pubsubSend: "Invia",
pubsubPublishFailed: "Pubblicazione fallita: {error}",
pubsubWsConnectFailed: "Connessione WebSocket fallita: {error}",
},
mongo: {
documents: "{count} documenti",

View File

@ -1386,6 +1386,27 @@
clearHistory: "Limpar histórico de comandos",
historyCleared: "Histórico de comandos Redis limpo",
blockedCommand: "O comando {command} está bloqueado por segurança. Desative o ícone de escudo na barra de ferramentas para permiti-lo.",
pubsub: "Pub/Sub",
pubsubConnected: "Conectado",
pubsubConnecting: "Conectando...",
pubsubDisconnected: "Desconectado",
pubsubConnect: "Conectar",
pubsubDisconnect: "Desconectar",
pubsubChannels: "Canais",
pubsubChannelPlaceholder: "Nome do canal",
pubsubSubscribe: "Inscrever-se",
pubsubPatterns: "Padrões",
pubsubPatternPlaceholder: "Padrão (ex. news:*)",
pubsubPsubscribe: "PInscrever-se",
pubsubMessages: "Mensagens",
pubsubClear: "Limpar",
pubsubEmpty: "Nenhuma mensagem. Inscreva-se em um canal para receber mensagens.",
pubsubPublish: "Publicar",
pubsubPublishChannel: "Canal",
pubsubPublishMessage: "Mensagem",
pubsubSend: "Enviar",
pubsubPublishFailed: "Publicação falhou: {error}",
pubsubWsConnectFailed: "Conexão WebSocket falhou: {error}",
},
mongo: {
documents: "{count} documentos",

View File

@ -1512,6 +1512,27 @@
clearHistory: "清除命令历史",
historyCleared: "Redis 命令历史已清除",
blockedCommand: "命令 {command} 因安全原因已被拦截。点击工具栏盾牌图标可关闭拦截。",
pubsub: "发布/订阅",
pubsubConnected: "已连接",
pubsubConnecting: "连接中...",
pubsubDisconnected: "未连接",
pubsubConnect: "连接",
pubsubDisconnect: "断开",
pubsubChannels: "频道",
pubsubChannelPlaceholder: "频道名称",
pubsubSubscribe: "订阅",
pubsubPatterns: "模式匹配",
pubsubPatternPlaceholder: "模式 (如 news:*)",
pubsubPsubscribe: "模式订阅",
pubsubMessages: "消息",
pubsubClear: "清空",
pubsubEmpty: "暂无消息,订阅一个频道来接收消息。",
pubsubPublish: "发布消息",
pubsubPublishChannel: "频道",
pubsubPublishMessage: "消息内容",
pubsubSend: "发送",
pubsubPublishFailed: "发布失败: {error}",
pubsubWsConnectFailed: "WebSocket 连接失败: {error}",
},
mongo: {
documents: "{count} 个文档",

View File

@ -1363,6 +1363,27 @@
clearHistory: "清除命令歷史",
historyCleared: "Redis 命令歷史已清除",
blockedCommand: "命令 {command} 因安全原因已被攔截。點擊工具列盾牌圖示可關閉攔截。",
pubsub: "發布/訂閱",
pubsubConnected: "已連線",
pubsubConnecting: "連線中...",
pubsubDisconnected: "未連線",
pubsubConnect: "連線",
pubsubDisconnect: "斷開",
pubsubChannels: "頻道",
pubsubChannelPlaceholder: "頻道名稱",
pubsubSubscribe: "訂閱",
pubsubPatterns: "模式匹配",
pubsubPatternPlaceholder: "模式 (如 news:*)",
pubsubPsubscribe: "模式訂閱",
pubsubMessages: "訊息",
pubsubClear: "清空",
pubsubEmpty: "暫無訊息,訂閱一個頻道來接收訊息。",
pubsubPublish: "發布訊息",
pubsubPublishChannel: "頻道",
pubsubPublishMessage: "訊息內容",
pubsubSend: "傳送",
pubsubPublishFailed: "發布失敗: {error}",
pubsubWsConnectFailed: "WebSocket 連線失敗: {error}",
},
mongo: {
documents: "{count} 個文件",

View File

@ -261,6 +261,12 @@ export const redisDeleteKeys = forward("redisDeleteKeys");
export const redisFlushDb = forward("redisFlushDb");
export const redisExecuteCommand = forward("redisExecuteCommand");
export const redisLoadMore = forward("redisLoadMore");
export const redisPubSubPublish = forward("redisPubSubPublish");
export function redisPubSubConnect(connectionId: string): WebSocket {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return new WebSocket(`${protocol}//${window.location.host}/api/redis/pubsub/ws?connectionId=${encodeURIComponent(connectionId)}`);
}
// etcd
export const etcdListPrefix = forward("etcdListPrefix");

View File

@ -1354,6 +1354,10 @@ export async function redisLoadMore(connectionId: string, db: number, keyRaw: st
return post("/api/redis/load-more", { connectionId, db, keyRaw, keyType, cursor, count });
}
export async function redisPubSubPublish(connectionId: string, db: number, channel: string, message: string): Promise<{ subscribers: number }> {
return post("/api/redis/pubsub/publish", { connectionId, db, channel, message });
}
// ---------------------------------------------------------------------------
// etcd
// ---------------------------------------------------------------------------

View File

@ -1192,6 +1192,10 @@ export async function redisLoadMore(connectionId: string, db: number, keyRaw: st
return invoke("redis_load_more", { connectionId, db, keyRaw, keyType, cursor, count });
}
export async function redisPubSubPublish(connectionId: string, db: number, channel: string, message: string): Promise<{ subscribers: number }> {
return invoke("redis_pubsub_publish", { connectionId, db, channel, message });
}
// --- etcd ---
export type KvValueEncoding = "utf8" | "base64";

View File

@ -45,6 +45,33 @@ function installStartupErrorHandlers() {
});
}
function installGlobalInputAttrs() {
const ATTRS: [string, string][] = [
["autocomplete", "off"],
["autocapitalize", "off"],
["autocorrect", "off"],
["spellcheck", "false"],
];
const MARKER = "data-input-attrs-set";
const apply = (el: Element) => {
if ((el.tagName === "INPUT" || el.tagName === "TEXTAREA") && !el.hasAttribute(MARKER)) {
for (const [k, v] of ATTRS) el.setAttribute(k, v);
el.setAttribute(MARKER, "");
}
};
document.querySelectorAll("input, textarea").forEach(apply);
new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node instanceof Element) {
apply(node);
node.querySelectorAll("input, textarea").forEach(apply);
}
}
}
}).observe(document.body, { childList: true, subtree: true });
}
async function bootstrap() {
console.log("[STARTUP] frontend bootstrap begin");
const [{ default: i18n, loadSavedLocale }, { default: App }] = await Promise.all([import("./i18n"), import("./App.vue")]);
@ -58,6 +85,8 @@ async function bootstrap() {
app.use(VueVirtualScroller);
app.mount("#root");
console.log("[STARTUP] vue mounted");
installGlobalInputAttrs();
}
installDebugLogCapture();

View File

@ -75,14 +75,13 @@ export default defineConfig(async () => ({
port: 1421,
}
: undefined,
proxy: isTauri
? undefined
: {
"/api": {
target: "http://localhost:4224",
changeOrigin: true,
},
},
proxy: {
"/api": {
target: "http://localhost:4224",
changeOrigin: true,
ws: true,
},
},
watch: {
ignored: ["**/src-tauri/**"],
},

View File

@ -68,6 +68,13 @@ pub struct RedisCommandResult {
pub value: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PubSubMessage {
pub channel: String,
pub pattern: Option<String>,
pub payload: String,
}
pub enum RedisConnection {
Direct(Mutex<redis::aio::MultiplexedConnection>),
Cluster(RedisClusterPool),
@ -456,6 +463,54 @@ pub async fn connect_direct_node(
connect_client(client).await
}
pub async fn connect_pubsub(
config: &ConnectionConfig,
host: &str,
port: u16,
timeout: std::time::Duration,
) -> Result<redis::aio::PubSub, String> {
let mut last_error = None;
for auth in redis_auth_candidates(&config.username, &config.password) {
let client = redis::Client::open(connection_info(
host,
port,
config.ssl,
config.redis_tls_insecure(),
&auth.username,
&auth.password,
redis_database_index(config),
))
.map_err(|e| format!("Redis connection failed: {e}"))?;
match tokio::time::timeout(timeout, client.get_async_pubsub()).await {
Ok(Ok(pubsub)) => return Ok(pubsub),
Ok(Err(err)) => {
let err_str = err.to_string();
if last_error.is_none() || is_redis_auth_error(&err_str) {
let should_retry = is_redis_auth_error(&err_str);
last_error = Some(err_str);
if !should_retry {
break;
}
} else {
return Err(err_str);
}
}
Err(_) => {
last_error = Some(format!("Redis PubSub connection timed out ({}s)", timeout.as_secs()));
break;
}
}
}
Err(last_error.unwrap_or_else(|| "Redis PubSub connection failed".to_string()))
}
pub async fn publish_message<C>(con: &mut C, channel: &str, message: &str) -> Result<u64, String>
where
C: ConnectionLike + Send + Sync + Unpin,
{
redis::cmd("PUBLISH").arg(channel).arg(message).query_async(con).await.map_err(|e| e.to_string())
}
pub async fn list_databases<C>(con: &mut C) -> Result<Vec<RedisDatabaseInfo>, String>
where
C: ConnectionLike + Send + Sync + Unpin,

View File

@ -739,3 +739,43 @@ pub async fn redis_load_more_in_db_core(
_ => Err("Not a Redis connection".to_string()),
}
}
pub async fn redis_publish_core(
state: &AppState,
connection_id: &str,
db: u32,
channel: &str,
message: &str,
) -> Result<u64, String> {
ensure_redis_pool(state, connection_id).await?;
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::Redis(redis) => match redis {
RedisConnection::Direct(con) => {
let mut con = con.lock().await;
redis_driver::select_db(&mut *con, db).await?;
redis_driver::publish_message(&mut *con, channel, message).await
}
RedisConnection::Cluster(cluster) => {
redis_driver::ensure_cluster_db(db)?;
let mut con = cluster.connection.lock().await;
redis_driver::publish_message(&mut *con, channel, message).await
}
},
_ => Err("Not a Redis connection".to_string()),
}
}
pub async fn redis_create_pubsub_core(state: &AppState, connection_id: &str) -> Result<redis::aio::PubSub, String> {
let configs = state.configs.read().await;
let config = configs.get(connection_id).ok_or("Connection config not found")?.clone();
drop(configs);
if config.db_type != crate::models::connection::DatabaseType::Redis {
return Err("Not a Redis connection".to_string());
}
let (host, port) = state.connection_host_port(connection_id, &config).await?;
let timeout = std::time::Duration::from_secs(config.effective_connect_timeout_secs());
redis_driver::connect_pubsub(&config, &host, port, timeout).await
}

View File

@ -10,7 +10,8 @@ path = "src/main.rs"
[dependencies]
dbx-core = { path = "../dbx-core" }
axum = { version = "0.8", features = ["multipart"] }
redis = { version = "0.32", features = ["tokio-comp"] }
axum = { version = "0.8", features = ["multipart", "ws"] }
tower-http = { version = "0.6", features = ["cors", "fs", "compression-gzip", "trace"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }

View File

@ -258,6 +258,8 @@ async fn main() {
.route("/redis/delete-keys", post(routes::redis::delete_keys))
.route("/redis/flush-db", post(routes::redis::flush_db))
.route("/redis/execute-command", post(routes::redis::execute_command))
.route("/redis/pubsub/publish", post(routes::redis::publish_message))
.route("/redis/pubsub/ws", get(routes::redis_pubsub_ws::ws_handler))
// etcd
.route("/etcd/list-prefix", post(routes::etcd::list_prefix))
.route("/etcd/get", post(routes::etcd::get))

View File

@ -12,6 +12,7 @@ pub mod mongo;
pub mod plugins;
pub mod query;
pub mod redis;
pub mod redis_pubsub_ws;
pub mod saved_sql;
pub mod schema;
pub mod schema_cache;

View File

@ -156,6 +156,15 @@ pub struct RedisCommandRequest {
pub skip_safety_check: Option<bool>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RedisPubSubPublishRequest {
pub connection_id: String,
pub db: u32,
pub channel: String,
pub message: String,
}
pub async fn list_databases(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisConnectionRequest>,
@ -452,3 +461,15 @@ pub async fn execute_command(
.map_err(AppError)?;
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}
pub async fn publish_message(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisPubSubPublishRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
ensure_writable(&state.app, &req.connection_id, "PUBLISH").await?;
let count =
dbx_core::redis_ops::redis_publish_core(&state.app, &req.connection_id, req.db, &req.channel, &req.message)
.await
.map_err(AppError)?;
Ok(Json(serde_json::json!({ "subscribers": count })))
}

View File

@ -0,0 +1,127 @@
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket};
use axum::extract::State;
use axum::extract::{Query, WebSocketUpgrade};
use axum::response::IntoResponse;
use futures::{SinkExt, StreamExt};
use serde::Deserialize;
use crate::state::WebState;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PubSubWsParams {
pub connection_id: String,
}
pub async fn ws_handler(
ws: WebSocketUpgrade,
Query(params): Query<PubSubWsParams>,
State(state): State<Arc<WebState>>,
) -> impl IntoResponse {
let connection_id = params.connection_id;
ws.on_upgrade(move |socket| handle_pubsub_socket(socket, state, connection_id))
}
async fn handle_pubsub_socket(socket: WebSocket, state: Arc<WebState>, connection_id: String) {
// Create PubSub connection
let pubsub = match dbx_core::redis_ops::redis_create_pubsub_core(&state.app, &connection_id).await {
Ok(p) => p,
Err(e) => {
let (mut sender, _) = socket.split();
let _ = sender.send(Message::Text(format!(r#"{{"error":"{e}"}}"#).into())).await;
return;
}
};
let (mut sink, mut stream) = pubsub.split();
let (mut ws_sender, mut ws_receiver) = socket.split();
// Channel for WebSocket commands -> PubSub sink
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
// Task: Read WebSocket commands
let ws_read = tokio::spawn(async move {
while let Some(Ok(msg)) = ws_receiver.next().await {
match msg {
Message::Text(text) => {
if cmd_tx.send(text.to_string()).is_err() {
break;
}
}
Message::Close(_) => break,
_ => {}
}
}
});
// Task: Apply commands to PubSub sink
let sink_handle = tokio::spawn(async move {
while let Some(text) = cmd_rx.recv().await {
if let Err(e) = handle_pubsub_command(&mut sink, &text).await {
log::warn!("PubSub command error: {e}");
}
}
});
// Forward Redis messages to WebSocket (uses ws_sender, no mutex)
while let Some(msg) = stream.next().await {
let payload: String = msg.get_payload().unwrap_or_default();
let channel = msg.get_channel_name().to_string();
let pattern: Option<String> = msg.get_pattern().ok();
let json = serde_json::json!({
"channel": channel,
"pattern": pattern,
"payload": payload,
});
let text = serde_json::to_string(&json).unwrap_or_default();
if ws_sender.send(Message::Text(text.into())).await.is_err() {
break;
}
}
ws_read.abort();
sink_handle.abort();
}
#[derive(Deserialize)]
#[serde(tag = "type")]
enum PubSubCommand {
#[serde(rename = "subscribe")]
Subscribe { channels: Vec<String> },
#[serde(rename = "psubscribe")]
Psubscribe { patterns: Vec<String> },
#[serde(rename = "unsubscribe")]
Unsubscribe { channels: Vec<String> },
#[serde(rename = "punsubscribe")]
Punsubscribe { patterns: Vec<String> },
}
async fn handle_pubsub_command(sink: &mut redis::aio::PubSubSink, text: &str) -> Result<(), String> {
let cmd: PubSubCommand = serde_json::from_str(text).map_err(|e| format!("Invalid PubSub command: {e}"))?;
match cmd {
PubSubCommand::Subscribe { channels } => {
for ch in &channels {
sink.subscribe(ch).await.map_err(|e| format!("Subscribe error: {e}"))?;
}
}
PubSubCommand::Psubscribe { patterns } => {
for pat in &patterns {
sink.psubscribe(pat).await.map_err(|e| format!("PSubscribe error: {e}"))?;
}
}
PubSubCommand::Unsubscribe { channels } => {
for ch in &channels {
sink.unsubscribe(ch).await.map_err(|e| format!("Unsubscribe error: {e}"))?;
}
}
PubSubCommand::Punsubscribe { patterns } => {
for pat in &patterns {
sink.punsubscribe(pat).await.map_err(|e| format!("PUnsubscribe error: {e}"))?;
}
}
}
Ok(())
}

View File

@ -56,5 +56,6 @@ csv = "1.4.0"
calamine = "0.30.1"
zip = "4.6.1"
dbx-core = { path = "../crates/dbx-core", default-features = false }
axum = { version = "0.8", features = ["ws"] }
font-kit = "0.14.3"
tauri-plugin-clipboard-manager = "2.3.2"

View File

@ -22,6 +22,7 @@ pub mod plugins;
pub mod query;
pub mod query_cancel;
pub mod redis_cmd;
pub mod redis_pubsub_server;
pub mod saved_sql;
pub mod schema;
pub mod schema_cache;

View File

@ -303,3 +303,15 @@ pub async fn redis_load_more(
dbx_core::redis_ops::redis_load_more_in_db_core(&state, &connection_id, db, &key_raw, &key_type, cursor, count)
.await
}
#[tauri::command]
pub async fn redis_pubsub_publish(
state: State<'_, Arc<AppState>>,
connection_id: String,
db: u32,
channel: String,
message: String,
) -> Result<u64, String> {
ensure_connection_writable(&state, &connection_id, "PUBLISH").await?;
dbx_core::redis_ops::redis_publish_core(&state, &connection_id, db, &channel, &message).await
}

View File

@ -0,0 +1,145 @@
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket};
use axum::extract::{Query, State, WebSocketUpgrade};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use futures::{SinkExt, StreamExt};
use serde::Deserialize;
use dbx_core::connection::AppState;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PubSubWsParams {
connection_id: String,
}
pub fn build_pubsub_router(state: Arc<AppState>) -> Router {
Router::new().route("/api/redis/pubsub/ws", get(ws_handler)).with_state(state)
}
async fn ws_handler(
ws: WebSocketUpgrade,
Query(params): Query<PubSubWsParams>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let connection_id = params.connection_id;
ws.on_upgrade(move |socket| handle_socket(socket, state, connection_id))
}
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, connection_id: String) {
// Create PubSub connection
let pubsub = match dbx_core::redis_ops::redis_create_pubsub_core(&state, &connection_id).await {
Ok(p) => p,
Err(e) => {
let (mut sender, _) = socket.split();
let _ = sender.send(Message::Text(format!(r#"{{"error":"{e}"}}"#).into())).await;
return;
}
};
let (mut sink, mut stream) = pubsub.split();
let (mut ws_sender, mut ws_receiver) = socket.split();
// Channel for WebSocket commands -> PubSub sink
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
// Task: Read WebSocket commands
let ws_read = tokio::spawn(async move {
while let Some(Ok(msg)) = ws_receiver.next().await {
match msg {
Message::Text(text) => {
if cmd_tx.send(text.to_string()).is_err() {
break;
}
}
Message::Close(_) => break,
_ => {}
}
}
});
// Task: Apply commands to PubSub sink
let sink_handle = tokio::spawn(async move {
while let Some(text) = cmd_rx.recv().await {
if let Err(e) = handle_command(&mut sink, &text).await {
log::warn!("PubSub command error: {e}");
}
}
});
// Forward Redis messages to WebSocket (uses ws_sender, no mutex contention)
while let Some(msg) = stream.next().await {
let payload: String = msg.get_payload().unwrap_or_default();
let channel = msg.get_channel_name().to_string();
let pattern: Option<String> = msg.get_pattern().ok();
let json = serde_json::json!({
"channel": channel,
"pattern": pattern,
"payload": payload,
});
let text = serde_json::to_string(&json).unwrap_or_default();
if ws_sender.send(Message::Text(text.into())).await.is_err() {
break;
}
}
ws_read.abort();
sink_handle.abort();
}
#[derive(Deserialize)]
#[serde(tag = "type")]
enum PubSubCommand {
#[serde(rename = "subscribe")]
Subscribe { channels: Vec<String> },
#[serde(rename = "psubscribe")]
Psubscribe { patterns: Vec<String> },
#[serde(rename = "unsubscribe")]
Unsubscribe { channels: Vec<String> },
#[serde(rename = "punsubscribe")]
Punsubscribe { patterns: Vec<String> },
}
async fn handle_command(sink: &mut redis::aio::PubSubSink, text: &str) -> Result<(), String> {
let cmd: PubSubCommand = serde_json::from_str(text).map_err(|e| format!("Invalid PubSub command: {e}"))?;
match cmd {
PubSubCommand::Subscribe { channels } => {
for ch in &channels {
sink.subscribe(ch).await.map_err(|e| format!("Subscribe error: {e}"))?;
}
}
PubSubCommand::Psubscribe { patterns } => {
for pat in &patterns {
sink.psubscribe(pat).await.map_err(|e| format!("PSubscribe error: {e}"))?;
}
}
PubSubCommand::Unsubscribe { channels } => {
for ch in &channels {
sink.unsubscribe(ch).await.map_err(|e| format!("Unsubscribe error: {e}"))?;
}
}
PubSubCommand::Punsubscribe { patterns } => {
for pat in &patterns {
sink.punsubscribe(pat).await.map_err(|e| format!("PUnsubscribe error: {e}"))?;
}
}
}
Ok(())
}
/// Start the embedded web server for PubSub WebSocket support.
/// Runs on a background task using the shared AppState.
pub fn start_pubsub_server(state: Arc<AppState>) {
let router = build_pubsub_router(state);
tauri::async_runtime::spawn(async move {
let port: u16 = std::env::var("DBX_PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(4224);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr).await.expect("Failed to bind PubSub server");
log::info!("PubSub WebSocket server listening on {addr}");
axum::serve(listener, router).await.expect("PubSub server error");
});
}

View File

@ -286,6 +286,7 @@ pub fn run() {
Arc::new(AppState::new_with_plugin_dir_and_app_version(storage, plugin_dir, env!("CARGO_PKG_VERSION")))
};
app.manage(state.clone());
commands::redis_pubsub_server::start_pubsub_server(state.clone());
app.manage(commands::saved_sql::SavedSqlStorageState { data_dir: data_dir.clone() });
app.manage(commands::external_sql::ExternalSqlOpenState::default());
app.manage(commands::external_db::ExternalDbOpenState::default());
@ -484,6 +485,7 @@ pub fn run() {
commands::redis_cmd::redis_flush_db,
commands::redis_cmd::redis_execute_command,
commands::redis_cmd::redis_load_more,
commands::redis_cmd::redis_pubsub_publish,
commands::etcd_cmd::etcd_list_prefix,
commands::etcd_cmd::etcd_get,
commands::etcd_cmd::etcd_put,