feat(redis): add custom key groups
This commit is contained in:
1 parent
a535cf8f51
commit
1cbb5abf3b
53 files changed
+1951
-13
No files matched your search
@@ -2541,6 +2541,7 @@ watch(
|
||||
redis_key_separator: config.redis_key_separator ?? ":",
|
||||
redis_scan_page_size: config.redis_scan_page_size ?? REDIS_SCAN_PAGE_SIZE_DEFAULT,
|
||||
redis_key_templates: normalizeRedisKeyTemplates(config.redis_key_templates),
|
||||
redis_key_grouping: config.redis_key_grouping,
|
||||
etcd_endpoints: config.etcd_endpoints || "",
|
||||
gbase_server: config.gbase_server || "",
|
||||
informix_server: config.informix_server || "",
|
||||
@@ -4038,6 +4039,7 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
|
||||
config.redis_scan_page_size = undefined;
|
||||
config.redis_database_aliases = undefined;
|
||||
config.redis_key_templates = undefined;
|
||||
config.redis_key_grouping = undefined;
|
||||
} else if (config.redis_connection_mode === "sentinel") {
|
||||
config.redis_sentinel_master = config.redis_sentinel_master?.trim() || "";
|
||||
config.redis_sentinel_nodes = normalizeRedisSentinelNodes(config.redis_sentinel_nodes || "");
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown, ChevronRight } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useId } from "vue";
|
||||
import type { RedisVirtualGroupRow } from "@/lib/redis/redisKeyGroupIndex";
|
||||
|
||||
defineProps<{ row: RedisVirtualGroupRow; expanded: boolean; navigation?: boolean; omitted?: boolean; path?: string; stickyDepth?: number }>();
|
||||
const emit = defineEmits<{ toggle: []; navigate: [] }>();
|
||||
const { t } = useI18n();
|
||||
const pathHintId = useId();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="navigation" class="group/redis-sticky relative flex h-[30px] items-center gap-1 border-b bg-background px-2 text-xs" :style="{ paddingLeft: `${8 + (stickyDepth ?? row.depth) * 10}px` }" :title="path">
|
||||
<button type="button" class="flex h-6 w-5 shrink-0 items-center justify-center hover:bg-accent/40 focus-visible:outline focus-visible:outline-ring" data-redis-sticky-toggle :aria-label="row.label" :aria-describedby="pathHintId" :aria-expanded="expanded" @click="emit('toggle')">
|
||||
<component :is="expanded ? ChevronDown : ChevronRight" class="h-3 w-3" />
|
||||
</button>
|
||||
<button type="button" class="flex h-full min-w-0 flex-1 items-center gap-1 text-left hover:bg-accent/40 focus-visible:outline focus-visible:outline-ring" data-redis-sticky-navigate :aria-label="row.label" :aria-describedby="pathHintId" :title="path" @click="emit('navigate')">
|
||||
<span v-if="omitted" aria-hidden="true">…</span>
|
||||
<span class="truncate">{{ row.label }}</span>
|
||||
<span class="shrink-0 text-muted-foreground">{{ t("redisGrouping.count", { count: row.count }) }}</span>
|
||||
</button>
|
||||
<span :id="pathHintId" role="tooltip" class="pointer-events-none absolute inset-x-0 top-full z-30 hidden break-all rounded border bg-popover p-2 text-popover-foreground shadow-md group-hover/redis-sticky:block group-focus-within/redis-sticky:block">{{ path }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-[30px] cursor-pointer items-center gap-1 border-b px-2 text-xs hover:bg-accent/40 focus-visible:outline focus-visible:outline-ring focus-visible:outline-offset-[-2px]"
|
||||
:style="{ paddingLeft: `${8 + row.depth * 10}px` }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="expanded"
|
||||
@click="emit('toggle')"
|
||||
@keydown.enter.prevent="emit('toggle')"
|
||||
@keydown.space.prevent="emit('toggle')"
|
||||
>
|
||||
<component :is="expanded ? ChevronDown : ChevronRight" class="h-3 w-3 shrink-0" />
|
||||
<span class="truncate">{{ row.label }}</span>
|
||||
<span class="shrink-0 text-muted-foreground">{{ t("redisGrouping.count", { count: row.count }) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,239 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, defineComponent, h, nextTick, shallowRef } from "vue";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import type { RedisKeyInfo } from "@/lib/backend/api";
|
||||
import { defaultRedisKeyGrouping } from "@/lib/redis/redisKeyGrouping";
|
||||
import RedisGroupedKeyList from "./RedisGroupedKeyList.vue";
|
||||
|
||||
// Deliberately use the installed RecycleScroller, not a mock that rerenders
|
||||
// whenever updateVisibleItems is called. Same-ID headers must really rebind.
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string, values?: { count: number }) => (values ? `${key}:${values.count}` : key) }) }));
|
||||
|
||||
const cleanups: (() => void)[] = [];
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).forEach((cleanup) => cleanup());
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function mountGroups(customConfig = { ...defaultRedisKeyGrouping(), enabled: true }) {
|
||||
const height = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(300);
|
||||
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(600);
|
||||
const keys = shallowRef<RedisKeyInfo[]>([]);
|
||||
const scope = shallowRef("c:0");
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const config = shallowRef(customConfig);
|
||||
const checked = new Set<string>();
|
||||
const selected = shallowRef<string | null>(null);
|
||||
const onSelect = vi.fn();
|
||||
const onCheck = vi.fn();
|
||||
const list = shallowRef<InstanceType<typeof RedisGroupedKeyList> | null>(null);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () => h(RedisGroupedKeyList, { ref: list, keys: keys.value, config: config.value, scope: scope.value, structureRevision: 0, separator: ":", selected: selected.value, checked, busy: false, metadataEpoch: 0, treeAllowed: true, onSelect, onCheck }),
|
||||
}),
|
||||
);
|
||||
app.mount(host);
|
||||
let mounted = true;
|
||||
const unmount = () => {
|
||||
if (!mounted) return;
|
||||
mounted = false;
|
||||
app.unmount();
|
||||
host.remove();
|
||||
};
|
||||
cleanups.push(unmount);
|
||||
return { host, keys, scope, config, checked, selected, onSelect, onCheck, list, unmount, height };
|
||||
}
|
||||
|
||||
function key(n: number): RedisKeyInfo {
|
||||
return { key_raw: btoa(`key:${n}`), key_display: `key:${n}`, ttl: -1, key_type: "string" };
|
||||
}
|
||||
|
||||
async function flushPublication() {
|
||||
if (!vi.isFakeTimers()) await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
for (let i = 0; i < 12; i++) await nextTick();
|
||||
}
|
||||
|
||||
it("automatically rebinds same-ID group counts through the real scroller", async () => {
|
||||
const { host, keys } = mountGroups();
|
||||
keys.value = [key(0)];
|
||||
await flushPublication();
|
||||
expect(host.textContent).toContain("redisGrouping.count:1");
|
||||
const header = host.querySelector('[role="button"]');
|
||||
keys.value = [...keys.value, key(1), key(2)];
|
||||
await flushPublication();
|
||||
expect(host.textContent).toContain("redisGrouping.count:3");
|
||||
expect(host.textContent).not.toContain("redisGrouping.preparing");
|
||||
expect(host.querySelector('[role="button"]')).toBe(header);
|
||||
});
|
||||
|
||||
it("finishes without interaction when timers are delayed instead of paying a timer per checkpoint", async () => {
|
||||
vi.useFakeTimers();
|
||||
let workTime = 0;
|
||||
vi.spyOn(performance, "now").mockImplementation(() => workTime++);
|
||||
const timer = globalThis.setTimeout;
|
||||
vi.spyOn(globalThis, "setTimeout").mockImplementation(((callback, delay, ...args) => timer(callback, Math.max(1000, delay ?? 0), ...args)) as typeof setTimeout);
|
||||
const { host, keys } = mountGroups();
|
||||
keys.value = [key(0)];
|
||||
await flushPublication();
|
||||
expect(host.textContent).toContain("redisGrouping.count:1");
|
||||
keys.value = Array.from({ length: 10_000 }, (_, n) => key(n));
|
||||
await flushPublication();
|
||||
expect(host.textContent).toContain("redisGrouping.preparing");
|
||||
// 20 chunks, but only two exhausted 8ms work budgets.
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await flushPublication();
|
||||
expect(host.textContent).toContain("redisGrouping.count:10000");
|
||||
expect(host.textContent).not.toContain("redisGrouping.preparing");
|
||||
});
|
||||
|
||||
it("does not publish a delayed build after changing database scope", async () => {
|
||||
vi.useFakeTimers();
|
||||
let workTime = 0;
|
||||
vi.spyOn(performance, "now").mockImplementation(() => workTime++);
|
||||
const { host, keys, scope } = mountGroups();
|
||||
keys.value = Array.from({ length: 10_000 }, (_, n) => key(n));
|
||||
await flushPublication();
|
||||
expect(host.textContent).toContain("redisGrouping.preparing");
|
||||
scope.value = "c:1";
|
||||
keys.value = [key(20_000), key(20_001)];
|
||||
await flushPublication();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await flushPublication();
|
||||
expect(host.textContent).toContain("redisGrouping.count:2");
|
||||
expect(host.textContent).not.toContain("redisGrouping.count:10000");
|
||||
expect(host.textContent).not.toContain("redisGrouping.preparing");
|
||||
});
|
||||
|
||||
it("cancels a delayed build on unmount without trying to refresh its scroller", async () => {
|
||||
vi.useFakeTimers();
|
||||
let workTime = 0;
|
||||
vi.spyOn(performance, "now").mockImplementation(() => workTime++);
|
||||
const { host, keys, unmount } = mountGroups();
|
||||
keys.value = Array.from({ length: 10_000 }, (_, n) => key(n));
|
||||
await flushPublication();
|
||||
expect(host.textContent).toContain("redisGrouping.preparing");
|
||||
unmount();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(host.childNodes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(["list", "tree"] as const)("pins the allowed ancestors in %s and collapses to a reachable header without changing selection", async (inner_view) => {
|
||||
const state = mountGroups({ ...defaultRedisKeyGrouping(), enabled: true, inner_view });
|
||||
state.keys.value = Array.from({ length: 200 }, (_, n) => key(n));
|
||||
await flushPublication();
|
||||
const outer = () => state.host.querySelector<HTMLElement>('[data-redis-group-header="custom:unmatched"]')!;
|
||||
expect(state.host.querySelector("[data-redis-sticky-group]")).toBeNull();
|
||||
expect(outer()).not.toBeNull();
|
||||
outer().click();
|
||||
await flushPublication();
|
||||
if (inner_view === "tree") {
|
||||
state.host.querySelector<HTMLElement>('[data-redis-group-header*="namespace"]')!.click();
|
||||
await flushPublication();
|
||||
}
|
||||
state.checked.add(key(20).key_raw);
|
||||
state.selected.value = key(20).key_raw;
|
||||
await nextTick();
|
||||
const element = state.host.querySelector<HTMLElement>(".redis-key-scroller")!;
|
||||
element.scrollTop = 1500;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
await nextTick();
|
||||
const pinned = state.host.querySelector<HTMLElement>("[data-redis-sticky-group]")!;
|
||||
expect(pinned.dataset.redisStickyGroup).toBe("custom:unmatched");
|
||||
expect(pinned.textContent).toContain("redisGrouping.count:200");
|
||||
expect((inner_view === "tree" ? pinned.querySelector("[data-redis-sticky-toggle]")! : pinned).getAttribute("aria-expanded")).toBe("true");
|
||||
expect(state.host.querySelectorAll("[data-redis-sticky-group]")).toHaveLength(inner_view === "tree" ? 2 : 1);
|
||||
// The outgoing legacy-view anchor must identify a visible descendant, not
|
||||
// the row hidden beneath the pinned header. Tree adds its namespace row.
|
||||
expect(state.list.value!.getViewportAnchor()).toEqual({ rowIndex: inner_view === "tree" ? 52 : 51, keyRaw: key(50).key_raw });
|
||||
if (inner_view === "tree") pinned.querySelector<HTMLButtonElement>("[data-redis-sticky-toggle]")!.click();
|
||||
else pinned.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
await flushPublication();
|
||||
expect(element.scrollTop).toBe(0);
|
||||
expect(outer().getAttribute("aria-expanded")).toBe("false");
|
||||
expect(document.activeElement).toBe(outer());
|
||||
expect(state.host.querySelector("[data-redis-sticky-group]")).toBeNull();
|
||||
expect(state.checked.has(key(20).key_raw)).toBe(true);
|
||||
expect(state.selected.value).toBe(key(20).key_raw);
|
||||
expect(state.onSelect).not.toHaveBeenCalled();
|
||||
expect(state.onCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pushes off at the next group, switches its identity and clears stale headers on scope replacement", async () => {
|
||||
const state = mountGroups({ ...defaultRedisKeyGrouping(), enabled: true, rules: [{ id: "first", name: "First", enabled: true, includes: ["key:?"], excludes: [] }] });
|
||||
state.keys.value = Array.from({ length: 200 }, (_, n) => key(n));
|
||||
await flushPublication();
|
||||
Array.from(state.host.querySelectorAll<HTMLElement>("[data-redis-group-header]"))
|
||||
.find((node) => node.dataset.redisGroupHeader === 'custom:"first"')!
|
||||
.click();
|
||||
await flushPublication();
|
||||
state.host.querySelector<HTMLElement>('[data-redis-group-header="custom:unmatched"]')!.click();
|
||||
await flushPublication();
|
||||
const element = state.host.querySelector<HTMLElement>(".redis-key-scroller")!;
|
||||
// First has 10 keys + its header; next boundary is at 11 * 30.
|
||||
element.scrollTop = 11 * 30 - 15;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
await nextTick();
|
||||
let pinned = state.host.querySelector<HTMLElement>("[data-redis-sticky-group]")!;
|
||||
expect(pinned).not.toBeNull();
|
||||
expect(pinned.dataset.redisStickyGroup).toBe('custom:"first"');
|
||||
expect(pinned.parentElement!.style.transform).toBe("translateY(-15px)");
|
||||
// During push-off only 15px is covered; the next natural header is visible.
|
||||
expect(state.list.value!.getViewportAnchor()).toEqual({ rowIndex: 11, keyRaw: undefined });
|
||||
element.scrollTop = 12 * 30;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
await nextTick();
|
||||
pinned = state.host.querySelector<HTMLElement>("[data-redis-sticky-group]")!;
|
||||
expect(pinned.dataset.redisStickyGroup).toBe("custom:unmatched");
|
||||
state.scope.value = "c:1";
|
||||
state.keys.value = [];
|
||||
await flushPublication();
|
||||
expect(state.host.querySelector("[data-redis-sticky-group]")).toBeNull();
|
||||
});
|
||||
|
||||
it("compresses deep ancestors, navigates without collapsing and restores an inner collapse through the installed scroller", async () => {
|
||||
const state = mountGroups({ ...defaultRedisKeyGrouping(), enabled: true, inner_view: "tree" });
|
||||
const names = Array.from({ length: 200 }, (_, n) => `a:b:c:d:${n}`);
|
||||
state.keys.value = names.map((name) => ({ key_display: name, key_raw: btoa(name), ttl: -1, key_type: "string" }));
|
||||
await flushPublication();
|
||||
const natural = (id: string) => Array.from(state.host.querySelectorAll<HTMLElement>("[data-redis-group-header]")).find((node) => node.dataset.redisGroupHeader === id)!;
|
||||
const namespace = (parts: string[]) => `custom:unmatched:namespace:${JSON.stringify(parts)}`;
|
||||
for (const id of ["custom:unmatched", namespace(["a"]), namespace(["a", "b"]), namespace(["a", "b", "c"]), namespace(["a", "b", "c", "d"])]) {
|
||||
natural(id).click();
|
||||
await flushPublication();
|
||||
}
|
||||
const element = state.host.querySelector<HTMLElement>(".redis-key-scroller")!;
|
||||
const scroll = async (top: number) => {
|
||||
element.scrollTop = top;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
await nextTick();
|
||||
};
|
||||
const pinned = () => Array.from(state.host.querySelectorAll<HTMLElement>("[data-redis-sticky-group]"));
|
||||
expect(natural(namespace(["a", "b", "c", "d"])).style.paddingLeft).toBe("48px");
|
||||
await scroll(1500);
|
||||
expect(pinned().map((node) => node.dataset.redisStickyGroup)).toEqual(["custom:unmatched", namespace(["a", "b", "c"]), namespace(["a", "b", "c", "d"])]);
|
||||
expect(pinned().map((node) => node.style.paddingLeft)).toEqual(["8px", "18px", "28px"]);
|
||||
expect(pinned()[1]!.textContent).toContain("…");
|
||||
expect(pinned()[1]!.querySelector("[data-redis-sticky-navigate]")!.getAttribute("aria-label")).toBe("c");
|
||||
expect(pinned()[1]!.querySelector('[role="tooltip"]')!.textContent).toContain("a › b › c › d");
|
||||
expect(pinned()[1]!.querySelectorAll("button button")).toHaveLength(0);
|
||||
expect(state.list.value!.getViewportAnchor()).toEqual({ rowIndex: 53, keyRaw: btoa(names[48]!) });
|
||||
state.checked.add(btoa(names[48]!));
|
||||
state.selected.value = btoa(names[48]!);
|
||||
pinned()[1]!.querySelector<HTMLButtonElement>("[data-redis-sticky-navigate]")!.click();
|
||||
await flushPublication();
|
||||
expect(natural(namespace(["a", "b", "c"])).getAttribute("aria-expanded")).toBe("true");
|
||||
expect(document.activeElement).toBe(natural(namespace(["a", "b", "c"])));
|
||||
await scroll(1500);
|
||||
pinned()[1]!.querySelector<HTMLButtonElement>("[data-redis-sticky-toggle]")!.click();
|
||||
await flushPublication();
|
||||
expect(natural(namespace(["a", "b", "c"])).getAttribute("aria-expanded")).toBe("false");
|
||||
expect(pinned().some((node) => node.dataset.redisStickyGroup === namespace(["a", "b", "c", "d"]))).toBe(false);
|
||||
expect(document.activeElement).toBe(natural(namespace(["a", "b", "c"])));
|
||||
expect(state.checked).toEqual(new Set([btoa(names[48]!)]));
|
||||
expect(state.selected.value).toBe(btoa(names[48]!));
|
||||
expect(state.onCheck).not.toHaveBeenCalled();
|
||||
expect(state.onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { defaultRedisKeyGrouping } from "@/lib/redis/redisKeyGrouping";
|
||||
import RedisGroupedKeyList from "./RedisGroupedKeyList.vue";
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
const refresh = vi.fn();
|
||||
const scroll = vi.fn();
|
||||
let bound: unknown;
|
||||
vi.mock("vue-virtual-scroller", () => ({
|
||||
RecycleScroller: defineComponent({
|
||||
props: ["items"],
|
||||
setup(props, { slots, expose }) {
|
||||
bound = props.items;
|
||||
const epoch = ref(0);
|
||||
expose({
|
||||
findItemIndex: () => 0,
|
||||
getScroll: () => ({ start: 0 }),
|
||||
scrollToItem: scroll,
|
||||
updateVisibleItems: (changed: boolean) => {
|
||||
refresh(changed);
|
||||
epoch.value++;
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
void epoch.value;
|
||||
return h(
|
||||
"div",
|
||||
Array.from(props.items as unknown[]).map((item) => slots.default?.({ item })),
|
||||
);
|
||||
};
|
||||
},
|
||||
}),
|
||||
}));
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("does not build a fuzzy tree over the existing limit or silently switch views", async () => {
|
||||
const config = { ...defaultRedisKeyGrouping(), enabled: true, inner_view: "tree" as const };
|
||||
const requestList = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(RedisGroupedKeyList, { keys: [], config, scope: "c:0", structureRevision: 0, separator: ":", selected: null, checked: new Set(), busy: false, metadataEpoch: 0, treeAllowed: false, onRequestList: requestList });
|
||||
app.mount(host);
|
||||
await nextTick();
|
||||
expect(host.querySelector(".redis-key-scroller")).toBeNull();
|
||||
expect(host.textContent).toContain("redisGrouping.fuzzyTreeUnavailable");
|
||||
expect(requestList).not.toHaveBeenCalled();
|
||||
expect(config.inner_view).toBe("tree");
|
||||
host.querySelector<HTMLButtonElement>("button")!.click();
|
||||
expect(requestList).toHaveBeenCalledOnce();
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("headers have no check/delete controls and expanding is local", async () => {
|
||||
const config = { ...defaultRedisKeyGrouping(), enabled: true };
|
||||
const deleted = vi.fn();
|
||||
const scrolled = vi.fn();
|
||||
const resized = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(RedisGroupedKeyList, {
|
||||
keys: [{ key_raw: btoa("a"), key_display: "a", ttl: -1, key_type: "string" }],
|
||||
config,
|
||||
scope: "c:0",
|
||||
structureRevision: 0,
|
||||
separator: ":",
|
||||
selected: null,
|
||||
checked: new Set(),
|
||||
busy: false,
|
||||
metadataEpoch: 0,
|
||||
onDelete: deleted,
|
||||
onScroll: scrolled,
|
||||
onResize: resized,
|
||||
});
|
||||
app.mount(host);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
const original = bound;
|
||||
expect(host.querySelectorAll('input[type="checkbox"]')).toHaveLength(0);
|
||||
expect(host.querySelectorAll("button")).toHaveLength(0);
|
||||
host.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
expect(bound).toBe(original);
|
||||
expect(refresh).toHaveBeenCalledWith(true);
|
||||
expect(host.querySelectorAll('input[type="checkbox"]')).toHaveLength(1);
|
||||
expect(deleted).not.toHaveBeenCalled();
|
||||
const element = host.querySelector<HTMLElement>(".redis-key-scroller")!;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
element.dispatchEvent(new Event("resize"));
|
||||
expect(scrolled).toHaveBeenCalledOnce();
|
||||
expect(scrolled.mock.calls[0]![0].target).toBe(element);
|
||||
expect(resized).toHaveBeenCalledOnce();
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, nextTick, onActivated, onDeactivated, onUnmounted, ref, shallowRef, watch } from "vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import { KeyRound, Trash2, Copy } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import CustomContextMenu from "@/components/ui/CustomContextMenu.vue";
|
||||
import type { RedisKeyInfo } from "@/lib/backend/api";
|
||||
import type { RedisKeyGrouping } from "@/lib/redis/redisKeyGrouping";
|
||||
import { RedisKeyGroupIndex, type RedisGroupedRow, type RedisOuterGroupBoundary, type RedisHeaderBoundary } from "@/lib/redis/redisKeyGroupIndex";
|
||||
import { redisStickyHeaders, redisStickyHeight, REDIS_GROUP_ROW_HEIGHT } from "@/lib/redis/redisStickyHeaders";
|
||||
import RedisGroupHeader from "./RedisGroupHeader.vue";
|
||||
import { createRedisKeyViewYield } from "@/lib/redis/redisKeyViewScheduler";
|
||||
|
||||
const props = defineProps<{ keys: readonly RedisKeyInfo[]; config: RedisKeyGrouping; separator: string; scope: string; structureRevision: number; selected: string | null; checked: ReadonlySet<string>; busy: boolean; metadataEpoch: number; treeAllowed?: boolean }>();
|
||||
const emit = defineEmits<{ select: [RedisKeyInfo]; check: [RedisKeyInfo, MouseEvent]; delete: [RedisKeyInfo, MouseEvent]; copy: [RedisKeyInfo]; scroll: [Event]; resize: []; requestList: [] }>();
|
||||
const { t } = useI18n();
|
||||
const expanded = shallowRef(new Set<string>());
|
||||
const working = ref(false);
|
||||
const active = ref(true);
|
||||
const scroller = ref<InstanceType<typeof RecycleScroller> | null>(null);
|
||||
const outerGroups = shallowRef<readonly RedisOuterGroupBoundary[]>([]);
|
||||
const headerBoundaries = shallowRef<readonly RedisHeaderBoundary[]>([]);
|
||||
const publishedTree = ref(false);
|
||||
const publishedExpanded = shallowRef<ReadonlySet<string>>(new Set());
|
||||
const scrollTop = ref(0);
|
||||
const viewportHeight = ref(0);
|
||||
const rowHeight = REDIS_GROUP_ROW_HEIGHT;
|
||||
let positions: { get: (id: string) => number | undefined } = { get: () => undefined };
|
||||
let pendingHeaderAnchor: string | undefined;
|
||||
const sticky = computed(() => {
|
||||
if (!active.value || treeBlocked.value) return [];
|
||||
return redisStickyHeaders(outerGroups.value, headerBoundaries.value, scrollTop.value, viewportHeight.value, publishedTree.value);
|
||||
});
|
||||
const stickyIds = computed(() => new Set(sticky.value.map((entry) => entry.row.id)));
|
||||
function updateScroll() {
|
||||
scrollTop.value = Math.max(0, scroller.value?.getScroll().start ?? 0);
|
||||
viewportHeight.value = (scroller.value?.$el as HTMLElement | undefined)?.clientHeight ?? 0;
|
||||
}
|
||||
function onScroll(event: Event) {
|
||||
updateScroll();
|
||||
emit("scroll", event);
|
||||
}
|
||||
function onResize() {
|
||||
const anchor = firstUnobscuredRowIndex();
|
||||
updateScroll();
|
||||
positionRow(anchor);
|
||||
emit("resize");
|
||||
}
|
||||
function collapseSticky(id: string) {
|
||||
pendingHeaderAnchor = id;
|
||||
toggle(id);
|
||||
}
|
||||
function positionRow(position: number) {
|
||||
// Solve using the destination overlay, not the previous branch's height.
|
||||
// Fixed rows and at most three sticky slots bound this to four lookups.
|
||||
let target = position * rowHeight;
|
||||
for (let step = 0; step <= 3; step++) {
|
||||
target = Math.max(0, (position - step) * rowHeight);
|
||||
const occupied = redisStickyHeight(redisStickyHeaders(outerGroups.value, headerBoundaries.value, target, viewportHeight.value, publishedTree.value));
|
||||
if (target + occupied <= position * rowHeight) break;
|
||||
}
|
||||
const api = scroller.value;
|
||||
api?.scrollToItem(Math.floor(target / rowHeight), { align: "start" });
|
||||
api?.updateVisibleItems(true);
|
||||
updateScroll();
|
||||
}
|
||||
function focusHeader(id: string) {
|
||||
const element = scroller.value?.$el as HTMLElement | undefined;
|
||||
const header = Array.from(element?.querySelectorAll<HTMLElement>("[data-redis-group-header]") ?? []).find((node) => node.dataset.redisGroupHeader === id);
|
||||
header?.focus({ preventScroll: true });
|
||||
}
|
||||
async function navigateHeader(id: string) {
|
||||
const position = positions.get(id);
|
||||
if (position === undefined) return;
|
||||
positionRow(position);
|
||||
await nextTick();
|
||||
focusHeader(id);
|
||||
}
|
||||
function revealFocusedRow(event: FocusEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const element = scroller.value?.$el as HTMLElement | undefined;
|
||||
if (!element || !sticky.value.length) return;
|
||||
const overlap = element.getBoundingClientRect().top + redisStickyHeight(sticky.value) - target.getBoundingClientRect().top;
|
||||
if (overlap > 0) {
|
||||
element.scrollTop = Math.max(0, element.scrollTop - overlap);
|
||||
updateScroll();
|
||||
}
|
||||
}
|
||||
const treeBlocked = computed(() => props.treeAllowed === false && props.config.inner_view === "tree");
|
||||
function firstUnobscuredRowIndex() {
|
||||
const api = scroller.value;
|
||||
return api?.findItemIndex((api.getScroll().start ?? 0) + redisStickyHeight(sticky.value)) ?? 0;
|
||||
}
|
||||
defineExpose({
|
||||
getScrollElement: () => scroller.value?.$el as HTMLElement | undefined,
|
||||
getViewportAnchor: () => {
|
||||
const rowIndex = firstUnobscuredRowIndex();
|
||||
const row = source[rowIndex];
|
||||
return { rowIndex, keyRaw: row?.kind === "key" ? row.key.key_raw : undefined };
|
||||
},
|
||||
});
|
||||
let source: readonly RedisGroupedRow[] = [];
|
||||
const rows = markRaw(
|
||||
new Proxy([] as RedisGroupedRow[], {
|
||||
get(target, property, receiver) {
|
||||
if (property === "length") return source.length;
|
||||
const index = typeof property === "string" && /^(0|[1-9]\d*)$/.test(property) ? Number(property) : -1;
|
||||
return index >= 0 ? source[index] : Reflect.get(target, property, receiver);
|
||||
},
|
||||
has(target, property) {
|
||||
const index = typeof property === "string" && /^(0|[1-9]\d*)$/.test(property) ? Number(property) : -1;
|
||||
return index >= 0 ? index < source.length : Reflect.has(target, property);
|
||||
},
|
||||
}),
|
||||
);
|
||||
let generation = 0;
|
||||
let index: RedisKeyGroupIndex | null = null;
|
||||
let signature = "";
|
||||
let previousKeys: readonly RedisKeyInfo[] = [];
|
||||
let previousScope = props.scope;
|
||||
|
||||
watch(
|
||||
[() => props.keys, () => props.config, () => props.scope, () => props.separator, () => props.structureRevision, expanded, active, treeBlocked],
|
||||
async () => {
|
||||
const currentGeneration = ++generation;
|
||||
if (props.scope !== previousScope || !active.value || treeBlocked.value) pendingHeaderAnchor = undefined;
|
||||
previousScope = props.scope;
|
||||
if (!active.value) return;
|
||||
if (treeBlocked.value) {
|
||||
working.value = false;
|
||||
return;
|
||||
}
|
||||
const current = () => currentGeneration === generation;
|
||||
const nextSignature = JSON.stringify([props.scope, props.separator, props.structureRevision, props.config.rules]);
|
||||
// A same-length structural replacement can rename a key anywhere, not just
|
||||
// at the tail. Reclassify it; ordinary append keeps the membership index.
|
||||
if (!index || nextSignature !== signature || !index.canAppend(props.keys) || (previousKeys !== props.keys && previousKeys.length === props.keys.length)) {
|
||||
index = new RedisKeyGroupIndex(props.config.rules, t("redisGrouping.unmatched"));
|
||||
signature = nextSignature;
|
||||
}
|
||||
previousKeys = props.keys;
|
||||
const building = index;
|
||||
working.value = true;
|
||||
// Cancellation checkpoints are cheap; only yield a browser task when this
|
||||
// build has exhausted its time budget. Nested timers can be throttled.
|
||||
const yieldControl = createRedisKeyViewYield();
|
||||
if (!(await building.append(props.keys, { current, yield: yieldControl }))) return;
|
||||
const snapshot = await building.project(props.config.inner_view, props.separator, expanded.value, { current, yield: yieldControl });
|
||||
if (!snapshot || !current()) return;
|
||||
const oldPosition = firstUnobscuredRowIndex();
|
||||
const headerAnchor = pendingHeaderAnchor;
|
||||
const anchorId = headerAnchor ?? source[oldPosition]?.id;
|
||||
const position = anchorId ? (snapshot.positions.get(anchorId) ?? Math.min(oldPosition, Math.max(0, snapshot.rows.length - 1))) : 0;
|
||||
source = snapshot.rows;
|
||||
outerGroups.value = snapshot.outerGroups;
|
||||
headerBoundaries.value = snapshot.headerBoundaries;
|
||||
positions = snapshot.positions;
|
||||
publishedTree.value = props.config.inner_view === "tree";
|
||||
publishedExpanded.value = new Set(expanded.value);
|
||||
pendingHeaderAnchor = undefined;
|
||||
positionRow(position);
|
||||
await nextTick();
|
||||
if (!current()) return;
|
||||
positionRow(position);
|
||||
if (headerAnchor) {
|
||||
await nextTick();
|
||||
if (!current()) return;
|
||||
focusHeader(headerAnchor);
|
||||
}
|
||||
working.value = false;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function toggle(id: string) {
|
||||
const next = new Set(expanded.value);
|
||||
if (!next.delete(id)) next.add(id);
|
||||
expanded.value = next;
|
||||
}
|
||||
onUnmounted(() => {
|
||||
generation++;
|
||||
});
|
||||
onActivated(() => {
|
||||
active.value = true;
|
||||
});
|
||||
onDeactivated(() => {
|
||||
active.value = false;
|
||||
generation++;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col" :data-metadata-epoch="metadataEpoch">
|
||||
<div v-if="treeBlocked" role="status" class="space-y-2 p-3 text-xs text-muted-foreground">
|
||||
<p>{{ t("redisGrouping.fuzzyTreeUnavailable") }}</p>
|
||||
<Button variant="outline" size="sm" @click="emit('requestList')">{{ t("redisGrouping.useList") }}</Button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<p class="shrink-0 px-2 py-1 text-xs text-muted-foreground">{{ working ? t("redisGrouping.preparing") : t("redisGrouping.loadedOnly") }}</p>
|
||||
<div class="relative flex min-h-0 flex-1 flex-col overflow-hidden" data-redis-group-viewport>
|
||||
<RecycleScroller ref="scroller" class="redis-key-scroller min-h-0 flex-1" :items="rows" :item-size="rowHeight" :buffer="600" key-field="id" @scroll="onScroll" @resize="onResize" @focusin="revealFocusedRow">
|
||||
<template #default="{ item }">
|
||||
<RedisGroupHeader
|
||||
v-if="item.kind === 'virtual-group'"
|
||||
:row="item"
|
||||
:expanded="publishedExpanded.has(item.id)"
|
||||
:data-redis-group-header="item.id"
|
||||
:class="stickyIds.has(item.id) ? 'invisible' : ''"
|
||||
:tabindex="stickyIds.has(item.id) ? -1 : 0"
|
||||
:aria-hidden="stickyIds.has(item.id) ? true : undefined"
|
||||
@toggle="toggle(item.id)"
|
||||
/>
|
||||
<CustomContextMenu v-else :items="[{ label: t('redis.copyKeyName'), icon: Copy, action: () => emit('copy', item.key) }]" v-slot="{ onContextMenu }">
|
||||
<div
|
||||
class="group flex h-[30px] cursor-pointer items-center gap-1 border-b px-2 text-[13px] hover:bg-accent/40"
|
||||
:class="selected === item.key.key_raw ? 'bg-accent' : checked.has(item.key.key_raw) ? 'bg-primary/10' : ''"
|
||||
:style="{ paddingLeft: `${8 + item.depth * 10}px` }"
|
||||
@click="emit('select', item.key)"
|
||||
@contextmenu="onContextMenu"
|
||||
>
|
||||
<input type="checkbox" :checked="checked.has(item.key.key_raw)" :disabled="busy" :aria-label="item.key.key_display" @click.stop="emit('check', item.key, $event)" />
|
||||
<KeyRound class="h-3 w-3 shrink-0 text-muted-foreground" /><span class="dbx-editor-font-family min-w-0 flex-1 truncate" :title="item.key.key_display">{{ item.label || t("redisGrouping.emptyKey") }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ item.key.key_type }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 text-destructive opacity-0 group-hover:opacity-100" :disabled="busy" :aria-label="t('redis.deleteKey')" @click.stop="emit('delete', item.key, $event)"><Trash2 class="h-3 w-3" /></Button>
|
||||
</div>
|
||||
</CustomContextMenu>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
<div v-for="(entry, slot) in sticky" :key="entry.row.id" class="absolute inset-x-0 top-0 bg-background" :style="{ transform: `translateY(${entry.top}px)`, zIndex: 20 - slot }">
|
||||
<RedisGroupHeader
|
||||
:row="entry.row"
|
||||
:expanded="publishedExpanded.has(entry.row.id)"
|
||||
:navigation="publishedTree"
|
||||
:sticky-depth="slot"
|
||||
:omitted="entry.omitted"
|
||||
:path="entry.path"
|
||||
:data-redis-sticky-group="entry.row.id"
|
||||
@toggle="collapseSticky(entry.row.id)"
|
||||
@navigate="navigateHeader(entry.row.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
import source from "./RedisKeyBrowser.vue?raw";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
it("uses the fuzzy toggle visual states while preserving grouping persistence and busy guards", () => {
|
||||
const button = source.match(/<Button\b[^>]*data-redis-grouping-toggle[^>]*>/s)?.[0];
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toContain("customGrouping.enabled ? 'bg-accent text-accent-foreground' : 'border border-dashed border-border/70 text-muted-foreground hover:text-foreground'");
|
||||
expect(button).toContain(':aria-pressed="customGrouping.enabled"');
|
||||
expect(button).toContain(':disabled="groupingSaving || isFetchingAll"');
|
||||
expect(button).toContain("saveGrouping({ ...customGrouping, enabled: !customGrouping.enabled })");
|
||||
});
|
||||
@@ -4,8 +4,14 @@ import { createApp, defineComponent, h, KeepAlive, nextTick, ref } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RedisKeyInfo } from "@/lib/backend/api";
|
||||
import { defaultRedisKeyGrouping, type RedisKeyGrouping } from "@/lib/redis/redisKeyGrouping";
|
||||
|
||||
const grouping = ref<RedisKeyGrouping>();
|
||||
vi.mock("@/lib/redis/redisKeyViewScheduler", () => ({ createRedisKeyViewYield: () => () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())) }));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
scrollerInitialSnapshot: vi.fn(),
|
||||
scrollerRefresh: vi.fn(),
|
||||
ensureConnected: vi.fn(),
|
||||
redisScanKeysBatch: vi.fn(),
|
||||
redisScanValues: vi.fn(),
|
||||
@@ -57,7 +63,10 @@ vi.mock("@/lib/backend/api", () => ({
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
ensureConnected: mocks.ensureConnected,
|
||||
getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: mocks.redisScanPageSize }),
|
||||
getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: mocks.redisScanPageSize, redis_key_grouping: grouping.value }),
|
||||
updateRedisKeyGrouping: async (_id: string, config: RedisKeyGrouping) => {
|
||||
grouping.value = config;
|
||||
},
|
||||
updateRedisDbKeyStats: mocks.updateRedisDbKeyStats,
|
||||
listRedisCompletionCommandDocs: mocks.listRedisCompletionCommandDocs,
|
||||
listRedisCompletionKeys: mocks.listRedisCompletionKeys,
|
||||
@@ -238,17 +247,32 @@ vi.mock("vue-virtual-scroller", async () => {
|
||||
RecycleScroller: defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { items: { type: Array, default: () => [] } },
|
||||
setup(props, { attrs, slots }) {
|
||||
setup(props, { attrs, slots, expose }) {
|
||||
// The installed library copies/keys all initial items in setup, even
|
||||
// though its rendered pool only contains the visible viewport.
|
||||
mocks.scrollerInitialSnapshot(props.items.slice().length);
|
||||
const epoch = ref(0);
|
||||
expose({
|
||||
findItemIndex: () => 0,
|
||||
getScroll: () => ({ start: 0 }),
|
||||
scrollToItem: () => {},
|
||||
updateVisibleItems: () => {
|
||||
mocks.scrollerRefresh(props.items.length);
|
||||
epoch.value++;
|
||||
},
|
||||
});
|
||||
// Real RecycleScroller only mounts as many rows as fit the viewport;
|
||||
// rendering everything here would defeat the point of this test, so
|
||||
// cap it the same way the expiry spec's mock does.
|
||||
const visibleItemCount = 50;
|
||||
return () =>
|
||||
h(
|
||||
return () => {
|
||||
void epoch.value;
|
||||
return h(
|
||||
"div",
|
||||
attrs,
|
||||
props.items.slice(0, visibleItemCount).map((item) => slots.default?.({ item })),
|
||||
);
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -378,6 +402,7 @@ function restoreViewportStub() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grouping.value = undefined;
|
||||
resetApiMocks();
|
||||
});
|
||||
|
||||
@@ -390,6 +415,124 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("RedisKeyBrowser infinite scroll auto-continue (issue #6022)", () => {
|
||||
it.each(["mount", "refresh"])("cancels legacy publication when grouping is re-enabled during %s", async (phase) => {
|
||||
grouping.value = { ...defaultRedisKeyGrouping(), enabled: true };
|
||||
mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 0, keys: [{ key_display: "a", key_raw: btoa("a"), key_type: "string", ttl: -1 }], total_keys: 1 });
|
||||
const host = mountBrowser();
|
||||
await settleThoroughly();
|
||||
const reenable = () => {
|
||||
grouping.value = { ...grouping.value!, enabled: true };
|
||||
};
|
||||
if (phase === "mount") mocks.scrollerInitialSnapshot.mockImplementationOnce(reenable);
|
||||
else mocks.scrollerRefresh.mockImplementationOnce(reenable);
|
||||
grouping.value = { ...grouping.value!, enabled: false };
|
||||
await settleThoroughly();
|
||||
expect(grouping.value.enabled).toBe(true);
|
||||
expect(host.querySelector('[role="button"]')).not.toBeNull();
|
||||
expect(host.textContent).not.toContain("redisGrouping.preparingLegacy");
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not publish a prepared old scope after the database changes or keys are cleared", async () => {
|
||||
grouping.value = { ...defaultRedisKeyGrouping(), enabled: true };
|
||||
const keys = Array.from({ length: 1100 }, (_, i) => ({ key_display: `old:${i}`, key_raw: btoa(`old:${i}`), key_type: "string", ttl: -1 }));
|
||||
mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 0, keys, total_keys: keys.length });
|
||||
const db = ref(0);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp(defineComponent({ setup: () => () => h(RedisKeyBrowser, { connectionId: "connection", db: db.value, blockDangerousRedisCommands: false }) }));
|
||||
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await settleThoroughly();
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
});
|
||||
try {
|
||||
grouping.value = { ...grouping.value!, enabled: false };
|
||||
await settle();
|
||||
expect(frames.length).toBeGreaterThan(0);
|
||||
mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 0, keys: [{ key_display: "new", key_raw: btoa("new"), key_type: "string", ttl: -1 }], total_keys: 1 });
|
||||
db.value = 1;
|
||||
await settleThoroughly();
|
||||
for (let i = 0; i < 30; i++) {
|
||||
for (const callback of frames.splice(0)) callback(i);
|
||||
await settle();
|
||||
}
|
||||
expect(host.querySelector(`[data-redis-leaf='${btoa("new")}']`)).not.toBeNull();
|
||||
expect(host.textContent).not.toContain("old");
|
||||
window.dispatchEvent(new CustomEvent("dbx-redis-db-flushed", { detail: { connectionId: "connection", db: 1 } }));
|
||||
await settleThoroughly();
|
||||
expect(host.querySelector(`[data-redis-leaf='${btoa("new")}']`)).toBeNull();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps custom rows during cooperative legacy preparation, cancels and retries", async () => {
|
||||
grouping.value = { ...defaultRedisKeyGrouping(), enabled: true };
|
||||
const keys = Array.from({ length: 1100 }, (_, i) => ({ key_display: `namespace:${i}`, key_raw: btoa(`namespace:${i}`), key_type: "string", ttl: -1 }));
|
||||
mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 0, keys, total_keys: keys.length });
|
||||
const host = mountBrowser();
|
||||
await settleThoroughly();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await settleThoroughly();
|
||||
expect(host.querySelector('[role="button"]')).not.toBeNull();
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
});
|
||||
try {
|
||||
grouping.value = { ...grouping.value!, enabled: false };
|
||||
await settle();
|
||||
expect(frames.length).toBeGreaterThan(0);
|
||||
expect(host.textContent).toContain("redisGrouping.preparingLegacy");
|
||||
expect(host.querySelector('[role="button"]')).not.toBeNull();
|
||||
grouping.value = { ...grouping.value!, enabled: true };
|
||||
for (const callback of frames.splice(0)) callback(0);
|
||||
await settle();
|
||||
expect(host.textContent).not.toContain("redisGrouping.preparingLegacy");
|
||||
expect(host.querySelector('[role="button"]')).not.toBeNull();
|
||||
mocks.scrollerInitialSnapshot.mockClear();
|
||||
grouping.value = { ...grouping.value!, enabled: false };
|
||||
for (let i = 0; i < 30; i++) {
|
||||
for (const callback of frames.splice(0)) callback(i);
|
||||
await settle();
|
||||
}
|
||||
expect(host.textContent).not.toContain("redisGrouping.preparingLegacy");
|
||||
expect(host.querySelector("[data-redis-group]")).not.toBeNull();
|
||||
expect(mocks.scrollerInitialSnapshot).toHaveBeenCalledWith(0);
|
||||
expect(mocks.scrollerInitialSnapshot.mock.calls.every(([length]) => length === 0)).toBe(true);
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("continues sparse custom groups and preserves explicit checks across layout toggles", async () => {
|
||||
grouping.value = { ...defaultRedisKeyGrouping(), enabled: true };
|
||||
stubNonOverflowingViewport();
|
||||
mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 7, keys: [{ key_display: "a", key_raw: btoa("a"), key_type: "string", ttl: -1 }], total_keys: 2 });
|
||||
mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 0, keys: [{ key_display: "b", key_raw: btoa("b"), key_type: "string", ttl: -1 }], total_keys: 2 });
|
||||
const host = mountBrowser();
|
||||
await settleThoroughly();
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(2);
|
||||
host.querySelector<HTMLButtonElement>("[data-redis-select-all]")!.click();
|
||||
await settleThoroughly();
|
||||
expect(host.querySelector("[data-redis-batch-delete]")?.textContent).toContain("2");
|
||||
grouping.value = { ...grouping.value!, enabled: false };
|
||||
await settleThoroughly();
|
||||
expect(host.querySelector("[data-redis-batch-delete]")?.textContent).toContain("2");
|
||||
grouping.value = { ...grouping.value!, enabled: true, inner_view: "tree" };
|
||||
await settleThoroughly();
|
||||
expect(host.querySelector("[data-redis-batch-delete]")?.textContent).toContain("2");
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps fetching pages on its own when the loaded rows don't overflow the viewport, without any scroll event", async () => {
|
||||
stubNonOverflowingViewport();
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import RedisValueViewer from "./RedisValueViewer.vue";
|
||||
import RedisPubSubPanel from "./RedisPubSubPanel.vue";
|
||||
import RedisSlowlogPanel from "./RedisSlowlogPanel.vue";
|
||||
import RedisGroupedKeyList from "./RedisGroupedKeyList.vue";
|
||||
import RedisKeyGroupingDialog from "./RedisKeyGroupingDialog.vue";
|
||||
import { defaultRedisKeyGrouping, type RedisKeyGrouping } from "@/lib/redis/redisKeyGrouping";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { RedisKeyInfo, RedisScanResult, RedisValue, HistoryEntry } from "@/lib/backend/api";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
@@ -65,10 +68,34 @@ import { applyRedisExpiryPolicy, type RedisExpiryMode, validateRedisExpiry } fro
|
||||
import { shouldLoadMoreRedisKeys } from "@/lib/redis/redisKeyInfiniteScroll";
|
||||
import { formatTtl } from "@/lib/common/ttlFormat";
|
||||
import { computeTtlCountdownValue } from "@/lib/redis/redisAutoRefresh";
|
||||
import { createRedisKeyViewYield } from "@/lib/redis/redisKeyViewScheduler";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const customGrouping = computed(() => connectionStore.getConfig(props.connectionId)?.redis_key_grouping ?? defaultRedisKeyGrouping());
|
||||
const groupingDialogOpen = ref(false);
|
||||
const groupingSaving = ref(false);
|
||||
const groupingError = ref("");
|
||||
const groupingStructureRevision = ref(0);
|
||||
const legacyGroupingPreparing = ref(false);
|
||||
let legacyGroupingPublishing = false;
|
||||
const showCustomGrouping = computed(() => customGrouping.value.enabled || legacyGroupingPreparing.value);
|
||||
let legacyGroupingGeneration = 0;
|
||||
let legacyExpandedBeforeGrouping: ReadonlySet<string> = new Set();
|
||||
async function saveGrouping(config: RedisKeyGrouping) {
|
||||
if (groupingSaving.value) return;
|
||||
groupingSaving.value = true;
|
||||
groupingError.value = "";
|
||||
try {
|
||||
await connectionStore.updateRedisKeyGrouping(props.connectionId, config);
|
||||
groupingDialogOpen.value = false;
|
||||
} catch (error) {
|
||||
groupingError.value = errorMessage(error);
|
||||
} finally {
|
||||
groupingSaving.value = false;
|
||||
}
|
||||
}
|
||||
const settingsStore = useSettingsStore();
|
||||
const editorFontFamilyStyle = useEditorFontFamilyStyle();
|
||||
|
||||
@@ -113,6 +140,7 @@ const fetchAllLoadedCount = ref(0);
|
||||
const rootRef = ref<HTMLElement>();
|
||||
const keyPaneRef = ref<HTMLElement>();
|
||||
const redisKeyScrollerRef = ref<InstanceType<typeof RecycleScroller> | null>(null);
|
||||
const groupedKeyListRef = ref<InstanceType<typeof RedisGroupedKeyList> | null>(null);
|
||||
let redisKeyScrollRevision = 0;
|
||||
let latestRedisKeyScrollAnchor: RedisKeyViewportAnchor | null = null;
|
||||
const valueViewerRef = ref<{ focusSearch: () => boolean } | null>(null);
|
||||
@@ -252,12 +280,12 @@ const isSearchMode = computed(() => (searchMode.value === "key" ? effectivePatte
|
||||
// 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 useFlatKeySearchRows = computed(() => showCustomGrouping.value || (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);
|
||||
// checkedKeys is always a subset of loaded keys, so size equality is enough.
|
||||
const allLoadedKeysSelected = computed(() => flatKeys.value.length > 0 && checkedKeys.value.size === flatKeys.value.length);
|
||||
const allKeysSelected = computed(() => allLoadedKeysSelected.value && !hasMore.value);
|
||||
const allKeysSelected = computed(() => (customGrouping.value.enabled ? filteredFlatKeys.value.length > 0 && checkedKeys.value.size === filteredFlatKeys.value.length : allLoadedKeysSelected.value && !hasMore.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");
|
||||
@@ -415,6 +443,9 @@ watch(activeCreateKeyTypeHelp, () => {
|
||||
void updateCreateKeyTypeHelpOffset();
|
||||
});
|
||||
const regularVisibleRows = computed(() => {
|
||||
// Prepared snapshots already own their rows. Even a watcher whose callback
|
||||
// ignores them would otherwise synchronously re-map/flatten the keyspace.
|
||||
if (showCustomGrouping.value || fetchAllVisibleRowsActive.value) return [];
|
||||
return useFlatKeySearchRows.value ? filteredFlatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(filteredTreeKeys.value, expandedGroupIds.value);
|
||||
});
|
||||
let fetchAllVisibleRowsSource: readonly RedisKeyTreeRow[] = [];
|
||||
@@ -545,6 +576,7 @@ function appendFlatKeyRecords(keys: readonly RedisKeyInfo[]) {
|
||||
}
|
||||
|
||||
function replaceFlatKeyRecords(keys: RedisKeyInfo[]) {
|
||||
groupingStructureRevision.value++;
|
||||
flatKeyByRaw.clear();
|
||||
for (const key of keys) flatKeyByRaw.set(key.key_raw, key);
|
||||
flatKeys.value = keys;
|
||||
@@ -662,7 +694,7 @@ function toggleNodeCheck(node: RedisKeyTreeNode, event: MouseEvent) {
|
||||
if (selectionBusy.value) return;
|
||||
focusKeyPane();
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (event.shiftKey && !customGrouping.value.enabled) {
|
||||
const rows = visibleRows.value;
|
||||
const to = rows.findIndex((row) => row.node.id === node.id);
|
||||
if (to < 0) return;
|
||||
@@ -682,11 +714,15 @@ function toggleNodeCheck(node: RedisKeyTreeNode, event: MouseEvent) {
|
||||
function selectAllLoadedKeys() {
|
||||
if (selectionBusy.value || loadedKeyRaws.size === 0) return;
|
||||
focusKeyPane();
|
||||
setKeysChecked(loadedKeyRaws, true);
|
||||
setKeysChecked(customGrouping.value.enabled ? filteredFlatKeys.value.map((key) => key.key_raw) : loadedKeyRaws, true);
|
||||
selectionAnchorRowId.value = visibleRows.value[0]?.id ?? null;
|
||||
}
|
||||
|
||||
async function selectAllKeys() {
|
||||
if (customGrouping.value.enabled) {
|
||||
selectAllLoadedKeys();
|
||||
return;
|
||||
}
|
||||
if (selectionBusy.value || (loadedKeyRaws.size === 0 && !hasMore.value)) return;
|
||||
const requestId = searchRequestId;
|
||||
if (hasMore.value) {
|
||||
@@ -1037,7 +1073,7 @@ async function maybeAutoLoadMoreRedisKeys() {
|
||||
// operation is spent, independent of how many (if any) new keys prior calls
|
||||
// yielded.
|
||||
if (autoLoadBudget.remaining <= 0) return;
|
||||
const scroller = redisKeyScrollerRef.value?.$el as HTMLElement | undefined;
|
||||
const scroller = showCustomGrouping.value ? groupedKeyListRef.value?.getScrollElement() : (redisKeyScrollerRef.value?.$el as HTMLElement | undefined);
|
||||
if (!scroller) return;
|
||||
const shouldLoad = shouldLoadMoreRedisKeys({
|
||||
enabled: redisInfiniteScrollEnabled.value,
|
||||
@@ -1253,7 +1289,7 @@ async function fetchAll(): Promise<boolean> {
|
||||
let published = !changed && !fetchAllStopRequested.value;
|
||||
try {
|
||||
if (changed) {
|
||||
snapshotConfig.flatRows = (searchMode.value === "key" && isSearchMode.value && !fuzzyKeySearch.value) || (isFuzzyKeySearch.value && !canBuildRedisFuzzyTree(initialFlatKeys.length + bufferedKeys.length));
|
||||
snapshotConfig.flatRows = customGrouping.value.enabled || (searchMode.value === "key" && isSearchMode.value && !fuzzyKeySearch.value) || (isFuzzyKeySearch.value && !canBuildRedisFuzzyTree(initialFlatKeys.length + bufferedKeys.length));
|
||||
snapshotConfig.expandAll = isSearchMode.value;
|
||||
snapshotConfig.expandedGroupIds = expandedGroupIds.value;
|
||||
snapshotConfig.noExpiryOnly = noExpiryOnly.value;
|
||||
@@ -2668,6 +2704,7 @@ onMounted(async () => {
|
||||
|
||||
onActivated(async () => {
|
||||
resumeRedisBrowserBackgroundWork();
|
||||
if (legacyGroupingPreparing.value || legacyGroupingPublishing) void prepareLegacyGroupingView();
|
||||
void autofocusSearchOnce();
|
||||
// Ensure the connection is still alive after reactivation (e.g. tab switch).
|
||||
// Retry an empty failed/interrupted load, but retain successful sparse pages.
|
||||
@@ -2729,11 +2766,114 @@ async function executeAiCommand(command: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => JSON.stringify(customGrouping.value.rules),
|
||||
() => resetCheckedKeys(),
|
||||
);
|
||||
watch(noExpiryOnly, () => resetCheckedKeys());
|
||||
async function prepareLegacyGroupingView() {
|
||||
const generation = ++legacyGroupingGeneration;
|
||||
legacyGroupingPublishing = false;
|
||||
const keys = flatKeys.value;
|
||||
const connectionId = props.connectionId;
|
||||
const db = props.db;
|
||||
const current = () => generation === legacyGroupingGeneration && !customGrouping.value.enabled && redisBrowserIsActive && props.connectionId === connectionId && props.db === db && flatKeys.value === keys;
|
||||
const yieldView = createRedisKeyViewYield();
|
||||
legacyGroupingPreparing.value = true;
|
||||
const snapshot = await buildRedisKeySnapshotCooperatively(
|
||||
[keys],
|
||||
{
|
||||
db,
|
||||
separator: redisKeySeparator.value,
|
||||
flatRows: (searchMode.value === "key" && isSearchMode.value && !fuzzyKeySearch.value) || fuzzyTreeLimitReached.value,
|
||||
expandAll: false,
|
||||
expandedGroupIds: legacyExpandedBeforeGrouping,
|
||||
noExpiryOnly: noExpiryOnly.value,
|
||||
},
|
||||
{ workChunkSize: 512, shouldContinue: current, yieldControl: yieldView },
|
||||
);
|
||||
if (!snapshot || !current()) return;
|
||||
let anchor = groupedKeyListRef.value?.getViewportAnchor();
|
||||
let rowIndex = Math.min(anchor?.rowIndex ?? 0, Math.max(0, snapshot.visibleRows.length - 1));
|
||||
if (anchor?.keyRaw) {
|
||||
for (let offset = 0; offset < snapshot.visibleRows.length; ) {
|
||||
if (!current()) return;
|
||||
const end = Math.min(offset + 512, snapshot.visibleRows.length);
|
||||
let found = false;
|
||||
for (; offset < end; offset++) {
|
||||
const row = snapshot.visibleRows[offset]!;
|
||||
if (row.node.kind === "leaf" && row.node.keyRaw === anchor.keyRaw) {
|
||||
rowIndex = offset;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
if (offset < snapshot.visibleRows.length) await yieldView();
|
||||
const latest = groupedKeyListRef.value?.getViewportAnchor();
|
||||
if (latest?.keyRaw !== anchor.keyRaw) {
|
||||
anchor = latest;
|
||||
rowIndex = Math.min(latest?.rowIndex ?? 0, Math.max(0, snapshot.visibleRows.length - 1));
|
||||
if (!anchor?.keyRaw) break;
|
||||
offset = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!current()) return;
|
||||
// Keep the complete custom view mounted until all expensive work is done.
|
||||
// Activate prepared rows before changing structural refs, so the regular
|
||||
// projection watcher cannot accidentally rebuild this snapshot synchronously.
|
||||
fetchAllVisibleRowsActive.value = true;
|
||||
treeIndex = snapshot.treeIndex;
|
||||
treeKeys.value = snapshot.treeIndex?.root ?? [];
|
||||
expandedGroupIds.value = snapshot.expandedGroupIds;
|
||||
// RecycleScroller snapshots every initial item during setup. Mount with an
|
||||
// empty backing source, then switch the same facade before the next paint.
|
||||
fetchAllVisibleRowsSource = [];
|
||||
fetchAllFilteredKeyCount.value = snapshot.filteredKeyCount;
|
||||
refreshSelectedGroupLeafCounts();
|
||||
legacyGroupingPublishing = true;
|
||||
legacyGroupingPreparing.value = false;
|
||||
await nextTick();
|
||||
if (current()) {
|
||||
fetchAllVisibleRowsSource = snapshot.visibleRows;
|
||||
redisKeyScroller()?.scrollToItem(rowIndex, { align: "start" });
|
||||
refreshRedisKeyScroller();
|
||||
await nextTick();
|
||||
if (!current()) return;
|
||||
redisKeyScroller()?.scrollToItem(rowIndex, { align: "start" });
|
||||
refreshRedisKeyScroller();
|
||||
legacyGroupingPublishing = false;
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => customGrouping.value.enabled,
|
||||
(enabled, previous) => {
|
||||
if (enabled) {
|
||||
legacyGroupingGeneration++;
|
||||
legacyGroupingPublishing = false;
|
||||
if (!legacyGroupingPreparing.value) legacyExpandedBeforeGrouping = expandedGroupIds.value;
|
||||
legacyGroupingPreparing.value = false;
|
||||
rebuildTree(false);
|
||||
} else if (previous) {
|
||||
void prepareLegacyGroupingView();
|
||||
}
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
watch(
|
||||
[flatKeys, groupingStructureRevision, redisKeySeparator, noExpiryOnly, noExpiryProjectionEpoch, effectivePattern, () => props.connectionId, () => props.db],
|
||||
() => {
|
||||
if ((legacyGroupingPreparing.value || legacyGroupingPublishing) && !customGrouping.value.enabled) void prepareLegacyGroupingView();
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="h-full" :style="editorFontFamilyStyle">
|
||||
<RedisKeyGroupingDialog v-model:open="groupingDialogOpen" :config="customGrouping" :saving="groupingSaving" :error="groupingError" @save="saveGrouping" />
|
||||
<Splitpanes class="redis-workspace-splitpanes h-full">
|
||||
<!-- Key tree (left) -->
|
||||
<Pane :size="36" :min-size="24">
|
||||
@@ -2754,7 +2894,17 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
</div>
|
||||
<span class="redis-key-count truncate text-xs text-muted-foreground" :title="keyCountText">{{ keyCountText }}</span>
|
||||
<div class="redis-key-toolbar-actions flex items-center justify-end gap-1">
|
||||
<Button v-if="(flatKeys.length > 0 || hasMore) && !allKeysSelected" variant="ghost" size="sm" class="h-6 shrink-0 px-1.5 text-xs" :disabled="selectionBusy" :title="t('redis.selectAllLoadedTitle')" data-redis-select-all @click="selectAllKeys">{{ t("redis.selectAllLoaded") }}</Button>
|
||||
<Button
|
||||
v-if="(flatKeys.length > 0 || hasMore) && !allKeysSelected"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 shrink-0 px-1.5 text-xs"
|
||||
:disabled="selectionBusy"
|
||||
:title="customGrouping.enabled ? t('redisGrouping.selectLoaded') : t('redis.selectAllLoadedTitle')"
|
||||
data-redis-select-all
|
||||
@click="selectAllKeys"
|
||||
>{{ customGrouping.enabled ? t("redisGrouping.selectLoaded") : t("redis.selectAllLoaded") }}</Button
|
||||
>
|
||||
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 px-1.5 text-xs" :disabled="selectionBusy" data-redis-deselect-all @click="clearAllCheckedKeys">{{ t("redis.deselectAll") }}</Button>
|
||||
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 text-xs text-destructive" :disabled="selectionBusy" data-redis-batch-delete @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 || loading || loadingMore || isFetchingAll" @click="loadKeys">
|
||||
@@ -2833,7 +2983,48 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-1 border-b px-2 py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 text-xs"
|
||||
:class="customGrouping.enabled ? 'bg-accent text-accent-foreground' : 'border border-dashed border-border/70 text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="customGrouping.enabled"
|
||||
:disabled="groupingSaving || isFetchingAll"
|
||||
data-redis-grouping-toggle
|
||||
@click="saveGrouping({ ...customGrouping, enabled: !customGrouping.enabled })"
|
||||
>{{ t("redisGrouping.toggle") }}</Button
|
||||
>
|
||||
<Button v-if="customGrouping.enabled" variant="ghost" size="sm" class="h-6 text-xs" :disabled="groupingSaving" @click="saveGrouping({ ...customGrouping, inner_view: customGrouping.inner_view === 'list' ? 'tree' : 'list' })">{{
|
||||
t(customGrouping.inner_view === "list" ? "redisGrouping.list" : "redisGrouping.tree")
|
||||
}}</Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="groupingDialogOpen = true">{{ t("redisGrouping.settings") }}</Button>
|
||||
<span v-if="groupingError" role="alert" class="text-xs text-destructive">{{ groupingError }}</span>
|
||||
</div>
|
||||
<p v-if="legacyGroupingPreparing" role="status" class="shrink-0 px-2 py-1 text-xs text-muted-foreground">{{ t("redisGrouping.preparingLegacy") }}</p>
|
||||
<RedisGroupedKeyList
|
||||
v-if="showCustomGrouping"
|
||||
ref="groupedKeyListRef"
|
||||
:key="`${connectionId}:${db}`"
|
||||
:keys="filteredFlatKeys"
|
||||
:config="customGrouping"
|
||||
:separator="redisKeySeparator"
|
||||
:scope="`${connectionId}:${db}`"
|
||||
:structure-revision="groupingStructureRevision"
|
||||
:selected="selectedKeyRaw"
|
||||
:checked="checkedKeys"
|
||||
:busy="selectionBusy"
|
||||
:metadata-epoch="keyMetadataEpoch"
|
||||
:tree-allowed="!fuzzyTreeLimitReached"
|
||||
@request-list="saveGrouping({ ...customGrouping, inner_view: 'list' })"
|
||||
@scroll="onRedisKeyScroll"
|
||||
@resize="maybeAutoLoadMoreRedisKeys"
|
||||
@select="(key) => onRowClick(redisKeyToFlatTreeRow(key, db).node)"
|
||||
@check="(key, event) => toggleNodeCheck(redisKeyToFlatTreeRow(key, db).node, event)"
|
||||
@copy="(key) => copyRedisKeyName(key.key_display)"
|
||||
@delete="(key, event) => requestKeyDelete(redisKeyToFlatTreeRow(key, db).node, event)"
|
||||
/>
|
||||
<div v-else-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 || deletingKeys" @click="loadMore()">
|
||||
@@ -2931,7 +3122,7 @@ 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">
|
||||
<div v-if="fuzzyTreeLimitReached && !showCustomGrouping" 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">
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { defaultRedisKeyGrouping, validateRedisKeyGrouping, type RedisKeyGrouping } from "@/lib/redis/redisKeyGrouping";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
|
||||
const props = defineProps<{ open: boolean; config: RedisKeyGrouping; saving: boolean; error: string }>();
|
||||
const emit = defineEmits<{ "update:open": [boolean]; save: [RedisKeyGrouping] }>();
|
||||
const { t } = useI18n();
|
||||
const draft = ref(defaultRedisKeyGrouping());
|
||||
const invalid = ref(false);
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
draft.value = validateRedisKeyGrouping(props.config);
|
||||
invalid.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
function add(example = false) {
|
||||
draft.value.rules.push({ id: uuid(), name: example ? "Redisson" : "", enabled: true, includes: example ? ["*:redisson_options"] : ["*"], excludes: [] });
|
||||
}
|
||||
function move(index: number, direction: number) {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= draft.value.rules.length) return;
|
||||
const rule = draft.value.rules.splice(index, 1)[0]!;
|
||||
draft.value.rules.splice(target, 0, rule);
|
||||
}
|
||||
function save() {
|
||||
try {
|
||||
emit("save", validateRedisKeyGrouping(draft.value));
|
||||
invalid.value = false;
|
||||
} catch {
|
||||
invalid.value = true;
|
||||
}
|
||||
}
|
||||
function patterns(event: Event): string[] {
|
||||
return (event.target as HTMLTextAreaElement).value.split("\n").filter((line) => line.length > 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
||||
<DialogContent class="sm:max-w-2xl">
|
||||
<DialogHeader
|
||||
><DialogTitle>{{ t("redisGrouping.settings") }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<p class="text-xs text-muted-foreground">{{ t("redisGrouping.help") }}</p>
|
||||
<div class="max-h-[55vh] space-y-3 overflow-y-auto">
|
||||
<fieldset v-for="(rule, index) in draft.rules" :key="rule.id" class="space-y-2 rounded border p-3" :disabled="saving">
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="rule.enabled" type="checkbox" :aria-label="t('redisGrouping.enabled')" />
|
||||
<Input v-model="rule.name" :placeholder="t('redisGrouping.name')" :aria-label="t('redisGrouping.name')" />
|
||||
<Button variant="outline" size="sm" :disabled="index === 0" :aria-label="t('redisGrouping.up')" @click="move(index, -1)">↑</Button>
|
||||
<Button variant="outline" size="sm" :disabled="index === draft.rules.length - 1" :aria-label="t('redisGrouping.down')" @click="move(index, 1)">↓</Button>
|
||||
<Button variant="outline" size="sm" @click="draft.rules.splice(index, 1)">{{ t("redisGrouping.remove") }}</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="text-xs">{{ t("redisGrouping.includes") }}<textarea class="w-full rounded border bg-background p-2" :value="rule.includes.join('\n')" @input="rule.includes = patterns($event)" /></label>
|
||||
<label class="text-xs">{{ t("redisGrouping.excludes") }}<textarea class="w-full rounded border bg-background p-2" :value="rule.excludes.join('\n')" @input="rule.excludes = patterns($event)" /></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" :disabled="saving || draft.rules.length >= 64" @click="add()">{{ t("redisGrouping.add") }}</Button>
|
||||
<Button variant="outline" :disabled="saving || draft.rules.length >= 64" @click="add(true)">{{ t("redisGrouping.example") }}</Button>
|
||||
</div>
|
||||
<p v-if="invalid || error" role="alert" class="text-sm text-destructive">{{ error || t("redisGrouping.invalid") }}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="saving" @click="emit('update:open', false)">{{ t("redisGrouping.cancel") }}</Button>
|
||||
<Button :disabled="saving" @click="save">{{ t("redisGrouping.save") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
Reference in new issue
Block a user