feat(redis): stream value search results
This commit is contained in:
parent
73cb490189
commit
8a81a01fac
|
|
@ -24,7 +24,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import RedisValueViewer from "./RedisValueViewer.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import type { RedisKeyInfo } from "@/lib/api";
|
||||
import type { RedisKeyInfo, RedisScanResult } from "@/lib/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import {
|
||||
|
|
@ -71,6 +71,7 @@ const commandText = ref("");
|
|||
const commandResult = ref<any>(null);
|
||||
const commandError = ref("");
|
||||
const commandRunning = ref(false);
|
||||
let searchRequestId = 0;
|
||||
|
||||
const keyGridStyle = {
|
||||
gridTemplateColumns: "minmax(12rem, 0.35fr) 80px 1fr 60px 60px",
|
||||
|
|
@ -84,6 +85,9 @@ const isSearchMode = computed(() =>
|
|||
const searchPlaceholder = computed(() =>
|
||||
searchMode.value === "key" ? t("redis.pattern") : t("redis.valueSearchPlaceholder"),
|
||||
);
|
||||
const loadingEmptyText = computed(() =>
|
||||
searchMode.value === "value" && valueQuery.value ? t("redis.searchingValues") : t("redis.loadingKeys"),
|
||||
);
|
||||
const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === selectedKeyRaw.value) ?? null);
|
||||
const dangerDetails = computed(() => {
|
||||
if (!pendingDanger.value) return "";
|
||||
|
|
@ -133,12 +137,14 @@ function rebuildTree(expandAll = false) {
|
|||
}
|
||||
}
|
||||
|
||||
async function scanNextPage() {
|
||||
async function fetchScanPage(): Promise<RedisScanResult> {
|
||||
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);
|
||||
return 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);
|
||||
}
|
||||
|
||||
function appendScanResult(result: RedisScanResult) {
|
||||
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;
|
||||
|
|
@ -150,7 +156,22 @@ async function scanNextPage() {
|
|||
});
|
||||
}
|
||||
|
||||
async function scanNextPage(requestId = searchRequestId): Promise<boolean> {
|
||||
const result = await fetchScanPage();
|
||||
if (requestId !== searchRequestId) return false;
|
||||
appendScanResult(result);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function streamValueSearch(requestId: number) {
|
||||
while (requestId === searchRequestId && searchMode.value === "value" && valueQuery.value && hasMore.value) {
|
||||
const applied = await scanNextPage(requestId);
|
||||
if (!applied) return;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadKeys() {
|
||||
const requestId = ++searchRequestId;
|
||||
loading.value = true;
|
||||
flatKeys.value = [];
|
||||
treeKeys.value = [];
|
||||
|
|
@ -163,17 +184,23 @@ async function loadKeys() {
|
|||
hasMore.value = false;
|
||||
return;
|
||||
}
|
||||
await scanNextPage();
|
||||
const applied = await scanNextPage(requestId);
|
||||
if (applied && searchMode.value === "value") {
|
||||
await streamValueSearch(requestId);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (requestId === searchRequestId) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!hasMore.value || loadingMore.value) return;
|
||||
const requestId = searchRequestId;
|
||||
loadingMore.value = true;
|
||||
try {
|
||||
await scanNextPage();
|
||||
await scanNextPage(requestId);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
|
|
@ -387,6 +414,7 @@ function onSearchKeydown(event: KeyboardEvent) {
|
|||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
searchRequestId++;
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
});
|
||||
|
||||
|
|
@ -442,7 +470,7 @@ defineExpose({ focusSearch });
|
|||
<RefreshCw v-else class="h-3 w-3" />
|
||||
</Button>
|
||||
<span class="text-xs text-muted-foreground shrink-0 ml-1">{{
|
||||
loading && flatKeys.length === 0 ? t("redis.loadingKeys") : t("redis.keys", { count: flatKeys.length })
|
||||
loading && flatKeys.length === 0 ? loadingEmptyText : t("redis.keys", { count: flatKeys.length })
|
||||
}}</span>
|
||||
<Button
|
||||
v-if="checkedKeys.size > 0"
|
||||
|
|
@ -455,12 +483,19 @@ defineExpose({ focusSearch });
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-9 flex items-center gap-1 px-2 border-b shrink-0">
|
||||
<div class="min-h-9 flex items-center gap-1 px-2 border-b bg-muted/20 shrink-0">
|
||||
<Terminal class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span
|
||||
class="h-5 px-1.5 rounded border bg-background/70 text-[10px] font-medium uppercase tracking-wide text-muted-foreground shrink-0"
|
||||
>
|
||||
{{ t("redis.commandPrefix") }}
|
||||
</span>
|
||||
<span class="font-mono text-xs text-muted-foreground shrink-0">></span>
|
||||
<Input
|
||||
v-model="commandText"
|
||||
class="h-6 text-xs border-0 shadow-none focus-visible:ring-0 font-mono"
|
||||
:placeholder="t('redis.commandPlaceholder')"
|
||||
data-redis-command-input
|
||||
class="h-6 text-xs border-0 shadow-none focus-visible:ring-0 font-mono bg-transparent"
|
||||
:placeholder="t('redis.commandHint')"
|
||||
@keydown.enter="executeCommand"
|
||||
/>
|
||||
<Button
|
||||
|
|
@ -515,7 +550,7 @@ defineExpose({ focusSearch });
|
|||
class="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-xs"
|
||||
>
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
<span>{{ t("redis.loadingKeys") }}</span>
|
||||
<span>{{ loadingEmptyText }}</span>
|
||||
</div>
|
||||
<RecycleScroller
|
||||
v-else
|
||||
|
|
@ -602,7 +637,13 @@ defineExpose({ focusSearch });
|
|||
</template>
|
||||
</RecycleScroller>
|
||||
<div v-if="hasMore" class="shrink-0 border-t px-2 py-1.5 flex items-center justify-center">
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs w-full" :disabled="loadingMore" @click="loadMore">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs w-full"
|
||||
:disabled="loadingMore || loading"
|
||||
@click="loadMore"
|
||||
>
|
||||
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
|
||||
{{ t("redis.loadMoreKeys") }}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -847,6 +847,7 @@ export default {
|
|||
searchByValue: "Value",
|
||||
keys: "{count} keys",
|
||||
loadingKeys: "Loading keys...",
|
||||
searchingValues: "Searching values...",
|
||||
loadMoreKeys: "Load more keys",
|
||||
items: "{count} items",
|
||||
fields: "{count} fields",
|
||||
|
|
@ -866,6 +867,8 @@ export default {
|
|||
flushDb: "Clear current DB",
|
||||
flushDbDetails: "Redis db{db}",
|
||||
commandPlaceholder: "Redis command, e.g. GET user:1",
|
||||
commandPrefix: "CMD",
|
||||
commandHint: "Enter a Redis command, e.g. SMEMBERS feature:flags",
|
||||
executeCommand: "Execute command",
|
||||
commandEmpty: "Enter a Redis command",
|
||||
commandBlocked: "This Redis command is blocked for safety",
|
||||
|
|
|
|||
|
|
@ -752,6 +752,7 @@ export default {
|
|||
searchByValue: "Valor",
|
||||
keys: "{count} claves",
|
||||
loadingKeys: "Cargando claves...",
|
||||
searchingValues: "Buscando por valor...",
|
||||
loadMoreKeys: "Cargar más claves",
|
||||
items: "{count} elementos",
|
||||
fields: "{count} campos",
|
||||
|
|
@ -771,6 +772,8 @@ export default {
|
|||
flushDb: "Limpiar DB actual",
|
||||
flushDbDetails: "Redis db{db}",
|
||||
commandPlaceholder: "Comando Redis, p. ej. GET user:1",
|
||||
commandPrefix: "CMD",
|
||||
commandHint: "Ingresa un comando Redis, p. ej. SMEMBERS feature:flags",
|
||||
executeCommand: "Ejecutar comando",
|
||||
commandEmpty: "Ingresa un comando Redis",
|
||||
commandBlocked: "Este comando Redis está bloqueado por seguridad",
|
||||
|
|
|
|||
|
|
@ -827,6 +827,7 @@ export default {
|
|||
searchByValue: "值",
|
||||
keys: "{count} 个 key",
|
||||
loadingKeys: "正在加载 key...",
|
||||
searchingValues: "正在按值搜索...",
|
||||
loadMoreKeys: "加载更多",
|
||||
items: "{count} 个元素",
|
||||
fields: "{count} 个字段",
|
||||
|
|
@ -845,6 +846,8 @@ export default {
|
|||
flushDb: "清空当前 DB",
|
||||
flushDbDetails: "Redis db{db}",
|
||||
commandPlaceholder: "Redis 命令,如 GET user:1",
|
||||
commandPrefix: "命令",
|
||||
commandHint: "输入 Redis 命令,如 SMEMBERS feature:flags",
|
||||
executeCommand: "执行命令",
|
||||
commandEmpty: "请输入 Redis 命令",
|
||||
commandBlocked: "出于安全考虑,此 Redis 命令已被阻止",
|
||||
|
|
|
|||
|
|
@ -361,22 +361,23 @@ pub async fn scan_values_page(
|
|||
query: &str,
|
||||
count: usize,
|
||||
) -> Result<RedisScanResult, String> {
|
||||
let total_keys: u64 = redis::cmd("DBSIZE").query_async(con).await.unwrap_or(0);
|
||||
if query.trim().is_empty() {
|
||||
return Ok(RedisScanResult { cursor, keys: Vec::new(), total_keys });
|
||||
}
|
||||
|
||||
let scan_count = count.max(1);
|
||||
let raw: RedisRawValue = redis::cmd("SCAN")
|
||||
.arg(cursor)
|
||||
.arg("MATCH")
|
||||
.arg(pattern)
|
||||
.arg("COUNT")
|
||||
.arg(count)
|
||||
.arg(scan_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 {
|
||||
|
|
|
|||
|
|
@ -11,3 +11,21 @@ test("Redis browser exposes key/value search modes", () => {
|
|||
assert.match(source, /redis\.searchByKey/);
|
||||
assert.match(source, /redis\.searchByValue/);
|
||||
});
|
||||
|
||||
test("Redis command input is visually distinct from search", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/redis/RedisKeyBrowser.vue", "utf8");
|
||||
|
||||
assert.match(source, /data-redis-command-input/);
|
||||
assert.match(source, /redis\.commandPrefix/);
|
||||
assert.match(source, /redis\.commandHint/);
|
||||
});
|
||||
|
||||
test("Redis value search streams incremental scan pages from the browser", () => {
|
||||
const browserSource = readFileSync("apps/desktop/src/components/redis/RedisKeyBrowser.vue", "utf8");
|
||||
const driverSource = readFileSync("crates/dbx-core/src/db/redis_driver.rs", "utf8");
|
||||
|
||||
assert.match(browserSource, /async function streamValueSearch/);
|
||||
assert.match(browserSource, /searchRequestId/);
|
||||
assert.match(browserSource, /redis\.searchingValues/);
|
||||
assert.doesNotMatch(driverSource, /while\s+result\.len\(\)\s*<\s*target_count/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue