feat(redis): add fuzzy key tree search
This commit is contained in:
parent
589fbb02c9
commit
4c57520a12
|
|
@ -1,7 +1,7 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { CalendarDateTime, resetLocalTimeZone, setLocalTimeZone } from "@internationalized/date";
|
||||
import { createApp, nextTick, type ComponentPublicInstance } from "vue";
|
||||
import { createApp, defineComponent, h, KeepAlive, nextTick, ref, type ComponentPublicInstance } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { calendarDateTimeToUnixSeconds } from "@/components/ui/date-time-picker/dateTimePicker";
|
||||
|
|
@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({
|
|||
redisCheckJsonModule: vi.fn(),
|
||||
redisDeleteKey: vi.fn(),
|
||||
redisDeleteKeys: vi.fn(),
|
||||
canBuildRedisFuzzyTree: vi.fn((loadedKeyCount: number) => loadedKeyCount <= 200_000),
|
||||
toast: vi.fn(),
|
||||
updateRedisDbKeyStats: vi.fn(),
|
||||
}));
|
||||
|
|
@ -42,6 +43,11 @@ vi.mock("@/lib/backend/api", () => ({
|
|||
redisDeleteKeys: mocks.redisDeleteKeys,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/redis/redisKeyTree", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/redis/redisKeyTree")>();
|
||||
return { ...actual, canBuildRedisFuzzyTree: mocks.canBuildRedisFuzzyTree };
|
||||
});
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -228,7 +234,30 @@ vi.mock("@/components/ui/CustomContextMenu.vue", async () => {
|
|||
|
||||
vi.mock("@/components/editor/DangerConfirmDialog.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return { default: defineComponent({ setup: () => () => h("div") }) };
|
||||
return {
|
||||
default: defineComponent({
|
||||
props: { open: Boolean, loading: Boolean, details: String },
|
||||
emits: ["confirm"],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
props.open
|
||||
? h("div", { "data-test-danger-dialog": "" }, [
|
||||
h("div", { "data-test-danger-details": "" }, props.details),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
disabled: props.loading,
|
||||
"data-test-danger-confirm": "",
|
||||
onClick: () => emit("confirm"),
|
||||
},
|
||||
"Confirm",
|
||||
),
|
||||
])
|
||||
: null;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./RedisValueViewer.vue", async () => {
|
||||
|
|
@ -248,7 +277,23 @@ vi.mock("./RedisSlowlogPanel.vue", async () => {
|
|||
|
||||
vi.mock("vue-virtual-scroller", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return { RecycleScroller: defineComponent({ setup: () => () => h("div") }) };
|
||||
return {
|
||||
RecycleScroller: defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { items: { type: Array, default: () => [] } },
|
||||
setup(props, { attrs, slots }) {
|
||||
// Mirror the real scroller: interaction tests should render a viewport,
|
||||
// not every row in a deliberately large result set.
|
||||
const visibleItemCount = 50;
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
attrs,
|
||||
props.items.slice(0, visibleItemCount).map((item) => slots.default?.({ item })),
|
||||
);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("splitpanes", async () => {
|
||||
|
|
@ -284,6 +329,16 @@ function redisKeyInfo(keyType = "json") {
|
|||
return { key_display: KEY_NAME, key_raw: KEY_RAW, key_type: keyType, ttl: 90, size: 7, value_preview: "{}" };
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((next, fail) => {
|
||||
resolve = next;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function resetApiMocks() {
|
||||
vi.clearAllMocks();
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 });
|
||||
|
|
@ -300,6 +355,7 @@ function resetApiMocks() {
|
|||
mocks.redisCheckJsonModule.mockResolvedValue(true);
|
||||
mocks.redisDeleteKey.mockResolvedValue(undefined);
|
||||
mocks.redisDeleteKeys.mockResolvedValue(0);
|
||||
mocks.canBuildRedisFuzzyTree.mockImplementation((loadedKeyCount: number) => loadedKeyCount <= 200_000);
|
||||
}
|
||||
|
||||
function mountBrowser() {
|
||||
|
|
@ -311,6 +367,36 @@ function mountBrowser() {
|
|||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
}
|
||||
|
||||
function mountKeptAliveBrowser() {
|
||||
const active = ref(true);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(KeepAlive, null, {
|
||||
default: () => (active.value ? h(RedisKeyBrowser, { connectionId: "connection", db: 0, blockDangerousRedisCommands: false }) : null),
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
|
||||
return {
|
||||
async deactivate() {
|
||||
active.value = false;
|
||||
await settle();
|
||||
},
|
||||
async activate() {
|
||||
active.value = true;
|
||||
await settle();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
|
|
@ -338,6 +424,22 @@ async function setInput(selector: string, value: string) {
|
|||
await settle();
|
||||
}
|
||||
|
||||
async function submitKeySearch(value: string) {
|
||||
const input = requiredElement<HTMLInputElement>("[data-redis-search-input]");
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
await settle();
|
||||
}
|
||||
|
||||
function groupCheckbox(label: string): HTMLInputElement {
|
||||
const labelElement = Array.from(document.querySelectorAll<HTMLElement>(".dbx-editor-font-family")).find((element) => element.textContent === label);
|
||||
expect(labelElement, label).toBeDefined();
|
||||
const checkbox = labelElement?.closest(".group")?.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
expect(checkbox, label).toBeDefined();
|
||||
return checkbox!;
|
||||
}
|
||||
|
||||
async function select(value: string) {
|
||||
const item = requiredElement<HTMLButtonElement>(`[data-test-select-value="${value}"]`);
|
||||
const root = item.closest<TestSelectRoot>("[data-test-select-root]");
|
||||
|
|
@ -522,3 +624,289 @@ describe("RedisKeyBrowser expiry creation", () => {
|
|||
expect(mocks.toast).toHaveBeenCalledWith("TTL command failed", 5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RedisKeyBrowser fuzzy key hierarchy", () => {
|
||||
it("keeps NUL-containing fuzzy groups isolated when selecting keys to delete", async () => {
|
||||
const firstKeyRaw = "cmF3LWZpcnN0";
|
||||
const secondKeyRaw = "cmF3LXNlY29uZA==";
|
||||
const keys = [
|
||||
{ key_display: `a\0b:c:x`, key_raw: firstKeyRaw, key_type: "string", ttl: -1 },
|
||||
{ key_display: `a:b\0c:y`, key_raw: secondKeyRaw, key_type: "string", ttl: -1 },
|
||||
];
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length });
|
||||
mocks.redisDeleteKeys.mockResolvedValue(1);
|
||||
mountBrowser();
|
||||
await settle();
|
||||
|
||||
await submitKeySearch("a");
|
||||
clickButtonWithText("redis.fuzzyMatch");
|
||||
await settle();
|
||||
|
||||
const firstGroupCheckbox = groupCheckbox(`a\0b`);
|
||||
firstGroupCheckbox.checked = true;
|
||||
firstGroupCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await settle();
|
||||
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "1");
|
||||
expect(deleteButton).toBeDefined();
|
||||
deleteButton!.click();
|
||||
await settle();
|
||||
requiredElement<HTMLButtonElement>("[data-test-danger-confirm]").click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisDeleteKeys).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.redisDeleteKeys).toHaveBeenCalledWith("connection", 0, [firstKeyRaw]);
|
||||
expect(mocks.redisDeleteKeys.mock.calls[0]?.[2]).not.toContain(secondKeyRaw);
|
||||
});
|
||||
|
||||
it("keeps regular key searches flat, then selects and deletes a loaded fuzzy branch", async () => {
|
||||
const keys = [
|
||||
{ key_display: "user:profile:email", key_raw: "cmF3LWVtYWls", key_type: "string", ttl: -1 },
|
||||
{ key_display: "user:profile:name", key_raw: "cmF3LW5hbWU=", key_type: "string", ttl: -1 },
|
||||
{ key_display: "user:settings", key_raw: "cmF3LXNldHRpbmdz", key_type: "hash", ttl: -1 },
|
||||
];
|
||||
// The first page is intentionally incomplete: branch selection must only
|
||||
// submit the currently loaded raw keys, never widen into a new SCAN.
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 7, keys, total_keys: 20 });
|
||||
mocks.redisDeleteKeys.mockResolvedValue(keys.length);
|
||||
mountBrowser();
|
||||
await settle();
|
||||
|
||||
await submitKeySearch("user");
|
||||
// Regular glob search keeps the pre-existing flat, virtualized result path.
|
||||
expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(keys.length);
|
||||
|
||||
clickButtonWithText("redis.fuzzyMatch");
|
||||
await settle();
|
||||
|
||||
// Fuzzy search restores the namespace hierarchy and exposes group selection.
|
||||
expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(keys.length + 2);
|
||||
const userCheckbox = groupCheckbox("user");
|
||||
userCheckbox.checked = true;
|
||||
userCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await settle();
|
||||
|
||||
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === String(keys.length));
|
||||
expect(deleteButton).toBeDefined();
|
||||
deleteButton!.click();
|
||||
await settle();
|
||||
requiredElement<HTMLButtonElement>("[data-test-danger-confirm]").click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisDeleteKeys).toHaveBeenCalledTimes(1);
|
||||
const [connectionId, db, deletedKeyRaws] = mocks.redisDeleteKeys.mock.calls[0] ?? [];
|
||||
expect(connectionId).toBe("connection");
|
||||
expect(db).toBe(0);
|
||||
expect(new Set(deletedKeyRaws)).toEqual(new Set(keys.map((key) => key.key_raw)));
|
||||
expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("falls back to flat rows at the fuzzy tree limit while retaining loaded-result delete wording", async () => {
|
||||
const keys = [
|
||||
{ key_display: "user:profile:email", key_raw: "cmF3LWVtYWls", key_type: "string", ttl: -1 },
|
||||
{ key_display: "user:profile:name", key_raw: "cmF3LW5hbWU=", key_type: "string", ttl: -1 },
|
||||
];
|
||||
mocks.canBuildRedisFuzzyTree.mockReturnValue(false);
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length });
|
||||
mocks.redisDeleteKeys.mockResolvedValue(1);
|
||||
mountBrowser();
|
||||
await settle();
|
||||
|
||||
await submitKeySearch("user");
|
||||
clickButtonWithText("redis.fuzzyMatch");
|
||||
await settle();
|
||||
|
||||
// The group controls disappear when the view falls back to virtualized rows.
|
||||
expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(keys.length);
|
||||
expect(document.body.textContent).toContain("redis.fuzzyTreeLimit");
|
||||
|
||||
requiredElement<HTMLInputElement>('input[type="checkbox"]').click();
|
||||
await settle();
|
||||
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "1");
|
||||
expect(deleteButton).toBeDefined();
|
||||
deleteButton!.click();
|
||||
await settle();
|
||||
expect(requiredElement<HTMLElement>("[data-test-danger-details]").textContent).toContain("redis.deleteLoadedSearchKeysDetails");
|
||||
requiredElement<HTMLButtonElement>("[data-test-danger-confirm]").click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisDeleteKeys).toHaveBeenCalledWith("connection", 0, [keys[0]!.key_raw]);
|
||||
});
|
||||
|
||||
it("keeps a selected fuzzy group partial when a later SCAN page adds matching keys", async () => {
|
||||
const firstPageKeys = [{ key_display: "user:one", key_raw: "dXNlci1vbmU=", key_type: "string", ttl: -1 }];
|
||||
const laterPageKeys = [{ key_display: "user:two", key_raw: "dXNlci10d28=", key_type: "string", ttl: -1 }];
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 7, keys: firstPageKeys, total_keys: 2 } : { cursor: 0, keys: laterPageKeys, total_keys: 0 }));
|
||||
mocks.redisDeleteKeys.mockResolvedValue(1);
|
||||
mountBrowser();
|
||||
await settle();
|
||||
|
||||
await submitKeySearch("user");
|
||||
clickButtonWithText("redis.fuzzyMatch");
|
||||
await settle();
|
||||
|
||||
const userCheckbox = groupCheckbox("user");
|
||||
userCheckbox.checked = true;
|
||||
userCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await settle();
|
||||
clickButtonWithText("redis.loadMoreKeys");
|
||||
await settle();
|
||||
|
||||
const updatedUserCheckbox = groupCheckbox("user");
|
||||
expect(updatedUserCheckbox.checked).toBe(false);
|
||||
expect(updatedUserCheckbox.indeterminate).toBe(true);
|
||||
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "1");
|
||||
expect(deleteButton).toBeDefined();
|
||||
deleteButton!.click();
|
||||
await settle();
|
||||
requiredElement<HTMLButtonElement>("[data-test-danger-confirm]").click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisDeleteKeys).toHaveBeenCalledWith("connection", 0, [firstPageKeys[0]!.key_raw]);
|
||||
});
|
||||
|
||||
it("sends a large selected fuzzy group in bounded delete batches", async () => {
|
||||
const keys = Array.from({ length: 1_001 }, (_, index) => ({
|
||||
key_display: `batch:${String(index).padStart(4, "0")}`,
|
||||
key_raw: `cmF3LWJhdGNoLS${index}`,
|
||||
key_type: "string",
|
||||
ttl: -1,
|
||||
}));
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length });
|
||||
mocks.redisDeleteKeys.mockImplementation(async (_connectionId: string, _db: number, keyRaws: string[]) => keyRaws.length);
|
||||
mountBrowser();
|
||||
await settle();
|
||||
|
||||
await submitKeySearch("batch");
|
||||
clickButtonWithText("redis.fuzzyMatch");
|
||||
await settle();
|
||||
|
||||
const batchCheckbox = groupCheckbox("batch");
|
||||
batchCheckbox.checked = true;
|
||||
batchCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await settle();
|
||||
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === String(keys.length));
|
||||
expect(deleteButton).toBeDefined();
|
||||
deleteButton!.click();
|
||||
await settle();
|
||||
requiredElement<HTMLButtonElement>("[data-test-danger-confirm]").click();
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisDeleteKeys.mock.calls.map((call) => call[2]?.length)).toEqual([1_000, 1]);
|
||||
expect(new Set(mocks.redisDeleteKeys.mock.calls.flatMap((call) => call[2] ?? []))).toEqual(new Set(keys.map((key) => key.key_raw)));
|
||||
});
|
||||
|
||||
it("reloads the result set when a later delete batch fails after an earlier batch succeeds", async () => {
|
||||
const keys = Array.from({ length: 1_001 }, (_, index) => ({
|
||||
key_display: `batch:${String(index).padStart(4, "0")}`,
|
||||
key_raw: `cmF3LWJhdGNoLS${index}`,
|
||||
key_type: "string",
|
||||
ttl: -1,
|
||||
}));
|
||||
const freshKeys = [{ key_display: "fresh:remaining", key_raw: "ZnJlc2gtcmVtYWluaW5n", key_type: "string", ttl: -1 }];
|
||||
let returnFreshResults = false;
|
||||
mocks.redisScanKeysBatch.mockImplementation(() => Promise.resolve(returnFreshResults ? { cursor: 0, keys: freshKeys, total_keys: 1 } : { cursor: 0, keys, total_keys: keys.length }));
|
||||
mocks.redisDeleteKeys.mockResolvedValueOnce(1_000).mockImplementationOnce(async () => {
|
||||
returnFreshResults = true;
|
||||
throw new Error("second batch failed");
|
||||
});
|
||||
mountBrowser();
|
||||
await settle();
|
||||
|
||||
await submitKeySearch("batch");
|
||||
clickButtonWithText("redis.fuzzyMatch");
|
||||
await settle();
|
||||
|
||||
const batchCheckbox = groupCheckbox("batch");
|
||||
batchCheckbox.checked = true;
|
||||
batchCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await settle();
|
||||
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === String(keys.length));
|
||||
expect(deleteButton).toBeDefined();
|
||||
deleteButton!.click();
|
||||
await settle();
|
||||
requiredElement<HTMLButtonElement>("[data-test-danger-confirm]").click();
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisDeleteKeys.mock.calls.map((call) => call[2]?.length)).toEqual([1_000, 1]);
|
||||
expect(mocks.toast).toHaveBeenCalledWith("second batch failed", 5000);
|
||||
expect(document.body.textContent).toContain("fresh");
|
||||
expect(document.body.textContent).not.toContain("0000");
|
||||
});
|
||||
|
||||
it("reloads after a later delete batch fails while the browser is deactivated", async () => {
|
||||
const keys = Array.from({ length: 1_001 }, (_, index) => ({
|
||||
key_display: `batch:${String(index).padStart(4, "0")}`,
|
||||
key_raw: `cmF3LWJhdGNoLS${index}`,
|
||||
key_type: "string",
|
||||
ttl: -1,
|
||||
}));
|
||||
const freshKeys = [{ key_display: "fresh:remaining", key_raw: "ZnJlc2gtcmVtYWluaW5n", key_type: "string", ttl: -1 }];
|
||||
const laterDelete = deferred<number>();
|
||||
let returnFreshResults = false;
|
||||
mocks.redisScanKeysBatch.mockImplementation(() => Promise.resolve(returnFreshResults ? { cursor: 0, keys: freshKeys, total_keys: 1 } : { cursor: 0, keys, total_keys: keys.length }));
|
||||
mocks.redisDeleteKeys.mockResolvedValueOnce(1_000).mockImplementationOnce(() => laterDelete.promise);
|
||||
const browser = mountKeptAliveBrowser();
|
||||
await settle();
|
||||
|
||||
await submitKeySearch("batch");
|
||||
clickButtonWithText("redis.fuzzyMatch");
|
||||
await settle();
|
||||
|
||||
const batchCheckbox = groupCheckbox("batch");
|
||||
batchCheckbox.checked = true;
|
||||
batchCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await settle();
|
||||
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === String(keys.length));
|
||||
expect(deleteButton).toBeDefined();
|
||||
deleteButton!.click();
|
||||
await settle();
|
||||
requiredElement<HTMLButtonElement>("[data-test-danger-confirm]").click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisDeleteKeys.mock.calls.map((call) => call[2]?.length)).toEqual([1_000, 1]);
|
||||
await browser.deactivate();
|
||||
returnFreshResults = true;
|
||||
laterDelete.reject(new Error("second batch failed while inactive"));
|
||||
await settle();
|
||||
const scanCountBeforeActivation = mocks.redisScanKeysBatch.mock.calls.length;
|
||||
|
||||
await browser.activate();
|
||||
await settle();
|
||||
|
||||
expect(mocks.toast).toHaveBeenCalledWith("second batch failed while inactive", 5000);
|
||||
expect(mocks.redisScanKeysBatch.mock.calls.length).toBeGreaterThan(scanCountBeforeActivation);
|
||||
expect(document.body.textContent).toContain("fresh");
|
||||
expect(document.body.textContent).not.toContain("0000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RedisKeyBrowser interrupted Fetch All", () => {
|
||||
it("reloads instead of advancing past an uncommitted buffered page after reactivation", async () => {
|
||||
const bufferedPage = deferred<{ cursor: number; keys: Array<{ key_display: string; key_raw: string; key_type: string; ttl: number }>; total_keys: number }>();
|
||||
let returnFreshPage = false;
|
||||
let freshPageRequests = 0;
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => {
|
||||
if (cursor === 1) return bufferedPage.promise;
|
||||
if (returnFreshPage) freshPageRequests++;
|
||||
return Promise.resolve(returnFreshPage ? { cursor: 0, keys: [{ key_display: "fresh:key", key_raw: "ZnJlc2gta2V5", key_type: "string", ttl: -1 }], total_keys: 2 } : { cursor: 1, keys: [{ key_display: "initial:key", key_raw: "aW5pdGlhbC1rZXk=", key_type: "string", ttl: -1 }], total_keys: 2 });
|
||||
});
|
||||
const browser = mountKeptAliveBrowser();
|
||||
await settle();
|
||||
|
||||
clickButtonWithText("redis.fetchAllKeys");
|
||||
await settle();
|
||||
|
||||
await browser.deactivate();
|
||||
returnFreshPage = true;
|
||||
bufferedPage.resolve({ cursor: 0, keys: [{ key_display: "buffered:key", key_raw: "YnVmZmVyZWQta2V5", key_type: "string", ttl: -1 }], total_keys: 0 });
|
||||
await settle();
|
||||
await browser.activate();
|
||||
await settle();
|
||||
|
||||
expect(document.body.textContent).toContain("fresh");
|
||||
expect(document.body.textContent).not.toContain("buffered");
|
||||
expect(freshPageRequests).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,7 +25,19 @@ import * as api from "@/lib/backend/api";
|
|||
import type { RedisKeyInfo, RedisScanResult, RedisValue, HistoryEntry } from "@/lib/backend/api";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { buildRedisKeyTree, collectExpandedGroupIds, collectRedisGroupKeyRaws, flattenVisibleRedisKeyTree, mergeKeysIntoRedisKeyTree, redisKeyNameCopyText, redisKeyToFlatTreeRow, type RedisKeyTreeNode } from "@/lib/redis/redisKeyTree";
|
||||
import {
|
||||
appendRedisKeysToTreeIndex,
|
||||
canBuildRedisFuzzyTree,
|
||||
collectExpandedGroupIds,
|
||||
collectRedisGroupKeyRaws,
|
||||
createRedisKeyTreeIndex,
|
||||
flattenVisibleRedisKeyTree,
|
||||
redisKeyNameCopyText,
|
||||
redisKeyToFlatTreeRow,
|
||||
type RedisKeyTreeGroupNode,
|
||||
type RedisKeyTreeIndex,
|
||||
type RedisKeyTreeNode,
|
||||
} from "@/lib/redis/redisKeyTree";
|
||||
import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety";
|
||||
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
|
||||
import { isRedisClearScreenCommand, nextRedisCommandDb, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession";
|
||||
|
|
@ -36,7 +48,7 @@ import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle
|
|||
import { useToast } from "@/composables/useToast";
|
||||
import { redisKeySearchPattern } from "@/lib/redis/redisKeyPattern";
|
||||
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
|
||||
import { collectUniqueRedisKeys } from "@/lib/redis/redisKeyBatch";
|
||||
import { chunkRedisKeyRaws, collectUniqueRedisKeys } from "@/lib/redis/redisKeyBatch";
|
||||
import { getRedisCreateKeyTypeHelp, redisCreateKeyTypeHelpOptionOnOpen, shouldActivateRedisCreateKeyTypeHelpOnFocus } from "@/lib/redis/redisCreateKeyTypeHelp";
|
||||
import { optionHelpPanelOffsetTop } from "@/lib/common/optionHelpPanelOffset";
|
||||
import { applyRedisExpiryPolicy, type RedisExpiryMode, validateRedisExpiry } from "@/lib/redis/redisExpiry";
|
||||
|
|
@ -76,7 +88,7 @@ const redisExpiryTransport = {
|
|||
};
|
||||
|
||||
const flatKeys = shallowRef<RedisKeyInfo[]>([]);
|
||||
const treeKeys = ref<RedisKeyTreeNode[]>([]);
|
||||
const treeKeys = shallowRef<RedisKeyTreeNode[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const searchPending = ref(false);
|
||||
|
|
@ -94,7 +106,9 @@ const hasMore = ref(false);
|
|||
const scanCursor = ref(0);
|
||||
const expandedGroupIds = ref<Set<string>>(new Set());
|
||||
const checkedKeys = ref<Set<string>>(new Set());
|
||||
const pendingDanger = ref<{ kind: "delete-keys"; title: string; keyRaws: string[] } | { kind: "command"; command: string } | null>(null);
|
||||
const selectedGroupLeafCounts = shallowRef<Map<string, number>>(new Map());
|
||||
const deletingKeys = ref(false);
|
||||
const pendingDanger = ref<{ kind: "delete-keys"; title: string; keyRaws: string[]; loadedSearchResults: boolean } | { kind: "command"; command: string } | null>(null);
|
||||
const showDangerConfirm = ref(false);
|
||||
const commandText = ref("");
|
||||
const commandRunning = ref(false);
|
||||
|
|
@ -128,14 +142,22 @@ const createKeyTypeHelpOffsetTop = ref(0);
|
|||
let nextEntryId = 0;
|
||||
let searchRequestId = 0;
|
||||
let redisBrowserIsActive = true;
|
||||
let reloadKeysOnActivation = false;
|
||||
let redisDbFlushedListenerRegistered = false;
|
||||
const loadedKeyRaws = new Set<string>();
|
||||
let treeIndex: RedisKeyTreeIndex | null = null;
|
||||
|
||||
const valueQuery = computed(() => searchPattern.value.trim());
|
||||
const isValueSearchMode = computed(() => searchMode.value === "value" || searchMode.value === "all");
|
||||
const effectivePattern = computed(() => (searchMode.value === "key" ? redisKeySearchPattern(searchPattern.value, fuzzyKeySearch.value) : "*"));
|
||||
const isSearchMode = computed(() => (searchMode.value === "key" ? effectivePattern.value !== "*" : valueQuery.value !== ""));
|
||||
const useFlatKeySearchRows = computed(() => searchMode.value === "key" && isSearchMode.value);
|
||||
// Keep regular glob search on the low-cost flat path. The explicit fuzzy mode
|
||||
// opts into the namespace hierarchy that users need for group selection.
|
||||
const isFuzzyKeySearch = computed(() => searchMode.value === "key" && isSearchMode.value && fuzzyKeySearch.value);
|
||||
const fuzzyTreeLimitReached = computed(() => isFuzzyKeySearch.value && !canBuildRedisFuzzyTree(flatKeys.value.length));
|
||||
const useFlatKeySearchRows = computed(() => (searchMode.value === "key" && isSearchMode.value && !fuzzyKeySearch.value) || fuzzyTreeLimitReached.value);
|
||||
const isFuzzyHierarchyView = computed(() => isFuzzyKeySearch.value && !fuzzyTreeLimitReached.value);
|
||||
const selectionBusy = computed(() => deletingKeys.value || loading.value || loadingMore.value || isFetchingAll.value || searchPending.value);
|
||||
const searchPlaceholder = computed(() => {
|
||||
if (searchMode.value === "key") return fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern");
|
||||
return searchMode.value === "all" ? t("redis.allSearchPlaceholder") : t("redis.valueSearchPlaceholder");
|
||||
|
|
@ -144,7 +166,14 @@ const loadingEmptyText = computed(() => (isValueSearchMode.value && valueQuery.v
|
|||
const redisKeySeparator = computed(() => connectionStore.getConfig(props.connectionId)?.redis_key_separator ?? ":");
|
||||
const redisScanPageSize = computed(() => connectionStore.getConfig(props.connectionId)?.redis_scan_page_size ?? REDIS_SCAN_PAGE_SIZE_DEFAULT);
|
||||
watch(redisKeySeparator, () => {
|
||||
if (flatKeys.value.length > 0) rebuildTree(false);
|
||||
if (flatKeys.value.length === 0) return;
|
||||
if (useFlatKeySearchRows.value) {
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
expandedGroupIds.value = new Set();
|
||||
return;
|
||||
}
|
||||
rebuildTree(false);
|
||||
});
|
||||
const lastTotalKeys = ref(0);
|
||||
const displayedKeyCount = computed(() => (isFetchingAll.value ? fetchAllLoadedCount.value : flatKeys.value.length));
|
||||
|
|
@ -166,7 +195,7 @@ const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw ===
|
|||
const dangerDetails = computed(() => {
|
||||
if (!pendingDanger.value) return "";
|
||||
if (pendingDanger.value.kind === "delete-keys") {
|
||||
return t("redis.deleteGroupDetails", {
|
||||
return t(pendingDanger.value.loadedSearchResults ? "redis.deleteLoadedSearchKeysDetails" : "redis.deleteGroupDetails", {
|
||||
target: pendingDanger.value.title,
|
||||
count: pendingDanger.value.keyRaws.length,
|
||||
});
|
||||
|
|
@ -256,13 +285,79 @@ watch(activeCreateKeyTypeHelp, () => {
|
|||
const visibleRows = computed(() => (useFlatKeySearchRows.value ? flatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(treeKeys.value, expandedGroupIds.value)));
|
||||
let commandHistoryId = 0;
|
||||
|
||||
function countLeaves(node: RedisKeyTreeNode): number {
|
||||
if (node.kind === "leaf") return 1;
|
||||
return node.children.reduce((sum, child) => sum + countLeaves(child), 0);
|
||||
function resetCheckedKeys() {
|
||||
checkedKeys.value = new Set();
|
||||
selectedGroupLeafCounts.value = new Map();
|
||||
}
|
||||
|
||||
function refreshSelectedGroupLeafCounts() {
|
||||
const nextChecked = new Set<string>();
|
||||
const nextCounts = new Map<string, number>();
|
||||
for (const keyRaw of checkedKeys.value) {
|
||||
if (!loadedKeyRaws.has(keyRaw)) continue;
|
||||
nextChecked.add(keyRaw);
|
||||
for (const groupId of treeIndex?.ancestorGroupIdsByKeyRaw.get(keyRaw) ?? []) {
|
||||
nextCounts.set(groupId, (nextCounts.get(groupId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
checkedKeys.value = nextChecked;
|
||||
selectedGroupLeafCounts.value = nextCounts;
|
||||
}
|
||||
|
||||
function setKeysChecked(keyRaws: Iterable<string>, checked: boolean) {
|
||||
const nextChecked = new Set(checkedKeys.value);
|
||||
const nextCounts = new Map(selectedGroupLeafCounts.value);
|
||||
let changed = false;
|
||||
|
||||
for (const keyRaw of keyRaws) {
|
||||
if (!loadedKeyRaws.has(keyRaw)) continue;
|
||||
const wasChecked = nextChecked.has(keyRaw);
|
||||
if (wasChecked === checked) continue;
|
||||
|
||||
if (checked) nextChecked.add(keyRaw);
|
||||
else nextChecked.delete(keyRaw);
|
||||
|
||||
const delta = checked ? 1 : -1;
|
||||
for (const groupId of treeIndex?.ancestorGroupIdsByKeyRaw.get(keyRaw) ?? []) {
|
||||
const nextCount = (nextCounts.get(groupId) ?? 0) + delta;
|
||||
if (nextCount > 0) nextCounts.set(groupId, nextCount);
|
||||
else nextCounts.delete(groupId);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
checkedKeys.value = nextChecked;
|
||||
selectedGroupLeafCounts.value = nextCounts;
|
||||
}
|
||||
|
||||
function setKeyChecked(keyRaw: string, checked: boolean) {
|
||||
setKeysChecked([keyRaw], checked);
|
||||
}
|
||||
|
||||
function isGroupFullyChecked(group: RedisKeyTreeGroupNode): boolean {
|
||||
return group.loadedLeafCount > 0 && selectedGroupLeafCounts.value.get(group.id) === group.loadedLeafCount;
|
||||
}
|
||||
|
||||
function isGroupPartiallyChecked(group: RedisKeyTreeGroupNode): boolean {
|
||||
const selectedCount = selectedGroupLeafCounts.value.get(group.id) ?? 0;
|
||||
return selectedCount > 0 && selectedCount < group.loadedLeafCount;
|
||||
}
|
||||
|
||||
function setGroupChecked(group: RedisKeyTreeGroupNode, checked: boolean) {
|
||||
setKeysChecked(collectRedisGroupKeyRaws(group), checked);
|
||||
}
|
||||
|
||||
function rebuildTree(expandAll = false) {
|
||||
const nextTree = buildRedisKeyTree(flatKeys.value, props.db, redisKeySeparator.value);
|
||||
if (useFlatKeySearchRows.value) {
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
expandedGroupIds.value = new Set();
|
||||
refreshSelectedGroupLeafCounts();
|
||||
return;
|
||||
}
|
||||
treeIndex = createRedisKeyTreeIndex(flatKeys.value, props.db, redisKeySeparator.value);
|
||||
const nextTree = treeIndex.root;
|
||||
treeKeys.value = nextTree;
|
||||
|
||||
const nextExpanded = new Set<string>();
|
||||
|
|
@ -275,6 +370,7 @@ function rebuildTree(expandAll = false) {
|
|||
}
|
||||
}
|
||||
expandedGroupIds.value = nextExpanded;
|
||||
refreshSelectedGroupLeafCounts();
|
||||
|
||||
if (selectedKeyRaw.value && !flatKeys.value.some((key) => key.key_raw === selectedKeyRaw.value)) {
|
||||
selectedKeyRaw.value = null;
|
||||
|
|
@ -283,18 +379,22 @@ function rebuildTree(expandAll = false) {
|
|||
|
||||
function mergeTree(newKeys: RedisKeyInfo[]) {
|
||||
if (newKeys.length === 0) return;
|
||||
treeKeys.value = mergeKeysIntoRedisKeyTree(treeKeys.value, newKeys, props.db, redisKeySeparator.value);
|
||||
if (!treeIndex) {
|
||||
rebuildTree(isSearchMode.value);
|
||||
return;
|
||||
}
|
||||
const { addedGroupIds } = appendRedisKeysToTreeIndex(treeIndex, newKeys, props.db, redisKeySeparator.value);
|
||||
// Trigger the shallow ref without proxying the whole namespace hierarchy.
|
||||
treeKeys.value = [...treeIndex.root];
|
||||
|
||||
const availableExpanded = collectExpandedGroupIds(treeKeys.value);
|
||||
const nextExpanded = new Set<string>();
|
||||
for (const id of expandedGroupIds.value) {
|
||||
if (availableExpanded.has(id)) nextExpanded.add(id);
|
||||
if (treeIndex.groupById.has(id)) nextExpanded.add(id);
|
||||
}
|
||||
if (isFuzzyHierarchyView.value) {
|
||||
for (const id of addedGroupIds) nextExpanded.add(id);
|
||||
}
|
||||
expandedGroupIds.value = nextExpanded;
|
||||
|
||||
if (selectedKeyRaw.value && !flatKeys.value.some((key) => key.key_raw === selectedKeyRaw.value)) {
|
||||
selectedKeyRaw.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchScanPage(requestId = searchRequestId): Promise<RedisScanResult> {
|
||||
|
|
@ -362,6 +462,7 @@ function appendScanResult(result: RedisScanResult, options: { updateTree?: boole
|
|||
if (options.updateTree ?? true) {
|
||||
if (useFlatKeySearchRows.value) {
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
expandedGroupIds.value = new Set();
|
||||
} else if (treeKeys.value.length === 0) {
|
||||
rebuildTree(isSearchMode.value);
|
||||
|
|
@ -405,8 +506,9 @@ async function loadKeys() {
|
|||
loadedKeyRaws.clear();
|
||||
flatKeys.value = [];
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
selectedKeyRaw.value = null;
|
||||
checkedKeys.value = new Set();
|
||||
resetCheckedKeys();
|
||||
expandedGroupIds.value = new Set();
|
||||
scanCursor.value = 0;
|
||||
lastTotalKeys.value = 0;
|
||||
|
|
@ -463,7 +565,15 @@ async function fetchAll() {
|
|||
} finally {
|
||||
if (requestId === searchRequestId) {
|
||||
if (bufferedKeys.length > 0) flatKeys.value = [...flatKeys.value, ...bufferedKeys];
|
||||
if (changed && !useFlatKeySearchRows.value) rebuildTree(isSearchMode.value);
|
||||
if (changed) {
|
||||
if (useFlatKeySearchRows.value) {
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
expandedGroupIds.value = new Set();
|
||||
} else {
|
||||
rebuildTree(isSearchMode.value);
|
||||
}
|
||||
}
|
||||
isFetchingAll.value = false;
|
||||
fetchAllStopRequested.value = false;
|
||||
fetchAllLoadedCount.value = 0;
|
||||
|
|
@ -497,10 +607,13 @@ function removeKnownKey(keyRaw: string) {
|
|||
loadedKeyRaws.delete(keyRaw);
|
||||
flatKeys.value = flatKeys.value.filter((key) => key.key_raw !== keyRaw);
|
||||
if (selectedKeyRaw.value === keyRaw) selectedKeyRaw.value = null;
|
||||
const nextChecked = new Set(checkedKeys.value);
|
||||
nextChecked.delete(keyRaw);
|
||||
checkedKeys.value = nextChecked;
|
||||
rebuildTree(false);
|
||||
if (useFlatKeySearchRows.value) {
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
refreshSelectedGroupLeafCounts();
|
||||
} else {
|
||||
rebuildTree(false);
|
||||
}
|
||||
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
|
||||
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
|
||||
totalDelta: -1,
|
||||
|
|
@ -532,29 +645,43 @@ function onKeyLoaded(value: RedisValue) {
|
|||
if (existingIndex < 0) return;
|
||||
flatKeys.value = flatKeys.value.map((key, index) => (index === existingIndex ? keyInfo : key));
|
||||
loadedKeyRaws.add(keyInfo.key_raw);
|
||||
rebuildTree(false);
|
||||
if (useFlatKeySearchRows.value) {
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
refreshSelectedGroupLeafCounts();
|
||||
} else {
|
||||
rebuildTree(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCheck(keyRaw: string, event: Event) {
|
||||
event.stopPropagation();
|
||||
const next = new Set(checkedKeys.value);
|
||||
if (next.has(keyRaw)) next.delete(keyRaw);
|
||||
else next.add(keyRaw);
|
||||
checkedKeys.value = next;
|
||||
if (selectionBusy.value) return;
|
||||
setKeyChecked(keyRaw, !checkedKeys.value.has(keyRaw));
|
||||
}
|
||||
|
||||
function requestBatchDelete() {
|
||||
if (checkedKeys.value.size === 0) return;
|
||||
pendingDanger.value = { kind: "delete-keys", title: t("redis.selectedKeys"), keyRaws: [...checkedKeys.value] };
|
||||
if (checkedKeys.value.size === 0 || selectionBusy.value) return;
|
||||
pendingDanger.value = {
|
||||
kind: "delete-keys",
|
||||
title: t("redis.selectedKeys"),
|
||||
keyRaws: [...checkedKeys.value],
|
||||
loadedSearchResults: isFuzzyKeySearch.value,
|
||||
};
|
||||
showDangerConfirm.value = true;
|
||||
}
|
||||
|
||||
function requestGroupDelete(node: RedisKeyTreeNode, event: Event) {
|
||||
event.stopPropagation();
|
||||
if (node.kind !== "group") return;
|
||||
if (node.kind !== "group" || selectionBusy.value) return;
|
||||
const keyRaws = collectRedisGroupKeyRaws(node);
|
||||
if (keyRaws.length === 0) return;
|
||||
pendingDanger.value = { kind: "delete-keys", title: node.pathSegments.join(":"), keyRaws };
|
||||
pendingDanger.value = {
|
||||
kind: "delete-keys",
|
||||
title: node.pathSegments.join(redisKeySeparator.value),
|
||||
keyRaws,
|
||||
loadedSearchResults: false,
|
||||
};
|
||||
showDangerConfirm.value = true;
|
||||
}
|
||||
|
||||
|
|
@ -593,27 +720,54 @@ function resetLoadedKeys() {
|
|||
loadedKeyRaws.clear();
|
||||
flatKeys.value = [];
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
selectedKeyRaw.value = null;
|
||||
checkedKeys.value = new Set();
|
||||
resetCheckedKeys();
|
||||
expandedGroupIds.value = new Set();
|
||||
hasMore.value = false;
|
||||
lastTotalKeys.value = 0;
|
||||
}
|
||||
|
||||
async function deleteKeyRaws(keys: string[]) {
|
||||
const deletedCount = await api.redisDeleteKeys(props.connectionId, props.db, keys);
|
||||
const deleted = new Set(keys);
|
||||
for (const key of deleted) loadedKeyRaws.delete(key);
|
||||
flatKeys.value = flatKeys.value.filter((k) => !deleted.has(k.key_raw));
|
||||
if (selectedKeyRaw.value && deleted.has(selectedKeyRaw.value)) {
|
||||
selectedKeyRaw.value = null;
|
||||
const uniqueKeys = [...new Set(keys)];
|
||||
if (uniqueKeys.length === 0 || deletingKeys.value) return;
|
||||
|
||||
// Ignore a late SCAN page while an explicit mutation changes this result set.
|
||||
searchRequestId++;
|
||||
fetchAllStopRequested.value = true;
|
||||
deletingKeys.value = true;
|
||||
try {
|
||||
let deletedCount = 0;
|
||||
for (const batch of chunkRedisKeyRaws(uniqueKeys)) {
|
||||
deletedCount += await api.redisDeleteKeys(props.connectionId, props.db, batch);
|
||||
}
|
||||
const deleted = new Set(uniqueKeys);
|
||||
for (const key of deleted) loadedKeyRaws.delete(key);
|
||||
flatKeys.value = flatKeys.value.filter((key) => !deleted.has(key.key_raw));
|
||||
if (selectedKeyRaw.value && deleted.has(selectedKeyRaw.value)) {
|
||||
selectedKeyRaw.value = null;
|
||||
}
|
||||
resetCheckedKeys();
|
||||
if (useFlatKeySearchRows.value) {
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
} else {
|
||||
rebuildTree(false);
|
||||
}
|
||||
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
|
||||
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
|
||||
totalDelta: -deletedCount,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast(message, 5000);
|
||||
// A Cluster request may have deleted an earlier shard before a later error.
|
||||
// Reload instead of leaving a potentially stale partial result in the tree.
|
||||
if (redisBrowserIsActive) await loadKeys();
|
||||
else reloadKeysOnActivation = true;
|
||||
} finally {
|
||||
deletingKeys.value = false;
|
||||
}
|
||||
checkedKeys.value = new Set();
|
||||
rebuildTree(false);
|
||||
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
|
||||
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
|
||||
totalDelta: -deletedCount,
|
||||
});
|
||||
}
|
||||
|
||||
function scrollCommandTerminalToEnd() {
|
||||
|
|
@ -1065,13 +1219,15 @@ async function executeCommand() {
|
|||
|
||||
async function applyDangerAction() {
|
||||
const pending = pendingDanger.value;
|
||||
pendingDanger.value = null;
|
||||
showDangerConfirm.value = false;
|
||||
if (!pending) return;
|
||||
|
||||
if (pending.kind === "delete-keys") {
|
||||
await deleteKeyRaws(pending.keyRaws);
|
||||
pendingDanger.value = null;
|
||||
showDangerConfirm.value = false;
|
||||
} else {
|
||||
pendingDanger.value = null;
|
||||
showDangerConfirm.value = false;
|
||||
await runRedisCommand(pending.command);
|
||||
}
|
||||
}
|
||||
|
|
@ -1175,6 +1331,10 @@ function unregisterRedisDbFlushedListener() {
|
|||
}
|
||||
|
||||
function pauseRedisBrowserBackgroundWork() {
|
||||
// Fetch All advances the cursor and duplicate filter before its local buffer
|
||||
// is committed. Discard that incomplete session so reactivation cannot skip
|
||||
// keys that were never rendered.
|
||||
const discardIncompleteFetchAll = isFetchingAll.value;
|
||||
redisBrowserIsActive = false;
|
||||
searchRequestId++;
|
||||
isFetchingAll.value = false;
|
||||
|
|
@ -1185,6 +1345,7 @@ function pauseRedisBrowserBackgroundWork() {
|
|||
searchPending.value = false;
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = null;
|
||||
if (discardIncompleteFetchAll) resetLoadedKeys();
|
||||
unregisterRedisDbFlushedListener();
|
||||
}
|
||||
|
||||
|
|
@ -1252,6 +1413,8 @@ onMounted(async () => {
|
|||
onActivated(async () => {
|
||||
resumeRedisBrowserBackgroundWork();
|
||||
void autofocusSearchOnce();
|
||||
const shouldReload = reloadKeysOnActivation;
|
||||
reloadKeysOnActivation = false;
|
||||
// Ensure the connection is still alive after reactivation (e.g. tab switch).
|
||||
// If keys failed to load previously (empty list), retry loading.
|
||||
try {
|
||||
|
|
@ -1259,7 +1422,7 @@ onActivated(async () => {
|
|||
} catch (e) {
|
||||
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
|
||||
}
|
||||
if (flatKeys.value.length === 0 && !loading.value) {
|
||||
if ((shouldReload || flatKeys.value.length === 0) && !loading.value) {
|
||||
void loadKeys();
|
||||
}
|
||||
});
|
||||
|
|
@ -1318,8 +1481,8 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
|||
</div>
|
||||
<div class="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1">
|
||||
<span class="min-w-0 max-w-full truncate text-xs text-muted-foreground" :title="keyCountText">{{ keyCountText }}</span>
|
||||
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 text-xs text-destructive" @click="requestBatchDelete"> <Trash2 class="w-3 h-3 mr-1" />{{ checkedKeys.size }} </Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" @click="loadKeys">
|
||||
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 text-xs text-destructive" :disabled="selectionBusy" @click="requestBatchDelete"> <Trash2 class="w-3 h-3 mr-1" />{{ checkedKeys.size }} </Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="deletingKeys" @click="loadKeys">
|
||||
<Loader2 v-if="loading" class="h-3 w-3 animate-spin" />
|
||||
<RefreshCw v-else class="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -1359,7 +1522,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
|||
<div v-if="flatKeys.length === 0 && !loading" class="flex-1 flex flex-col items-center justify-center text-muted-foreground text-xs p-4 text-center">
|
||||
<template v-if="hasMore">
|
||||
<span class="mb-3">{{ t("redis.noKeysInScanHint") }}</span>
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs" :disabled="loadingMore || searchPending" @click="loadMore">
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs" :disabled="loadingMore || searchPending || deletingKeys" @click="loadMore">
|
||||
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
|
||||
{{ t("redis.loadMoreKeys") }}
|
||||
</Button>
|
||||
|
|
@ -1384,15 +1547,33 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
|||
>
|
||||
<div class="min-w-0 flex flex-1 items-center gap-1 overflow-hidden" :style="{ paddingLeft: `${12 + row.depth * 16}px` }">
|
||||
<template v-if="row.node.kind === 'group'">
|
||||
<input
|
||||
v-if="isFuzzyHierarchyView"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 shrink-0 accent-primary cursor-pointer"
|
||||
:checked="isGroupFullyChecked(row.node)"
|
||||
:indeterminate="isGroupPartiallyChecked(row.node)"
|
||||
:disabled="selectionBusy"
|
||||
:aria-label="t('redis.selectLoadedGroupKeys', { count: row.node.loadedLeafCount })"
|
||||
@click.stop
|
||||
@change="setGroupChecked(row.node, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<component :is="expandedGroupIds.has(row.node.id) ? ChevronDown : ChevronRight" class="w-3 h-3 shrink-0 text-muted-foreground" />
|
||||
<component :is="expandedGroupIds.has(row.node.id) ? FolderOpen : FolderClosed" class="w-3 h-3 shrink-0 text-amber-500" />
|
||||
<span class="dbx-editor-font-family truncate">{{ row.node.label }}</span>
|
||||
<span class="text-muted-foreground ml-1">({{ countLeaves(row.node) }})</span>
|
||||
<span class="text-muted-foreground ml-1" :title="isFuzzyHierarchyView ? t('redis.loadedMatchingKeys', { count: row.node.loadedLeafCount }) : undefined">({{ row.node.loadedLeafCount }})</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="relative flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<KeyRound class="h-3.5 w-3.5 text-muted-foreground/70 transition-opacity group-hover:opacity-0" :class="{ 'opacity-0': checkedKeys.has(row.node.keyRaw) }" />
|
||||
<input type="checkbox" class="absolute h-3.5 w-3.5 accent-primary cursor-pointer opacity-0 group-hover:opacity-100" :class="{ 'opacity-100': checkedKeys.has(row.node.keyRaw) }" :checked="checkedKeys.has(row.node.keyRaw)" @click="toggleCheck(row.node.keyRaw, $event)" />
|
||||
<input
|
||||
type="checkbox"
|
||||
class="absolute h-3.5 w-3.5 accent-primary cursor-pointer opacity-0 group-hover:opacity-100"
|
||||
:class="{ 'opacity-100': checkedKeys.has(row.node.keyRaw) }"
|
||||
:checked="checkedKeys.has(row.node.keyRaw)"
|
||||
:disabled="selectionBusy"
|
||||
@click="toggleCheck(row.node.keyRaw, $event)"
|
||||
/>
|
||||
</span>
|
||||
<span class="dbx-editor-font-family truncate">{{ row.node.label }}</span>
|
||||
</template>
|
||||
|
|
@ -1400,7 +1581,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
|||
|
||||
<div class="flex shrink-0 items-center justify-end gap-1">
|
||||
<Badge v-if="row.node.kind === 'leaf' && row.node.keyType" variant="outline" class="text-xs px-1.5 py-0" :class="typeColor(row.node.keyType)">{{ row.node.keyType }}</Badge>
|
||||
<Button v-if="row.node.kind === 'group'" variant="ghost" size="icon" class="h-5 w-5 shrink-0 text-destructive opacity-0 group-hover:opacity-100" :title="t('redis.deleteGroup')" @click="requestGroupDelete(row.node, $event)">
|
||||
<Button v-if="row.node.kind === 'group' && !isFuzzyHierarchyView" variant="ghost" size="icon" class="h-5 w-5 shrink-0 text-destructive opacity-0 group-hover:opacity-100" :title="t('redis.deleteGroup')" :disabled="selectionBusy" @click="requestGroupDelete(row.node, $event)">
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1408,12 +1589,15 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
|||
</CustomContextMenu>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
<div v-if="fuzzyTreeLimitReached" class="shrink-0 border-t px-3 py-2 text-center text-xs text-muted-foreground">
|
||||
{{ t("redis.fuzzyTreeLimit", { count: flatKeys.length }) }}
|
||||
</div>
|
||||
<div v-if="hasMore && !isFetchingAll" class="shrink-0 border-t px-2 py-1.5 flex items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loadingMore || loading || searchPending" @click="loadMore">
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loadingMore || loading || searchPending || deletingKeys" @click="loadMore">
|
||||
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
|
||||
{{ t("redis.loadMoreKeys") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loading || searchPending || !hasMore" @click="fetchAll">
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loading || searchPending || deletingKeys || !hasMore" @click="fetchAll">
|
||||
{{ t("redis.fetchAllKeys") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1421,7 +1605,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
|||
<div class="text-xs text-muted-foreground text-center">
|
||||
{{ fetchAllProgressText }}
|
||||
</div>
|
||||
<Button variant="destructive" size="sm" class="h-7 text-xs w-full" :disabled="fetchAllStopRequested" @click="stopFetchAll">
|
||||
<Button variant="destructive" size="sm" class="h-7 text-xs w-full" :disabled="fetchAllStopRequested || deletingKeys" @click="stopFetchAll">
|
||||
{{ t("redis.stopFetchAll") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1509,7 +1693,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
|||
</Pane>
|
||||
</Splitpanes>
|
||||
|
||||
<DangerConfirmDialog v-model:open="showDangerConfirm" :message="dangerMessage" :details="dangerDetails" :confirm-label="dangerConfirmLabel" @confirm="applyDangerAction" />
|
||||
<DangerConfirmDialog v-model:open="showDangerConfirm" :message="dangerMessage" :details="dangerDetails" :confirm-label="dangerConfirmLabel" :loading="deletingKeys" :close-on-confirm="pendingDanger?.kind !== 'delete-keys'" @confirm="applyDangerAction" />
|
||||
|
||||
<Dialog :open="showCreateKeyDialog" @update:open="onCreateKeyDialogOpenChange">
|
||||
<DialogContent class="sm:max-w-md" :show-close-button="!creatingKey" :style="editorFontFamilyStyle">
|
||||
|
|
|
|||
|
|
@ -2909,8 +2909,12 @@ export default {
|
|||
columnTTL: "TTL",
|
||||
binaryStringReadonlyHint: "Binary string values are shown as escaped text in read-only mode; editing raw bytes is not supported.",
|
||||
selectedKeys: "Selected keys",
|
||||
selectLoadedGroupKeys: "Select {count} loaded matching keys",
|
||||
loadedMatchingKeys: "{count} loaded matching keys",
|
||||
fuzzyTreeLimit: "{count} fuzzy matches are shown as a flat list to keep the browser responsive. Refine the search to view the hierarchy.",
|
||||
deleteGroup: "Delete group",
|
||||
deleteGroupDetails: "{target}\n{count} keys",
|
||||
deleteLoadedSearchKeysDetails: "{target}\n{count} loaded matching keys",
|
||||
flushDb: "Clear current DB",
|
||||
flushDbMessage: "This will delete every key in the current Redis database and cannot be undone. Continue?",
|
||||
flushDbDetails: "Target: Redis db{db}\nScope: all keys in the current DB",
|
||||
|
|
|
|||
|
|
@ -2770,8 +2770,12 @@ export default withEnglishFallback({
|
|||
columnTTL: "TTL",
|
||||
binaryStringReadonlyHint: "Los valores de cadena binaria se muestran como texto escapado en modo de solo lectura; la edición de bytes sin procesar no está disponible.",
|
||||
selectedKeys: "Claves seleccionadas",
|
||||
selectLoadedGroupKeys: "Seleccionar {count} claves coincidentes cargadas",
|
||||
loadedMatchingKeys: "{count} claves coincidentes cargadas",
|
||||
fuzzyTreeLimit: "Se muestran {count} coincidencias difusas como lista plana para mantener el navegador responsivo. Refina la búsqueda para ver la jerarquía.",
|
||||
deleteGroup: "Eliminar grupo",
|
||||
deleteGroupDetails: "{target}\n{count} claves",
|
||||
deleteLoadedSearchKeysDetails: "{target}\n{count} claves coincidentes cargadas",
|
||||
flushDb: "Limpiar DB actual",
|
||||
flushDbMessage: "Esto eliminará todas las claves de la base de datos Redis actual y no se puede deshacer. ¿Continuar?",
|
||||
flushDbDetails: "Destino: Redis db{db}\nAlcance: todas las claves de la DB actual",
|
||||
|
|
|
|||
|
|
@ -2768,8 +2768,12 @@ export default withEnglishFallback({
|
|||
columnTTL: "TTL",
|
||||
binaryStringReadonlyHint: "I valori delle stringhe binarie sono mostrati come testo con escape in modalità sola lettura; la modifica dei byte grezzi non è supportata.",
|
||||
selectedKeys: "Chiavi selezionate",
|
||||
selectLoadedGroupKeys: "Seleziona {count} chiavi corrispondenti caricate",
|
||||
loadedMatchingKeys: "{count} chiavi corrispondenti caricate",
|
||||
fuzzyTreeLimit: "{count} corrispondenze fuzzy sono mostrate come elenco piatto per mantenere il browser reattivo. Restringi la ricerca per vedere la gerarchia.",
|
||||
deleteGroup: "Elimina gruppo",
|
||||
deleteGroupDetails: "{target}\n{count} chiavi",
|
||||
deleteLoadedSearchKeysDetails: "{target}\n{count} chiavi corrispondenti caricate",
|
||||
flushDb: "Pulisci DB corrente",
|
||||
flushDbMessage: "Questo eliminerà ogni chiave nel database Redis corrente e non può essere annullato. Continuare?",
|
||||
flushDbDetails: "Destinazione: Redis db{db}\nAmbito: tutte le chiavi nel DB corrente",
|
||||
|
|
|
|||
|
|
@ -2769,8 +2769,12 @@ export default withEnglishFallback({
|
|||
columnTTL: "TTL",
|
||||
binaryStringReadonlyHint: "バイナリ文字列値は読み取り専用モードでエスケープテキストとして表示されます。生バイトの編集はサポートされていません。",
|
||||
selectedKeys: "選択中のキー",
|
||||
selectLoadedGroupKeys: "読み込み済みの一致キー {count} 件を選択",
|
||||
loadedMatchingKeys: "読み込み済みの一致キー {count} 件",
|
||||
fuzzyTreeLimit: "ブラウザーの応答性を保つため、あいまい一致 {count} 件をフラットな一覧で表示しています。階層を表示するには検索条件を絞り込んでください。",
|
||||
deleteGroup: "グループを削除",
|
||||
deleteGroupDetails: "{target}\n{count}個のキー",
|
||||
deleteLoadedSearchKeysDetails: "{target}\n読み込み済みの一致キー {count} 件",
|
||||
flushDb: "現在のDBをクリア",
|
||||
flushDbMessage: "現在のRedisデータベース内のすべてのキーが削除されます。この操作は取り消せません。続行しますか?",
|
||||
flushDbDetails: "対象: Redis db{db}\n範囲: 現在のDB内のすべてのキー",
|
||||
|
|
|
|||
|
|
@ -2770,8 +2770,12 @@ export default withEnglishFallback({
|
|||
columnTTL: "TTL",
|
||||
binaryStringReadonlyHint: "Valores de string binária são exibidos como texto escapado no modo somente leitura; a edição de bytes brutos não é suportada.",
|
||||
selectedKeys: "Chaves selecionadas",
|
||||
selectLoadedGroupKeys: "Selecionar {count} chaves correspondentes carregadas",
|
||||
loadedMatchingKeys: "{count} chaves correspondentes carregadas",
|
||||
fuzzyTreeLimit: "{count} correspondências difusas são mostradas como uma lista plana para manter o navegador responsivo. Refine a busca para ver a hierarquia.",
|
||||
deleteGroup: "Excluir grupo",
|
||||
deleteGroupDetails: "{target}\n{count} chaves",
|
||||
deleteLoadedSearchKeysDetails: "{target}\n{count} chaves correspondentes carregadas",
|
||||
flushDb: "Limpar DB atual",
|
||||
flushDbMessage: "Isto excluirá todas as chaves do banco de dados Redis atual e não pode ser desfeito. Continuar?",
|
||||
flushDbDetails: "Alvo: Redis db{db}\nEscopo: todas as chaves do DB atual",
|
||||
|
|
|
|||
|
|
@ -2909,8 +2909,12 @@ export default withEnglishFallback({
|
|||
columnTTL: "TTL",
|
||||
binaryStringReadonlyHint: "二进制字符串按转义文本只读展示;当前不支持直接编辑原始字节值。",
|
||||
selectedKeys: "已选择的 key",
|
||||
selectLoadedGroupKeys: "选择已加载的 {count} 个匹配 key",
|
||||
loadedMatchingKeys: "已加载的 {count} 个匹配 key",
|
||||
fuzzyTreeLimit: "已加载 {count} 个模糊匹配 key。为保持浏览器响应,当前以扁平列表展示;请缩小搜索范围以查看层级。",
|
||||
deleteGroup: "删除分组",
|
||||
deleteGroupDetails: "{target}\n{count} 个 key",
|
||||
deleteLoadedSearchKeysDetails: "{target}\n已加载的 {count} 个匹配 key",
|
||||
flushDb: "清空当前 DB",
|
||||
flushDbMessage: "这会删除当前 Redis 数据库中的所有 key,且不可撤销。确认继续?",
|
||||
flushDbDetails: "目标: Redis db{db}\n范围: 当前 DB 的所有 key",
|
||||
|
|
|
|||
|
|
@ -2441,8 +2441,12 @@ export default withEnglishFallback({
|
|||
columnTTL: "TTL",
|
||||
binaryStringReadonlyHint: "二進位字串值會以逸出文字唯讀顯示;不支援編輯原始位元組。",
|
||||
selectedKeys: "已選擇的 key",
|
||||
selectLoadedGroupKeys: "選取已載入的 {count} 個相符 key",
|
||||
loadedMatchingKeys: "已載入的 {count} 個相符 key",
|
||||
fuzzyTreeLimit: "已載入 {count} 個模糊相符 key。為保持瀏覽器回應,目前以扁平清單顯示;請縮小搜尋範圍以查看層級。",
|
||||
deleteGroup: "刪除群組",
|
||||
deleteGroupDetails: "{target}\n{count} 個 key",
|
||||
deleteLoadedSearchKeysDetails: "{target}\n已載入的 {count} 個相符 key",
|
||||
flushDb: "清空目前 DB",
|
||||
flushDbMessage: "這會刪除目前 Redis 資料庫中的所有 key,且無法復原。確認繼續?",
|
||||
flushDbDetails: "目標: Redis db{db}\n範圍: 目前 DB 的所有 key",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import type { RedisKeyInfo } from "@/lib/backend/api";
|
||||
|
||||
// Keep desktop IPC and Redis command payloads bounded for large group deletes.
|
||||
export const REDIS_DELETE_KEY_BATCH_SIZE = 1_000;
|
||||
|
||||
export function collectUniqueRedisKeys(keys: RedisKeyInfo[], loadedKeyRaws: Set<string>): RedisKeyInfo[] {
|
||||
const uniqueKeys: RedisKeyInfo[] = [];
|
||||
|
||||
|
|
@ -11,3 +14,9 @@ export function collectUniqueRedisKeys(keys: RedisKeyInfo[], loadedKeyRaws: Set<
|
|||
|
||||
return uniqueKeys;
|
||||
}
|
||||
|
||||
export function* chunkRedisKeyRaws(keyRaws: readonly string[], batchSize = REDIS_DELETE_KEY_BATCH_SIZE): Generator<string[]> {
|
||||
for (let start = 0; start < keyRaws.length; start += batchSize) {
|
||||
yield keyRaws.slice(start, start + batchSize);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import type { RedisKeyInfo } from "@/lib/backend/api";
|
||||
|
||||
// A hierarchy duplicates key metadata and indexes; above this limit keep
|
||||
// fuzzy results virtualized as flat rows instead of exhausting desktop memory.
|
||||
export const REDIS_FUZZY_TREE_MAX_KEYS = 200_000;
|
||||
|
||||
export function canBuildRedisFuzzyTree(loadedKeyCount: number): boolean {
|
||||
return loadedKeyCount <= REDIS_FUZZY_TREE_MAX_KEYS;
|
||||
}
|
||||
|
||||
export interface RedisKeyTreeLeafNode {
|
||||
kind: "leaf";
|
||||
id: string;
|
||||
|
|
@ -20,6 +28,8 @@ export interface RedisKeyTreeGroupNode {
|
|||
label: string;
|
||||
pathSegments: string[];
|
||||
children: RedisKeyTreeNode[];
|
||||
// The rendered count must not recursively walk an entire visible subtree.
|
||||
loadedLeafCount: number;
|
||||
}
|
||||
|
||||
export type RedisKeyTreeNode = RedisKeyTreeLeafNode | RedisKeyTreeGroupNode;
|
||||
|
|
@ -30,6 +40,17 @@ export interface RedisKeyTreeRow {
|
|||
depth: number;
|
||||
}
|
||||
|
||||
export interface RedisKeyTreeIndex {
|
||||
root: RedisKeyTreeNode[];
|
||||
groupById: Map<string, RedisKeyTreeGroupNode>;
|
||||
keyRaws: Set<string>;
|
||||
ancestorGroupIdsByKeyRaw: Map<string, readonly string[]>;
|
||||
}
|
||||
|
||||
export interface AppendRedisKeysResult {
|
||||
addedGroupIds: Set<string>;
|
||||
}
|
||||
|
||||
export function redisKeyNameCopyText(node: RedisKeyTreeNode): string | null {
|
||||
// keyRaw is base64-encoded for backend roundtrips; copy the user-visible
|
||||
// Redis key name instead of the internal transport value.
|
||||
|
|
@ -37,7 +58,7 @@ export function redisKeyNameCopyText(node: RedisKeyTreeNode): string | null {
|
|||
}
|
||||
|
||||
function buildGroupId(db: number, pathSegments: string[]): string {
|
||||
return `group:${db}:${pathSegments.join("\u0000")}`;
|
||||
return `group:${db}:${JSON.stringify(pathSegments)}`;
|
||||
}
|
||||
|
||||
function buildLeafId(db: number, keyRaw: string): string {
|
||||
|
|
@ -70,35 +91,73 @@ function compareRedisTreeNodes(a: RedisKeyTreeNode, b: RedisKeyTreeNode): number
|
|||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
|
||||
function sortRedisTreeNodes(nodes: RedisKeyTreeNode[]): RedisKeyTreeNode[] {
|
||||
return [...nodes].sort(compareRedisTreeNodes).map((node) =>
|
||||
node.kind === "group"
|
||||
? {
|
||||
...node,
|
||||
children: sortRedisTreeNodes(node.children),
|
||||
}
|
||||
: node,
|
||||
);
|
||||
function redisKeyInfoFromLeaf(node: RedisKeyTreeLeafNode): RedisKeyInfo {
|
||||
return {
|
||||
key_display: node.fullKeyDisplay,
|
||||
key_raw: node.keyRaw,
|
||||
key_type: node.keyType,
|
||||
ttl: node.ttl,
|
||||
size: node.size,
|
||||
value_preview: node.valuePreview,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number, separator = ":"): RedisKeyTreeNode[] {
|
||||
const root: RedisKeyTreeNode[] = [];
|
||||
const groupMap = new Map<string, RedisKeyTreeGroupNode>();
|
||||
export function createRedisKeyTreeIndex(keys: readonly RedisKeyInfo[], db: number, separator = ":"): RedisKeyTreeIndex {
|
||||
const index: RedisKeyTreeIndex = {
|
||||
root: [],
|
||||
groupById: new Map(),
|
||||
keyRaws: new Set(),
|
||||
ancestorGroupIdsByKeyRaw: new Map(),
|
||||
};
|
||||
appendRedisKeysToTreeIndex(index, keys, db, separator);
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts one SCAN batch while sorting only sibling arrays changed by that
|
||||
* batch. This keeps repeated "load more" merges from re-sorting the full tree.
|
||||
*/
|
||||
export function appendRedisKeysToTreeIndex(index: RedisKeyTreeIndex, keys: readonly RedisKeyInfo[], db: number, separator = ":"): AppendRedisKeysResult {
|
||||
const touchedLevels = new Set<RedisKeyTreeNode[]>();
|
||||
const addedGroupIds = new Set<string>();
|
||||
|
||||
for (const key of keys) {
|
||||
insertKeyIntoTree(root, groupMap, key, db, separator);
|
||||
}
|
||||
if (index.keyRaws.has(key.key_raw)) continue;
|
||||
|
||||
return sortRedisTreeNodes(root);
|
||||
}
|
||||
const pathSegments = separator ? key.key_display.split(separator) : [key.key_display];
|
||||
const ancestorGroupIds: string[] = [];
|
||||
let currentLevel = index.root;
|
||||
|
||||
function insertKeyIntoTree(root: RedisKeyTreeNode[], groupMap: Map<string, RedisKeyTreeGroupNode>, key: RedisKeyInfo, db: number, separator: string): void {
|
||||
const pathSegments = separator ? key.key_display.split(separator) : [key.key_display];
|
||||
if (pathSegments.length === 1) {
|
||||
root.push({
|
||||
if (pathSegments.length > 1) {
|
||||
const groupSegments: string[] = [];
|
||||
for (const segment of pathSegments.slice(0, -1)) {
|
||||
groupSegments.push(segment);
|
||||
const groupId = buildGroupId(db, groupSegments);
|
||||
let group = index.groupById.get(groupId);
|
||||
if (!group) {
|
||||
group = {
|
||||
kind: "group",
|
||||
id: groupId,
|
||||
label: segment,
|
||||
pathSegments: [...groupSegments],
|
||||
children: [],
|
||||
loadedLeafCount: 0,
|
||||
};
|
||||
index.groupById.set(groupId, group);
|
||||
currentLevel.push(group);
|
||||
touchedLevels.add(currentLevel);
|
||||
addedGroupIds.add(groupId);
|
||||
}
|
||||
group.loadedLeafCount++;
|
||||
ancestorGroupIds.push(groupId);
|
||||
currentLevel = group.children;
|
||||
}
|
||||
}
|
||||
|
||||
currentLevel.push({
|
||||
kind: "leaf",
|
||||
id: buildLeafId(db, key.key_raw),
|
||||
label: pathSegments[0],
|
||||
label: pathSegments[pathSegments.length - 1],
|
||||
fullKeyDisplay: key.key_display,
|
||||
keyRaw: key.key_raw,
|
||||
db,
|
||||
|
|
@ -108,113 +167,62 @@ function insertKeyIntoTree(root: RedisKeyTreeNode[], groupMap: Map<string, Redis
|
|||
valuePreview: key.value_preview ?? "",
|
||||
pathSegments,
|
||||
});
|
||||
return;
|
||||
touchedLevels.add(currentLevel);
|
||||
index.keyRaws.add(key.key_raw);
|
||||
index.ancestorGroupIdsByKeyRaw.set(key.key_raw, ancestorGroupIds);
|
||||
}
|
||||
|
||||
let currentLevel = root;
|
||||
const groupSegments: string[] = [];
|
||||
for (const segment of pathSegments.slice(0, -1)) {
|
||||
groupSegments.push(segment);
|
||||
const groupId = buildGroupId(db, groupSegments);
|
||||
let group = groupMap.get(groupId);
|
||||
if (!group) {
|
||||
group = {
|
||||
kind: "group",
|
||||
id: groupId,
|
||||
label: segment,
|
||||
pathSegments: [...groupSegments],
|
||||
children: [],
|
||||
};
|
||||
groupMap.set(groupId, group);
|
||||
currentLevel.push(group);
|
||||
}
|
||||
currentLevel = group.children;
|
||||
}
|
||||
|
||||
currentLevel.push({
|
||||
kind: "leaf",
|
||||
id: buildLeafId(db, key.key_raw),
|
||||
label: pathSegments[pathSegments.length - 1],
|
||||
fullKeyDisplay: key.key_display,
|
||||
keyRaw: key.key_raw,
|
||||
db,
|
||||
keyType: key.key_type ?? "",
|
||||
ttl: key.ttl ?? -2,
|
||||
size: key.size ?? 0,
|
||||
valuePreview: key.value_preview ?? "",
|
||||
pathSegments,
|
||||
});
|
||||
for (const nodes of touchedLevels) nodes.sort(compareRedisTreeNodes);
|
||||
return { addedGroupIds };
|
||||
}
|
||||
|
||||
function rebuildGroupMap(tree: RedisKeyTreeNode[]): Map<string, RedisKeyTreeGroupNode> {
|
||||
const groupMap = new Map<string, RedisKeyTreeGroupNode>();
|
||||
|
||||
const walk = (nodes: RedisKeyTreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "group") {
|
||||
groupMap.set(node.id, node);
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(tree);
|
||||
return groupMap;
|
||||
export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number, separator = ":"): RedisKeyTreeNode[] {
|
||||
return createRedisKeyTreeIndex(keys, db, separator).root;
|
||||
}
|
||||
|
||||
export function mergeKeysIntoRedisKeyTree(existingTree: RedisKeyTreeNode[], newKeys: RedisKeyInfo[], db: number, separator = ":"): RedisKeyTreeNode[] {
|
||||
if (existingTree.length === 0) return buildRedisKeyTree(newKeys, db, separator);
|
||||
|
||||
const groupMap = rebuildGroupMap(existingTree);
|
||||
const existingKeyIds = new Set<string>();
|
||||
const collectKeys = (nodes: RedisKeyTreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "leaf") {
|
||||
existingKeyIds.add(node.keyRaw);
|
||||
} else {
|
||||
collectKeys(node.children);
|
||||
}
|
||||
const existingKeys: RedisKeyInfo[] = [];
|
||||
const stack = [...existingTree];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
if (node.kind === "leaf") {
|
||||
existingKeys.push(redisKeyInfoFromLeaf(node));
|
||||
} else {
|
||||
for (const child of node.children) stack.push(child);
|
||||
}
|
||||
};
|
||||
collectKeys(existingTree);
|
||||
|
||||
for (const key of newKeys) {
|
||||
if (existingKeyIds.has(key.key_raw)) continue;
|
||||
insertKeyIntoTree(existingTree, groupMap, key, db, separator);
|
||||
}
|
||||
|
||||
return sortRedisTreeNodes(existingTree);
|
||||
const index = createRedisKeyTreeIndex(existingKeys, db, separator);
|
||||
appendRedisKeysToTreeIndex(index, newKeys, db, separator);
|
||||
return index.root;
|
||||
}
|
||||
|
||||
export function collectExpandedGroupIds(nodes: RedisKeyTreeNode[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
|
||||
const visit = (entries: RedisKeyTreeNode[]) => {
|
||||
for (const node of entries) {
|
||||
if (node.kind !== "group") continue;
|
||||
ids.add(node.id);
|
||||
visit(node.children);
|
||||
}
|
||||
};
|
||||
|
||||
visit(nodes);
|
||||
const stack = [...nodes];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
if (node.kind !== "group") continue;
|
||||
ids.add(node.id);
|
||||
for (const child of node.children) stack.push(child);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function collectRedisGroupKeyRaws(group: RedisKeyTreeGroupNode): string[] {
|
||||
const keyRaws: string[] = [];
|
||||
|
||||
const visit = (nodes: RedisKeyTreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "leaf") {
|
||||
keyRaws.push(node.keyRaw);
|
||||
} else {
|
||||
visit(node.children);
|
||||
const stack = [...group.children].reverse();
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
if (node.kind === "leaf") {
|
||||
keyRaws.push(node.keyRaw);
|
||||
} else {
|
||||
// Push in reverse so the iterative walk keeps the rendered tree order.
|
||||
for (let index = node.children.length - 1; index >= 0; index--) {
|
||||
stack.push(node.children[index]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visit(group.children);
|
||||
}
|
||||
return keyRaws;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { test } from "vitest";
|
||||
import assert from "node:assert/strict";
|
||||
import { collectUniqueRedisKeys } from "../../apps/desktop/src/lib/redis/redisKeyBatch.ts";
|
||||
import { chunkRedisKeyRaws, collectUniqueRedisKeys, REDIS_DELETE_KEY_BATCH_SIZE } from "../../apps/desktop/src/lib/redis/redisKeyBatch.ts";
|
||||
import type { RedisKeyInfo } from "../../apps/desktop/src/lib/backend/api.ts";
|
||||
|
||||
function makeKey(key: string): RedisKeyInfo {
|
||||
|
|
@ -40,3 +40,14 @@ test("collectUniqueRedisKeys handles large batches without changing key objects"
|
|||
assert.equal(keys[0], input[0]);
|
||||
assert.equal(keys.at(-1), input.at(-1));
|
||||
});
|
||||
|
||||
test("chunkRedisKeyRaws bounds large delete payloads without changing key order", () => {
|
||||
const keyRaws = Array.from({ length: REDIS_DELETE_KEY_BATCH_SIZE * 2 + 1 }, (_, index) => `key:${index}`);
|
||||
const batches = [...chunkRedisKeyRaws(keyRaws)];
|
||||
|
||||
assert.deepEqual(
|
||||
batches.map((batch) => batch.length),
|
||||
[REDIS_DELETE_KEY_BATCH_SIZE, REDIS_DELETE_KEY_BATCH_SIZE, 1],
|
||||
);
|
||||
assert.deepEqual(batches.flat(), keyRaws);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
import { test } from "vitest";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildRedisKeyTree, collectRedisGroupKeyRaws, collectExpandedGroupIds, flattenVisibleRedisKeyTree, mergeKeysIntoRedisKeyTree, redisKeyNameCopyText, redisKeyToFlatTreeRow, type RedisKeyTreeNode } from "../../apps/desktop/src/lib/redis/redisKeyTree.ts";
|
||||
import {
|
||||
appendRedisKeysToTreeIndex,
|
||||
buildRedisKeyTree,
|
||||
canBuildRedisFuzzyTree,
|
||||
collectRedisGroupKeyRaws,
|
||||
collectExpandedGroupIds,
|
||||
createRedisKeyTreeIndex,
|
||||
flattenVisibleRedisKeyTree,
|
||||
mergeKeysIntoRedisKeyTree,
|
||||
redisKeyNameCopyText,
|
||||
redisKeyToFlatTreeRow,
|
||||
REDIS_FUZZY_TREE_MAX_KEYS,
|
||||
type RedisKeyTreeGroupNode,
|
||||
type RedisKeyTreeNode,
|
||||
} from "../../apps/desktop/src/lib/redis/redisKeyTree.ts";
|
||||
import type { RedisKeyInfo } from "../../apps/desktop/src/lib/backend/api.ts";
|
||||
|
||||
function makeKey(key_display: string, key_raw: string, key_type = "string", ttl = -1): RedisKeyInfo {
|
||||
|
|
@ -11,6 +25,18 @@ function leafLabels(nodes: RedisKeyTreeNode[]): string[] {
|
|||
return nodes.filter((node) => node.kind === "leaf").map((node) => node.label);
|
||||
}
|
||||
|
||||
function findGroup(nodes: RedisKeyTreeNode[], label: string): RedisKeyTreeGroupNode {
|
||||
const group = nodes.find((node) => node.kind === "group" && node.label === label);
|
||||
assert.ok(group);
|
||||
assert.equal(group.kind, "group");
|
||||
return group;
|
||||
}
|
||||
|
||||
test("fuzzy hierarchy stops before the configured key limit is exceeded", () => {
|
||||
assert.equal(canBuildRedisFuzzyTree(REDIS_FUZZY_TREE_MAX_KEYS), true);
|
||||
assert.equal(canBuildRedisFuzzyTree(REDIS_FUZZY_TREE_MAX_KEYS + 1), false);
|
||||
});
|
||||
|
||||
test("buildRedisKeyTree groups colon-delimited keys by segment", () => {
|
||||
const tree = buildRedisKeyTree([makeKey("a:b:c", "k1"), makeKey("a:b:d", "k2"), makeKey("a:e", "k3"), makeKey("x", "k4")], 0);
|
||||
|
||||
|
|
@ -127,6 +153,81 @@ test("collectRedisGroupKeyRaws returns every leaf key under a group", () => {
|
|||
assert.deepEqual(collectRedisGroupKeyRaws(userGroup), ["k2", "k1", "k3"]);
|
||||
});
|
||||
|
||||
test("group identity keeps NUL-containing path segments isolated", () => {
|
||||
const firstKeyRaw = "raw-first";
|
||||
const secondKeyRaw = "raw-second";
|
||||
const index = createRedisKeyTreeIndex([makeKey(`a\0b:c:x`, firstKeyRaw), makeKey(`a:b\0c:y`, secondKeyRaw)], 0);
|
||||
|
||||
const firstRoot = findGroup(index.root, `a\0b`);
|
||||
const firstBranch = findGroup(firstRoot.children, "c");
|
||||
const secondRoot = findGroup(index.root, "a");
|
||||
const secondBranch = findGroup(secondRoot.children, `b\0c`);
|
||||
|
||||
assert.notEqual(firstBranch.id, secondBranch.id);
|
||||
assert.equal(index.groupById.size, 4);
|
||||
assert.deepEqual(collectRedisGroupKeyRaws(firstRoot), [firstKeyRaw]);
|
||||
assert.deepEqual(collectRedisGroupKeyRaws(secondRoot), [secondKeyRaw]);
|
||||
assert.deepEqual(index.ancestorGroupIdsByKeyRaw.get(firstKeyRaw), [firstRoot.id, firstBranch.id]);
|
||||
assert.deepEqual(index.ancestorGroupIdsByKeyRaw.get(secondKeyRaw), [secondRoot.id, secondBranch.id]);
|
||||
});
|
||||
|
||||
test("tree index incrementally merges SCAN pages with loaded counts and selection ancestry", () => {
|
||||
const index = createRedisKeyTreeIndex([makeKey("team:api:v1", "k1"), makeKey("team:web:home", "k2")], 0);
|
||||
const team = findGroup(index.root, "team");
|
||||
const api = findGroup(team.children, "api");
|
||||
|
||||
const { addedGroupIds } = appendRedisKeysToTreeIndex(
|
||||
index,
|
||||
[
|
||||
makeKey("team:api:v2", "k3"),
|
||||
makeKey("orders:1", "k4"),
|
||||
// SCAN may return the same key more than once across pages.
|
||||
makeKey("team:api:v1", "k1"),
|
||||
],
|
||||
0,
|
||||
);
|
||||
|
||||
const orders = findGroup(index.root, "orders");
|
||||
assert.deepEqual(
|
||||
index.root.map((node) => node.label),
|
||||
["orders", "team"],
|
||||
);
|
||||
assert.equal(team.loadedLeafCount, 3);
|
||||
assert.equal(api.loadedLeafCount, 2);
|
||||
assert.equal(orders.loadedLeafCount, 1);
|
||||
assert.equal(index.keyRaws.size, 4);
|
||||
assert.deepEqual(index.ancestorGroupIdsByKeyRaw.get("k3"), [team.id, api.id]);
|
||||
assert.ok(addedGroupIds.has(orders.id));
|
||||
assert.ok(!addedGroupIds.has(team.id));
|
||||
assert.deepEqual(collectRedisGroupKeyRaws(team), ["k1", "k3", "k2"]);
|
||||
});
|
||||
|
||||
test("buildRedisKeyTree honors custom and empty Redis key separators", () => {
|
||||
const customSeparatorTree = buildRedisKeyTree([makeKey("service/api/v1", "k1"), makeKey("service/web", "k2"), makeKey("literal:colon", "k3")], 0, "/");
|
||||
const service = findGroup(customSeparatorTree, "service");
|
||||
const api = findGroup(service.children, "api");
|
||||
|
||||
assert.equal(service.loadedLeafCount, 2);
|
||||
assert.equal(api.loadedLeafCount, 1);
|
||||
assert.deepEqual(collectRedisGroupKeyRaws(service), ["k1", "k2"]);
|
||||
assert.deepEqual(
|
||||
buildRedisKeyTree([makeKey("service/api/v1", "k1"), makeKey("literal:colon", "k2")], 0, "").map((node) => `${node.kind}:${node.label}`),
|
||||
["leaf:literal:colon", "leaf:service/api/v1"],
|
||||
);
|
||||
});
|
||||
|
||||
test("tree keeps a namespace leaf alongside its child group", () => {
|
||||
const tree = buildRedisKeyTree([makeKey("session", "k1"), makeKey("session:active", "k2")], 0);
|
||||
const sessionGroup = findGroup(tree, "session");
|
||||
|
||||
assert.equal(sessionGroup.loadedLeafCount, 1);
|
||||
assert.deepEqual(
|
||||
tree.map((node) => `${node.kind}:${node.label}`),
|
||||
["group:session", "leaf:session"],
|
||||
);
|
||||
assert.deepEqual(collectRedisGroupKeyRaws(sessionGroup), ["k2"]);
|
||||
});
|
||||
|
||||
test("mergeKeysIntoRedisKeyTree adds new leaf to existing group", () => {
|
||||
const tree = buildRedisKeyTree([makeKey("user:profile:name", "k1")], 0);
|
||||
const merged = mergeKeysIntoRedisKeyTree(tree, [makeKey("user:profile:email", "k2")], 0);
|
||||
|
|
|
|||
Loading…
Reference in New Issue