feat(redis): support MONITOR live command log
This commit is contained in:
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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4702,6 +4702,8 @@ export default withEnglishFallback({
|
||||
selectedKeysWriteDenied: "対象の接続には選択した Key への書き込み権限がないため、変更は実行されませんでした。",
|
||||
},
|
||||
redis: {
|
||||
monitorStop: "監視を停止",
|
||||
monitorListening: "MONITOR 監視中 · 最新1,000件を保持 · 長い記録は省略",
|
||||
setDatabaseAlias: "データベースの別名を設定",
|
||||
databaseAliasTitle: "db{db} の別名",
|
||||
databaseAliasPlaceholder: "例:注文、キャッシュ、開発",
|
||||
|
||||
@@ -4573,6 +4573,8 @@ export default withEnglishFallback({
|
||||
keyspace: "키스페이스",
|
||||
},
|
||||
},
|
||||
monitorStop: "모니터링 중지",
|
||||
monitorListening: "MONITOR 모니터링 중 · 최근 1000개 보관 · 너무 긴 레코드는 잘림 표시",
|
||||
},
|
||||
mongo: {
|
||||
documents: "문서 {count}개",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4953,6 +4953,8 @@ export default withEnglishFallback({
|
||||
summarySize: "大小",
|
||||
},
|
||||
redis: {
|
||||
monitorStop: "停止监听",
|
||||
monitorListening: "MONITOR 监听中 · 保留最近 1000 条 · 超长记录截断显示",
|
||||
setDatabaseAlias: "设置数据库别名",
|
||||
databaseAliasTitle: "设置 db{db} 的别名",
|
||||
databaseAliasPlaceholder: "例如:订单、缓存、开发环境",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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 在取消失败或查询先完成时会被清掉,无法承担这个语义)
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user