diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 2a59df6a4..8e31e1c38 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -179,7 +179,11 @@ pub async fn load_connections(app: AppHandle) -> Result, S #[tauri::command] pub async fn test_connection(config: ConnectionConfig) -> Result { let url = config.connection_url(); - log::info!("[test_connection] db_type={:?} url={}", config.db_type, &url[..url.len().min(80)]); + log::info!( + "[test_connection] db_type={:?} target={}", + config.db_type, + config.redacted_connection_url() + ); match config.db_type { DatabaseType::Mysql => { let pool = db::mysql::connect(&url).await?; diff --git a/src-tauri/src/db/redis_driver.rs b/src-tauri/src/db/redis_driver.rs index a25fd4e7b..8f13a16ff 100644 --- a/src-tauri/src/db/redis_driver.rs +++ b/src-tauri/src/db/redis_driver.rs @@ -1,6 +1,8 @@ -use redis::AsyncCommands; +use redis::{AsyncCommands, Value as RedisRawValue}; use serde::{Deserialize, Serialize}; +const STREAM_ENTRY_LIMIT: usize = 100; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RedisKeyInfo { pub key: String, @@ -40,7 +42,9 @@ pub async fn connect(url: &str) -> Result Result, String> { +pub async fn list_databases( + con: &mut redis::aio::MultiplexedConnection, +) -> Result, String> { let info: String = redis::cmd("INFO") .arg("keyspace") .query_async(con) @@ -139,7 +143,10 @@ pub async fn get_value( .zrange_withscores(key, 0, -1) .await .map_err(|e| e.to_string())?; - serde_json::json!(v.iter().map(|(m, s)| serde_json::json!({"member": m, "score": s})).collect::>()) + serde_json::json!(v + .iter() + .map(|(m, s)| serde_json::json!({"member": m, "score": s})) + .collect::>()) } "hash" => { let v: Vec<(String, String)> = con.hgetall(key).await.map_err(|e| e.to_string())?; @@ -149,6 +156,7 @@ pub async fn get_value( .collect(); serde_json::Value::Object(map) } + "stream" => get_stream_entries(con, key).await?, _ => serde_json::Value::Null, }; @@ -160,6 +168,75 @@ pub async fn get_value( }) } +async fn get_stream_entries( + con: &mut redis::aio::MultiplexedConnection, + key: &str, +) -> Result { + let raw: RedisRawValue = redis::cmd("XRANGE") + .arg(key) + .arg("-") + .arg("+") + .arg("COUNT") + .arg(STREAM_ENTRY_LIMIT) + .query_async(con) + .await + .map_err(|e| e.to_string())?; + + Ok(parse_stream_entries(raw)) +} + +fn parse_stream_entries(raw: RedisRawValue) -> serde_json::Value { + match raw { + RedisRawValue::Array(entries) => { + serde_json::Value::Array(entries.into_iter().filter_map(parse_stream_entry).collect()) + } + _ => serde_json::Value::Null, + } +} + +fn parse_stream_entry(entry: RedisRawValue) -> Option { + let mut parts = match entry { + RedisRawValue::Array(parts) if parts.len() == 2 => parts.into_iter(), + _ => return None, + }; + + let id = redis_value_to_string(parts.next()?)?; + let fields = match parts.next()? { + RedisRawValue::Array(fields) => fields, + _ => return None, + }; + + let mut field_map = serde_json::Map::new(); + let mut fields = fields.into_iter(); + while let Some(field) = fields.next() { + let Some(value) = fields.next() else { + break; + }; + if let Some(field_name) = redis_value_to_string(field) { + let value = redis_value_to_string(value).unwrap_or_default(); + field_map.insert(field_name, serde_json::Value::String(value)); + } + } + + Some(serde_json::json!({ + "id": id, + "fields": field_map, + })) +} + +fn redis_value_to_string(value: RedisRawValue) -> Option { + match value { + RedisRawValue::BulkString(bytes) => Some(String::from_utf8_lossy(&bytes).to_string()), + RedisRawValue::SimpleString(value) => Some(value), + RedisRawValue::Int(value) => Some(value.to_string()), + RedisRawValue::Double(value) => Some(value.to_string()), + RedisRawValue::Boolean(value) => Some(value.to_string()), + RedisRawValue::VerbatimString { text, .. } => Some(text), + RedisRawValue::Okay => Some("OK".to_string()), + _ => None, + } +} + pub async fn set_string( con: &mut redis::aio::MultiplexedConnection, key: &str, @@ -254,3 +331,65 @@ pub async fn set_remove( .await .map_err(|e| e.to_string()) } + +#[cfg(test)] +mod tests { + use super::{parse_stream_entries, RedisRawValue}; + + fn bulk(value: &str) -> RedisRawValue { + RedisRawValue::BulkString(value.as_bytes().to_vec()) + } + + #[test] + fn parses_stream_entries() { + let raw = RedisRawValue::Array(vec![RedisRawValue::Array(vec![ + bulk("1714470000000-0"), + RedisRawValue::Array(vec![ + bulk("event"), + bulk("login"), + bulk("user_id"), + bulk("42"), + ]), + ])]); + + let parsed = parse_stream_entries(raw); + + assert_eq!( + parsed, + serde_json::json!([ + { + "id": "1714470000000-0", + "fields": { + "event": "login", + "user_id": "42" + } + } + ]) + ); + } + + #[test] + fn skips_malformed_stream_entries() { + let raw = RedisRawValue::Array(vec![ + RedisRawValue::Array(vec![bulk("1714470000000-0")]), + RedisRawValue::Array(vec![ + bulk("1714470000001-0"), + RedisRawValue::Array(vec![bulk("event"), bulk("logout")]), + ]), + ]); + + let parsed = parse_stream_entries(raw); + + assert_eq!( + parsed, + serde_json::json!([ + { + "id": "1714470000001-0", + "fields": { + "event": "logout" + } + } + ]) + ); + } +} diff --git a/src-tauri/src/models/connection.rs b/src-tauri/src/models/connection.rs index a80d0cafd..0752af9dc 100644 --- a/src-tauri/src/models/connection.rs +++ b/src-tauri/src/models/connection.rs @@ -35,7 +35,9 @@ pub struct ConnectionConfig { pub ssl: bool, } -fn default_ssh_port() -> u16 { 22 } +fn default_ssh_port() -> u16 { + 22 +} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] @@ -61,6 +63,46 @@ impl ConnectionConfig { self.connection_url_with_host(&self.host, self.port) } + pub fn redacted_connection_url(&self) -> String { + self.redacted_connection_url_with_host(&self.host, self.port) + } + + pub fn redacted_connection_url_with_host(&self, host: &str, port: u16) -> String { + let db_part = self + .database + .as_deref() + .filter(|d| !d.is_empty()) + .map(|d| format!("/{}", encode_url_part(d))) + .unwrap_or_default(); + let params = self.normalized_url_params(); + + match self.db_type { + DatabaseType::Sqlite | DatabaseType::DuckDb => { + format!("{}?mode=rwc", self.host) + } + DatabaseType::Redis => { + let scheme = if self.ssl { "rediss" } else { "redis" }; + format!("{scheme}://{host}:{port}/") + } + DatabaseType::Mysql => format!("mysql://{host}:{port}{db_part}?{params}"), + DatabaseType::Postgres => { + let suffix = if params.is_empty() { + String::new() + } else { + format!("?{params}") + }; + format!("postgres://{host}:{port}{db_part}{suffix}") + } + DatabaseType::ClickHouse => format!("http://{host}:{port}{db_part}"), + DatabaseType::SqlServer => format!( + "server=tcp:{host},{port};database={}", + self.database.as_deref().unwrap_or("master") + ), + DatabaseType::MongoDb => format!("mongodb://{host}:{port}{db_part}"), + DatabaseType::Oracle => format!("oracle://{host}:{port}{db_part}"), + } + } + pub fn connection_url_with_host(&self, host: &str, port: u16) -> String { let db_part = self .database @@ -86,17 +128,27 @@ impl ConnectionConfig { format!("{scheme}://{username}:{password}@{host}:{port}/") } } - DatabaseType::Mysql => format!("mysql://{}:{}@{host}:{port}{db_part}?{params}", username, password), - DatabaseType::Postgres => { - let suffix = if params.is_empty() { String::new() } else { format!("?{params}") }; - format!("postgres://{}:{}@{host}:{port}{db_part}{suffix}", username, password) - } - DatabaseType::ClickHouse => format!( - "http://{host}:{port}{db_part}" + DatabaseType::Mysql => format!( + "mysql://{}:{}@{host}:{port}{db_part}?{params}", + username, password ), + DatabaseType::Postgres => { + let suffix = if params.is_empty() { + String::new() + } else { + format!("?{params}") + }; + format!( + "postgres://{}:{}@{host}:{port}{db_part}{suffix}", + username, password + ) + } + DatabaseType::ClickHouse => format!("http://{host}:{port}{db_part}"), DatabaseType::SqlServer => format!( "server=tcp:{host},{port};user={};password={};database={}", - self.username, self.password, self.database.as_deref().unwrap_or("master") + self.username, + self.password, + self.database.as_deref().unwrap_or("master") ), DatabaseType::MongoDb => { if self.username.is_empty() { @@ -105,10 +157,9 @@ impl ConnectionConfig { format!("mongodb://{username}:{password}@{host}:{port}{db_part}") } } - DatabaseType::Oracle => format!( - "oracle://{}:{}@{host}:{port}{db_part}", - username, password - ), + DatabaseType::Oracle => { + format!("oracle://{}:{}@{host}:{port}{db_part}", username, password) + } } } @@ -204,4 +255,41 @@ mod tests { "postgres://postgres:secret@10.1.2.3:2883/test?sslmode=disable" ); } + + #[test] + fn redacted_mysql_url_omits_credentials() { + let config = mysql_config("user@tenant#cluster", "p@ss:word#1", Some("db/name")); + + let url = config.redacted_connection_url(); + + assert_eq!(url, "mysql://10.1.2.3:2883/db%2Fname?ssl-mode=preferred"); + assert!(!url.contains("user")); + assert!(!url.contains("p%40ss")); + assert!(!url.contains("p@ss")); + } + + #[test] + fn redacted_sqlserver_url_omits_credentials() { + let mut config = mysql_config("sa", "super-secret", Some("master")); + config.db_type = DatabaseType::SqlServer; + + let url = config.redacted_connection_url(); + + assert_eq!(url, "server=tcp:10.1.2.3,2883;database=master"); + assert!(!url.contains("sa")); + assert!(!url.contains("super-secret")); + } + + #[test] + fn redacted_redis_url_omits_credentials_and_keeps_tls_scheme() { + let mut config = mysql_config("default", "redis-secret", None); + config.db_type = DatabaseType::Redis; + config.ssl = true; + + let url = config.redacted_connection_url(); + + assert_eq!(url, "rediss://10.1.2.3:2883/"); + assert!(!url.contains("default")); + assert!(!url.contains("redis-secret")); + } } diff --git a/src/components/redis/RedisKeyBrowser.vue b/src/components/redis/RedisKeyBrowser.vue index 7534a5b77..c8c64d039 100644 --- a/src/components/redis/RedisKeyBrowser.vue +++ b/src/components/redis/RedisKeyBrowser.vue @@ -73,6 +73,7 @@ function typeColor(type: string): string { case "set": return "text-purple-500"; case "zset": return "text-amber-500"; case "hash": return "text-orange-500"; + case "stream": return "text-teal-500"; default: return "text-muted-foreground"; } } diff --git a/src/components/redis/RedisValueViewer.vue b/src/components/redis/RedisValueViewer.vue index 2384d22bf..834b2a963 100644 --- a/src/components/redis/RedisValueViewer.vue +++ b/src/components/redis/RedisValueViewer.vue @@ -234,6 +234,20 @@ onMounted(load); + +
+
+ {{ t('redis.entries', { count: Array.isArray(data.value) ? data.value.length : 0 }) }} +
+
+
{{ entry.id }}
+
+ {{ field }} + {{ val }} +
+
+
+
{{ formatValue(data.value) }}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 8e4016ab8..c1cf39e5e 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -173,6 +173,7 @@ export default { items: "{count} items", fields: "{count} fields", members: "{count} members", + entries: "{count} entries", noExpiry: "no expiry", }, mongo: { diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index fc602c05a..3a98b5fa7 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -171,6 +171,7 @@ export default { items: "{count} 个元素", fields: "{count} 个字段", members: "{count} 个成员", + entries: "{count} 条记录", noExpiry: "永不过期", }, mongo: { diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index fc97557a0..7e0fc2586 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -459,7 +459,7 @@ export const useConnectionStore = defineStore("connection", () => { const { writeTextFile } = await import("@tauri-apps/plugin-fs"); const path = await save({ filters: [{ name: "JSON", extensions: ["json"] }], defaultPath: "dbx-connections.json" }); if (!path) return; - const data = connections.value.map((c) => ({ ...c, password: "" })); + const data = connections.value.map((c) => ({ ...c, password: "", ssh_password: "" })); await writeTextFile(path, JSON.stringify(data, null, 2)); }