feat(redis): search list set and zset members

This commit is contained in:
onenewcode
2026-09-03 11:56:46 +08:00
committed by GitHub
parent 2c7a420ef6
commit aa4f503353
17 changed files with 715 additions and 78 deletions
@@ -0,0 +1,275 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
redisGetValue: vi.fn(),
redisGetTtl: vi.fn(),
redisSetTtl: vi.fn(),
redisSetExpireAt: vi.fn(),
redisLoadMore: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
redisGetValue: mocks.redisGetValue,
redisGetTtl: mocks.redisGetTtl,
redisSetTtl: mocks.redisSetTtl,
redisSetExpireAt: mocks.redisSetExpireAt,
redisLoadMore: mocks.redisLoadMore,
}));
vi.mock("@/composables/useEditorFontFamilyStyle", () => ({
useEditorFontFamilyStyle: () => ({}),
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("@/lib/common/shikiJsonHighlighter", () => ({
createShikiJsonHighlighter: vi.fn().mockResolvedValue(() => ""),
}));
import RedisValueViewer from "./RedisValueViewer.vue";
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
function createLocalStorage(): Storage {
const entries = new Map<string, string>();
return {
get length() {
return entries.size;
},
clear() {
entries.clear();
},
getItem(key) {
return entries.get(key) ?? null;
},
key(index) {
return [...entries.keys()][index] ?? null;
},
removeItem(key) {
entries.delete(key);
},
setItem(key, value) {
entries.set(key, String(value));
},
};
}
afterEach(() => {
for (const { unmount, host } of mountedApps.splice(0)) {
unmount();
host.remove();
}
vi.unstubAllGlobals();
});
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("localStorage", createLocalStorage());
});
async function settle() {
await nextTick();
await Promise.resolve();
await nextTick();
await Promise.resolve();
await nextTick();
}
function blob(text: string) {
return { raw_base64: btoa(text), encoding: "utf8" as const };
}
function zsetValue() {
return {
key_display: "rank:order",
key_raw: "rank:order",
ttl: -1,
redis_type: "zset",
data: {
kind: "zset" as const,
items: [
{ score: "1", member: blob("2314099896:11842851748888") },
{ score: "2", member: blob("2314101855:22903060973580") },
],
total: 100,
scan_cursor: 2,
},
};
}
function setValue() {
return {
key_display: "members",
key_raw: "members",
ttl: -1,
redis_type: "set",
data: { kind: "set" as const, items: [{ member: blob("alpha") }], total: 1, scan_cursor: undefined },
};
}
function listValue() {
return {
key_display: "queue",
key_raw: "queue",
ttl: -1,
redis_type: "list",
data: { kind: "list" as const, items: [{ index: 0, value: blob("first") }], total: 1, scan_cursor: undefined },
};
}
const testI18nMessages = {
en: {
redis: {
searchFields: "Search fields and values",
searchMembers: "Search members",
searchItems: "Search items",
members: "{count} members",
items: "{count} items",
loadedMembers: "{loaded} / {total} members loaded",
loadedItems: "{loaded} / {total} items loaded",
loadMoreKeys: "Load more",
},
},
};
function mountViewer() {
const host = document.createElement("div");
document.body.append(host);
const app = createApp(
defineComponent({
setup() {
return () => h(RedisValueViewer, { connectionId: "connection", db: 0, keyDisplay: "key", keyRaw: "key" });
},
}),
);
app.use(createI18n({ legacy: false, locale: "en", messages: testI18nMessages, missingWarn: false, fallbackWarn: false }));
app.mount(host);
mountedApps.push({ unmount: () => app.unmount(), host });
}
function searchInput(): HTMLInputElement | null {
return document.querySelector<HTMLInputElement>("input[placeholder='Search members'], input[placeholder='Search items']");
}
function loadMoreButton(): HTMLButtonElement {
const buttons = [...document.querySelectorAll<HTMLButtonElement>("button")];
return buttons.find((button) => button.textContent?.includes("Load more"))!;
}
async function typeSearch(query: string) {
const input = searchInput()!;
input.value = query;
input.dispatchEvent(new Event("input", { bubbles: true }));
// The Input wrapper syncs its v-model through a passive watcher, so the query only
// reaches the viewer on the next tick — press Enter after it lands, like real typing.
await nextTick();
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await settle();
}
describe("Redis collection member search", () => {
it("offers a member search box for zset keys", async () => {
mocks.redisGetValue.mockResolvedValue(zsetValue());
mountViewer();
await settle();
expect(searchInput()).not.toBeNull();
});
it("offers a search box for set and list keys", async () => {
mocks.redisGetValue.mockResolvedValue(setValue());
mountViewer();
await settle();
expect(searchInput()?.placeholder).toBe("Search members");
for (const { unmount, host } of mountedApps.splice(0)) {
unmount();
host.remove();
}
mocks.redisGetValue.mockResolvedValue(listValue());
mountViewer();
await settle();
expect(searchInput()?.placeholder).toBe("Search items");
});
it("filters zset members server-side and keeps the sort direction", async () => {
mocks.redisGetValue.mockResolvedValue(zsetValue());
mocks.redisLoadMore.mockResolvedValue({
kind: "zset",
items: [{ score: "2", member: blob("2314101855:22903060973580") }],
scan_cursor: undefined,
});
mountViewer();
await settle();
await typeSearch("22903060");
expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "zset", 0, 200, "22903060", "asc");
expect(document.body.textContent).toContain("2314101855:22903060973580");
expect(document.body.textContent).not.toContain("2314099896:11842851748888");
});
it("carries the active query into load-more paging", async () => {
mocks.redisGetValue.mockResolvedValue(zsetValue());
mocks.redisLoadMore.mockResolvedValue({
kind: "zset",
items: [{ score: "2", member: blob("2314101855:22903060973580") }],
scan_cursor: 200,
});
mountViewer();
await settle();
await typeSearch("22903060");
mocks.redisLoadMore.mockClear();
mocks.redisLoadMore.mockResolvedValue({ kind: "zset", items: [], scan_cursor: undefined });
loadMoreButton().click();
await settle();
expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "zset", 200, 200, "22903060", "asc");
});
it("keeps the query when the zset sort direction is toggled", async () => {
mocks.redisGetValue.mockResolvedValue(zsetValue());
mocks.redisLoadMore.mockResolvedValue({
kind: "zset",
items: [{ score: "2", member: blob("2314101855:22903060973580") }],
scan_cursor: undefined,
});
mountViewer();
await settle();
await typeSearch("22903060");
mocks.redisLoadMore.mockClear();
[...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("Score"))!.click();
await settle();
// Reloading for the new sort order must re-apply the query instead of silently dropping it.
expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "zset", 0, 200, "22903060", "desc");
expect(searchInput()?.value).toBe("22903060");
});
it("drops the total from the count label while a search is active", async () => {
mocks.redisGetValue.mockResolvedValue(zsetValue());
mocks.redisLoadMore.mockResolvedValue({
kind: "zset",
items: [{ score: "2", member: blob("2314101855:22903060973580") }],
scan_cursor: undefined,
});
mountViewer();
await settle();
expect(document.body.textContent).toContain("2 / 100 members loaded");
await typeSearch("22903060");
expect(document.body.textContent).toContain("1 members");
expect(document.body.textContent).not.toContain("/ 100 members loaded");
});
});
@@ -398,8 +398,8 @@ const valueSearchOpen = ref(false);
const valueSearchQuery = ref("");
const valueSearchMatchIndex = ref(0);
const valueSearchHasNavigated = ref(false);
const hashSearchQuery = ref("");
const activeHashSearchQuery = ref("");
const collectionSearchQuery = ref("");
const activeCollectionSearchQuery = ref("");
const searchLoading = ref(false);
const valueSearchBarRef = ref<{ focusInput: (select?: boolean) => void } | null>(null);
type JsonEditorHandle = { openSearch: () => boolean; selectRange?: (from: number, to: number, options?: { focus?: boolean }) => boolean };
@@ -426,7 +426,7 @@ async function toggleZsetSort() {
const previousDirection = zsetSortDir.value;
zsetSortDir.value = previousDirection === "asc" ? "desc" : "asc";
try {
await load({ notifyParent: false });
await reloadPreservingCollectionSearch({ notifyParent: false });
} catch (error) {
zsetSortDir.value = previousDirection;
toast(errorMessage(error), 3000);
@@ -764,8 +764,8 @@ const canHighlightContentSearch = computed(() => valueSearchOpen.value && Boolea
const canHighlightStringSurface = computed(() => canHighlightContentSearch.value && !showMemberDetail.value);
const canHighlightMemberSurface = computed(() => canHighlightContentSearch.value && showMemberDetail.value);
let hashSearchTimer: ReturnType<typeof setTimeout> | null = null;
let hashSearchRequestId = 0;
let collectionSearchTimer: ReturnType<typeof setTimeout> | null = null;
let collectionSearchRequestId = 0;
let hashResizeStartX = 0;
let hashResizeStartWidth = 0;
let zsetResizeStartX = 0;
@@ -774,7 +774,7 @@ let zsetResizeStartWidth = 0;
function shouldPauseAutoValueRefresh(): boolean {
const loadedPageSize = data.value ? redisValueCollectionItems(data.value).length : 0;
const hasExpandedCollectionPage = collectionItems.value.length > loadedPageSize;
return showMemberDetail.value || editingZsetMemberKey.value !== null || showHashFieldTtlDialog.value || valueSearchOpen.value || Boolean(hashSearchQuery.value.trim()) || Boolean(activeHashSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage;
return showMemberDetail.value || editingZsetMemberKey.value !== null || showHashFieldTtlDialog.value || valueSearchOpen.value || Boolean(collectionSearchQuery.value.trim()) || Boolean(activeCollectionSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage;
}
type PendingDelete = { kind: "key" } | { kind: "hash"; field: string } | { kind: "list"; index: number } | { kind: "set"; member: string } | { kind: "zset"; member: string };
@@ -1092,40 +1092,55 @@ function collectionCountLabel(kind: "items" | "fields" | "members", loaded: numb
return t(`redis.loaded${kind[0].toUpperCase()}${kind.slice(1)}`, { loaded, total });
}
function onHashSearchInput() {
if (hashSearchTimer) clearTimeout(hashSearchTimer);
hashSearchTimer = setTimeout(() => void onHashSearch(), 400);
/** True for the collection kinds whose toolbar offers a server-filtered member search. */
const collectionSearchSupported = computed(() => redisKind.value === "hash" || redisKind.value === "list" || redisKind.value === "set" || redisKind.value === "zset");
function onCollectionSearchInput() {
if (collectionSearchTimer) clearTimeout(collectionSearchTimer);
collectionSearchTimer = setTimeout(() => void onCollectionSearch(), 400);
}
function onHashSearchKeydown(event: KeyboardEvent) {
function onCollectionSearchKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
if (hashSearchTimer) clearTimeout(hashSearchTimer);
hashSearchTimer = null;
void onHashSearch();
if (collectionSearchTimer) clearTimeout(collectionSearchTimer);
collectionSearchTimer = null;
void onCollectionSearch();
return;
}
if (event.key === "Escape") {
if (hashSearchTimer) clearTimeout(hashSearchTimer);
hashSearchTimer = null;
hashSearchQuery.value = "";
void onHashSearch();
if (collectionSearchTimer) clearTimeout(collectionSearchTimer);
collectionSearchTimer = null;
collectionSearchQuery.value = "";
void onCollectionSearch();
}
}
async function onHashSearch() {
const query = hashSearchQuery.value.trim();
if (redisKind.value !== "hash") return;
const requestId = ++hashSearchRequestId;
async function onCollectionSearch() {
const query = collectionSearchQuery.value.trim();
const keyType = redisKind.value;
if (!collectionSearchSupported.value) return;
const requestId = ++collectionSearchRequestId;
searchLoading.value = true;
try {
const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, "hash", 0, 200, query || undefined);
if (requestId !== hashSearchRequestId || result.kind !== "hash") return;
activeHashSearchQuery.value = query;
const sortDirection = keyType === "zset" ? zsetSortDir.value : undefined;
const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, 0, 200, query || undefined, sortDirection);
if (requestId !== collectionSearchRequestId || result.kind !== keyType) return;
activeCollectionSearchQuery.value = query;
collectionItems.value = result.items;
scanCursor.value = result.scan_cursor ?? undefined;
if (!hasRetainedMemberDraft.value) clearSelectedMember();
} finally {
if (requestId === hashSearchRequestId) searchLoading.value = false;
if (requestId === collectionSearchRequestId) searchLoading.value = false;
}
}
/** `load()` clears the search box, so anything that reloads mid-search has to re-apply the query. */
async function reloadPreservingCollectionSearch(options: { notifyParent?: boolean } = {}) {
const query = activeCollectionSearchQuery.value || collectionSearchQuery.value.trim();
await load({ ...options, selectDefaultMember: false });
if (query) {
collectionSearchQuery.value = query;
await onCollectionSearch();
}
}
@@ -1526,11 +1541,11 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
return false;
}
if (hashSearchTimer) clearTimeout(hashSearchTimer);
hashSearchTimer = null;
hashSearchRequestId++;
hashSearchQuery.value = "";
activeHashSearchQuery.value = "";
if (collectionSearchTimer) clearTimeout(collectionSearchTimer);
collectionSearchTimer = null;
collectionSearchRequestId++;
collectionSearchQuery.value = "";
activeCollectionSearchQuery.value = "";
searchLoading.value = false;
resetValueSearch();
data.value = loadedValue;
@@ -1587,16 +1602,16 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
}
async function loadMore() {
if (!data.value || !hasMore.value || loadingMore.value || (redisKind.value === "hash" && searchLoading.value)) return;
if (!(redisKind.value === "list" || redisKind.value === "set" || redisKind.value === "hash" || redisKind.value === "zset")) return;
if (!data.value || !hasMore.value || loadingMore.value || searchLoading.value) return;
if (!collectionSearchSupported.value) return;
const keyType = redisKind.value;
const hashFilter = keyType === "hash" ? activeHashSearchQuery.value || undefined : undefined;
const requestId = hashSearchRequestId;
const filter = activeCollectionSearchQuery.value || undefined;
const requestId = collectionSearchRequestId;
loadingMore.value = true;
try {
const sortDirection = keyType === "zset" ? zsetSortDir.value : undefined;
const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, scanCursor.value!, 200, hashFilter, sortDirection);
if (keyType === "hash" && requestId !== hashSearchRequestId) return;
const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, scanCursor.value!, 200, filter, sortDirection);
if (requestId !== collectionSearchRequestId) return;
const newItems = redisCollectionPageItems(result);
collectionItems.value = [...collectionItems.value, ...newItems];
scanCursor.value = result.scan_cursor ?? undefined;
@@ -2402,15 +2417,6 @@ function handleHashFieldTtlOpenChange(open: boolean) {
}
}
async function reloadHashPreservingSearch() {
const query = activeHashSearchQuery.value || hashSearchQuery.value.trim();
await load({ selectDefaultMember: false });
if (query) {
hashSearchQuery.value = query;
await onHashSearch();
}
}
async function saveHashFieldTtl() {
if (savingHashFieldTtl.value || !editingHashField.value) return;
const validation = validateRedisExpiry(hashFieldTtlMode.value, hashFieldTtlInput.value, hashFieldExpireAt.value);
@@ -2430,7 +2436,7 @@ async function saveHashFieldTtl() {
await api.redisHashFieldSetExpireAt(props.connectionId, props.db, props.keyRaw, field, validation.policy.expireAt);
}
cancelEditHashFieldTtl(true);
await reloadHashPreservingSearch();
await reloadPreservingCollectionSearch();
} catch (error) {
toast(errorMessage(error), 3000);
} finally {
@@ -2691,7 +2697,7 @@ onBeforeUnmount(() => {
stopRefreshTimers();
stopResizeHashColumns();
stopResizeZsetColumns();
if (hashSearchTimer) clearTimeout(hashSearchTimer);
if (collectionSearchTimer) clearTimeout(collectionSearchTimer);
});
defineExpose({ focusSearch });
@@ -2954,7 +2960,11 @@ defineExpose({ focusSearch });
<!-- List -->
<div v-else-if="redisKind === 'list'" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground">{{ collectionCountLabel("items", listRows.length, collectionTotal) }}</span>
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("items", listRows.length, activeCollectionSearchQuery ? null : collectionTotal) }}</span>
<div class="relative flex-1 max-w-60">
<Search class="pointer-events-none absolute left-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground/80" />
<Input v-model="collectionSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchItems')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
</div>
<span class="flex-1" />
<Input v-model="newValue" class="h-6 w-40 text-xs" placeholder="value" @keydown.enter="listPush" />
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="listPush"><Plus class="w-3 h-3 mr-1" />Push</Button>
@@ -2986,7 +2996,7 @@ defineExpose({ focusSearch });
</template>
<template #after>
<div v-if="hasMore" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore" @click="loadMore">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore || searchLoading" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
@@ -2998,7 +3008,11 @@ defineExpose({ focusSearch });
<!-- Set -->
<div v-else-if="redisKind === 'set'" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground">{{ collectionCountLabel("items", setRows.length, collectionTotal) }}</span>
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("items", setRows.length, activeCollectionSearchQuery ? null : collectionTotal) }}</span>
<div class="relative flex-1 max-w-60">
<Search class="pointer-events-none absolute left-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground/80" />
<Input v-model="collectionSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchMembers')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
</div>
<span class="flex-1" />
<Input v-model="newValue" class="h-6 w-40 text-xs" placeholder="member" @keydown.enter="setAdd" />
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="setAdd"><Plus class="w-3 h-3 mr-1" />Add</Button>
@@ -3033,7 +3047,7 @@ defineExpose({ focusSearch });
</template>
<template #after>
<div v-if="hasMore" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore" @click="loadMore">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore || searchLoading" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
@@ -3045,10 +3059,10 @@ defineExpose({ focusSearch });
<!-- Hash -->
<div v-else-if="redisKind === 'hash'" ref="hashTableRef" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("fields", hashCollectionRows.length, activeHashSearchQuery ? null : collectionTotal) }}</span>
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("fields", hashCollectionRows.length, activeCollectionSearchQuery ? null : collectionTotal) }}</span>
<div class="relative flex-1 max-w-60">
<Search class="pointer-events-none absolute left-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground/80" />
<Input v-model="hashSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchFields')" @input="onHashSearchInput" @keydown="onHashSearchKeydown" />
<Input v-model="collectionSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchFields')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
</div>
<span class="flex-1" />
<Input v-model="newField" class="h-6 w-24 text-xs" placeholder="field" />
@@ -3126,7 +3140,11 @@ defineExpose({ focusSearch });
<!-- Sorted Set -->
<div v-else-if="redisKind === 'zset'" ref="zsetTableRef" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground">{{ collectionCountLabel("members", zsetRows.length, collectionTotal) }}</span>
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("members", zsetRows.length, activeCollectionSearchQuery ? null : collectionTotal) }}</span>
<div class="relative flex-1 max-w-60">
<Search class="pointer-events-none absolute left-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground/80" />
<Input v-model="collectionSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchMembers')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
</div>
<span class="flex-1" />
<Input v-model="newScore" class="h-6 w-20 text-xs" placeholder="score" />
<Input v-model="newValue" class="h-6 w-32 text-xs" placeholder="member" @keydown.enter="zsetAdd" />
@@ -3189,7 +3207,7 @@ defineExpose({ focusSearch });
</template>
<template #after>
<div v-if="hasMore" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore" @click="loadMore">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore || searchLoading" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
+2
View File
@@ -4759,6 +4759,8 @@ export default {
searchByValue: "Value",
searchByAll: "All",
searchFields: "Search fields and values",
searchMembers: "Search members",
searchItems: "Search items",
keys: "{count} keys",
loadedKeys: "{loaded} / {total} keys loaded",
loadingKeys: "Loading keys...",
+2
View File
@@ -4566,6 +4566,8 @@ export default withEnglishFallback({
searchByValue: "Valor",
searchByAll: "Todo",
searchFields: "Buscar campos y valores",
searchMembers: "Buscar miembros",
searchItems: "Buscar elementos",
keys: "{count} claves",
loadedKeys: "{loaded} / {total} claves cargadas",
loadingKeys: "Cargando claves...",
+2
View File
@@ -4564,6 +4564,8 @@ export default withEnglishFallback({
searchByValue: "Valore",
searchByAll: "Tutto",
searchFields: "Cerca campi e valori",
searchMembers: "Cerca membri",
searchItems: "Cerca elementi",
keys: "{count} chiavi",
loadedKeys: "{loaded} / {total} chiavi caricate",
loadingKeys: "Caricamento chiavi...",
+2
View File
@@ -4592,6 +4592,8 @@ export default withEnglishFallback({
searchByValue: "値",
searchByAll: "すべて",
searchFields: "フィールドまたは値を検索",
searchMembers: "メンバーを検索",
searchItems: "要素を検索",
keys: "{count}キー",
loadedKeys: "{loaded}/{total}キー読み込み完了",
loadingKeys: "キーを読み込み中...",
+2
View File
@@ -4181,6 +4181,8 @@ export default withEnglishFallback({
searchByValue: "값",
searchByAll: "전체",
searchFields: "필드와 값 검색",
searchMembers: "멤버 검색",
searchItems: "항목 검색",
keys: "키 {count}개",
loadedKeys: "{total}개 중 {loaded}개 키 로드됨",
loadingKeys: "키를 불러오는 중...",
+2
View File
@@ -4566,6 +4566,8 @@ export default withEnglishFallback({
searchByValue: "Valor",
searchByAll: "Tudo",
searchFields: "Pesquisar campos e valores",
searchMembers: "Pesquisar membros",
searchItems: "Pesquisar itens",
keys: "{count} chaves",
loadedKeys: "{loaded} / {total} chaves carregadas",
loadingKeys: "Carregando chaves...",
+2
View File
@@ -4743,6 +4743,8 @@ export default withEnglishFallback({
searchByValue: "值",
searchByAll: "全部",
searchFields: "搜索字段或值",
searchMembers: "搜索成员",
searchItems: "搜索元素",
keys: "{count} 个 key",
loadedKeys: "已加载 {loaded} / 共 {total} 个 key",
loadingKeys: "正在加载 key...",
+2
View File
@@ -3889,6 +3889,8 @@ export default withEnglishFallback({
searchByValue: "值",
searchByAll: "全部",
searchFields: "搜尋欄位或值",
searchMembers: "搜尋成員",
searchItems: "搜尋元素",
keys: "{count} 個 key",
loadedKeys: "已載入 {loaded} / 共 {total} 個 key",
loadingKeys: "正在載入 key……",
@@ -238,7 +238,7 @@ describe("native RedisJSON editor", () => {
it("keeps retained drafts out of background refresh and exposes word wrap for every JSON editor", () => {
const refreshAutoValue = findFunction("refreshAutoValue");
const hashSearch = findFunction("onHashSearch");
const collectionSearch = findFunction("onCollectionSearch");
const viewMember = findFunction("viewMember");
const setMemberValueFormat = findFunction("setMemberValueFormat");
const selectMember = findFunction("selectMember");
@@ -254,7 +254,7 @@ describe("native RedisJSON editor", () => {
const refreshButton = findTemplateElement((element) => element.tag === "Button" && element.props.some((prop) => prop.type === 6 && prop.name === "data-redis-value-refresh"));
expect(refreshAutoValue.getText().match(/hasUnsavedRedisDraft\.value/g)).toHaveLength(2);
expect(hashSearch.getText()).toContain("if (!hasRetainedMemberDraft.value) clearSelectedMember();");
expect(collectionSearch.getText()).toContain("if (!hasRetainedMemberDraft.value) clearSelectedMember();");
expect(viewMember.getText()).toContain("hasRetainedMemberDraft.value");
// Clean JSON → other format must clear memberDraftFormat so rawText is not compared to the pretty baseline.
expect(setMemberValueFormat.getText()).toContain("memberDraftFormat.value = null");
+347 -19
View File
@@ -18,7 +18,7 @@ const STREAM_ENTRY_PAGE_SIZE: usize = 50;
const STREAM_PENDING_PAGE_SIZE: usize = 100;
const COLLECTION_PAGE_SIZE: usize = 200;
const STRING_PREVIEW_MAX_BYTES: usize = 64 * 1024;
const HASH_FILTER_SCAN_MAX_ITERATIONS: usize = 10;
const COLLECTION_FILTER_SCAN_MAX_ITERATIONS: usize = 10;
const DEFAULT_REDIS_DATABASES: u32 = 16;
const JS_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
const CLUSTER_CURSOR_NODE_BITS: u64 = 16;
@@ -3536,13 +3536,18 @@ pub async fn load_more_collection<C>(
where
C: ConnectionLike + Send + Sync + Unpin,
{
let filter_query = filter_query.filter(|query| !query.is_empty());
match key_type {
"list" => {
let len: u64 = redis::cmd("LLEN").arg(key).query_async(con).await.unwrap_or(0);
if let Some(query) = filter_query {
let (items, next) = lrange_filtered_page_raw(con, key, cursor, count, len, query).await?;
return Ok(RedisCollectionPage::List { items, scan_cursor: (next < len).then_some(next) });
}
let start = cursor as i64;
let end = start + count as i64 - 1;
let v: RedisRawValue =
redis::cmd("LRANGE").arg(key).arg(start).arg(end).query_async(con).await.map_err(|e| e.to_string())?;
let len: u64 = redis::cmd("LLEN").arg(key).query_async(con).await.unwrap_or(0);
let next = cursor + count as u64;
Ok(RedisCollectionPage::List {
items: redis_list_items_from_raw(v, cursor),
@@ -3550,7 +3555,11 @@ where
})
}
"set" => {
let (next_cursor, items) = sscan_page_raw(con, key, cursor, count).await?;
let (next_cursor, items) = if let Some(query) = filter_query {
sscan_filtered_page_raw(con, key, cursor, count, query).await?
} else {
sscan_page_raw(con, key, cursor, count).await?
};
Ok(RedisCollectionPage::Set { items, scan_cursor: (next_cursor > 0).then_some(next_cursor) })
}
"zset" => {
@@ -3559,6 +3568,11 @@ where
"desc" => true,
direction => return Err(format!("Invalid ZSet sort direction: {direction}")),
};
if let Some(query) = filter_query {
let (items, next, has_more) =
zrange_filtered_page_raw(con, key, cursor, count, descending, query).await?;
return Ok(RedisCollectionPage::Zset { items, scan_cursor: has_more.then_some(next) });
}
let start = cursor as i64;
let end = start + count as i64;
let mut items = zrange_page_raw(con, key, start, end, descending).await?;
@@ -3568,7 +3582,7 @@ where
Ok(RedisCollectionPage::Zset { items, scan_cursor: has_more.then_some(next) })
}
"hash" => {
let (next_cursor, mut items) = if let Some(query) = filter_query.filter(|query| !query.is_empty()) {
let (next_cursor, mut items) = if let Some(query) = filter_query {
hscan_filtered_page_raw(con, key, cursor, count, query).await?
} else {
hscan_page_raw(con, key, cursor, count, None).await?
@@ -3612,10 +3626,11 @@ where
let mut cur = cursor;
let mut items = Vec::new();
let target = count.max(1);
let lowered = query.to_lowercase();
for _ in 0..HASH_FILTER_SCAN_MAX_ITERATIONS {
for _ in 0..COLLECTION_FILTER_SCAN_MAX_ITERATIONS {
let (next, page) = hscan_page_raw(con, key, cur, target, None).await?;
items.extend(page.into_iter().filter(|item| hash_entry_matches_query(item, query)));
items.extend(page.into_iter().filter(|item| hash_entry_matches_query(item, &lowered)));
cur = next;
// HSCAN MATCH only checks field names, so value search has to filter returned pairs client-side.
// Keep a hard scan bound so sparse value matches cannot turn one UI search into a full hash walk.
@@ -3627,6 +3642,118 @@ where
Ok((cur, items))
}
async fn sscan_filtered_page_raw<C>(
con: &mut C,
key: &[u8],
cursor: u64,
count: usize,
query: &str,
) -> Result<(u64, Vec<RedisSetItem>), String>
where
C: ConnectionLike + Send + Sync + Unpin,
{
let mut cur = cursor;
let mut items = Vec::new();
let target = count.max(1);
let lowered = query.to_lowercase();
for _ in 0..COLLECTION_FILTER_SCAN_MAX_ITERATIONS {
let (next, page) = sscan_page_raw(con, key, cur, target).await?;
items.extend(page.into_iter().filter(|item| blob_matches_query(&item.member, &lowered)));
cur = next;
// SSCAN MATCH uses glob syntax over the raw member bytes, so substring search filters
// returned members client-side. Keep the same hard scan bound the hash filter uses.
if cur == 0 || items.len() >= target {
break;
}
}
Ok((cur, items))
}
/// Scans ZRANGE/ZREVRANGE pages so score ordering and the index-based cursor survive filtering.
async fn zrange_filtered_page_raw<C>(
con: &mut C,
key: &[u8],
cursor: u64,
count: usize,
descending: bool,
query: &str,
) -> Result<(Vec<RedisZsetItem>, u64, bool), String>
where
C: ConnectionLike + Send + Sync + Unpin,
{
let mut cur = cursor;
let mut items = Vec::new();
let target = count.max(1);
let lowered = query.to_lowercase();
let mut has_more = false;
for _ in 0..COLLECTION_FILTER_SCAN_MAX_ITERATIONS {
let start = cur as i64;
// Overshoot by one so a full page tells us more members remain past this window.
let mut page = zrange_page_raw(con, key, start, start + target as i64, descending).await?;
has_more = page.len() > target;
page.truncate(target);
let scanned = page.len();
items.extend(page.into_iter().filter(|item| blob_matches_query(&item.member, &lowered)));
cur += scanned as u64;
if !has_more || items.len() >= target {
break;
}
}
Ok((items, cur, has_more))
}
/// Walks LRANGE windows and filters each one after indices are assigned, so the surviving
/// items keep their real list positions (the UI deletes list entries by index).
async fn lrange_filtered_page_raw<C>(
con: &mut C,
key: &[u8],
cursor: u64,
count: usize,
len: u64,
query: &str,
) -> Result<(Vec<RedisListItem>, u64), String>
where
C: ConnectionLike + Send + Sync + Unpin,
{
let mut cur = cursor;
let mut items = Vec::new();
let target = count.max(1);
let lowered = query.to_lowercase();
for _ in 0..COLLECTION_FILTER_SCAN_MAX_ITERATIONS {
// LLEN is only a bound here: it falls back to 0 when the command is unavailable,
// and an empty LRANGE window is what actually proves the walk is done.
if len > 0 && cur >= len {
break;
}
let start = cur as i64;
let raw: RedisRawValue = redis::cmd("LRANGE")
.arg(key)
.arg(start)
.arg(start + target as i64 - 1)
.query_async(con)
.await
.map_err(|e| e.to_string())?;
let page = redis_list_items_from_raw(raw, cur);
let scanned = page.len();
items.extend(page.into_iter().filter(|item| blob_matches_query(&item.value, &lowered)));
if scanned == 0 {
cur = len;
break;
}
cur += scanned as u64;
if items.len() >= target {
break;
}
}
Ok((items, cur))
}
async fn attach_hash_field_ttls<C>(con: &mut C, key: &[u8], items: &mut [RedisHashItem]) -> Result<(), String>
where
C: ConnectionLike + Send + Sync + Unpin,
@@ -3697,14 +3824,12 @@ fn is_optional_hash_field_expiry_error(error: &redis::RedisError) -> bool {
|| detail.contains("no permission")
}
fn hash_entry_matches_query(item: &RedisHashItem, query: &str) -> bool {
let query = query.to_lowercase();
if query.is_empty() {
return true;
}
let field = redis_blob_display_text(&item.field);
let value = redis_blob_display_text(&item.value);
field.to_lowercase().contains(&query) || value.to_lowercase().contains(&query)
fn blob_matches_query(blob: &RedisBlob, lowered_query: &str) -> bool {
redis_blob_display_text(blob).to_lowercase().contains(lowered_query)
}
fn hash_entry_matches_query(item: &RedisHashItem, lowered_query: &str) -> bool {
blob_matches_query(&item.field, lowered_query) || blob_matches_query(&item.value, lowered_query)
}
async fn sscan_page_raw<C>(
@@ -3846,8 +3971,9 @@ mod tests {
redis_key_matches_query, redis_key_raw_to_bytes, redis_key_value_preview, redis_sentinel_master_endpoint,
redis_value_matches_query, redis_value_to_bytes, standalone_connection_infos, RedisAuthCandidate, RedisBlob,
RedisBlobEncoding, RedisClusterSlotRange, RedisCollectionPage, RedisCommandSafety, RedisHashItem,
RedisNodeEndpoint, RedisNodeRoute, RedisRawValue, RedisSetItem, RedisStreamConsumer, RedisStreamEntry,
RedisStreamField, RedisStreamGroup, RedisStreamPendingEntry, RedisValue, RedisValueData, RedisZsetItem,
RedisListItem, RedisNodeEndpoint, RedisNodeRoute, RedisRawValue, RedisSetItem, RedisStreamConsumer,
RedisStreamEntry, RedisStreamField, RedisStreamGroup, RedisStreamPendingEntry, RedisValue, RedisValueData,
RedisZsetItem,
};
use crate::models::connection::ConnectionConfig;
use redis::{aio::ConnectionLike, Cmd, ConnectionAddr, Pipeline, RedisFuture};
@@ -5521,6 +5647,208 @@ mod tests {
assert!(!con.commands[0].contains("\r\nMATCH\r\n"));
}
#[tokio::test]
async fn filtered_set_load_more_matches_members() {
let mut con = FakeRedisConnection::new(vec![scan_response("0", vec!["Ada Lovelace", "Bob"])]);
let result =
super::load_more_collection(&mut con, b"set-key", "set", 0, 20, Some("lovelace"), None).await.unwrap();
let RedisCollectionPage::Set { items, scan_cursor } = result else {
panic!("expected set collection page");
};
assert_eq!(scan_cursor, None);
assert_eq!(items, vec![RedisSetItem { member: text_blob("Ada Lovelace") }]);
assert_eq!(con.command_count("SSCAN"), 1);
// Substring search cannot be expressed as an SSCAN glob, so no MATCH is sent.
assert!(!con.commands[0].contains("\r\nMATCH\r\n"));
}
#[tokio::test]
async fn filtered_set_load_more_keeps_scanning_until_the_page_fills() {
let mut con =
FakeRedisConnection::new(vec![scan_response("512", vec!["Bob"]), scan_response("0", vec!["Ada Lovelace"])]);
let result =
super::load_more_collection(&mut con, b"set-key", "set", 0, 20, Some("lovelace"), None).await.unwrap();
let RedisCollectionPage::Set { items, scan_cursor } = result else {
panic!("expected set collection page");
};
assert_eq!(scan_cursor, None);
assert_eq!(items, vec![RedisSetItem { member: text_blob("Ada Lovelace") }]);
assert_eq!(con.command_count("SSCAN"), 2);
}
#[tokio::test]
async fn filtered_set_load_more_caps_sparse_scan_iterations() {
let responses = (1..=super::COLLECTION_FILTER_SCAN_MAX_ITERATIONS + 1)
.map(|cursor| scan_response(&cursor.to_string(), vec![]))
.collect();
let mut con = FakeRedisConnection::new(responses);
let result =
super::load_more_collection(&mut con, b"set-key", "set", 0, 20, Some("missing"), None).await.unwrap();
let RedisCollectionPage::Set { items, scan_cursor } = result else {
panic!("expected set collection page");
};
assert_eq!(scan_cursor, Some(super::COLLECTION_FILTER_SCAN_MAX_ITERATIONS as u64));
assert!(items.is_empty());
assert_eq!(con.command_count("SSCAN"), super::COLLECTION_FILTER_SCAN_MAX_ITERATIONS);
}
#[tokio::test]
async fn filtered_zset_load_more_matches_members_and_keeps_score_order() {
let mut con = FakeRedisConnection::new(vec![zrange_response(vec![
("rank:alice", "1"),
("bob", "2"),
("rank:carol", "3"),
])]);
let result =
super::load_more_collection(&mut con, b"scores", "zset", 0, 20, Some("RANK:"), Some("asc")).await.unwrap();
let RedisCollectionPage::Zset { items, scan_cursor } = result else {
panic!("expected zset collection page");
};
assert_eq!(scan_cursor, None);
assert_eq!(
items,
vec![
RedisZsetItem { score: "1".to_string(), member: text_blob("rank:alice") },
RedisZsetItem { score: "3".to_string(), member: text_blob("rank:carol") },
]
);
// Ranked paging is kept so the score sort and the index cursor survive filtering.
assert_eq!(con.command_count("ZRANGE"), 1);
assert_eq!(con.command_count("ZSCAN"), 0);
}
#[tokio::test]
async fn filtered_zset_load_more_honors_descending_pages() {
let mut con = FakeRedisConnection::new(vec![zrange_response(vec![
("rank:carol", "3"),
("bob", "2"),
("rank:alice", "1"),
])]);
let result =
super::load_more_collection(&mut con, b"scores", "zset", 0, 20, Some("rank:"), Some("desc")).await.unwrap();
let RedisCollectionPage::Zset { items, scan_cursor } = result else {
panic!("expected zset collection page");
};
assert_eq!(scan_cursor, None);
assert_eq!(
items,
vec![
RedisZsetItem { score: "3".to_string(), member: text_blob("rank:carol") },
RedisZsetItem { score: "1".to_string(), member: text_blob("rank:alice") },
]
);
assert_eq!(con.command_count("ZREVRANGE"), 1);
assert_eq!(con.command_count("ZRANGE"), 0);
}
#[tokio::test]
async fn filtered_zset_load_more_advances_the_cursor_past_every_scanned_member() {
let mut con = FakeRedisConnection::new(vec![
zrange_response(vec![("bob", "2"), ("dave", "4"), ("erin", "5")]),
zrange_response(vec![("rank:alice", "1"), ("rank:carol", "3"), ("grace", "7")]),
]);
let result =
super::load_more_collection(&mut con, b"scores", "zset", 0, 2, Some("rank:"), Some("asc")).await.unwrap();
let RedisCollectionPage::Zset { items, scan_cursor } = result else {
panic!("expected zset collection page");
};
assert_eq!(
items,
vec![
RedisZsetItem { score: "1".to_string(), member: text_blob("rank:alice") },
RedisZsetItem { score: "3".to_string(), member: text_blob("rank:carol") },
]
);
// Two windows of two were consumed, so resuming must not replay the first one.
assert_eq!(scan_cursor, Some(4));
assert_eq!(con.command_count("ZRANGE"), 2);
}
#[tokio::test]
async fn filtered_list_load_more_preserves_original_indices() {
let mut con = FakeRedisConnection::new(vec![
RedisRawValue::Int(8),
RedisRawValue::Array(vec![
bulk("alpha"),
bulk("beta"),
bulk("gamma"),
bulk("target-one"),
bulk("delta"),
bulk("epsilon"),
bulk("zeta"),
bulk("target-two"),
]),
]);
let result =
super::load_more_collection(&mut con, b"list-key", "list", 0, 20, Some("target-"), None).await.unwrap();
let RedisCollectionPage::List { items, scan_cursor } = result else {
panic!("expected list collection page");
};
assert_eq!(scan_cursor, None);
// Indices must stay absolute: the viewer deletes list entries by index.
assert_eq!(
items,
vec![
RedisListItem { index: 3, value: text_blob("target-one") },
RedisListItem { index: 7, value: text_blob("target-two") },
]
);
assert_eq!(con.command_count("LRANGE"), 1);
}
#[tokio::test]
async fn filtered_list_load_more_still_walks_when_llen_is_not_permitted() {
let mut con = FakeRedisConnection::with_results(vec![
Err(noperm("llen")),
Ok(RedisRawValue::Array(vec![bulk("target-one"), bulk("beta")])),
Ok(RedisRawValue::Array(vec![])),
]);
let result =
super::load_more_collection(&mut con, b"list-key", "list", 0, 2, Some("target-"), None).await.unwrap();
let RedisCollectionPage::List { items, scan_cursor } = result else {
panic!("expected list collection page");
};
assert_eq!(items, vec![RedisListItem { index: 0, value: text_blob("target-one") }]);
assert_eq!(scan_cursor, None);
assert_eq!(con.command_count("LRANGE"), 2);
}
#[tokio::test]
async fn filtered_list_load_more_stops_at_the_list_length() {
let mut con = FakeRedisConnection::new(vec![
RedisRawValue::Int(3),
RedisRawValue::Array(vec![bulk("alpha"), bulk("beta")]),
RedisRawValue::Array(vec![bulk("gamma")]),
]);
let result =
super::load_more_collection(&mut con, b"list-key", "list", 0, 2, Some("missing"), None).await.unwrap();
let RedisCollectionPage::List { items, scan_cursor } = result else {
panic!("expected list collection page");
};
assert!(items.is_empty());
assert_eq!(scan_cursor, None);
assert_eq!(con.command_count("LRANGE"), 2);
assert_eq!(con.command_count("LLEN"), 1);
}
#[tokio::test]
async fn hash_load_more_attaches_field_ttl_when_supported() {
let mut con = FakeRedisConnection::new(vec![
@@ -5764,7 +6092,7 @@ mod tests {
#[tokio::test]
async fn filtered_hash_load_more_caps_sparse_scan_iterations() {
let responses = (1..=super::HASH_FILTER_SCAN_MAX_ITERATIONS + 1)
let responses = (1..=super::COLLECTION_FILTER_SCAN_MAX_ITERATIONS + 1)
.map(|cursor| hscan_response(&cursor.to_string(), vec![]))
.collect();
let mut con = FakeRedisConnection::new(responses);
@@ -5775,9 +6103,9 @@ mod tests {
let RedisCollectionPage::Hash { items, scan_cursor } = result else {
panic!("expected hash collection page");
};
assert_eq!(scan_cursor, Some(super::HASH_FILTER_SCAN_MAX_ITERATIONS as u64));
assert_eq!(scan_cursor, Some(super::COLLECTION_FILTER_SCAN_MAX_ITERATIONS as u64));
assert!(items.is_empty());
assert_eq!(con.command_count("HSCAN"), super::HASH_FILTER_SCAN_MAX_ITERATIONS);
assert_eq!(con.command_count("HSCAN"), super::COLLECTION_FILTER_SCAN_MAX_ITERATIONS);
}
#[tokio::test]
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+2 -2
View File
@@ -66,8 +66,8 @@ test("content find is for STRING + RedisJSON + member detail; hash toolbar searc
// STRING, RedisJSON, or member detail.
assert.match(viewer, /showMemberDetail\.value \|\| isStringLikeKind\.value \|\| redisKind\.value === "json"/);
assert.match(viewer, /function openValueSearch/);
assert.match(viewer, /function onHashSearch/);
assert.match(viewer, /v-model="hashSearchQuery"/);
assert.match(viewer, /function onCollectionSearch/);
assert.match(viewer, /v-model="collectionSearchQuery"/);
assert.match(viewer, /data-redis-member-detail/);
assert.match(viewer, /v-if="valueSearchOpen && showMemberDetail"/);
assert.match(viewer, /ref="redisJsonEditorRef"/);