feat(redis): search keys by value
This commit is contained in:
parent
da00405e10
commit
ea5e15612f
|
|
@ -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<HTMLElement>();
|
||||
const searchPattern = ref("*");
|
||||
const searchMode = ref<RedisSearchMode>("key");
|
||||
const selectedKeyRaw = ref<string | null>(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<HTMLInputElement>("[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 });
|
|||
<!-- Toolbar -->
|
||||
<div class="h-9 flex items-center gap-1 px-2 border-b shrink-0">
|
||||
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<div class="h-6 flex rounded-md border bg-muted/30 p-0.5 shrink-0" role="group">
|
||||
<button
|
||||
type="button"
|
||||
class="h-5 px-2 text-xs rounded-sm transition-colors"
|
||||
:class="
|
||||
searchMode === 'key'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="setSearchMode('key')"
|
||||
>
|
||||
{{ t("redis.searchByKey") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-5 px-2 text-xs rounded-sm transition-colors"
|
||||
:class="
|
||||
searchMode === 'value'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="setSearchMode('value')"
|
||||
>
|
||||
{{ t("redis.searchByValue") }}
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
v-model="searchPattern"
|
||||
data-redis-search-input
|
||||
class="h-6 text-xs border-0 shadow-none focus-visible:ring-0"
|
||||
:placeholder="t('redis.pattern')"
|
||||
:placeholder="searchPlaceholder"
|
||||
@input="onSearchInput"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -822,6 +822,9 @@ export default {
|
|||
selectKey: "选择一个 key 查看值",
|
||||
noKeys: "未找到 key",
|
||||
pattern: "匹配模式 (如 user:*)",
|
||||
valueSearchPlaceholder: "按值内容搜索...",
|
||||
searchByKey: "键",
|
||||
searchByValue: "值",
|
||||
keys: "{count} 个 key",
|
||||
loadingKeys: "正在加载 key...",
|
||||
loadMoreKeys: "加载更多",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<RedisScanResult> {
|
||||
return post("/api/redis/scan-values", { connectionId, db, cursor, pattern, query, count });
|
||||
}
|
||||
|
||||
export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise<RedisValue> {
|
||||
return post("/api/redis/get-value", { connectionId, db, keyRaw });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RedisScanResult> {
|
||||
return invoke("redis_scan_values", { connectionId, db, cursor, pattern, query, count });
|
||||
}
|
||||
|
||||
export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise<RedisValue> {
|
||||
return invoke("redis_get_value", { connectionId, db, keyRaw });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RedisScanResult, String> {
|
||||
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<RedisValue, String> {
|
||||
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::<String>();
|
||||
preview.push('…');
|
||||
preview
|
||||
}
|
||||
|
||||
fn redis_search_value_size(value: &serde_json::Value, total: Option<u64>) -> 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);
|
||||
|
|
|
|||
|
|
@ -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<RedisScanResult, String> {
|
||||
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<RedisValue, String> {
|
||||
redis_get_value_in_db_core(state, connection_id, 0, key).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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<Arc<WebState>>,
|
||||
Json(req): Json<RedisValueScanRequest>,
|
||||
) -> Result<Json<serde_json::Value>, 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<Arc<WebState>>,
|
||||
Json(req): Json<RedisKeyRequest>,
|
||||
|
|
|
|||
|
|
@ -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<RedisSearchMode>\("key"\)/);
|
||||
assert.match(source, /redisScanValues/);
|
||||
assert.match(source, /redis\.searchByKey/);
|
||||
assert.match(source, /redis\.searchByValue/);
|
||||
});
|
||||
|
|
@ -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<AppState>>,
|
||||
connection_id: String,
|
||||
db: u32,
|
||||
cursor: u64,
|
||||
pattern: String,
|
||||
query: String,
|
||||
count: usize,
|
||||
) -> Result<RedisScanResult, String> {
|
||||
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<AppState>>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue