diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 9cf5c3a65..42f4acad8 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -41,6 +41,8 @@ const { t } = useI18n(); const connectionStore = useConnectionStore(); const settingsStore = useSettingsStore(); +type RedisSearchMode = "key" | "value"; + const props = defineProps<{ connectionId: string; db: number; @@ -52,6 +54,7 @@ const loading = ref(false); const loadingMore = ref(false); const rootRef = ref(); const searchPattern = ref("*"); +const searchMode = ref("key"); const selectedKeyRaw = ref(null); const hasMore = ref(false); const scanCursor = ref(0); @@ -73,8 +76,14 @@ const keyGridStyle = { gridTemplateColumns: "minmax(12rem, 0.35fr) 80px 1fr 60px 60px", }; -const effectivePattern = computed(() => searchPattern.value.trim() || "*"); -const isSearchMode = computed(() => effectivePattern.value !== "*"); +const valueQuery = computed(() => searchPattern.value.trim()); +const effectivePattern = computed(() => (searchMode.value === "key" ? searchPattern.value.trim() || "*" : "*")); +const isSearchMode = computed(() => + searchMode.value === "key" ? effectivePattern.value !== "*" : valueQuery.value !== "", +); +const searchPlaceholder = computed(() => + searchMode.value === "key" ? t("redis.pattern") : t("redis.valueSearchPlaceholder"), +); const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === selectedKeyRaw.value) ?? null); const dangerDetails = computed(() => { if (!pendingDanger.value) return ""; @@ -125,13 +134,11 @@ function rebuildTree(expandAll = false) { } async function scanNextPage() { - const result = await api.redisScanKeys( - props.connectionId, - props.db, - scanCursor.value, - effectivePattern.value, - settingsStore.editorSettings.redisScanPageSize, - ); + const pageSize = settingsStore.editorSettings.redisScanPageSize; + const result = + searchMode.value === "value" + ? await api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize) + : await api.redisScanKeys(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize); const existingKeys = new Set(flatKeys.value.map((key) => key.key_raw)); flatKeys.value = [...flatKeys.value, ...result.keys.filter((key) => !existingKeys.has(key.key_raw))]; scanCursor.value = result.cursor; @@ -146,10 +153,16 @@ async function scanNextPage() { async function loadKeys() { loading.value = true; flatKeys.value = []; + treeKeys.value = []; selectedKeyRaw.value = null; checkedKeys.value = new Set(); + expandedGroupIds.value = new Set(); scanCursor.value = 0; try { + if (searchMode.value === "value" && !valueQuery.value) { + hasMore.value = false; + return; + } await scanNextPage(); } finally { loading.value = false; @@ -341,6 +354,15 @@ function onSearchInput() { searchTimer = setTimeout(loadKeys, 400); } +function setSearchMode(mode: RedisSearchMode) { + if (searchMode.value === mode) return; + searchMode.value = mode; + if (mode === "key" && !searchPattern.value.trim()) { + searchPattern.value = "*"; + } + void loadKeys(); +} + function getSearchInput(): HTMLInputElement | null { return rootRef.value?.querySelector("[data-redis-search-input]") ?? null; } @@ -360,7 +382,7 @@ function onSearchKeydown(event: KeyboardEvent) { } if (!isCancelSearchShortcut(event)) return; event.preventDefault(); - searchPattern.value = "*"; + searchPattern.value = searchMode.value === "key" ? "*" : ""; void loadKeys(); } @@ -381,11 +403,37 @@ defineExpose({ focusSearch });
+
+ + +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index fb8598cbc..e169c0dfb 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -842,6 +842,9 @@ export default { selectKey: "Select a key to view its value", noKeys: "No keys found", pattern: "pattern (e.g. user:*)", + valueSearchPlaceholder: "value contains...", + searchByKey: "Key", + searchByValue: "Value", keys: "{count} keys", loadingKeys: "Loading keys...", loadMoreKeys: "Load more keys", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 3160c555d..e55300be1 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -747,6 +747,9 @@ export default { selectKey: "Selecciona una clave para ver su valor", noKeys: "No se encontraron claves", pattern: "patrón (p. ej. usuario:*)", + valueSearchPlaceholder: "el valor contiene...", + searchByKey: "Clave", + searchByValue: "Valor", keys: "{count} claves", loadingKeys: "Cargando claves...", loadMoreKeys: "Cargar más claves", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 3d41b3180..eda357b86 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -822,6 +822,9 @@ export default { selectKey: "选择一个 key 查看值", noKeys: "未找到 key", pattern: "匹配模式 (如 user:*)", + valueSearchPlaceholder: "按值内容搜索...", + searchByKey: "键", + searchByValue: "值", keys: "{count} 个 key", loadingKeys: "正在加载 key...", loadMoreKeys: "加载更多", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 9a532c66b..32b29f22e 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -120,6 +120,7 @@ export const cancelDatabaseExport = forward("cancelDatabaseExport"); // Redis export const redisListDatabases = forward("redisListDatabases"); export const redisScanKeys = forward("redisScanKeys"); +export const redisScanValues = forward("redisScanValues"); export const redisGetValue = forward("redisGetValue"); export const redisSetString = forward("redisSetString"); export const redisDeleteKey = forward("redisDeleteKey"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index c99a1eccc..3c145c7ed 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -638,6 +638,17 @@ export async function redisScanKeys( return post("/api/redis/scan-keys", { connectionId, db, cursor, pattern, count }); } +export async function redisScanValues( + connectionId: string, + db: number, + cursor: number, + pattern: string, + query: string, + count: number, +): Promise { + return post("/api/redis/scan-values", { connectionId, db, cursor, pattern, query, count }); +} + export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise { return post("/api/redis/get-value", { connectionId, db, keyRaw }); } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index d027b5cbe..73dd6f3b2 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -491,6 +491,17 @@ export async function redisScanKeys( return invoke("redis_scan_keys", { connectionId, db, cursor, pattern, count }); } +export async function redisScanValues( + connectionId: string, + db: number, + cursor: number, + pattern: string, + query: string, + count: number, +): Promise { + return invoke("redis_scan_values", { connectionId, db, cursor, pattern, query, count }); +} + export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise { return invoke("redis_get_value", { connectionId, db, keyRaw }); } diff --git a/crates/dbx-core/src/db/redis_driver.rs b/crates/dbx-core/src/db/redis_driver.rs index 1640f7efa..d4a3b5e9a 100644 --- a/crates/dbx-core/src/db/redis_driver.rs +++ b/crates/dbx-core/src/db/redis_driver.rs @@ -354,6 +354,52 @@ pub async fn scan_keys_page( Ok(RedisScanResult { cursor: next_cursor, keys: result, total_keys }) } +pub async fn scan_values_page( + con: &mut redis::aio::MultiplexedConnection, + cursor: u64, + pattern: &str, + query: &str, + count: usize, +) -> Result { + let raw: RedisRawValue = redis::cmd("SCAN") + .arg(cursor) + .arg("MATCH") + .arg(pattern) + .arg("COUNT") + .arg(count) + .query_async(con) + .await + .map_err(|e| e.to_string())?; + + let (next_cursor, keys) = parse_scan_keys(raw)?; + let total_keys: u64 = redis::cmd("DBSIZE").query_async(con).await.unwrap_or(0); + if keys.is_empty() || query.trim().is_empty() { + return Ok(RedisScanResult { cursor: next_cursor, keys: Vec::new(), total_keys }); + } + + let mut result = Vec::new(); + for key in keys { + let Ok(value) = get_value(con, &key).await else { + continue; + }; + if !redis_value_matches_query(&value.value, query) { + continue; + } + + let value_preview = redis_search_value_preview(&value.value); + result.push(RedisKeyInfo { + key_display: value.key_display, + key_raw: value.key_raw, + key_type: value.key_type, + ttl: value.ttl, + size: redis_search_value_size(&value.value, value.total), + value_preview, + }); + } + + Ok(RedisScanResult { cursor: next_cursor, keys: result, total_keys }) +} + pub async fn get_value(con: &mut redis::aio::MultiplexedConnection, key: &[u8]) -> Result { let key_type: String = redis::cmd("TYPE").arg(key).query_async(con).await.map_err(|e| e.to_string())?; @@ -412,6 +458,42 @@ pub async fn get_value(con: &mut redis::aio::MultiplexedConnection, key: &[u8]) }) } +fn redis_value_matches_query(value: &serde_json::Value, query: &str) -> bool { + let query = query.trim(); + if query.is_empty() { + return false; + } + redis_search_value_text(value).to_lowercase().contains(&query.to_lowercase()) +} + +fn redis_search_value_text(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(text) => text.clone(), + other => serde_json::to_string(other).unwrap_or_else(|_| other.to_string()), + } +} + +fn redis_search_value_preview(value: &serde_json::Value) -> String { + const MAX_PREVIEW_LEN: usize = 160; + let text = redis_search_value_text(value); + if text.chars().count() <= MAX_PREVIEW_LEN { + return text; + } + let mut preview = text.chars().take(MAX_PREVIEW_LEN).collect::(); + preview.push('…'); + preview +} + +fn redis_search_value_size(value: &serde_json::Value, total: Option) -> u64 { + if let Some(total) = total { + return total; + } + match value { + serde_json::Value::String(text) => text.len() as u64, + _ => 0, + } +} + async fn get_stream_entries( con: &mut redis::aio::MultiplexedConnection, key: &[u8], @@ -819,7 +901,7 @@ mod tests { classify_command, is_redis_json_type, parse_command_argv, parse_database_count, parse_scan_keys, parse_stream_entries, redis_command_raw_to_json, redis_json_raw_to_json, redis_json_value_preview, redis_key_bytes_to_display, redis_key_bytes_to_raw, redis_key_raw_to_bytes, redis_key_value_preview, - redis_raw_to_json, redis_value_contains_binary, RedisCommandSafety, RedisRawValue, + redis_raw_to_json, redis_value_contains_binary, redis_value_matches_query, RedisCommandSafety, RedisRawValue, }; fn bulk(value: &str) -> RedisRawValue { @@ -950,6 +1032,14 @@ mod tests { assert_eq!(parse_command_argv(" ").unwrap_err(), "Redis command is empty"); } + #[test] + fn matches_redis_values_case_insensitively() { + assert!(redis_value_matches_query(&serde_json::json!("Hello Redis"), "redis")); + assert!(redis_value_matches_query(&serde_json::json!({"field": "Ada Lovelace"}), "lovelace")); + assert!(!redis_value_matches_query(&serde_json::json!("Hello Redis"), "")); + assert!(!redis_value_matches_query(&serde_json::json!("Hello Redis"), "mysql")); + } + #[test] fn classifies_safe_confirmed_and_blocked_commands() { assert_eq!(classify_command("GET"), RedisCommandSafety::Allowed); diff --git a/crates/dbx-core/src/redis_ops.rs b/crates/dbx-core/src/redis_ops.rs index 06ae42a1d..7032de362 100644 --- a/crates/dbx-core/src/redis_ops.rs +++ b/crates/dbx-core/src/redis_ops.rs @@ -36,6 +36,27 @@ pub async fn redis_scan_keys_core( } } +pub async fn redis_scan_values_core( + state: &AppState, + connection_id: &str, + db: u32, + cursor: u64, + pattern: &str, + query: &str, + count: usize, +) -> Result { + let connections = state.connections.read().await; + let pool = connections.get(connection_id).ok_or("Connection not found")?; + match pool { + PoolKind::Redis(con) => { + let mut con = con.lock().await; + redis_driver::select_db(&mut con, db).await?; + redis_driver::scan_values_page(&mut con, cursor, pattern, query, count).await + } + _ => Err("Not a Redis connection".to_string()), + } +} + pub async fn redis_get_value_core(state: &AppState, connection_id: &str, key: &str) -> Result { redis_get_value_in_db_core(state, connection_id, 0, key).await } diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 98749f70a..585d99300 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -123,6 +123,7 @@ async fn main() { // Redis .route("/redis/list-databases", post(routes::redis::list_databases)) .route("/redis/scan-keys", post(routes::redis::scan_keys)) + .route("/redis/scan-values", post(routes::redis::scan_values)) .route("/redis/get-value", post(routes::redis::get_value)) .route("/redis/set-string", post(routes::redis::set_string)) .route("/redis/delete-key", post(routes::redis::delete_key)) diff --git a/crates/dbx-web/src/routes/redis.rs b/crates/dbx-web/src/routes/redis.rs index ec092f074..1171c59c9 100644 --- a/crates/dbx-web/src/routes/redis.rs +++ b/crates/dbx-web/src/routes/redis.rs @@ -23,6 +23,17 @@ pub struct RedisScanRequest { pub count: usize, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RedisValueScanRequest { + pub connection_id: String, + pub db: u32, + pub cursor: u64, + pub pattern: String, + pub query: String, + pub count: usize, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct RedisKeyRequest { @@ -119,6 +130,24 @@ pub async fn scan_keys( Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) } +pub async fn scan_values( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + let result = dbx_core::redis_ops::redis_scan_values_core( + &state.app, + &req.connection_id, + req.db, + req.cursor, + &req.pattern, + &req.query, + req.count, + ) + .await + .map_err(AppError)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) +} + pub async fn get_value( State(state): State>, Json(req): Json, diff --git a/packages/app-tests/redisValueSearch.test.ts b/packages/app-tests/redisValueSearch.test.ts new file mode 100644 index 000000000..f5a136ac0 --- /dev/null +++ b/packages/app-tests/redisValueSearch.test.ts @@ -0,0 +1,13 @@ +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +test("Redis browser exposes key/value search modes", () => { + const source = readFileSync("apps/desktop/src/components/redis/RedisKeyBrowser.vue", "utf8"); + + assert.match(source, /type RedisSearchMode = "key" \| "value"/); + assert.match(source, /searchMode\s*=\s*ref\("key"\)/); + assert.match(source, /redisScanValues/); + assert.match(source, /redis\.searchByKey/); + assert.match(source, /redis\.searchByValue/); +}); diff --git a/src-tauri/src/commands/redis_cmd.rs b/src-tauri/src/commands/redis_cmd.rs index b36ba7fb0..ccf115feb 100644 --- a/src-tauri/src/commands/redis_cmd.rs +++ b/src-tauri/src/commands/redis_cmd.rs @@ -24,6 +24,19 @@ pub async fn redis_scan_keys( dbx_core::redis_ops::redis_scan_keys_core(&state, &connection_id, db, cursor, &pattern, count).await } +#[tauri::command] +pub async fn redis_scan_values( + state: State<'_, Arc>, + connection_id: String, + db: u32, + cursor: u64, + pattern: String, + query: String, + count: usize, +) -> Result { + dbx_core::redis_ops::redis_scan_values_core(&state, &connection_id, db, cursor, &pattern, &query, count).await +} + #[tauri::command] pub async fn redis_get_value( state: State<'_, Arc>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a6ef47200..f29fe73a0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -202,6 +202,7 @@ pub fn run() { commands::table_import::cancel_table_import, commands::redis_cmd::redis_list_databases, commands::redis_cmd::redis_scan_keys, + commands::redis_cmd::redis_scan_values, commands::redis_cmd::redis_get_value, commands::redis_cmd::redis_set_string, commands::redis_cmd::redis_delete_key,