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 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 resultAutoSave = computed(() => props.activeTab.resultAutoSave === true);
const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.active)); 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 showResultRunTabs = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "tabs");
const showResultRunSelector = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "list"); 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)); 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>
<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 <ExplainPlanViewer
v-if="activeOutputView === 'explain'" v-if="activeOutputView === 'explain'"
class="flex-1 min-h-0" class="flex-1 min-h-0"
+2
View File
@@ -4969,6 +4969,8 @@ export default {
summarySize: "size", summarySize: "size",
}, },
redis: { redis: {
monitorStop: "Stop listening",
monitorListening: "MONITOR listening · latest 1,000 entries · long entries truncated",
setDatabaseAlias: "Set Database Alias", setDatabaseAlias: "Set Database Alias",
databaseAliasTitle: "Alias for db{db}", databaseAliasTitle: "Alias for db{db}",
databaseAliasPlaceholder: "e.g. orders, cache, development", databaseAliasPlaceholder: "e.g. orders, cache, development",
+2
View File
@@ -4675,6 +4675,8 @@ export default withEnglishFallback({
summarySize: "tamaño", summarySize: "tamaño",
}, },
redis: { redis: {
monitorStop: "Detener escucha",
monitorListening: "MONITOR activo · últimas 1000 entradas · entradas largas truncadas",
setDatabaseAlias: "Establecer alias de base de datos", setDatabaseAlias: "Establecer alias de base de datos",
databaseAliasTitle: "Alias para db{db}", databaseAliasTitle: "Alias para db{db}",
databaseAliasPlaceholder: "p. ej. pedidos, caché, desarrollo", databaseAliasPlaceholder: "p. ej. pedidos, caché, desarrollo",
+2
View File
@@ -4673,6 +4673,8 @@ export default withEnglishFallback({
summarySize: "dimensione", summarySize: "dimensione",
}, },
redis: { redis: {
monitorStop: "Interrompi ascolto",
monitorListening: "MONITOR attivo · ultime 1000 voci · voci lunghe troncate",
setDatabaseAlias: "Imposta alias database", setDatabaseAlias: "Imposta alias database",
databaseAliasTitle: "Alias per db{db}", databaseAliasTitle: "Alias per db{db}",
databaseAliasPlaceholder: "es. ordini, cache, sviluppo", databaseAliasPlaceholder: "es. ordini, cache, sviluppo",
+2
View File
@@ -4702,6 +4702,8 @@ export default withEnglishFallback({
selectedKeysWriteDenied: "対象の接続には選択した Key への書き込み権限がないため、変更は実行されませんでした。", selectedKeysWriteDenied: "対象の接続には選択した Key への書き込み権限がないため、変更は実行されませんでした。",
}, },
redis: { redis: {
monitorStop: "監視を停止",
monitorListening: "MONITOR 監視中 · 最新1,000件を保持 · 長い記録は省略",
setDatabaseAlias: "データベースの別名を設定", setDatabaseAlias: "データベースの別名を設定",
databaseAliasTitle: "db{db} の別名", databaseAliasTitle: "db{db} の別名",
databaseAliasPlaceholder: "例:注文、キャッシュ、開発", databaseAliasPlaceholder: "例:注文、キャッシュ、開発",
+2
View File
@@ -4573,6 +4573,8 @@ export default withEnglishFallback({
keyspace: "키스페이스", keyspace: "키스페이스",
}, },
}, },
monitorStop: "모니터링 중지",
monitorListening: "MONITOR 모니터링 중 · 최근 1000개 보관 · 너무 긴 레코드는 잘림 표시",
}, },
mongo: { mongo: {
documents: "문서 {count}개", documents: "문서 {count}개",
+2
View File
@@ -4675,6 +4675,8 @@ export default withEnglishFallback({
summarySize: "tamanho", summarySize: "tamanho",
}, },
redis: { redis: {
monitorStop: "Parar escuta",
monitorListening: "MONITOR ativo · últimas 1000 entradas · entradas longas truncadas",
setDatabaseAlias: "Definir alias do banco", setDatabaseAlias: "Definir alias do banco",
databaseAliasTitle: "Alias para db{db}", databaseAliasTitle: "Alias para db{db}",
databaseAliasPlaceholder: "ex.: pedidos, cache, desenvolvimento", databaseAliasPlaceholder: "ex.: pedidos, cache, desenvolvimento",
+2
View File
@@ -4953,6 +4953,8 @@ export default withEnglishFallback({
summarySize: "大小", summarySize: "大小",
}, },
redis: { redis: {
monitorStop: "停止监听",
monitorListening: "MONITOR 监听中 · 保留最近 1000 条 · 超长记录截断显示",
setDatabaseAlias: "设置数据库别名", setDatabaseAlias: "设置数据库别名",
databaseAliasTitle: "设置 db{db} 的别名", databaseAliasTitle: "设置 db{db} 的别名",
databaseAliasPlaceholder: "例如:订单、缓存、开发环境", databaseAliasPlaceholder: "例如:订单、缓存、开发环境",
+2
View File
@@ -3995,6 +3995,8 @@ export default withEnglishFallback({
dropTableNotSupported: "目前資料庫不支援從圖刪除資料表", dropTableNotSupported: "目前資料庫不支援從圖刪除資料表",
}, },
redis: { redis: {
monitorStop: "停止監聽",
monitorListening: "MONITOR 監聽中 · 保留最近 1000 筆 · 超長記錄截斷顯示",
setDatabaseAlias: "設定資料庫別名", setDatabaseAlias: "設定資料庫別名",
databaseAliasTitle: "設定 db{db} 的別名", databaseAliasTitle: "設定 db{db} 的別名",
databaseAliasPlaceholder: "例如:訂單、快取、開發環境", databaseAliasPlaceholder: "例如:訂單、快取、開發環境",
@@ -81,6 +81,15 @@ describe("openTabsPersistence originalSql round-trip", () => {
expect(restored.isExecuting).toBe(false); 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", () => { it("preserves an external Doris catalog across tab restore", () => {
const [restored] = roundTrip([queryTab({ database: "dbx_catalog_completion", catalog: "dbx_mysql_catalog" })]); 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, mode,
sql: typeof tab.sql === "string" ? tab.sql : "", sql: typeof tab.sql === "string" ? tab.sql : "",
isExecuting: false, isExecuting: false,
redisMonitorActive: false,
isCancelling: false, isCancelling: false,
queryExecutionStartedAt: undefined, queryExecutionStartedAt: undefined,
executingResultRunId: 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> { export async function redisPubSubConnect(connectionId: string, monitor = false): Promise<WebSocket> {
return new WebSocket(apiWebSocketUrl(`/api/redis/pubsub/ws?connectionId=${encodeURIComponent(connectionId)}`)); 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[]> { 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 }); 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"); 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[]> { 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"; 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"]); 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 { export function classifyRedisCommandSafety(command: string): RedisCommandSafety {
if (isRedisMonitorCommand(command)) return "allowed";
const token = firstRedisCommandToken(command); const token = firstRedisCommandToken(command);
if (BLOCKED_COMMANDS.has(token)) return "blocked"; if (BLOCKED_COMMANDS.has(token)) return "blocked";
if (CONFIRM_COMMANDS.has(token)) return "confirm"; 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 { UPDATE_RESTORE_KEY, assertUpdateAllowsInteraction } from "@/lib/app/updatePreparation";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { isRedisMonitorCommand, startRedisMonitor } from "@/lib/redis/redisMonitor";
import { uuid } from "@/lib/common/utils"; import { uuid } from "@/lib/common/utils";
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue"; import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
@@ -789,6 +790,7 @@ function getI18nT() {
} }
export const useQueryStore = defineStore("query", () => { export const useQueryStore = defineStore("query", () => {
const redisMonitors = new Map<string, () => void>();
const t = getI18nT(); const t = getI18nT();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const tabs = ref<QueryTab[]>([]); const tabs = ref<QueryTab[]>([]);
@@ -5769,6 +5771,43 @@ export const useQueryStore = defineStore("query", () => {
.map((line) => line.trim()) .map((line) => line.trim())
.filter((line) => line.length > 0); .filter((line) => line.length > 0);
if (commands.length === 0) return false; 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 }); queryExecutionLog("info", "redis:start", { traceId, db: currentDb, commandCount: commands.length, sqlLength: sql.length });
const allResults: QueryResult[] = []; const allResults: QueryResult[] = [];
@@ -7205,6 +7244,13 @@ export const useQueryStore = defineStore("query", () => {
const executionId = tab.executionId; const executionId = tab.executionId;
if (!executionId) return false; 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; tab.isCancelling = true;
// 单调递增、不随取消结果回退:导航流程据此判断"执行期间用户请求过停止" // 单调递增、不随取消结果回退:导航流程据此判断"执行期间用户请求过停止"
// (isCancelling 在取消失败或查询先完成时会被清掉,无法承担这个语义) // (isCancelling 在取消失败或查询先完成时会被清掉,无法承担这个语义)
+1
View File
@@ -1224,6 +1224,7 @@ export interface QueryTab {
explainTableSql?: string; explainTableSql?: string;
lastExplainedSql?: string; lastExplainedSql?: string;
isExecuting: boolean; isExecuting: boolean;
redisMonitorActive?: boolean;
isCancelling?: boolean; isCancelling?: boolean;
queryExecutionStartedAt?: number; queryExecutionStartedAt?: number;
/** Ephemeral per-statement progress for the latest multi-statement execution. */ /** Ephemeral per-statement progress for the latest multi-statement execution. */
+34
View File
@@ -1121,6 +1121,35 @@ pub async fn connect_direct_node(
connect_client(client).await connect_client(client).await
} }
pub async fn connect_monitor(
config: &ConnectionConfig,
host: &str,
port: u16,
timeout: std::time::Duration,
) -> Result<redis::aio::Monitor, String> {
let mut last_error = String::new();
for info in standalone_connection_infos(config, host, port) {
let client = redis::Client::open(info).map_err(|error| error.to_string())?;
let connect = async {
let mut validation = client.get_multiplexed_async_connection().await?;
redis::cmd("MONITOR").query_async::<()>(&mut validation).await?;
drop(validation);
client.get_async_monitor().await
};
match tokio::time::timeout(timeout, connect).await {
Ok(Ok(monitor)) => return Ok(monitor),
Ok(Err(error)) => {
last_error = error.to_string();
if !is_redis_auth_error(&last_error) {
break;
}
}
Err(_) => return Err("Redis MONITOR connection timed out".to_string()),
}
}
Err(last_error)
}
pub async fn connect_pubsub( pub async fn connect_pubsub(
config: &ConnectionConfig, config: &ConnectionConfig,
host: &str, host: &str,
@@ -2218,6 +2247,11 @@ where
{ {
let argv = parse_command_argv(command_text)?; let argv = parse_command_argv(command_text)?;
let command = argv[0].to_ascii_uppercase(); let command = argv[0].to_ascii_uppercase();
if command == "MONITOR" {
return Err(
"MONITOR requires a dedicated streaming connection; run MONITOR alone in the query editor".to_string()
);
}
let safety = classify_command(&command); let safety = classify_command(&command);
if !skip_safety_check && safety == RedisCommandSafety::Blocked { if !skip_safety_check && safety == RedisCommandSafety::Blocked {
return Err(format!("Redis command is blocked for safety: {command}")); return Err(format!("Redis command is blocked for safety: {command}"));
+22
View File
@@ -1141,6 +1141,28 @@ pub async fn redis_publish_core(
} }
} }
pub async fn redis_create_monitor_core(state: &AppState, connection_id: &str) -> Result<redis::aio::Monitor, String> {
let config = state.configs.read().await.get(connection_id).ok_or("Connection config not found")?.clone();
if config.db_type != crate::models::connection::DatabaseType::Redis {
return Err("Not a Redis connection".to_string());
}
if config.uses_redis_sentinel() || config.uses_redis_cluster() {
return Err(
"MONITOR requires a direct Redis node connection; connect to the node you want to monitor".to_string()
);
}
let (host, port) = state.connection_host_port(connection_id, &config).await?;
let mut runtime_config = config.clone();
state.apply_session_credential(&config, &mut runtime_config, connection_id);
redis_driver::connect_monitor(
&runtime_config,
&host,
port,
std::time::Duration::from_secs(config.effective_connect_timeout_secs()),
)
.await
}
pub async fn redis_create_pubsub_core(state: &AppState, connection_id: &str) -> Result<redis::aio::PubSub, 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 configs = state.configs.read().await;
let config = configs.get(connection_id).ok_or("Connection config not found")?.clone(); let config = configs.get(connection_id).ok_or("Connection config not found")?.clone();
+57 -1
View File
@@ -13,6 +13,8 @@ use crate::state::WebState;
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PubSubWsParams { pub struct PubSubWsParams {
pub connection_id: String, pub connection_id: String,
#[serde(default)]
monitor: bool,
} }
pub async fn ws_handler( pub async fn ws_handler(
@@ -21,7 +23,61 @@ pub async fn ws_handler(
State(state): State<Arc<WebState>>, State(state): State<Arc<WebState>>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let connection_id = params.connection_id; let connection_id = params.connection_id;
ws.on_upgrade(move |socket| handle_pubsub_socket(socket, state, connection_id)) let owner = dbx_core::session_credentials::current_credential_owner();
ws.on_upgrade(move |socket| async move {
if params.monitor {
dbx_core::session_credentials::with_credential_owner(
owner,
handle_monitor_socket(socket, state, connection_id),
)
.await;
} else {
handle_pubsub_socket(socket, state, connection_id).await;
}
})
}
async fn handle_monitor_socket(mut socket: WebSocket, state: Arc<WebState>, connection_id: String) {
let monitor = tokio::select! {
result = dbx_core::redis_ops::redis_create_monitor_core(&state.app, &connection_id) => result,
_ = socket.recv() => return,
};
let monitor = match monitor {
Ok(monitor) => monitor,
Err(error) => {
let _ = socket.send(Message::Text(serde_json::json!({ "error": error }).to_string().into())).await;
return;
}
};
if socket.send(Message::Text(serde_json::json!({ "ready": true }).to_string().into())).await.is_err() {
return;
}
let mut messages = monitor.into_on_message::<String>();
let (mut sender, mut receiver) = socket.split();
loop {
tokio::select! {
incoming = receiver.next() => {
match incoming {
Some(Ok(Message::Ping(payload))) => {
if sender.send(Message::Pong(payload)).await.is_err() { break; }
}
Some(Ok(Message::Pong(_))) => {}
_ => break,
}
}
message = messages.next() => {
let Some(message) = message else {
let _ = sender.send(Message::Text(serde_json::json!({ "error": "Redis MONITOR connection closed" }).to_string().into())).await;
break;
};
let event = Message::Text(serde_json::json!({ "message": message }).to_string().into());
match tokio::time::timeout(std::time::Duration::from_secs(5), sender.send(event)).await {
Ok(Ok(())) => {}
_ => break,
}
}
}
}
} }
async fn handle_pubsub_socket(socket: WebSocket, state: Arc<WebState>, connection_id: String) { async fn handle_pubsub_socket(socket: WebSocket, state: Arc<WebState>, connection_id: String) {
+10
View File
@@ -114,6 +114,16 @@ Key 统计和 Slowlog 会额外调用 Redis 命令。大型实例或集群中应
清空 DB、批量删除和 `FLUSH*` 无法撤销。只读连接会拒绝写入;生产环境保护和危险命令规则会继续生效。 清空 DB、批量删除和 `FLUSH*` 无法撤销。只读连接会拒绝写入;生产环境保护和危险命令规则会继续生效。
## 实时 MONITOR
![Redis MONITOR 实时日志](/screenshots/redis-monitor.png)
在 Redis 查询编辑器中单独执行 `MONITOR`,结果区会持续显示当前节点的命令日志,而不是只返回初始 `OK`。监听范围包含该节点的所有 DB,不受编辑器当前选择的 DB 限制。
点击“停止监听”或执行工具栏的停止按钮结束监听;关闭页签也会释放专用连接。界面保留最近 1000 条记录,单条超长记录最多显示 16384 个字符。监听结束后保留已显示的结果。
目前支持直连 Redis 节点及其 TLS、隧道配置。Sentinel 和 Cluster 连接请另建目标节点的直连连接;不会自动汇总集群日志。Redis 账号需要具备执行 `MONITOR` 的权限,持续监听会增加服务端开销。
## 安全与性能建议 ## 安全与性能建议
- 生产 Redis 使用只读账号或 ACL,并在 DBX 启用只读/生产保护 - 生产 Redis 使用只读账号或 ACL,并在 DBX 启用只读/生产保护
+10
View File
@@ -114,6 +114,16 @@ Key statistics and Slowlog require additional Redis commands. Limit scope and en
Flush, bulk delete, and `FLUSH*` operations cannot be undone. Read-only connections reject writes, while production and dangerous-command checks remain active. Flush, bulk delete, and `FLUSH*` operations cannot be undone. Read-only connections reject writes, while production and dangerous-command checks remain active.
## Live MONITOR
![Live Redis MONITOR logs](/screenshots/redis-monitor.png)
Run `MONITOR` on its own in the Redis query editor to receive live command logs instead of only the initial `OK`. Monitoring covers all databases on the connected node, regardless of the database selected in the editor.
Use **Stop listening**, the execution toolbar's stop button, or close the tab to release the dedicated connection. The result retains the latest 1,000 entries, with each entry limited to 16,384 characters. Stopping preserves the displayed results.
Direct Redis node connections, including their TLS and tunnel settings, are supported. For Sentinel or Cluster, create a direct connection to the node to inspect; logs are not aggregated across nodes. The Redis account needs permission to execute `MONITOR`, which adds server overhead while active.
## Safety and Performance Guidance ## Safety and Performance Guidance
- Use a read-only account or ACL for production and enable DBX read-only/production protection - Use a read-only account or ACL for production and enable DBX read-only/production protection
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+52 -1
View File
@@ -56,6 +56,8 @@ impl Drop for PubSubServerState {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct PubSubWsParams { struct PubSubWsParams {
connection_id: String, connection_id: String,
#[serde(default)]
monitor: bool,
} }
pub fn build_pubsub_router(state: Arc<AppState>) -> Router { pub fn build_pubsub_router(state: Arc<AppState>) -> Router {
@@ -93,7 +95,56 @@ async fn ws_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let connection_id = params.connection_id; let connection_id = params.connection_id;
ws.on_upgrade(move |socket| handle_socket(socket, state, connection_id)) ws.on_upgrade(move |socket| async move {
if params.monitor {
handle_monitor_socket(socket, state, connection_id).await;
} else {
handle_socket(socket, state, connection_id).await;
}
})
}
async fn handle_monitor_socket(mut socket: WebSocket, state: Arc<AppState>, connection_id: String) {
let monitor = tokio::select! {
result = dbx_core::redis_ops::redis_create_monitor_core(&state, &connection_id) => result,
_ = socket.recv() => return,
};
let monitor = match monitor {
Ok(monitor) => monitor,
Err(error) => {
let _ = socket.send(Message::Text(serde_json::json!({ "error": error }).to_string().into())).await;
return;
}
};
if socket.send(Message::Text(serde_json::json!({ "ready": true }).to_string().into())).await.is_err() {
return;
}
let mut messages = monitor.into_on_message::<String>();
let (mut sender, mut receiver) = socket.split();
loop {
tokio::select! {
incoming = receiver.next() => {
match incoming {
Some(Ok(Message::Ping(payload))) => {
if sender.send(Message::Pong(payload)).await.is_err() { break; }
}
Some(Ok(Message::Pong(_))) => {}
_ => break,
}
}
message = messages.next() => {
let Some(message) = message else {
let _ = sender.send(Message::Text(serde_json::json!({ "error": "Redis MONITOR connection closed" }).to_string().into())).await;
break;
};
let event = Message::Text(serde_json::json!({ "message": message }).to_string().into());
match tokio::time::timeout(std::time::Duration::from_secs(5), sender.send(event)).await {
Ok(Ok(())) => {}
_ => break,
}
}
}
}
} }
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, connection_id: String) { async fn handle_socket(socket: WebSocket, state: Arc<AppState>, connection_id: String) {