feat(redis): limit the sidebar database list
This commit is contained in:
1 parent
745c201f06
commit
ec356293aa
18 files changed
+265
-22
No files matched your search
+106
@@ -0,0 +1,106 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => data.delete(key)),
|
||||
});
|
||||
}
|
||||
|
||||
function redisConnection(): ConnectionConfig {
|
||||
return {
|
||||
id: "redis-1",
|
||||
name: "Redis",
|
||||
db_type: "redis",
|
||||
host: "127.0.0.1",
|
||||
port: 6379,
|
||||
username: "",
|
||||
password: "",
|
||||
database: "0",
|
||||
};
|
||||
}
|
||||
|
||||
function manyRedisDatabases(count: number) {
|
||||
return Array.from({ length: count }, (_, db) => ({ db, keys: db === 0 ? 5 : 0 }));
|
||||
}
|
||||
|
||||
describe("connectionStore Redis database display limit (#1236)", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
installLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("caps the sidebar list at the configured limit and appends a load-more node", async () => {
|
||||
const redisListDatabases = vi.fn().mockResolvedValue(manyRedisDatabases(30));
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
||||
redisListDatabases,
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
useSettingsStore().updateEditorSettings({ redisDatabaseDisplayLimit: 10 });
|
||||
|
||||
const store = useConnectionStore();
|
||||
const connection = redisConnection();
|
||||
store.connections = [connection];
|
||||
store.connectedIds.add(connection.id);
|
||||
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [] }];
|
||||
|
||||
await store.loadRedisDatabases(connection.id);
|
||||
|
||||
const children = store.treeNodes[0].children ?? [];
|
||||
expect(children.filter((node) => node.type === "redis-db")).toHaveLength(10);
|
||||
const loadMore = children.find((node) => node.type === "load-more");
|
||||
expect(loadMore).toBeDefined();
|
||||
expect(loadMore?.loadMore).toEqual({ parentId: connection.id, offset: 10, pageSize: 20 });
|
||||
|
||||
await store.loadMoreObjectGroupChildren(loadMore!);
|
||||
|
||||
const expandedChildren = store.treeNodes[0].children ?? [];
|
||||
expect(expandedChildren.filter((node) => node.type === "redis-db")).toHaveLength(30);
|
||||
expect(expandedChildren.some((node) => node.type === "load-more")).toBe(false);
|
||||
expect(redisListDatabases).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not truncate when the database count is within the configured limit", async () => {
|
||||
const redisListDatabases = vi.fn().mockResolvedValue(manyRedisDatabases(16));
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
||||
redisListDatabases,
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
// Default limit (1000) — the historical "show everything" behavior must be unchanged.
|
||||
expect(useSettingsStore().editorSettings.redisDatabaseDisplayLimit).toBe(1000);
|
||||
|
||||
const store = useConnectionStore();
|
||||
const connection = redisConnection();
|
||||
store.connections = [connection];
|
||||
store.connectedIds.add(connection.id);
|
||||
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [] }];
|
||||
|
||||
await store.loadRedisDatabases(connection.id);
|
||||
|
||||
const children = store.treeNodes[0].children ?? [];
|
||||
expect(children.filter((node) => node.type === "redis-db")).toHaveLength(16);
|
||||
expect(children.some((node) => node.type === "load-more")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -145,7 +145,7 @@ import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metad
|
||||
import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation";
|
||||
import { etcdPermissionsAllowKey } from "@/lib/etcd/keyPermissions";
|
||||
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
|
||||
import { normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias";
|
||||
import { limitRedisDatabaseList, normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias";
|
||||
import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
|
||||
import { appendAgentDriverUpdateHint, connectionUsesSsh, hasAgentDriverUpdate, hasInstalledAgentVersion, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint";
|
||||
import { appendConnectionErrorHints, isMysqlMissingPasswordFailure, isSqliteMissingEncryptionPasswordFailure } from "@/lib/connection/connectionErrorHints";
|
||||
@@ -4841,7 +4841,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRedisDatabases(connectionId: string) {
|
||||
async function loadRedisDatabases(connectionId: string, options?: { showAll?: boolean }) {
|
||||
const node = findConnectionNode(connectionId);
|
||||
return runConnectionTreeMetadataLoad(connectionId, node, async (load) => {
|
||||
const dbs = await withMetadataLoadTimeout(connectionId, api.redisListDatabases(connectionId), "Redis databases");
|
||||
@@ -4857,26 +4857,25 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
connectionId,
|
||||
dbs.map((db) => String(db.db)),
|
||||
);
|
||||
setChildren(
|
||||
targetNode,
|
||||
withSavedSqlRoot(
|
||||
const databaseNodes: TreeNode[] = dbs
|
||||
.filter((db) => visibleNameSet.has(String(db.db)))
|
||||
.map((db) => ({
|
||||
id: `${connectionId}:db${db.db}`,
|
||||
label: redisDatabaseLabel(db.db, config?.redis_database_aliases, db.keys),
|
||||
type: "redis-db" as const,
|
||||
connectionId,
|
||||
dbs
|
||||
.filter((db) => visibleNameSet.has(String(db.db)))
|
||||
.map((db) => ({
|
||||
id: `${connectionId}:db${db.db}`,
|
||||
label: redisDatabaseLabel(db.db, config?.redis_database_aliases, db.keys),
|
||||
type: "redis-db" as const,
|
||||
connectionId,
|
||||
database: String(db.db),
|
||||
loadedKeyCount: 0,
|
||||
totalKeyCount: db.keys,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
})),
|
||||
targetNode,
|
||||
),
|
||||
);
|
||||
database: String(db.db),
|
||||
loadedKeyCount: 0,
|
||||
totalKeyCount: db.keys,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
}));
|
||||
// #1236: a `databases` count in the low hundreds is rare but not unheard
|
||||
// of, and dumping every one of them into the sidebar at once is the
|
||||
// reported pain point. Cap the initial render and let a "load more" node
|
||||
// reveal the rest on demand, unless the user explicitly asked to see all.
|
||||
const { visible, hasMore } = options?.showAll ? { visible: databaseNodes, hasMore: false } : limitRedisDatabaseList(databaseNodes, useSettingsStore().editorSettings.redisDatabaseDisplayLimit);
|
||||
setChildren(targetNode, withSavedSqlRoot(connectionId, hasMore ? [...visible, buildLoadMoreNode(targetNode, visible.length, databaseNodes.length - visible.length)] : visible, targetNode));
|
||||
targetNode.isExpanded = true;
|
||||
});
|
||||
}
|
||||
@@ -6127,6 +6126,18 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
if (node.type !== "load-more" || !node.loadMore) return;
|
||||
const loadMore = node.loadMore;
|
||||
const parent = findNode(treeNodes.value, node.loadMore.parentId);
|
||||
if (parent?.type === "connection" && parent.connectionId && getConfig(parent.connectionId)?.db_type === "redis") {
|
||||
// The full database list is already fetched in one cheap call, so "load
|
||||
// more" here just re-renders it without the display-limit truncation
|
||||
// instead of paging through another backend round trip.
|
||||
node.isLoading = true;
|
||||
try {
|
||||
await loadRedisDatabases(parent.connectionId, { showAll: true });
|
||||
} finally {
|
||||
node.isLoading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!parent?.connectionId || !hasTreeNodeDatabaseContext(parent)) return;
|
||||
const parentConnectionId = parent.connectionId;
|
||||
const configForScope = getConfig(parentConnectionId);
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTar
|
||||
import type { ConnectionListSortMode } from "@/lib/sidebar/connectionListSort";
|
||||
import { type ColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy";
|
||||
import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
|
||||
import { REDIS_DATABASE_DISPLAY_LIMIT_DEFAULT, REDIS_DATABASE_DISPLAY_LIMIT_MIN, REDIS_DATABASE_DISPLAY_LIMIT_MAX } from "@/lib/redis/redisDatabaseAlias";
|
||||
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
|
||||
import { normalizeSidebarCopyTableNameSeparator } from "@/lib/sidebar/sidebarTableNameCopy";
|
||||
import type { SidebarActivation } from "@/lib/sidebar/treeNodeClick";
|
||||
@@ -889,6 +890,8 @@ export interface EditorSettings {
|
||||
csvQuoteMode: CsvQuoteMode;
|
||||
/** Global Redis key-search templates; overridden by non-empty connection templates. */
|
||||
redisKeyTemplates: string[];
|
||||
/** Sidebar database-list cap for Redis connections; the rest are revealed via "load more". */
|
||||
redisDatabaseDisplayLimit: number;
|
||||
exportRowLimitEnabled: boolean;
|
||||
exportRowLimit: number;
|
||||
queryExportKeysetOptimizationEnabled: boolean;
|
||||
@@ -1135,6 +1138,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
|
||||
exportBatchSize: 2000,
|
||||
csvQuoteMode: DEFAULT_CSV_QUOTE_MODE,
|
||||
redisKeyTemplates: [],
|
||||
redisDatabaseDisplayLimit: REDIS_DATABASE_DISPLAY_LIMIT_DEFAULT,
|
||||
exportRowLimitEnabled: false,
|
||||
exportRowLimit: 100000,
|
||||
queryExportKeysetOptimizationEnabled: true,
|
||||
@@ -1669,6 +1673,10 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
|
||||
exportBatchSize: typeof settings.exportBatchSize === "number" && settings.exportBatchSize >= 100 && settings.exportBatchSize <= 100000 ? Math.round(settings.exportBatchSize) : DEFAULT_EDITOR_SETTINGS.exportBatchSize,
|
||||
csvQuoteMode: normalizeCsvQuoteMode(settings.csvQuoteMode),
|
||||
redisKeyTemplates: normalizeRedisKeyTemplates(settings.redisKeyTemplates),
|
||||
redisDatabaseDisplayLimit:
|
||||
typeof settings.redisDatabaseDisplayLimit === "number" && settings.redisDatabaseDisplayLimit >= REDIS_DATABASE_DISPLAY_LIMIT_MIN && settings.redisDatabaseDisplayLimit <= REDIS_DATABASE_DISPLAY_LIMIT_MAX
|
||||
? Math.round(settings.redisDatabaseDisplayLimit)
|
||||
: DEFAULT_EDITOR_SETTINGS.redisDatabaseDisplayLimit,
|
||||
exportRowLimitEnabled: typeof settings.exportRowLimitEnabled === "boolean" ? settings.exportRowLimitEnabled : DEFAULT_EDITOR_SETTINGS.exportRowLimitEnabled,
|
||||
exportRowLimit: typeof settings.exportRowLimit === "number" && settings.exportRowLimit >= 100 && settings.exportRowLimit <= 2147483647 ? Math.round(settings.exportRowLimit) : DEFAULT_EDITOR_SETTINGS.exportRowLimit,
|
||||
queryExportKeysetOptimizationEnabled: typeof settings.queryExportKeysetOptimizationEnabled === "boolean" ? settings.queryExportKeysetOptimizationEnabled : DEFAULT_EDITOR_SETTINGS.queryExportKeysetOptimizationEnabled,
|
||||
@@ -2398,6 +2406,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
if (partial.exportBatchSize !== undefined) editorSettings.value.exportBatchSize = Math.min(100000, Math.max(100, Math.round(partial.exportBatchSize)));
|
||||
if (partial.csvQuoteMode !== undefined) editorSettings.value.csvQuoteMode = normalizeCsvQuoteMode(partial.csvQuoteMode);
|
||||
if (partial.redisKeyTemplates !== undefined) editorSettings.value.redisKeyTemplates = normalizeRedisKeyTemplates(partial.redisKeyTemplates);
|
||||
if (partial.redisDatabaseDisplayLimit !== undefined) editorSettings.value.redisDatabaseDisplayLimit = Math.min(REDIS_DATABASE_DISPLAY_LIMIT_MAX, Math.max(REDIS_DATABASE_DISPLAY_LIMIT_MIN, Math.round(partial.redisDatabaseDisplayLimit)));
|
||||
if (partial.exportRowLimitEnabled !== undefined) editorSettings.value.exportRowLimitEnabled = partial.exportRowLimitEnabled;
|
||||
if (partial.exportRowLimit !== undefined) editorSettings.value.exportRowLimit = Math.min(2147483647, Math.max(100, Math.round(partial.exportRowLimit)));
|
||||
if (partial.queryExportKeysetOptimizationEnabled !== undefined) editorSettings.value.queryExportKeysetOptimizationEnabled = partial.queryExportKeysetOptimizationEnabled;
|
||||
|
||||
Reference in new issue
Block a user