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..beb5dc1cb 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,119 @@ 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() < 4 { + 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 = if fields.len() > 4 { redis_raw_value_to_optional_string(&fields[4]) } else { None }; + 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_command_arg(&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, + } +} + +/// Convert a RedisRawValue to an command argument string. +/// Unlike `redis_raw_value_to_optional_string`, this preserves empty strings +/// and uses `redis_bytes_to_display` to handle non-UTF-8 binary data. +fn redis_raw_value_to_command_arg(v: &RedisRawValue) -> Option { + match v { + RedisRawValue::BulkString(bytes) => Some(redis_bytes_to_display(bytes)), + 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..651d60e1e 100644 --- a/crates/dbx-core/src/redis_ops.rs +++ b/crates/dbx-core/src/redis_ops.rs @@ -836,3 +836,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,