mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 05:10:26 +00:00
feat(redis): add custom key groups
This commit is contained in:
@@ -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>
|
||||
@@ -1,3 +1,4 @@
|
||||
import { redisGroupingEn as redisGrouping } from "./redisGrouping";
|
||||
import docs from "./docs/en";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
|
||||
@@ -79,6 +80,7 @@ const consul = {
|
||||
};
|
||||
|
||||
export default {
|
||||
redisGrouping,
|
||||
customType: {
|
||||
kinds: {
|
||||
base: "Base",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
export const redisGroupingEn = {
|
||||
preparingLegacy: "Preparing the previous view; custom groups remain available…",
|
||||
fuzzyTreeUnavailable: "This fuzzy search exceeds the existing tree resource limit. Choose List to browse the loaded matches, or narrow the search to use Tree.",
|
||||
useList: "Use List",
|
||||
settings: "Grouping rules",
|
||||
toggle: "Custom groups",
|
||||
list: "List",
|
||||
tree: "Tree",
|
||||
help: "Connection-wide rules, first match wins. One case-sensitive Redis glob per line (*, ?, [a-z], backslash escape). Includes are OR; exclusions veto. Empty includes match all. Counts cover loaded keys only. Groups are not deletion boundaries.",
|
||||
enabled: "Enable rule",
|
||||
name: "Group name",
|
||||
up: "Move up",
|
||||
down: "Move down",
|
||||
remove: "Remove",
|
||||
includes: "Include patterns",
|
||||
excludes: "Exclude patterns",
|
||||
add: "Add rule",
|
||||
example: "Add Redisson example",
|
||||
invalid: "Enter unique rule IDs, a name and at least one pattern per rule (maximum 64 rules, 64 patterns per list, 1024 bytes per pattern).",
|
||||
cancel: "Cancel",
|
||||
save: "Save",
|
||||
unmatched: "Unmatched",
|
||||
preparing: "Updating loaded groups…",
|
||||
loadedOnly: "Groups show loaded matches only; expand does not scan Redis.",
|
||||
count: "({count} loaded)",
|
||||
emptyKey: "(empty key)",
|
||||
selectLoaded: "Select loaded matches",
|
||||
};
|
||||
export const redisGroupingZhCN = {
|
||||
preparingLegacy: "正在准备原有视图,期间仍可浏览自定义分组…",
|
||||
fuzzyTreeUnavailable: "当前模糊搜索超过已有树视图资源限制。请选择列表浏览已加载匹配项,或缩小搜索范围后使用前缀树。",
|
||||
useList: "使用列表",
|
||||
settings: "分组规则",
|
||||
toggle: "自定义分组",
|
||||
list: "列表",
|
||||
tree: "前缀树",
|
||||
help: "规则按连接保存,按顺序首次匹配归组。每行一个区分大小写的 Redis 通配模式(*、?、[a-z]、反斜杠转义)。包含条件任一命中即可,排除条件优先;包含为空表示全部。计数仅涵盖已加载 Key,分组不是删除边界。",
|
||||
enabled: "启用规则",
|
||||
name: "分组名称",
|
||||
up: "上移",
|
||||
down: "下移",
|
||||
remove: "移除",
|
||||
includes: "包含模式",
|
||||
excludes: "排除模式",
|
||||
add: "添加规则",
|
||||
example: "添加 Redisson 示例",
|
||||
invalid: "请填写唯一规则 ID、名称及至少一个模式(最多 64 个规则,每组包含/排除各 64 个模式,每个模式 1024 字节)。",
|
||||
cancel: "取消",
|
||||
save: "保存",
|
||||
unmatched: "未匹配",
|
||||
preparing: "正在更新已加载分组…",
|
||||
loadedOnly: "分组仅展示已加载匹配结果,展开不会扫描 Redis。",
|
||||
count: "(已加载 {count})",
|
||||
emptyKey: "(空 Key)",
|
||||
selectLoaded: "选择已加载匹配项",
|
||||
};
|
||||
export const redisGroupingZhTW = {
|
||||
preparingLegacy: "正在準備原有檢視,期間仍可瀏覽自訂分組…",
|
||||
fuzzyTreeUnavailable: "目前模糊搜尋超過既有樹狀檢視資源限制。請選擇列表瀏覽已載入匹配項,或縮小搜尋範圍後使用前綴樹。",
|
||||
useList: "使用列表",
|
||||
settings: "分組規則",
|
||||
toggle: "自訂分組",
|
||||
list: "列表",
|
||||
tree: "前綴樹",
|
||||
help: "規則按連線儲存,按順序首次匹配歸組。每行一個區分大小寫的 Redis 萬用模式(*、?、[a-z]、反斜線跳脫)。包含條件任一命中即可,排除條件優先;包含為空表示全部。計數僅涵蓋已載入 Key,分組不是刪除邊界。",
|
||||
enabled: "啟用規則",
|
||||
name: "分組名稱",
|
||||
up: "上移",
|
||||
down: "下移",
|
||||
remove: "移除",
|
||||
includes: "包含模式",
|
||||
excludes: "排除模式",
|
||||
add: "新增規則",
|
||||
example: "新增 Redisson 範例",
|
||||
invalid: "請填寫唯一規則 ID、名稱及至少一個模式(最多 64 個規則,每組包含/排除各 64 個模式,每個模式 1024 位元組)。",
|
||||
cancel: "取消",
|
||||
save: "儲存",
|
||||
unmatched: "未匹配",
|
||||
preparing: "正在更新已載入分組…",
|
||||
loadedOnly: "分組僅展示已載入匹配結果,展開不會掃描 Redis。",
|
||||
count: "(已載入 {count})",
|
||||
emptyKey: "(空 Key)",
|
||||
selectLoaded: "選擇已載入匹配項",
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
import { redisGroupingZhCN as redisGrouping } from "./redisGrouping";
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/zh-CN";
|
||||
import { consul } from "./consulZhCN";
|
||||
import { meilisearchManagementZhCN } from "./meilisearchManagement";
|
||||
|
||||
export default withEnglishFallback({
|
||||
redisGrouping,
|
||||
customType: {
|
||||
kinds: {
|
||||
base: "基础类型",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { redisGroupingZhTW as redisGrouping } from "./redisGrouping";
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import { meilisearchManagementZhTW } from "./meilisearchManagement";
|
||||
import docs from "./docs/zh-TW";
|
||||
@@ -81,6 +82,7 @@ const consul = {
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
redisGrouping,
|
||||
customType: {
|
||||
kinds: {
|
||||
base: "基礎型別",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { RedisKeyGroupIndex, findRedisOuterGroup } from "../redisKeyGroupIndex";
|
||||
|
||||
const work = { current: () => true, yield: async () => {} };
|
||||
const rules = [
|
||||
{ id: "a", name: "A", enabled: true, includes: ["a:*"], excludes: [] },
|
||||
{ id: "empty", name: "Empty", enabled: true, includes: ["missing:*"], excludes: [] },
|
||||
];
|
||||
const key = (name: string) => ({ key_raw: btoa(name), key_display: name, key_type: "string", ttl: -1 });
|
||||
|
||||
it.each(["list", "tree"] as const)("captures atomic outer boundaries for %s, excluding namespace headers", async (view) => {
|
||||
const index = new RedisKeyGroupIndex(rules, "Unmatched");
|
||||
await index.append([key("a:1"), key("a:2"), key("b:1")], work);
|
||||
const expanded = new Set(['custom:"a"', 'custom:"a":namespace:["a"]', "custom:unmatched"]);
|
||||
const first = (await index.project(view, ":", expanded, work))!;
|
||||
expect(first.outerGroups).toHaveLength(3);
|
||||
expect(first.outerGroups.map((group) => group.row.label)).toEqual(["A", "Empty", "Unmatched"]);
|
||||
for (const group of first.outerGroups) {
|
||||
expect(first.rows[group.start]).toBe(group.row);
|
||||
expect(findRedisOuterGroup(first.outerGroups, group.start)).toBe(group);
|
||||
expect(findRedisOuterGroup(first.outerGroups, group.end - 0.1)).toBe(group);
|
||||
}
|
||||
expect(findRedisOuterGroup(first.outerGroups, -1)).toBeUndefined();
|
||||
expect(findRedisOuterGroup(first.outerGroups, first.rows.length)).toBeUndefined();
|
||||
const before = first.outerGroups.map((group) => ({ ...group }));
|
||||
await index.append([key("a:1"), key("a:2"), key("b:1"), key("a:3")], work);
|
||||
const collapsed = (await index.project(view, ":", new Set(), work))!;
|
||||
expect(collapsed.outerGroups.map((group) => group.end - group.start)).toEqual([1, 1, 1]);
|
||||
expect(first.outerGroups).toEqual(before);
|
||||
expect(first.outerGroups[0]!.row.count).toBe(2);
|
||||
expect(collapsed.outerGroups[0]!.row.count).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps million-key boundary storage bounded by groups, including unmatched", async () => {
|
||||
const index = new RedisKeyGroupIndex(rules, "Unmatched");
|
||||
const keys = Array.from({ length: 1_000_000 }, (_, n) => key(`a:${n}`));
|
||||
await index.append(keys, work);
|
||||
const snapshot = (await index.project("list", ":", new Set(['custom:"a"']), work))!;
|
||||
expect(snapshot.rows.length).toBe(1_000_002);
|
||||
expect(snapshot.outerGroups).toHaveLength(2);
|
||||
expect(findRedisOuterGroup(snapshot.outerGroups, 999_999)?.row.id).toBe('custom:"a"');
|
||||
expect(findRedisOuterGroup(snapshot.outerGroups, 1_000_001)?.row.label).toBe("Empty");
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compileRedisGroupGlob, compileRedisKeyGroups, defaultRedisKeyGrouping, validateRedisKeyGrouping } from "../redisKeyGrouping";
|
||||
import { RedisKeyGroupIndex } from "../redisKeyGroupIndex";
|
||||
import type { RedisKeyInfo } from "@/lib/backend/api";
|
||||
|
||||
const raw = (value: string) => btoa(String.fromCharCode(...new TextEncoder().encode(value)));
|
||||
const key = (name: string): RedisKeyInfo => ({ key_raw: raw(name), key_display: name, key_type: "string", ttl: -1, size: 1 });
|
||||
const rule = { id: "suffix", name: "Metadata", enabled: true, includes: ["*:redisson_options"], excludes: [] };
|
||||
const work = { current: () => true, yield: async () => {} };
|
||||
|
||||
describe("Redis grouping contracts", () => {
|
||||
it("is opt-in and rejects malformed edits", () => {
|
||||
expect(validateRedisKeyGrouping(defaultRedisKeyGrouping()).enabled).toBe(false);
|
||||
expect(() => validateRedisKeyGrouping({ ...defaultRedisKeyGrouping(), rules: [{ ...rule, includes: [] }] })).toThrow();
|
||||
expect(() => validateRedisKeyGrouping({ ...defaultRedisKeyGrouping(), rules: [rule, rule] })).toThrow();
|
||||
expect(() => validateRedisKeyGrouping({ ...defaultRedisKeyGrouping(), version: 2 })).toThrow();
|
||||
});
|
||||
it("uses whole-byte glob semantics, escapes and ranges", () => {
|
||||
const matches = (pattern: string, text: string) => compileRedisGroupGlob(pattern)(new TextEncoder().encode(text));
|
||||
expect(matches("?", "中")).toBe(false);
|
||||
expect(matches("???", "中")).toBe(true);
|
||||
expect(matches("[z-a]", "q")).toBe(true);
|
||||
expect(matches("[^a]", "b")).toBe(true);
|
||||
expect(matches("a\\*", "a*")).toBe(true);
|
||||
expect(matches("a", "ab")).toBe(false);
|
||||
expect(matches("*", "")).toBe(true);
|
||||
expect(matches("**", "")).toBe(false);
|
||||
expect(compileRedisGroupGlob("?")(new Uint8Array([255]))).toBe(true);
|
||||
expect(matches("*a*a*a*a*a*b", "a".repeat(10000))).toBe(false);
|
||||
});
|
||||
it("uses first match and exclusion veto without lossy display text", () => {
|
||||
const classify = compileRedisKeyGroups([rule, { ...rule, id: "business", includes: [], excludes: ["private:*"] }]);
|
||||
expect(classify(raw("cache:redisson_options"))).toBe("suffix");
|
||||
expect(classify(raw("cache:one"))).toBe("business");
|
||||
expect(classify(raw("private:one"))).toBe(null);
|
||||
expect(compileRedisKeyGroups([{ ...rule, includes: ["?"], enabled: false }])("/w==")).toBe(null);
|
||||
});
|
||||
it("deduplicates append and exposes immutable-length range snapshots", async () => {
|
||||
const index = new RedisKeyGroupIndex([rule], "Other");
|
||||
const first = [key("a:redisson_options"), key("b")];
|
||||
await index.append(first, work);
|
||||
const expanded = new Set(['custom:"suffix"', "custom:unmatched"]);
|
||||
const before = (await index.project("list", ":", expanded, work))!;
|
||||
expect(before.rows.map((row) => row.kind)).toEqual(["virtual-group", "key", "virtual-group", "key"]);
|
||||
await index.append([...first, first[0]!, key("c")], work);
|
||||
expect(before.rows.length).toBe(4);
|
||||
const after = (await index.project("list", ":", expanded, work))!;
|
||||
expect(after.rows.length).toBe(5);
|
||||
expect(after.positions.get(`key:${raw("c")}`)).toBe(4);
|
||||
});
|
||||
it("scopes namespace IDs by outer group and never produces legacy group nodes", async () => {
|
||||
const index = new RedisKeyGroupIndex([rule], "Other");
|
||||
await index.append([key("a:redisson_options"), key("a:other")], work);
|
||||
const result = (await index.project("tree", ":", new Set(['custom:"suffix"', "custom:unmatched"]), work))!;
|
||||
expect(new Set(result.rows.map((row) => row.id)).size).toBe(result.rows.length);
|
||||
expect(result.rows.every((row) => row.kind === "virtual-group")).toBe(true);
|
||||
const expanded = new Set(result.rows.map((row) => row.id));
|
||||
const leaves = (await index.project("tree", ":", expanded, work))!;
|
||||
expect(leaves.rows.filter((row) => row.kind === "key")).toHaveLength(2);
|
||||
});
|
||||
it("cancels bounded work and resumes append without duplicates", async () => {
|
||||
const index = new RedisKeyGroupIndex([], "Other");
|
||||
const keys = Array.from({ length: 2000 }, (_, i) => key(String(i)));
|
||||
let alive = true;
|
||||
expect(
|
||||
await index.append(keys, {
|
||||
current: () => alive,
|
||||
yield: async () => {
|
||||
alive = false;
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
await index.append(keys, work);
|
||||
const result = (await index.project("list", ":", new Set(["custom:unmatched"]), work))!;
|
||||
expect(result.rows.length).toBe(2001);
|
||||
});
|
||||
it("does not materialize each loaded key again when projecting a large list", async () => {
|
||||
const index = new RedisKeyGroupIndex([], "Other");
|
||||
await index.append(
|
||||
Array.from({ length: 100000 }, (_, i) => key(String(i))),
|
||||
work,
|
||||
);
|
||||
let yields = 0;
|
||||
const result = (await index.project("list", ":", new Set(["custom:unmatched"]), {
|
||||
current: () => true,
|
||||
yield: async () => {
|
||||
yields++;
|
||||
},
|
||||
}))!;
|
||||
expect(yields).toBe(0);
|
||||
expect(result.rows.length).toBe(100001);
|
||||
expect(result.positions.get(`key:${raw("99999")}`)).toBe(100000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { createRedisKeyViewYield } from "../redisKeyViewScheduler";
|
||||
import { buildRedisKeySnapshotCooperatively } from "../redisKeyTree";
|
||||
|
||||
it("uses small checkpoints without paying a task/frame for every checkpoint", async () => {
|
||||
let clock = 0;
|
||||
let tasks = 0;
|
||||
const checkpoint = createRedisKeyViewYield({
|
||||
now: () => clock,
|
||||
yieldTask: async () => {
|
||||
tasks++;
|
||||
},
|
||||
budgetMs: 8,
|
||||
});
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
clock += 0.125;
|
||||
await checkpoint();
|
||||
}
|
||||
expect(tasks).toBe(15);
|
||||
});
|
||||
|
||||
it("retains builder cancellation checks before a real task is needed", async () => {
|
||||
let checks = 0;
|
||||
let tasks = 0;
|
||||
const keys = Array.from({ length: 2000 }, (_, i) => ({ key_raw: String(i), key_display: String(i), ttl: -1, key_type: "string" }));
|
||||
const result = await buildRedisKeySnapshotCooperatively(
|
||||
[keys],
|
||||
{ db: 0, flatRows: true, expandAll: false, expandedGroupIds: new Set() },
|
||||
{
|
||||
workChunkSize: 512,
|
||||
shouldContinue: () => ++checks < 5,
|
||||
yieldControl: createRedisKeyViewYield({
|
||||
now: () => 0,
|
||||
yieldTask: async () => {
|
||||
tasks++;
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(tasks).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { findRedisHeaderChain, RedisKeyGroupIndex } from "../redisKeyGroupIndex";
|
||||
import { redisStickyHeaders, redisStickyHeight } from "../redisStickyHeaders";
|
||||
|
||||
const work = { current: () => true, yield: async () => {} };
|
||||
async function fixture() {
|
||||
const index = new RedisKeyGroupIndex([], "Unmatched");
|
||||
await index.append(
|
||||
Array.from({ length: 1000 }, (_, n) => ({ key_display: `a:b:c:d:${n}`, key_raw: btoa(`a:b:c:d:${n}`), ttl: -1, key_type: "string" })),
|
||||
work,
|
||||
);
|
||||
const expanded = new Set(["custom:unmatched"]);
|
||||
for (const path of [["a"], ["a", "b"], ["a", "b", "c"], ["a", "b", "c", "d"]]) expanded.add(`custom:unmatched:namespace:${JSON.stringify(path)}`);
|
||||
return { index, expanded, snapshot: (await index.project("tree", ":", expanded, work))! };
|
||||
}
|
||||
|
||||
it("indexes ancestry once per header, with atomic end ranges and no leaf access during lookup", async () => {
|
||||
const { index, expanded, snapshot } = await fixture();
|
||||
expect(snapshot.headerBoundaries).toHaveLength(5);
|
||||
let reads = 0;
|
||||
const headers = new Proxy(snapshot.headerBoundaries, {
|
||||
get(target, prop, receiver) {
|
||||
if (typeof prop === "string" && /^\d+$/.test(prop)) reads++;
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
expect(findRedisHeaderChain(headers, 999).map((header) => header.row.label)).toEqual(["Unmatched", "a", "b", "c", "d"]);
|
||||
expect(reads).toBeLessThanOrEqual(5);
|
||||
expanded.delete('custom:unmatched:namespace:["a","b"]');
|
||||
const collapsed = (await index.project("tree", ":", expanded, work))!;
|
||||
expect(collapsed.headerBoundaries).toHaveLength(3);
|
||||
expect(snapshot.headerBoundaries[0]!.end).toBe(1005);
|
||||
expect(findRedisHeaderChain(collapsed.headerBoundaries, 3)).toEqual([]);
|
||||
});
|
||||
|
||||
it("bounds height at zero, one, two and three rows and indicates omitted ancestors", async () => {
|
||||
const { snapshot } = await fixture();
|
||||
const sticky = (height: number, scroll = 500) => redisStickyHeaders(snapshot.outerGroups, snapshot.headerBoundaries, scroll, height, true);
|
||||
expect(sticky(99)).toEqual([]);
|
||||
expect(sticky(100).map((entry) => entry.row.label)).toEqual(["Unmatched"]);
|
||||
expect(sticky(200).map((entry) => entry.row.label)).toEqual(["Unmatched", "d"]);
|
||||
expect(sticky(300).map((entry) => entry.row.label)).toEqual(["Unmatched", "c", "d"]);
|
||||
expect(sticky(300)[1]!.omitted).toBe(true);
|
||||
expect(sticky(300)[1]!.path).toBe("Unmatched › a › b › c › d");
|
||||
expect(redisStickyHeight(sticky(1000))).toBe(90);
|
||||
expect(sticky(300, 0)).toEqual([]);
|
||||
expect(redisStickyHeaders(snapshot.outerGroups, snapshot.headerBoundaries, 500, 99, false)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("retains expanded parents above a collapsed current namespace before compressing", async () => {
|
||||
const { index, expanded } = await fixture();
|
||||
expanded.delete('custom:unmatched:namespace:["a","b","c","d"]');
|
||||
const snapshot = (await index.project("tree", ":", expanded, work))!;
|
||||
expect(redisStickyHeaders(snapshot.outerGroups, snapshot.headerBoundaries, 60, 300, true).map((entry) => entry.row.label)).toEqual(["Unmatched", "b", "c"]);
|
||||
});
|
||||
|
||||
it("pushes the outgoing sibling behind its parents and never pins the incoming natural header twice", async () => {
|
||||
const index = new RedisKeyGroupIndex([], "Unmatched");
|
||||
await index.append(
|
||||
["a", "b"].flatMap((branch) => Array.from({ length: 10 }, (_, n) => ({ key_display: `p:${branch}:${n}`, key_raw: btoa(`p:${branch}:${n}`), ttl: -1, key_type: "string" }))),
|
||||
work,
|
||||
);
|
||||
const expanded = new Set(["custom:unmatched", 'custom:unmatched:namespace:["p"]', 'custom:unmatched:namespace:["p","a"]', 'custom:unmatched:namespace:["p","b"]']);
|
||||
const snapshot = (await index.project("tree", ":", expanded, work))!;
|
||||
const b = snapshot.headerBoundaries.find((header) => header.row.label === "b")!;
|
||||
const before = redisStickyHeaders(snapshot.outerGroups, snapshot.headerBoundaries, b.start * 30 - 75, 300, true);
|
||||
expect(before.map((entry) => entry.row.label)).toEqual(["Unmatched", "p", "a"]);
|
||||
expect(before[2]!.top).toBe(45);
|
||||
expect(redisStickyHeight(before)).toBe(75);
|
||||
const aligned = redisStickyHeaders(snapshot.outerGroups, snapshot.headerBoundaries, b.start * 30 - 60, 300, true);
|
||||
expect(aligned.map((entry) => entry.row.label)).toEqual(["Unmatched", "p"]);
|
||||
const after = redisStickyHeaders(snapshot.outerGroups, snapshot.headerBoundaries, b.start * 30 - 59, 300, true);
|
||||
expect(after.map((entry) => entry.row.label)).toEqual(["Unmatched", "p", "b"]);
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { RedisKeyInfo } from "@/lib/backend/api";
|
||||
import { compileRedisKeyGroups, type RedisKeyGroupRule } from "./redisKeyGrouping";
|
||||
|
||||
export interface RedisVirtualGroupRow {
|
||||
kind: "virtual-group";
|
||||
id: string;
|
||||
label: string;
|
||||
depth: number;
|
||||
count: number;
|
||||
}
|
||||
export interface RedisGroupedKeyRow {
|
||||
kind: "key";
|
||||
id: string;
|
||||
key: RedisKeyInfo;
|
||||
label: string;
|
||||
depth: number;
|
||||
}
|
||||
export type RedisGroupedRow = RedisVirtualGroupRow | RedisGroupedKeyRow;
|
||||
export interface RedisOuterGroupBoundary {
|
||||
start: number;
|
||||
end: number;
|
||||
row: RedisVirtualGroupRow;
|
||||
}
|
||||
export interface RedisHeaderBoundary extends RedisOuterGroupBoundary {
|
||||
parent?: RedisHeaderBoundary;
|
||||
}
|
||||
|
||||
/** Header-only binary lookup plus parent links; never reads leaf rows. */
|
||||
export function findRedisHeaderChain(headers: readonly RedisHeaderBoundary[], position: number): RedisHeaderBoundary[] {
|
||||
let low = 0;
|
||||
let high = headers.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if (headers[mid]!.start <= position) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
let header: RedisHeaderBoundary | undefined = headers[low - 1];
|
||||
const chain: RedisHeaderBoundary[] = [];
|
||||
while (header) {
|
||||
if (position < header.end) chain.push(header);
|
||||
header = header.parent;
|
||||
}
|
||||
return chain.reverse();
|
||||
}
|
||||
|
||||
/** Only searches outer sections (at most 65), never the virtual key source. */
|
||||
export function findRedisOuterGroup(groups: readonly RedisOuterGroupBoundary[], position: number): RedisOuterGroupBoundary | undefined {
|
||||
let low = 0;
|
||||
let high = groups.length - 1;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
const group = groups[mid]!;
|
||||
if (position < group.start) high = mid - 1;
|
||||
else if (position >= group.end) low = mid + 1;
|
||||
else return group;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
interface Namespace {
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
children: Map<string, Namespace>;
|
||||
keys: RedisKeyInfo[];
|
||||
}
|
||||
interface Group {
|
||||
id: string;
|
||||
label: string;
|
||||
keys: RedisKeyInfo[];
|
||||
tree?: Namespace;
|
||||
treeLength: number;
|
||||
}
|
||||
export interface GroupWork {
|
||||
current: () => boolean;
|
||||
yield: () => Promise<void>;
|
||||
}
|
||||
const CHUNK = 512;
|
||||
|
||||
/** Non-reactive index: ordinary append batches classify only new identities. */
|
||||
export class RedisKeyGroupIndex {
|
||||
readonly groups: Group[];
|
||||
private readonly classify: ReturnType<typeof compileRedisKeyGroups>;
|
||||
private readonly byId = new Map<string | null, Group>();
|
||||
private readonly locations = new Map<string, { group: Group; offset: number; namespace?: Namespace; namespaceOffset?: number }>();
|
||||
private consumed = 0;
|
||||
private lastRaw: string | undefined;
|
||||
|
||||
constructor(rules: readonly RedisKeyGroupRule[], unmatched: string) {
|
||||
this.classify = compileRedisKeyGroups(rules);
|
||||
this.groups = rules
|
||||
.filter((rule) => rule.enabled)
|
||||
.map((rule) => {
|
||||
const group = { id: `custom:${JSON.stringify(rule.id)}`, label: rule.name, keys: [], treeLength: 0 };
|
||||
this.byId.set(rule.id, group);
|
||||
return group;
|
||||
});
|
||||
const group = { id: "custom:unmatched", label: unmatched, keys: [], treeLength: 0 };
|
||||
this.byId.set(null, group);
|
||||
this.groups.push(group);
|
||||
}
|
||||
|
||||
canAppend(keys: readonly RedisKeyInfo[]): boolean {
|
||||
return keys.length >= this.consumed && (!this.consumed || keys[this.consumed - 1]?.key_raw === this.lastRaw);
|
||||
}
|
||||
|
||||
async append(keys: readonly RedisKeyInfo[], work: GroupWork): Promise<boolean> {
|
||||
for (let offset = this.consumed; offset < keys.length; ) {
|
||||
if (!work.current()) return false;
|
||||
const end = Math.min(offset + CHUNK, keys.length);
|
||||
for (; offset < end; offset++) {
|
||||
const key = keys[offset]!;
|
||||
if (this.locations.has(key.key_raw)) continue;
|
||||
const group = this.byId.get(this.classify(key.key_raw))!;
|
||||
this.locations.set(key.key_raw, { group, offset: group.keys.length });
|
||||
group.keys.push(key);
|
||||
}
|
||||
this.consumed = offset;
|
||||
this.lastRaw = keys[offset - 1]?.key_raw;
|
||||
if (offset < keys.length) await work.yield();
|
||||
}
|
||||
return work.current();
|
||||
}
|
||||
|
||||
async project(view: "list" | "tree", separator: string, expanded: ReadonlySet<string>, work: GroupWork): Promise<{ rows: RedisGroupedRow[]; outerGroups: RedisOuterGroupBoundary[]; headerBoundaries: RedisHeaderBoundary[]; positions: { get: (id: string) => number | undefined } } | null> {
|
||||
// Row ranges reference canonical key arrays. A million-key list projection
|
||||
// is O(groups), not O(keys) per incoming batch, and only mounted leaves
|
||||
// allocate presentation objects. Captured lengths make publication atomic.
|
||||
const ranges: { start: number; length: number; row?: RedisVirtualGroupRow; keys?: readonly RedisKeyInfo[]; depth: number }[] = [];
|
||||
const headers = new Map<string, number>();
|
||||
const outerGroups: RedisOuterGroupBoundary[] = [];
|
||||
const headerBoundaries: RedisHeaderBoundary[] = [];
|
||||
const openHeaders: RedisHeaderBoundary[] = [];
|
||||
const keyRanges = new Map<string, number>();
|
||||
let length = 0;
|
||||
let units = 0;
|
||||
const checkpoint = async () => {
|
||||
if (++units % CHUNK === 0) await work.yield();
|
||||
return work.current();
|
||||
};
|
||||
const add = (row: RedisVirtualGroupRow) => {
|
||||
while (openHeaders.length && openHeaders[openHeaders.length - 1]!.row.depth >= row.depth) openHeaders.pop()!.end = length;
|
||||
const boundary = { start: length, end: length + 1, row, parent: openHeaders[openHeaders.length - 1] };
|
||||
headerBoundaries.push(boundary);
|
||||
openHeaders.push(boundary);
|
||||
headers.set(row.id, length);
|
||||
ranges.push({ start: length++, length: 1, row, depth: row.depth });
|
||||
};
|
||||
const addKeys = (id: string, keys: readonly RedisKeyInfo[], depth: number) => {
|
||||
keyRanges.set(id, length);
|
||||
if (keys.length) ranges.push({ start: length, length: keys.length, keys, depth });
|
||||
length += keys.length;
|
||||
};
|
||||
const leaf = (key: RedisKeyInfo, label: string, depth: number): RedisGroupedKeyRow => ({ kind: "key", id: `key:${key.key_raw}`, key, label, depth });
|
||||
for (const group of this.groups) {
|
||||
if (!work.current()) return null;
|
||||
if (group.id === "custom:unmatched" && !group.keys.length) continue;
|
||||
const previous = outerGroups[outerGroups.length - 1];
|
||||
if (previous) previous.end = length;
|
||||
const row: RedisVirtualGroupRow = { kind: "virtual-group", id: group.id, label: group.label, depth: 0, count: group.keys.length };
|
||||
outerGroups.push({ start: length, end: length + 1, row });
|
||||
add(row);
|
||||
if (!expanded.has(group.id)) continue;
|
||||
if (view === "list" || !separator) {
|
||||
addKeys(group.id, group.keys, 1);
|
||||
} else {
|
||||
// A namespace index is built only for an expanded outer group. It holds
|
||||
// references to canonical records, not copies of metadata.
|
||||
group.tree ??= { id: group.id, label: "", count: 0, children: new Map(), keys: [] };
|
||||
for (let i = group.treeLength; i < group.keys.length; i++) {
|
||||
const key = group.keys[i]!;
|
||||
const parts = key.key_display.split(separator);
|
||||
let node = group.tree;
|
||||
const path: string[] = [];
|
||||
node.count++;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
path.push(part);
|
||||
let child = node.children.get(part);
|
||||
if (!child) {
|
||||
child = { id: `${group.id}:namespace:${JSON.stringify(path)}`, label: part, count: 0, children: new Map(), keys: [] };
|
||||
node.children.set(part, child);
|
||||
}
|
||||
child.count++;
|
||||
node = child;
|
||||
}
|
||||
const location = this.locations.get(key.key_raw)!;
|
||||
location.namespace = node;
|
||||
location.namespaceOffset = node.keys.length;
|
||||
node.keys.push(key);
|
||||
group.treeLength = i + 1;
|
||||
if (!(await checkpoint())) return null;
|
||||
}
|
||||
const stack: { children: Iterator<Namespace>; depth: number }[] = [{ children: group.tree.children.values(), depth: 1 }];
|
||||
addKeys(group.tree.id, group.tree.keys, 1);
|
||||
while (stack.length) {
|
||||
const frame = stack[stack.length - 1]!;
|
||||
const result = frame.children.next();
|
||||
if (result.done) {
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
const node = result.value;
|
||||
add({ kind: "virtual-group", id: node.id, label: node.label, depth: frame.depth, count: node.count });
|
||||
if (expanded.has(node.id)) {
|
||||
addKeys(node.id, node.keys, frame.depth + 1);
|
||||
stack.push({ children: node.children.values(), depth: frame.depth + 1 });
|
||||
}
|
||||
if (!(await checkpoint())) return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
const lastGroup = outerGroups[outerGroups.length - 1];
|
||||
if (lastGroup) lastGroup.end = length;
|
||||
for (const header of openHeaders) header.end = length;
|
||||
const at = (index: number): RedisGroupedRow | undefined => {
|
||||
if (index < 0 || index >= length) return undefined;
|
||||
let low = 0;
|
||||
let high = ranges.length - 1;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
const range = ranges[mid]!;
|
||||
if (index < range.start) high = mid - 1;
|
||||
else if (index >= range.start + range.length) low = mid + 1;
|
||||
else {
|
||||
if (range.row) return range.row;
|
||||
const key = range.keys![index - range.start]!;
|
||||
const parts = view === "tree" && separator ? key.key_display.split(separator) : [key.key_display];
|
||||
return leaf(key, parts[parts.length - 1] ?? "", range.depth);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const rows = new Proxy([] as RedisGroupedRow[], {
|
||||
get(target, property, receiver) {
|
||||
if (property === "length") return length;
|
||||
const n = typeof property === "string" && /^(0|[1-9]\d*)$/.test(property) ? Number(property) : -1;
|
||||
return n >= 0 ? at(n) : Reflect.get(target, property, receiver);
|
||||
},
|
||||
has(target, property) {
|
||||
const n = typeof property === "string" && /^(0|[1-9]\d*)$/.test(property) ? Number(property) : -1;
|
||||
return n >= 0 ? n < length : Reflect.has(target, property);
|
||||
},
|
||||
});
|
||||
const positions = {
|
||||
get: (id: string) => {
|
||||
if (!id.startsWith("key:")) return headers.get(id);
|
||||
const location = this.locations.get(id.slice(4));
|
||||
if (!location) return undefined;
|
||||
const tree = view === "tree" && !!separator;
|
||||
const start = keyRanges.get(tree ? (location.namespace?.id ?? "") : location.group.id);
|
||||
return start === undefined ? undefined : start + (tree ? location.namespaceOffset! : location.offset);
|
||||
},
|
||||
};
|
||||
return work.current() ? { rows, outerGroups, headerBoundaries, positions } : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/** Connection-persisted rules. Classification never changes Redis keys or SCAN. */
|
||||
export interface RedisKeyGroupRule {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
includes: string[];
|
||||
excludes: string[];
|
||||
}
|
||||
|
||||
export interface RedisKeyGrouping {
|
||||
version: 1;
|
||||
enabled: boolean;
|
||||
inner_view: "list" | "tree";
|
||||
rules: RedisKeyGroupRule[];
|
||||
}
|
||||
|
||||
export function defaultRedisKeyGrouping(): RedisKeyGrouping {
|
||||
return { version: 1, enabled: false, inner_view: "list", rules: [] };
|
||||
}
|
||||
|
||||
/** Reject invalid edits/imports rather than silently discarding saved rules. */
|
||||
export function validateRedisKeyGrouping(value: unknown): RedisKeyGrouping {
|
||||
if (!value || typeof value !== "object") throw new Error("Invalid Redis grouping configuration");
|
||||
const config = value as Partial<RedisKeyGrouping>;
|
||||
if (config.version !== 1 || typeof config.enabled !== "boolean" || !["list", "tree"].includes(config.inner_view ?? "") || !Array.isArray(config.rules) || config.rules.length > 64) throw new Error("Invalid Redis grouping configuration");
|
||||
const ids = new Set<string>();
|
||||
const rules = config.rules.map((rule) => {
|
||||
if (!rule || typeof rule.id !== "string" || !rule.id || ids.has(rule.id) || typeof rule.name !== "string" || !rule.name.trim() || typeof rule.enabled !== "boolean") throw new Error("Invalid Redis grouping rule");
|
||||
ids.add(rule.id);
|
||||
for (const patterns of [rule.includes, rule.excludes]) {
|
||||
if (!Array.isArray(patterns) || patterns.length > 64 || patterns.some((pattern) => typeof pattern !== "string" || new TextEncoder().encode(pattern).length > 1024)) throw new Error("Invalid Redis grouping patterns");
|
||||
}
|
||||
if (!rule.includes.length && !rule.excludes.length) throw new Error("Empty Redis grouping rule");
|
||||
return { ...rule, name: rule.name.trim(), includes: [...rule.includes], excludes: [...rule.excludes] };
|
||||
});
|
||||
return { version: 1, enabled: config.enabled, inner_view: config.inner_view!, rules };
|
||||
}
|
||||
|
||||
type Token = { kind: "star" } | { kind: "byte"; value: number } | { kind: "any" } | { kind: "class"; values: Set<number>; negate: boolean };
|
||||
|
||||
/** Redis MATCH is byte-oriented, including ? and bracket ranges. */
|
||||
export function compileRedisGroupGlob(pattern: string): (bytes: Uint8Array) => boolean {
|
||||
const source = new TextEncoder().encode(pattern);
|
||||
const tokens: Token[] = [];
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const byte = source[i]!;
|
||||
if (byte === 42) {
|
||||
if (tokens[tokens.length - 1]?.kind !== "star") tokens.push({ kind: "star" });
|
||||
} else if (byte === 63) tokens.push({ kind: "any" });
|
||||
else if (byte === 92 && i + 1 < source.length) tokens.push({ kind: "byte", value: source[++i]! });
|
||||
else if (byte === 91) {
|
||||
const values = new Set<number>();
|
||||
let cursor = i + 1;
|
||||
const negate = source[cursor] === 94;
|
||||
if (negate) cursor++;
|
||||
while (cursor < source.length && source[cursor] !== 93) {
|
||||
if (source[cursor] === 92 && cursor + 1 < source.length) values.add(source[++cursor]!);
|
||||
else if (cursor + 2 < source.length && source[cursor + 1] === 45) {
|
||||
const a = source[cursor]!;
|
||||
const b = source[cursor + 2]!;
|
||||
for (let n = Math.min(a, b); n <= Math.max(a, b); n++) values.add(n);
|
||||
cursor += 2;
|
||||
} else values.add(source[cursor]!);
|
||||
cursor++;
|
||||
}
|
||||
tokens.push({ kind: "class", values, negate });
|
||||
i = cursor;
|
||||
} else tokens.push({ kind: "byte", value: byte });
|
||||
}
|
||||
// Greedy star retry is bounded O(pattern * key), not recursive/exponential.
|
||||
return (bytes) => {
|
||||
// Redis SCAN special-cases MATCH "*"; other nonempty patterns do not
|
||||
// match an empty key, even a run of stars such as "**".
|
||||
if (!bytes.length) return source.length === 0 || pattern === "*";
|
||||
let p = 0;
|
||||
let k = 0;
|
||||
let star = -1;
|
||||
let retry = 0;
|
||||
while (k < bytes.length) {
|
||||
const token = tokens[p];
|
||||
if (token?.kind === "star") {
|
||||
star = p++;
|
||||
retry = k;
|
||||
continue;
|
||||
}
|
||||
if (token && (token.kind === "any" || (token.kind === "byte" && token.value === bytes[k]) || (token.kind === "class" && token.values.has(bytes[k]!) !== token.negate))) {
|
||||
p++;
|
||||
k++;
|
||||
continue;
|
||||
}
|
||||
if (star < 0) return false;
|
||||
p = star + 1;
|
||||
k = ++retry;
|
||||
}
|
||||
while (tokens[p]?.kind === "star") p++;
|
||||
return p === tokens.length;
|
||||
};
|
||||
}
|
||||
|
||||
export function compileRedisKeyGroups(rules: readonly RedisKeyGroupRule[]): (keyRaw: string) => string | null {
|
||||
const compiled = rules.filter((rule) => rule.enabled).map((rule) => ({ id: rule.id, includes: rule.includes.map(compileRedisGroupGlob), excludes: rule.excludes.map(compileRedisGroupGlob) }));
|
||||
return (keyRaw) => {
|
||||
const bytes = Uint8Array.from(atob(keyRaw), (char) => char.charCodeAt(0));
|
||||
for (const rule of compiled) {
|
||||
if ((!rule.includes.length || rule.includes.some((matches) => matches(bytes))) && !rule.excludes.some((matches) => matches(bytes))) return rule.id;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Small cancellation checkpoints need not each consume a browser frame. */
|
||||
export function createRedisKeyViewYield(options: { now?: () => number; yieldTask?: () => Promise<void>; budgetMs?: number } = {}): () => Promise<void> {
|
||||
const now = options.now ?? (() => performance.now());
|
||||
const yieldTask = options.yieldTask ?? (() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
|
||||
const budget = options.budgetMs ?? 8;
|
||||
let started = now();
|
||||
return async () => {
|
||||
if (now() - started < budget) return;
|
||||
await yieldTask();
|
||||
started = now();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { findRedisHeaderChain, findRedisOuterGroup, type RedisHeaderBoundary, type RedisOuterGroupBoundary } from "./redisKeyGroupIndex";
|
||||
|
||||
export const REDIS_GROUP_ROW_HEIGHT = 30;
|
||||
|
||||
export function redisStickyHeaders(outer: readonly RedisOuterGroupBoundary[], headers: readonly RedisHeaderBoundary[], scroll: number, height: number, tree: boolean) {
|
||||
const size = REDIS_GROUP_ROW_HEIGHT;
|
||||
const capacity = tree ? Math.min(3, Math.floor((height * 0.3) / size)) : 1;
|
||||
const group = findRedisOuterGroup(outer, scroll / size);
|
||||
if (!capacity || !group || group.end <= group.start + 1 || scroll <= group.start * size) return [];
|
||||
const outerOffset = Math.min(0, group.end * size - scroll - size);
|
||||
if (!tree) return [{ row: group.row, top: outerOffset, omitted: false, path: group.row.label }];
|
||||
// Probe only the small overlay band, never the virtual source. Do not cross
|
||||
// the outer boundary while choosing descendants at a section transition.
|
||||
const chain = findRedisHeaderChain(headers, Math.min(group.end - 0.001, (scroll + (capacity - 1) * size) / size)).filter((entry) => entry.end > entry.start + 1);
|
||||
const candidates = chain.length > capacity ? [chain[0]!, ...chain.slice(-(capacity - 1))] : chain;
|
||||
if (capacity === 1) candidates.splice(1);
|
||||
const path = chain.map((entry) => entry.row.label).join(" › ");
|
||||
return candidates.flatMap((entry, slot) => {
|
||||
const top = slot * size;
|
||||
if (entry.start * size >= scroll + top || entry.end * size <= scroll + top || entry.end <= entry.start + 1) return [];
|
||||
return [{ row: entry.row, top: Math.min(top, entry.end * size - scroll - size), omitted: slot === 1 && chain.length > capacity, path }];
|
||||
});
|
||||
}
|
||||
|
||||
export function redisStickyHeight(sticky: ReturnType<typeof redisStickyHeaders>) {
|
||||
return sticky.reduce((height, row) => Math.max(height, row.top + REDIS_GROUP_ROW_HEIGHT), 0);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { defaultRedisKeyGrouping } from "@/lib/redis/redisKeyGrouping";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
setActivePinia(createPinia());
|
||||
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {} });
|
||||
});
|
||||
|
||||
it("persists presentation without reconnecting and keeps old config after failure", async () => {
|
||||
const saveConnections = vi.fn().mockResolvedValue(undefined);
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({ saveConnections }));
|
||||
const { useConnectionStore } = await import("../connectionStore");
|
||||
const store = useConnectionStore();
|
||||
store.addEphemeralConnection({ id: "group-test", name: "Redis", db_type: "redis", host: "localhost", port: 6379, username: "", password: "" });
|
||||
const config = { ...defaultRedisKeyGrouping(), enabled: true };
|
||||
await store.updateRedisKeyGrouping("group-test", config);
|
||||
expect(saveConnections).toHaveBeenCalledWith([expect.objectContaining({ redis_key_grouping: config })]);
|
||||
expect(store.connectedIds.has("group-test")).toBe(true);
|
||||
saveConnections.mockRejectedValueOnce(new Error("disk full"));
|
||||
await expect(store.updateRedisKeyGrouping("group-test", { ...config, enabled: false })).rejects.toThrow("disk full");
|
||||
expect(store.getConfig("group-test")?.redis_key_grouping?.enabled).toBe(true);
|
||||
});
|
||||
@@ -3433,6 +3433,18 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRedisKeyGrouping(connectionId: string, grouping: import("@/lib/redis/redisKeyGrouping").RedisKeyGrouping) {
|
||||
const { validateRedisKeyGrouping } = await import("@/lib/redis/redisKeyGrouping");
|
||||
const validated = validateRedisKeyGrouping(grouping);
|
||||
const index = connections.value.findIndex((connection) => connection.id === connectionId);
|
||||
if (index < 0) throw new Error("Connection not found");
|
||||
const next = [...connections.value];
|
||||
next[index] = { ...next[index]!, redis_key_grouping: validated };
|
||||
await persistConnections(next);
|
||||
// Presentation-only preferences must not invalidate a live connection.
|
||||
connections.value = next;
|
||||
}
|
||||
|
||||
async function renameConnection(connectionId: string, name: string): Promise<boolean> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return false;
|
||||
@@ -8970,6 +8982,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
pasteConnectionClipboard,
|
||||
addEphemeralConnection,
|
||||
updateConnection,
|
||||
updateRedisKeyGrouping,
|
||||
renameConnection,
|
||||
applyGlobalTimeouts,
|
||||
updateConnectionDatabaseInfo,
|
||||
|
||||
@@ -126,6 +126,7 @@ export interface ConnectionConfig {
|
||||
redis_database_aliases?: Record<string, string>;
|
||||
/** Key-search templates for the Redis browser. Non-empty overrides global settings. */
|
||||
redis_key_templates?: string[];
|
||||
redis_key_grouping?: import("@/lib/redis/redisKeyGrouping").RedisKeyGrouping;
|
||||
etcd_endpoints?: string;
|
||||
gbase_server?: string;
|
||||
informix_server?: string;
|
||||
|
||||
@@ -199,6 +199,7 @@ fn connection_config(id: &str, database: BenchDatabase) -> Result<ConnectionConf
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -741,6 +741,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -1485,6 +1485,7 @@ for line in sys.stdin:
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -1802,6 +1802,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
@@ -1864,6 +1865,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
@@ -2124,6 +2126,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -6060,6 +6060,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -869,6 +869,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -6988,6 +6988,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -69,6 +69,30 @@ pub fn database_info_from_protocol_value(value: &Value) -> Option<DatabaseConnec
|
||||
serde_json::from_value::<DatabaseInfoEnvelope>(value.clone()).ok()?.database_info
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RedisKeyGroupRule {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
pub includes: Vec<String>,
|
||||
pub excludes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RedisKeyGroupView {
|
||||
List,
|
||||
Tree,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RedisKeyGrouping {
|
||||
pub version: u8,
|
||||
pub enabled: bool,
|
||||
pub inner_view: RedisKeyGroupView,
|
||||
pub rules: Vec<RedisKeyGroupRule>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, PartialEq)]
|
||||
pub struct ConnectionConfig {
|
||||
pub id: String,
|
||||
@@ -159,6 +183,8 @@ pub struct ConnectionConfig {
|
||||
/// Empty means inherit the global editor setting.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub redis_key_templates: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub redis_key_grouping: Option<RedisKeyGrouping>,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub etcd_endpoints: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
@@ -596,6 +622,8 @@ struct ConnectionConfigData {
|
||||
#[serde(default)]
|
||||
pub redis_key_templates: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub redis_key_grouping: Option<RedisKeyGrouping>,
|
||||
#[serde(default)]
|
||||
pub etcd_endpoints: String,
|
||||
#[serde(default)]
|
||||
pub gbase_server: String,
|
||||
@@ -669,6 +697,7 @@ impl From<ConnectionConfigData> for ConnectionConfig {
|
||||
redis_scan_page_size: data.redis_scan_page_size,
|
||||
redis_database_aliases: data.redis_database_aliases,
|
||||
redis_key_templates: data.redis_key_templates,
|
||||
redis_key_grouping: data.redis_key_grouping,
|
||||
etcd_endpoints: data.etcd_endpoints,
|
||||
gbase_server: data.gbase_server,
|
||||
informix_server: data.informix_server,
|
||||
@@ -693,6 +722,23 @@ impl<'de> Deserialize<'de> for ConnectionConfig {
|
||||
let mut value = Value::deserialize(deserializer)?;
|
||||
migrate_legacy_transport_layers(&mut value);
|
||||
let data = ConnectionConfigData::deserialize(value).map_err(serde::de::Error::custom)?;
|
||||
if let Some(grouping) = &data.redis_key_grouping {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
if grouping.version != 1
|
||||
|| grouping.rules.len() > 64
|
||||
|| grouping.rules.iter().any(|rule| {
|
||||
rule.id.is_empty()
|
||||
|| !ids.insert(&rule.id)
|
||||
|| rule.name.trim().is_empty()
|
||||
|| (rule.includes.is_empty() && rule.excludes.is_empty())
|
||||
|| rule.includes.len() > 64
|
||||
|| rule.excludes.len() > 64
|
||||
|| rule.includes.iter().chain(&rule.excludes).any(|pattern| pattern.len() > 1024)
|
||||
})
|
||||
{
|
||||
return Err(serde::de::Error::custom("Invalid Redis grouping configuration"));
|
||||
}
|
||||
}
|
||||
Ok(data.into())
|
||||
}
|
||||
}
|
||||
@@ -2556,6 +2602,29 @@ fn gaussdb_single_host_port(host: &str, default_port: u16) -> (String, u16) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn redis_key_grouping_roundtrip_and_validation() {
|
||||
let mut config = mysql_config("root", "", None);
|
||||
assert!(config.redis_key_grouping.is_none());
|
||||
config.redis_key_grouping = Some(super::RedisKeyGrouping {
|
||||
version: 1,
|
||||
enabled: true,
|
||||
inner_view: super::RedisKeyGroupView::Tree,
|
||||
rules: vec![super::RedisKeyGroupRule {
|
||||
id: "r1".into(),
|
||||
name: "Metadata".into(),
|
||||
enabled: true,
|
||||
includes: vec!["*:redisson_options".into()],
|
||||
excludes: vec![],
|
||||
}],
|
||||
});
|
||||
let value = serde_json::to_value(&config).unwrap();
|
||||
let restored: super::ConnectionConfig = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(restored.redis_key_grouping, config.redis_key_grouping);
|
||||
let mut invalid = value;
|
||||
invalid["redis_key_grouping"]["version"] = serde_json::json!(2);
|
||||
assert!(serde_json::from_value::<super::ConnectionConfig>(invalid).is_err());
|
||||
}
|
||||
use super::{
|
||||
database_info_from_protocol_value, default_query_timeout_secs, default_redis_key_separator,
|
||||
default_ssh_connect_timeout_secs, sqlserver_legacy_compatibility_param,
|
||||
@@ -2679,6 +2748,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -224,6 +224,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -944,6 +944,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -411,6 +411,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -687,6 +687,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
@@ -764,6 +765,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -805,6 +805,7 @@ mod tests {
|
||||
redis_scan_page_size: Some(1000),
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -6739,6 +6739,7 @@ for line in sys.stdin:
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
@@ -9258,6 +9259,7 @@ for line in sys.stdin:
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -3402,6 +3402,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -5968,6 +5968,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
@@ -6037,6 +6038,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -9573,6 +9573,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
@@ -14380,6 +14381,7 @@ SELECT 1 FROM dual"#
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -66,6 +66,7 @@ fn live_postgres_config(
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -62,6 +62,7 @@ fn live_postgres_config(
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -57,6 +57,7 @@ fn live_postgres_config(
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -62,6 +62,7 @@ fn live_postgres_config(
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -56,6 +56,7 @@ fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -63,6 +63,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -52,6 +52,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -55,6 +55,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> ConnectionConfig {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -176,6 +176,7 @@ pub fn postgres_test_config(id: &str, port: u16) -> ConnectionConfig {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -864,6 +864,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -719,6 +719,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
@@ -271,6 +271,7 @@ mod tests {
|
||||
redis_scan_page_size: None,
|
||||
redis_database_aliases: Default::default(),
|
||||
redis_key_templates: Vec::new(),
|
||||
redis_key_grouping: None,
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
|
||||
Reference in New Issue
Block a user