feat(redis): support MONITOR live command log

This commit is contained in:
zipg authored and GitHub committed 2026-09-10 19:23:18 +08:00
1 parent 7b7ed6f7b1
commit 77323715e7
25 files changed
+439 -7

No files matched your search

@@ -483,7 +483,7 @@ const resultArchiveExporting = ref(false);
const canExportResultArchive = computed(() => props.activeTab.mode === "query" && (!!props.activeTab.result || !!props.activeTab.results?.length || !!props.activeTab.resultRuns?.length));
const resultAutoSave = computed(() => props.activeTab.resultAutoSave === true);
const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.active));
const activeResultIsLoading = computed(() => isActiveResultLoading(props.activeTab));
const activeResultIsLoading = computed(() => !props.activeTab.redisMonitorActive && isActiveResultLoading(props.activeTab));
const showResultRunTabs = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "tabs");
const showResultRunSelector = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "list");
const canCloseQueryResult = computed(() => props.activeTab.mode === "query" && !props.activeTab.isExecuting && !props.activeTab.activeResultRunId && (!!props.activeTab.result || !!props.activeTab.results?.length || props.activeTab.resultEvicted === true));
@@ -1717,6 +1717,11 @@ defineExpose({
/>
</div>
<div v-if="activeTab.redisMonitorActive" class="flex shrink-0 items-center justify-between border-b px-3 py-1 text-xs text-muted-foreground">
<span>{{ t("redis.monitorListening") }}</span>
<Button variant="ghost" size="sm" :disabled="activeTab.isCancelling" @click="emit('cancel', activeTab.id)">{{ t("redis.monitorStop") }}</Button>
</div>
<ExplainPlanViewer
v-if="activeOutputView === 'explain'"
class="flex-1 min-h-0"
+2
View File
@@ -4969,6 +4969,8 @@ export default {
summarySize: "size",
},
redis: {
monitorStop: "Stop listening",
monitorListening: "MONITOR listening · latest 1,000 entries · long entries truncated",
setDatabaseAlias: "Set Database Alias",
databaseAliasTitle: "Alias for db{db}",
databaseAliasPlaceholder: "e.g. orders, cache, development",
+2
View File
@@ -4675,6 +4675,8 @@ export default withEnglishFallback({
summarySize: "tamaño",
},
redis: {
monitorStop: "Detener escucha",
monitorListening: "MONITOR activo · últimas 1000 entradas · entradas largas truncadas",
setDatabaseAlias: "Establecer alias de base de datos",
databaseAliasTitle: "Alias para db{db}",
databaseAliasPlaceholder: "p. ej. pedidos, caché, desarrollo",
+2
View File
@@ -4673,6 +4673,8 @@ export default withEnglishFallback({
summarySize: "dimensione",
},
redis: {
monitorStop: "Interrompi ascolto",
monitorListening: "MONITOR attivo · ultime 1000 voci · voci lunghe troncate",
setDatabaseAlias: "Imposta alias database",
databaseAliasTitle: "Alias per db{db}",
databaseAliasPlaceholder: "es. ordini, cache, sviluppo",
+2
View File
@@ -4702,6 +4702,8 @@ export default withEnglishFallback({
selectedKeysWriteDenied: "対象の接続には選択した Key への書き込み権限がないため、変更は実行されませんでした。",
},
redis: {
monitorStop: "監視を停止",
monitorListening: "MONITOR 監視中 · 最新1,000件を保持 · 長い記録は省略",
setDatabaseAlias: "データベースの別名を設定",
databaseAliasTitle: "db{db} の別名",
databaseAliasPlaceholder: "例:注文、キャッシュ、開発",
+2
View File
@@ -4573,6 +4573,8 @@ export default withEnglishFallback({
keyspace: "키스페이스",
},
},
monitorStop: "모니터링 중지",
monitorListening: "MONITOR 모니터링 중 · 최근 1000개 보관 · 너무 긴 레코드는 잘림 표시",
},
mongo: {
documents: "문서 {count}개",
+2
View File
@@ -4675,6 +4675,8 @@ export default withEnglishFallback({
summarySize: "tamanho",
},
redis: {
monitorStop: "Parar escuta",
monitorListening: "MONITOR ativo · últimas 1000 entradas · entradas longas truncadas",
setDatabaseAlias: "Definir alias do banco",
databaseAliasTitle: "Alias para db{db}",
databaseAliasPlaceholder: "ex.: pedidos, cache, desenvolvimento",
+2
View File
@@ -4953,6 +4953,8 @@ export default withEnglishFallback({
summarySize: "大小",
},
redis: {
monitorStop: "停止监听",
monitorListening: "MONITOR 监听中 · 保留最近 1000 条 · 超长记录截断显示",
setDatabaseAlias: "设置数据库别名",
databaseAliasTitle: "设置 db{db} 的别名",
databaseAliasPlaceholder: "例如:订单、缓存、开发环境",
+2
View File
@@ -3995,6 +3995,8 @@ export default withEnglishFallback({
dropTableNotSupported: "目前資料庫不支援從圖刪除資料表",
},
redis: {
monitorStop: "停止監聽",
monitorListening: "MONITOR 監聽中 · 保留最近 1000 筆 · 超長記錄截斷顯示",
setDatabaseAlias: "設定資料庫別名",
databaseAliasTitle: "設定 db{db} 的別名",
databaseAliasPlaceholder: "例如:訂單、快取、開發環境",
@@ -81,6 +81,15 @@ describe("openTabsPersistence originalSql round-trip", () => {
expect(restored.isExecuting).toBe(false);
});
it("does not resume a MONITOR stream when restoring tabs", () => {
const tab = queryTab({ sql: "MONITOR", redisMonitorActive: true, isExecuting: true });
const [saved] = serializeOpenTabs([tab]);
const [restored] = roundTrip([tab]);
expect(saved).not.toHaveProperty("redisMonitorActive");
expect(restored.redisMonitorActive).toBe(false);
expect(restored.isExecuting).toBe(false);
});
it("preserves an external Doris catalog across tab restore", () => {
const [restored] = roundTrip([queryTab({ database: "dbx_catalog_completion", catalog: "dbx_mysql_catalog" })]);
@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { REDIS_MONITOR_MAX_ROWS, startRedisMonitor } from "../../redis/redisMonitor";
import { classifyRedisCommandSafety } from "../../redis/redisCommandSafety";
function createSocket() {
return { close: vi.fn(), onmessage: null, onerror: null, onclose: null } as unknown as WebSocket;
}
function receive(socket: WebSocket, data: object) {
socket.onmessage?.(new MessageEvent("message", { data: JSON.stringify(data) }));
}
afterEach(() => vi.useRealTimers());
describe("Redis MONITOR stream", () => {
it("allows standalone MONITOR without permitting arbitrary commands", () => {
expect(classifyRedisCommandSafety("monitor")).toBe("allowed");
expect(classifyRedisCommandSafety(" MONITOR; ")).toBe("allowed");
expect(classifyRedisCommandSafety("MONITOR extra")).toBe("blocked");
expect(classifyRedisCommandSafety("MONITOR; FLUSHALL")).toBe("blocked");
});
it("streams successive batches and bounds retained rows", async () => {
vi.useFakeTimers();
const socket = createSocket();
const onRows = vi.fn();
const monitor = startRedisMonitor(async () => socket, onRows);
await Promise.resolve();
receive(socket, { ready: true });
expect(onRows).toHaveBeenLastCalledWith([]);
receive(socket, { message: "first" });
vi.advanceTimersByTime(100);
expect(onRows).toHaveBeenLastCalledWith(["first"]);
for (let index = 0; index < REDIS_MONITOR_MAX_ROWS + 10; index++) receive(socket, { message: String(index) });
vi.advanceTimersByTime(100);
const rows = onRows.mock.lastCall![0];
expect(rows).toHaveLength(REDIS_MONITOR_MAX_ROWS);
expect(rows[0]).toBe("10");
monitor.stop();
await monitor.done;
expect(socket.close).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it("closes a socket that arrives after cancellation", async () => {
const socket = createSocket();
let resolveConnection!: (socket: WebSocket) => void;
const monitor = startRedisMonitor(
() =>
new Promise((resolve) => {
resolveConnection = resolve;
}),
vi.fn(),
);
monitor.stop();
resolveConnection(socket);
await monitor.done;
expect(socket.close).toHaveBeenCalledOnce();
});
it("reports server errors and cleans up", async () => {
const socket = createSocket();
const monitor = startRedisMonitor(async () => socket, vi.fn());
const failure = expect(monitor.done).rejects.toThrow("NOPERM");
await Promise.resolve();
receive(socket, { error: "NOPERM" });
await failure;
expect(socket.close).toHaveBeenCalledOnce();
});
it("reports unexpected disconnects and ignores events after stop", async () => {
vi.useFakeTimers();
const socket = createSocket();
const onRows = vi.fn();
const monitor = startRedisMonitor(async () => socket, onRows);
const failure = expect(monitor.done).rejects.toThrow("connection closed");
await Promise.resolve();
receive(socket, { ready: true });
socket.onclose?.(new Event("close") as CloseEvent);
await failure;
receive(socket, { message: "late" });
vi.advanceTimersByTime(1000);
expect(onRows).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
it("times out setup without timing out an idle ready stream", async () => {
vi.useFakeTimers();
const socket = createSocket();
const monitor = startRedisMonitor(async () => socket, vi.fn());
await Promise.resolve();
receive(socket, { ready: true });
vi.advanceTimersByTime(60000);
expect(socket.close).not.toHaveBeenCalled();
monitor.stop();
await monitor.done;
const pending = startRedisMonitor(() => new Promise(() => {}), vi.fn());
const failure = expect(pending.done).rejects.toThrow("timed out");
vi.advanceTimersByTime(30000);
await failure;
expect(vi.getTimerCount()).toBe(0);
});
});
@@ -233,6 +233,7 @@ function restoreOpenTabsArray(parsed: unknown, rawActiveTabId: string | null, op
mode,
sql: typeof tab.sql === "string" ? tab.sql : "",
isExecuting: false,
redisMonitorActive: false,
isCancelling: false,
queryExecutionStartedAt: undefined,
executingResultRunId: undefined,
+2 -2
View File
@@ -2989,8 +2989,8 @@ export async function redisPubSubPublish(connectionId: string, db: number, chann
});
}
export async function redisPubSubConnect(connectionId: string): Promise<WebSocket> {
return new WebSocket(apiWebSocketUrl(`/api/redis/pubsub/ws?connectionId=${encodeURIComponent(connectionId)}`));
export async function redisPubSubConnect(connectionId: string, monitor = false): Promise<WebSocket> {
return new WebSocket(apiWebSocketUrl(`/api/redis/pubsub/ws?connectionId=${encodeURIComponent(connectionId)}&monitor=${monitor}`));
}
export async function redisSlowlogGet(connectionId: string, count: number, nodeHost?: string, nodePort?: number): Promise<RedisSlowlogEntry[]> {
+2 -2
View File
@@ -2926,9 +2926,9 @@ export async function redisPubSubPublish(connectionId: string, db: number, chann
return invoke("redis_pubsub_publish", { connectionId, db, channel, message });
}
export async function redisPubSubConnect(connectionId: string): Promise<WebSocket> {
export async function redisPubSubConnect(connectionId: string, monitor = false): Promise<WebSocket> {
const port = await invoke<number>("redis_pubsub_server_port");
return new WebSocket(`ws://127.0.0.1:${port}/api/redis/pubsub/ws?connectionId=${encodeURIComponent(connectionId)}`);
return new WebSocket(`ws://127.0.0.1:${port}/api/redis/pubsub/ws?connectionId=${encodeURIComponent(connectionId)}&monitor=${monitor}`);
}
export async function redisSlowlogGet(connectionId: string, count: number, nodeHost?: string, nodePort?: number): Promise<RedisSlowlogEntry[]> {
@@ -1,3 +1,5 @@
import { isRedisMonitorCommand } from "./redisMonitor";
export type RedisCommandSafety = "allowed" | "write" | "confirm" | "blocked";
const BLOCKED_COMMANDS = new Set(["KEYS", "FLUSHALL", "SHUTDOWN", "CONFIG", "SAVE", "BGSAVE", "SLAVEOF", "REPLICAOF", "MIGRATE", "MODULE", "SCRIPT", "EVAL", "EVALSHA"]);
@@ -273,6 +275,7 @@ export function firstRedisCommandToken(command: string): string {
}
export function classifyRedisCommandSafety(command: string): RedisCommandSafety {
if (isRedisMonitorCommand(command)) return "allowed";
const token = firstRedisCommandToken(command);
if (BLOCKED_COMMANDS.has(token)) return "blocked";
if (CONFIRM_COMMANDS.has(token)) return "confirm";
@@ -0,0 +1,66 @@
export const REDIS_MONITOR_MAX_ROWS = 1000;
export function isRedisMonitorCommand(command: string): boolean {
return /^MONITOR\s*;?$/i.test(command.trim());
}
export function startRedisMonitor(connect: () => Promise<WebSocket>, onRows: (rows: string[]) => void) {
let socket: WebSocket | undefined;
let finished = false;
let rows: string[] = [];
let dirty = false;
let resolveDone!: () => void;
let rejectDone!: (error: Error) => void;
const done = new Promise<void>((resolve, reject) => {
resolveDone = resolve;
rejectDone = reject;
});
const flush = () => {
if (dirty) {
dirty = false;
onRows(rows.slice());
}
};
const timer = setInterval(flush, 100);
const timeout = setTimeout(() => finish(new Error("Redis MONITOR connection timed out")), 30000);
function finish(error?: Error) {
if (finished) return;
finished = true;
clearInterval(timer);
clearTimeout(timeout);
flush();
socket?.close();
if (error) rejectDone(error);
else resolveDone();
}
void connect()
.then((connected) => {
socket = connected;
if (finished) {
socket.close();
return;
}
socket.onmessage = (event) => {
if (finished) return;
try {
const data = JSON.parse(event.data);
if (typeof data.error === "string") {
finish(new Error(data.error));
} else if (data.ready === true) {
clearTimeout(timeout);
onRows([]);
} else if (typeof data.message === "string") {
rows.push(data.message.slice(0, 16384));
if (rows.length > REDIS_MONITOR_MAX_ROWS) rows = rows.slice(-REDIS_MONITOR_MAX_ROWS);
dirty = true;
}
} catch {
finish(new Error("Invalid Redis MONITOR response"));
}
};
socket.onerror = () => finish(new Error("Redis MONITOR connection failed"));
socket.onclose = () => finish(new Error("Redis MONITOR connection closed"));
})
.catch((error) => finish(error instanceof Error ? error : new Error(String(error))));
return { done, stop: () => finish() };
}
+46
View File
@@ -1,5 +1,6 @@
import { UPDATE_RESTORE_KEY, assertUpdateAllowsInteraction } from "@/lib/app/updatePreparation";
import { defineStore } from "pinia";
import { isRedisMonitorCommand, startRedisMonitor } from "@/lib/redis/redisMonitor";
import { uuid } from "@/lib/common/utils";
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
@@ -789,6 +790,7 @@ function getI18nT() {
}
export const useQueryStore = defineStore("query", () => {
const redisMonitors = new Map<string, () => void>();
const t = getI18nT();
const settingsStore = useSettingsStore();
const tabs = ref<QueryTab[]>([]);
@@ -5769,6 +5771,43 @@ export const useQueryStore = defineStore("query", () => {
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (commands.length === 0) return false;
if (commands.length === 1 && isRedisMonitorCommand(commands[0])) {
const monitor = startRedisMonitor(
() => api.redisPubSubConnect(executionConnectionId, true),
(rows) => {
const current = findExecutionTab(id);
if (current?.executionId !== executionId) return;
current.result = markQueryResultRowsRaw(annotateQueryResultSource({ columns: ["MONITOR"], rows: rows.map((message) => [message]), affected_rows: 0, execution_time_ms: performance.now() - startedAt }, "MONITOR"));
current.results = undefined;
current.activeResultIndex = undefined;
current.queryEditabilityReason = undefined;
current.queryWriteTargets = undefined;
current.tableMeta = undefined;
current.resultBaseSql = "MONITOR";
current.redisMonitorActive = true;
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
current.resultColumnComments = undefined;
current.queryDisplaySourceColumns = undefined;
current.mongoEditTarget = undefined;
if (!producedResult) {
publishResultGeneration(current, "execute");
syncDisplayedResultRun(current, "MONITOR", captureResultRun);
}
producedResult = true;
touchResult(current);
},
);
redisMonitors.set(executionId, monitor.stop);
try {
await monitor.done;
} finally {
redisMonitors.delete(executionId);
const current = findExecutionTab(id);
if (current?.executionId === executionId) current.redisMonitorActive = false;
}
return producedResult;
}
queryExecutionLog("info", "redis:start", { traceId, db: currentDb, commandCount: commands.length, sqlLength: sql.length });
const allResults: QueryResult[] = [];
@@ -7205,6 +7244,13 @@ export const useQueryStore = defineStore("query", () => {
const executionId = tab.executionId;
if (!executionId) return false;
const stopMonitor = redisMonitors.get(executionId);
if (stopMonitor) {
tab.isCancelling = true;
tab.cancelRequestCount = (tab.cancelRequestCount ?? 0) + 1;
stopMonitor();
return true;
}
tab.isCancelling = true;
// 单调递增、不随取消结果回退:导航流程据此判断"执行期间用户请求过停止"
// (isCancelling 在取消失败或查询先完成时会被清掉,无法承担这个语义)
+1
View File
@@ -1224,6 +1224,7 @@ export interface QueryTab {
explainTableSql?: string;
lastExplainedSql?: string;
isExecuting: boolean;
redisMonitorActive?: boolean;
isCancelling?: boolean;
queryExecutionStartedAt?: number;
/** Ephemeral per-statement progress for the latest multi-statement execution. */