From d4ee0e528a9de6ad0fc19c4d7120f929ffd60425 Mon Sep 17 00:00:00 2001 From: dalew <15072697283@139.com> Date: Sat, 20 Jun 2026 01:02:16 +0800 Subject: [PATCH 1/2] feat(redis): add slowlog panel and integrate with backend APIs --- .../src/components/redis/RedisKeyBrowser.vue | 13 +- .../components/redis/RedisSlowlogPanel.vue | 238 ++++++++++++++++++ apps/desktop/src/i18n/locales/en.ts | 16 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 16 ++ apps/desktop/src/lib/api.ts | 4 + apps/desktop/src/lib/http.ts | 10 + apps/desktop/src/lib/tauri.ts | 22 ++ crates/dbx-core/src/db/redis_driver.rs | 113 ++++++++- crates/dbx-core/src/redis_ops.rs | 49 +++- crates/dbx-web/src/main.rs | 3 + crates/dbx-web/src/routes/redis.rs | 40 +++ src-tauri/src/commands/redis_cmd.rs | 19 ++ src-tauri/src/lib.rs | 2 + 13 files changed, 541 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/components/redis/RedisSlowlogPanel.vue diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index ffa781095..155e57207 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -1,7 +1,7 @@ + + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index da6f68f15..153cad066 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1614,6 +1614,22 @@ export default { pubsubSend: "Send", pubsubPublishFailed: "Publish failed: {error}", pubsubWsConnectFailed: "WebSocket connection failed: {error}", + slowlog: "Slow Log", + slowlogCount: "Count", + slowlogQuery: "Query", + slowlogNode: "Node", + slowlogSelectNode: "Select a node", + slowlogNodeRequired: "Please select a node first", + slowlogEmpty: "Click Query to fetch slow log entries", + slowlogFetchFailed: "Failed to fetch slow log: {error}", + slowlogColumnId: "ID", + slowlogColumnTimestamp: "Timestamp", + slowlogColumnDuration: "Duration (μs)", + slowlogColumnCommand: "Command", + slowlogColumnClientAddr: "Client", + slowlogColumnClientName: "Client Name", + slowlogTotal: "{count} entries", + slowlogDetailTitle: "Slow Log Entry #{id}", }, mongo: { documents: "{count} documents", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 87c87eb94..94622f302 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1613,6 +1613,22 @@ export default { pubsubSend: "发送", pubsubPublishFailed: "发布失败: {error}", pubsubWsConnectFailed: "WebSocket 连接失败: {error}", + slowlog: "慢日志", + slowlogCount: "条数", + slowlogQuery: "查询", + slowlogNode: "节点", + slowlogSelectNode: "请选择节点", + slowlogNodeRequired: "请先选择一个节点", + slowlogEmpty: "点击查询获取慢日志", + slowlogFetchFailed: "获取慢日志失败: {error}", + slowlogColumnId: "ID", + slowlogColumnTimestamp: "时间", + slowlogColumnDuration: "耗时 (μs)", + slowlogColumnCommand: "命令", + slowlogColumnClientAddr: "来源", + slowlogColumnClientName: "客户端名称", + slowlogTotal: "共 {count} 条", + slowlogDetailTitle: "慢日志条目 #{id}", }, mongo: { documents: "{count} 个文档", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 84546822f..c2fcf61de 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -271,6 +271,8 @@ export const redisFlushDb = forward("redisFlushDb"); export const redisExecuteCommand = forward("redisExecuteCommand"); export const redisLoadMore = forward("redisLoadMore"); export const redisPubSubPublish = forward("redisPubSubPublish"); +export const redisSlowlogGet = forward("redisSlowlogGet"); +export const redisClusterMasterNodes = forward("redisClusterMasterNodes"); export function redisPubSubConnect(connectionId: string): WebSocket { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; @@ -396,6 +398,8 @@ export type { RedisScanResult, RedisCommandSafety, RedisCommandResult, + RedisSlowlogEntry, + RedisNodeEndpoint, KvValueEncoding, KvValue, KvKeyMetadata, diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 56bb4b8e4..3b2332995 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -49,6 +49,8 @@ import type { RedisValue, RedisScanResult, RedisCommandResult, + RedisSlowlogEntry, + RedisNodeEndpoint, KvValue, KvListPrefixResponse, KvGetResponse, @@ -1444,6 +1446,14 @@ export async function redisPubSubPublish(connectionId: string, db: number, chann return post("/api/redis/pubsub/publish", { connectionId, db, channel, message }); } +export async function redisSlowlogGet(connectionId: string, count: number, nodeHost?: string, nodePort?: number): Promise { + return post("/api/redis/slowlog-get", { connectionId, count, nodeHost, nodePort }); +} + +export async function redisClusterMasterNodes(connectionId: string): Promise { + return post("/api/redis/cluster-master-nodes", { connectionId }); +} + // --------------------------------------------------------------------------- // etcd // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index b1b9b0d19..aee843a5c 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -1129,6 +1129,20 @@ export interface RedisCommandResult { value: any; } +export interface RedisSlowlogEntry { + id: number; + timestamp: number; + duration_micros: number; + command: string; + client_addr: string | null; + client_name: string | null; +} + +export interface RedisNodeEndpoint { + host: string; + port: number; +} + export async function redisListDatabases(connectionId: string): Promise { return invoke("redis_list_databases", { connectionId }); } @@ -1229,6 +1243,14 @@ export async function redisPubSubPublish(connectionId: string, db: number, chann return invoke("redis_pubsub_publish", { connectionId, db, channel, message }); } +export async function redisSlowlogGet(connectionId: string, count: number, nodeHost?: string, nodePort?: number): Promise { + return invoke("redis_slowlog_get", { connectionId, count, nodeHost, nodePort }); +} + +export async function redisClusterMasterNodes(connectionId: string): Promise { + return invoke("redis_cluster_master_nodes", { connectionId }); +} + // --- etcd --- export type KvValueEncoding = "utf8" | "base64"; diff --git a/crates/dbx-core/src/db/redis_driver.rs b/crates/dbx-core/src/db/redis_driver.rs index eb930e963..e9541fcdf 100644 --- a/crates/dbx-core/src/db/redis_driver.rs +++ b/crates/dbx-core/src/db/redis_driver.rs @@ -100,6 +100,16 @@ pub struct RedisClusterAuth { pub password: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedisSlowlogEntry { + pub id: u64, + pub timestamp: i64, + pub duration_micros: u64, + pub command: String, + pub client_addr: Option, + pub client_name: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RedisNodeRoute { pub advertised: RedisNodeEndpoint, @@ -1016,7 +1026,7 @@ pub async fn cluster_key_connection<'a>( connect_cluster_node(pool, &endpoint).await.map(RedisClusterConnectionGuard::Direct) } -async fn connect_cluster_node( +pub async fn connect_cluster_node( pool: &RedisClusterPool, advertised_endpoint: &RedisNodeEndpoint, ) -> Result { @@ -1244,6 +1254,107 @@ where redis::cmd("FLUSHDB").query_async::<()>(con).await.map_err(|e| e.to_string()) } +/// Retrieve slowlog entries via `SLOWLOG GET `. +/// The response is a nested array where each entry has the structure: +/// [id, timestamp_unix_secs, duration_micros, [arg1, arg2, ...], client_addr, client_name, ...] +pub async fn get_slowlog(con: &mut C, count: usize) -> Result, String> +where + C: ConnectionLike + Send + Sync + Unpin, +{ + let raw: RedisRawValue = redis::cmd("SLOWLOG") + .arg("GET") + .arg(count as u64) + .query_async(con) + .await + .map_err(|e| format!("SLOWLOG GET failed: {e}"))?; + + let RedisRawValue::Array(entries) = raw else { + return Err("SLOWLOG GET returned non-array response".to_string()); + }; + + let mut result = Vec::with_capacity(entries.len()); + for entry in entries { + let RedisRawValue::Array(fields) = entry else { + continue; + }; + if fields.len() < 5 { + continue; + } + + let id = redis_value_to_u64(&fields[0]).unwrap_or(0); + let timestamp = fields[1].clone(); + let duration = fields[2].clone(); + let args_raw = fields[3].clone(); + let client_addr = redis_raw_value_to_optional_string(&fields[4]); + let client_name = if fields.len() > 5 { redis_raw_value_to_optional_string(&fields[5]) } else { None }; + + let command = match args_raw { + RedisRawValue::Array(args) => { + let mut parts = Vec::with_capacity(args.len()); + for arg in args { + if let Some(s) = redis_raw_value_to_optional_string(&arg) { + parts.push(s); + } + } + parts.join(" ") + } + _ => String::new(), + }; + + let timestamp_secs = match timestamp { + RedisRawValue::Int(i) => i, + RedisRawValue::BulkString(ref bytes) => { + std::str::from_utf8(bytes).ok().and_then(|s| s.parse::().ok()).unwrap_or(0) + } + _ => 0, + }; + + let duration_micros = match duration { + RedisRawValue::Int(i) => i as u64, + RedisRawValue::BulkString(ref bytes) => { + std::str::from_utf8(bytes).ok().and_then(|s| s.parse::().ok()).unwrap_or(0) + } + _ => 0, + }; + + result.push(RedisSlowlogEntry { + id, + timestamp: timestamp_secs, + duration_micros, + command, + client_addr, + client_name, + }); + } + + Ok(result) +} + +/// Try to convert a RedisRawValue to an optional string (None for Nil). +fn redis_raw_value_to_optional_string(v: &RedisRawValue) -> Option { + match v { + RedisRawValue::BulkString(bytes) => { + if bytes.is_empty() { + None + } else { + std::str::from_utf8(bytes).ok().map(|s| s.to_string()) + } + } + RedisRawValue::SimpleString(s) => Some(s.clone()), + RedisRawValue::Nil => None, + _ => None, + } +} + +/// Try to convert a RedisRawValue to a u64. +fn redis_value_to_u64(v: &RedisRawValue) -> Option { + match v { + RedisRawValue::Int(i) => Some(*i as u64), + RedisRawValue::BulkString(bytes) => std::str::from_utf8(bytes).ok().and_then(|s| s.parse().ok()), + _ => None, + } +} + /// Extract a string reference from a `RedisRawValue` if it is a BulkString or SimpleString. fn redis_raw_value_as_str(v: &RedisRawValue) -> Option<&str> { match v { diff --git a/crates/dbx-core/src/redis_ops.rs b/crates/dbx-core/src/redis_ops.rs index e4a13bf5b..fa08d801d 100644 --- a/crates/dbx-core/src/redis_ops.rs +++ b/crates/dbx-core/src/redis_ops.rs @@ -1,6 +1,7 @@ use crate::connection::{AppState, PoolKind}; use crate::db::redis_driver::{ - self, RedisCommandResult, RedisConnection, RedisDatabaseInfo, RedisKeyInfo, RedisScanResult, RedisValue, + self, RedisCommandResult, RedisConnection, RedisDatabaseInfo, RedisKeyInfo, RedisNodeEndpoint, RedisScanResult, + RedisSlowlogEntry, RedisValue, }; async fn ensure_redis_pool(state: &AppState, connection_id: &str) -> Result<(), String> { @@ -836,3 +837,49 @@ pub async fn redis_create_pubsub_core(state: &AppState, connection_id: &str) -> let timeout = std::time::Duration::from_secs(config.effective_connect_timeout_secs()); redis_driver::connect_pubsub(&config, &host, port, timeout).await } + +pub async fn redis_slowlog_get_core( + state: &AppState, + connection_id: &str, + count: usize, + node_host: Option, + node_port: Option, +) -> Result, 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; + // SLOWLOG is a server-level command, no select_db needed + redis_driver::get_slowlog(&mut *con, count).await + } + RedisConnection::Cluster(cluster) => { + if let (Some(host), Some(port)) = (node_host.as_ref(), node_port) { + let endpoint = redis_driver::RedisNodeEndpoint { host: host.clone(), port }; + let mut con = redis_driver::connect_cluster_node(cluster, &endpoint).await?; + redis_driver::get_slowlog(&mut con, count).await + } else { + // No node specified — return empty (frontend enforces selection) + Ok(Vec::new()) + } + } + }, + _ => Err("Not a Redis connection".to_string()), + } +} + +pub async fn redis_cluster_master_nodes_core( + state: &AppState, + connection_id: &str, +) -> Result, 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::Cluster(cluster) => redis_driver::cluster_master_nodes(cluster).await, + _ => Ok(Vec::new()), + }, + _ => Err("Not a Redis connection".to_string()), + } +} diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 9b7e713cc..ce97a056f 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -324,6 +324,9 @@ async fn main() { .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)) + // Redis Slowlog + .route("/redis/slowlog-get", post(routes::redis::slowlog_get)) + .route("/redis/cluster-master-nodes", post(routes::redis::cluster_master_nodes)) // etcd .route("/etcd/list-prefix", post(routes::etcd::list_prefix)) .route("/etcd/get", post(routes::etcd::get)) diff --git a/crates/dbx-web/src/routes/redis.rs b/crates/dbx-web/src/routes/redis.rs index e1d51bc4a..5959190fd 100644 --- a/crates/dbx-web/src/routes/redis.rs +++ b/crates/dbx-web/src/routes/redis.rs @@ -181,6 +181,21 @@ pub struct RedisPubSubPublishRequest { pub message: String, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SlowlogGetRequest { + pub connection_id: String, + pub count: usize, + pub node_host: Option, + pub node_port: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClusterNodesRequest { + pub connection_id: String, +} + pub async fn list_databases( State(state): State>, Json(req): Json, @@ -507,3 +522,28 @@ pub async fn publish_message( .map_err(AppError)?; Ok(Json(serde_json::json!({ "subscribers": count }))) } + +pub async fn slowlog_get( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + let result = dbx_core::redis_ops::redis_slowlog_get_core( + &state.app, + &req.connection_id, + req.count, + req.node_host, + req.node_port, + ) + .await + .map_err(AppError)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) +} + +pub async fn cluster_master_nodes( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + let result = + dbx_core::redis_ops::redis_cluster_master_nodes_core(&state.app, &req.connection_id).await.map_err(AppError)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) +} diff --git a/src-tauri/src/commands/redis_cmd.rs b/src-tauri/src/commands/redis_cmd.rs index b76048c20..6deebe6c1 100644 --- a/src-tauri/src/commands/redis_cmd.rs +++ b/src-tauri/src/commands/redis_cmd.rs @@ -330,3 +330,22 @@ pub async fn redis_pubsub_publish( ensure_connection_writable(&state, &connection_id, "PUBLISH").await?; dbx_core::redis_ops::redis_publish_core(&state, &connection_id, db, &channel, &message).await } + +#[tauri::command] +pub async fn redis_slowlog_get( + state: State<'_, Arc>, + connection_id: String, + count: usize, + node_host: Option, + node_port: Option, +) -> Result, String> { + dbx_core::redis_ops::redis_slowlog_get_core(&state, &connection_id, count, node_host, node_port).await +} + +#[tauri::command] +pub async fn redis_cluster_master_nodes( + state: State<'_, Arc>, + connection_id: String, +) -> Result, String> { + dbx_core::redis_ops::redis_cluster_master_nodes_core(&state, &connection_id).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c5847cd20..f1601f480 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -492,6 +492,8 @@ pub fn run() { commands::redis_cmd::redis_execute_command, commands::redis_cmd::redis_load_more, commands::redis_cmd::redis_pubsub_publish, + commands::redis_cmd::redis_slowlog_get, + commands::redis_cmd::redis_cluster_master_nodes, commands::etcd_cmd::etcd_list_prefix, commands::etcd_cmd::etcd_get, commands::etcd_cmd::etcd_put, From fc886452c0894ad66e659fdc8ecbaab21655cb0c Mon Sep 17 00:00:00 2001 From: dalew <15072697283@139.com> Date: Sat, 20 Jun 2026 12:09:27 +0800 Subject: [PATCH 2/2] fix(redis):Slowlog Panel Compatibility Issues --- .../components/redis/RedisSlowlogPanel.vue | 31 ++++++++++++------- crates/dbx-core/src/db/redis_driver.rs | 18 +++++++++-- crates/dbx-core/src/redis_ops.rs | 3 +- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/components/redis/RedisSlowlogPanel.vue b/apps/desktop/src/components/redis/RedisSlowlogPanel.vue index 537c353d3..a8700237d 100644 --- a/apps/desktop/src/components/redis/RedisSlowlogPanel.vue +++ b/apps/desktop/src/components/redis/RedisSlowlogPanel.vue @@ -24,7 +24,7 @@ const connectionStore = useConnectionStore(); // --- State --- const count = ref(100); const nodes = ref([]); -const selectedNode = ref(""); +const selectedNodeIndex = ref(-1); const entries = ref([]); const sortField = ref("id"); const sortOrder = ref<"asc" | "desc">("asc"); @@ -46,6 +46,15 @@ const nodeOptions = computed(() => { return nodes.value.map((n) => `${n.host}:${n.port}`); }); +const selectedEndpoint = computed(() => { + if (selectedNodeIndex.value < 0) return null; + return nodes.value[selectedNodeIndex.value] ?? null; +}); + +const showClientColumns = computed(() => { + return entries.value.some((e) => e.client_addr != null || e.client_name != null); +}); + // --- Load cluster nodes on mount --- onMounted(async () => { if (connectionMode.value === "cluster") { @@ -101,7 +110,7 @@ function displayValue(val: string | null): string { } async function querySlowlog() { - if (showNodeSelector.value && !selectedNode.value) { + if (showNodeSelector.value && selectedNodeIndex.value < 0) { toast(t("redis.slowlogNodeRequired"), 3000); return; } @@ -109,10 +118,8 @@ async function querySlowlog() { loading.value = true; try { let result: RedisSlowlogEntry[]; - if (showNodeSelector.value && selectedNode.value) { - const [host, portStr] = selectedNode.value.split(":"); - const port = parseInt(portStr, 10); - result = await api.redisSlowlogGet(props.connectionId, count.value, host, isNaN(port) ? undefined : port); + if (showNodeSelector.value && selectedEndpoint.value) { + result = await api.redisSlowlogGet(props.connectionId, count.value, selectedEndpoint.value.host, selectedEndpoint.value.port); } else { result = await api.redisSlowlogGet(props.connectionId, count.value); } @@ -136,12 +143,12 @@ async function querySlowlog() {