feat(redis): support per-database alias in the Redis viewer tree

This commit is contained in:
Matt Van Horn
2026-06-21 00:33:03 -07:00
parent e7b8e78f9c
commit 4408bce159
12 changed files with 373 additions and 11 deletions

View File

@@ -1,4 +1,4 @@
import { Modal, message, type MenuProps } from 'antd';
import { Input, Modal, message, type MenuProps } from 'antd';
import {
CheckSquareOutlined,
CloudOutlined,
@@ -27,11 +27,81 @@ import {
WarningOutlined,
} from '@ant-design/icons';
import { t } from '../../i18n';
import { useStore } from '../../store';
import type { SavedConnection, SavedQuery } from '../../types';
import { getDataSourceCapabilities } from '../../utils/dataSourceCapabilities';
import { buildTableSelectQuery } from '../../utils/objectQueryTemplates';
import {
MAX_REDIS_DB_ALIAS_LENGTH,
buildRedisDbNodeLabel,
getRedisDbAlias,
} from '../../utils/redisDbAlias';
import { supportsTableTruncateAction } from '../tableDataDangerActions';
const updateTreeNodeTitle = (
nodes: any[],
targetKey: string,
title: string,
): any[] =>
nodes.map((node) => {
if (node.key === targetKey) {
return { ...node, title };
}
if (Array.isArray(node.children)) {
return { ...node, children: updateTreeNodeTitle(node.children, targetKey, title) };
}
return node;
});
const openRedisDbAliasModal = (
node: any,
context: SidebarLegacyNodeMenuContext,
): void => {
const { id, redisDB, redisKeyCount } = node.dataRef;
const { treeDataRef, setTreeData } = context;
const currentAlias = getRedisDbAlias(
useStore.getState().appearance.redisDbAliases,
id,
redisDB,
);
let draft = currentAlias;
Modal.confirm({
title: t('redis.db_alias.modal.title', { db: `db${redisDB}` }),
icon: null,
content: (
<Input
defaultValue={currentAlias}
maxLength={MAX_REDIS_DB_ALIAS_LENGTH}
placeholder={t('redis.db_alias.modal.placeholder')}
onChange={(event) => {
draft = event.target.value;
}}
onPressEnter={(event) => {
draft = (event.target as HTMLInputElement).value;
}}
/>
),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: () => {
useStore.getState().setRedisDbAlias(id, redisDB, draft);
if (treeDataRef?.current && typeof setTreeData === 'function') {
const nextAlias = getRedisDbAlias(
useStore.getState().appearance.redisDbAliases,
id,
redisDB,
);
const keyCount = Number(redisKeyCount);
const suffix = Number.isFinite(keyCount) && keyCount > 0 ? ` (${keyCount})` : '';
const nextTitle = buildRedisDbNodeLabel(redisDB, nextAlias, suffix);
const nextTree = updateTreeNodeTitle(treeDataRef.current, node.key, nextTitle);
treeDataRef.current = nextTree;
setTreeData(nextTree);
}
},
});
};
type TreeNode = {
type?: string;
title?: string;
@@ -526,6 +596,12 @@ export const buildSidebarLegacyNodeMenuItems = (
redisDB: redisDB
});
}
},
{
key: 'set-db-alias',
label: t('redis.db_alias.menu.set'),
icon: <EditOutlined />,
onClick: () => openRedisDbAliasModal(node, context)
}
];
} else if (node.type === 'database') {

View File

@@ -17,6 +17,7 @@ import type { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTree
import { useStore } from '../../store';
import { t } from '../../i18n';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { buildRedisDbNodeLabel, getRedisDbAlias } from '../../utils/redisDbAlias';
import { buildJVMMonitoringActionDescriptors } from '../../utils/jvmSidebarActions';
import { type SidebarViewMetadataEntry } from '../../utils/sidebarMetadata';
import { buildExternalSQLRootNode, type ExternalSQLTreeNode } from '../../utils/externalSqlTree';
@@ -308,15 +309,23 @@ export const useSidebarTreeLoaders = ({
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
const redisRows: any[] = Array.isArray(res.data) ? res.data : [];
let dbs = redisRows.map((db: any) => ({
title: `db${db.index}${db.keys > 0 ? ` (${db.keys})` : ''}`,
key: `${conn.id}-db${db.index}`,
icon: <DatabaseOutlined style={{ color: '#DC382D' }} />,
type: 'redis-db' as const,
dataRef: { ...conn, redisDB: db.index },
isLeaf: true,
dbIndex: db.index,
}));
const redisDbAliases = useStore.getState().appearance.redisDbAliases;
let dbs = redisRows.map((db: any) => {
const keyCount = Number(db.keys) > 0 ? Number(db.keys) : 0;
return {
title: buildRedisDbNodeLabel(
db.index,
getRedisDbAlias(redisDbAliases, conn.id, db.index),
keyCount > 0 ? ` (${keyCount})` : '',
),
key: `${conn.id}-db${db.index}`,
icon: <DatabaseOutlined style={{ color: '#DC382D' }} />,
type: 'redis-db' as const,
dataRef: { ...conn, redisDB: db.index, redisKeyCount: keyCount },
isLeaf: true,
dbIndex: db.index,
};
});
// Filter Redis databases if configured
if (conn.includeRedisDatabases && conn.includeRedisDatabases.length > 0) {
dbs = dbs.filter(db => conn.includeRedisDatabases!.includes(db.dbIndex));

View File

@@ -169,7 +169,13 @@ export const useSidebarV2ContextMenu = ({
return count > 0 ? count.toLocaleString() : '';
}
if (node.type === 'redis-db') {
const match = String(node.title || '').match(/\((\d+)\)/);
const keyCount = Number(node?.dataRef?.redisKeyCount);
if (Number.isFinite(keyCount) && keyCount > 0) {
return keyCount.toLocaleString();
}
// Fallback for nodes built before redisKeyCount was tracked; avoid
// matching an alias by only reading a trailing count suffix.
const match = String(node.title || '').match(/\((\d+)\)\s*$/);
return match?.[1] || '';
}
if (node.type === 'table') {

View File

@@ -60,6 +60,12 @@ import {
sanitizeTabDisplaySettings,
type TabDisplaySettings,
} from "./utils/tabDisplay";
import {
DEFAULT_REDIS_DB_ALIASES,
sanitizeRedisDbAliases,
setRedisDbAlias as applyRedisDbAlias,
type RedisDbAliasMap,
} from "./utils/redisDbAlias";
import {
captureLegacySavedQueriesSnapshot,
deleteSavedQueryFromBackend,
@@ -80,6 +86,7 @@ export interface AppearanceSettings extends DataGridDisplaySettings {
customUIFontFamily: string | null;
customMonoFontFamily: string | null;
tabDisplay: TabDisplaySettings;
redisDbAliases: RedisDbAliasMap;
}
export const DEFAULT_APPEARANCE: AppearanceSettings = {
@@ -94,6 +101,7 @@ export const DEFAULT_APPEARANCE: AppearanceSettings = {
customUIFontFamily: null,
customMonoFontFamily: null,
tabDisplay: DEFAULT_TAB_DISPLAY_SETTINGS,
redisDbAliases: DEFAULT_REDIS_DB_ALIASES,
...DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
};
const DEFAULT_UI_SCALE = 1.0;
@@ -1337,6 +1345,11 @@ interface AppState {
setTheme: (theme: "light" | "dark") => void;
setLanguagePreference: (languagePreference: LanguagePreference) => void;
setAppearance: (appearance: Partial<AppearanceSettings>) => void;
setRedisDbAlias: (
connectionId: string,
dbIndex: number,
alias: string,
) => void;
setUiScale: (scale: number) => void;
setFontSize: (size: number) => void;
setStartupFullscreen: (enabled: boolean) => void;
@@ -1976,6 +1989,7 @@ const sanitizeAppearance = (
customUIFontFamily: sanitizeFontFamilyInput(appearance.customUIFontFamily),
customMonoFontFamily: sanitizeFontFamilyInput(appearance.customMonoFontFamily),
tabDisplay: sanitizeTabDisplaySettings(appearance.tabDisplay),
redisDbAliases: sanitizeRedisDbAliases(appearance.redisDbAliases),
showDataTableVerticalBorders:
dataGridDisplaySettings.showDataTableVerticalBorders,
dataTableDensity: dataGridDisplaySettings.dataTableDensity,
@@ -3018,6 +3032,21 @@ export const useStore = create<AppState>()(
PERSIST_VERSION,
),
})),
setRedisDbAlias: (connectionId, dbIndex, alias) =>
set((state) => ({
appearance: sanitizeAppearance(
{
...state.appearance,
redisDbAliases: applyRedisDbAlias(
state.appearance.redisDbAliases,
connectionId,
dbIndex,
alias,
),
},
PERSIST_VERSION,
),
})),
setUiScale: (scale) => set({ uiScale: sanitizeUiScale(scale) }),
setFontSize: (size) => set({ fontSize: sanitizeFontSize(size) }),
setStartupFullscreen: (enabled) => {

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import {
MAX_REDIS_DB_ALIAS_LENGTH,
buildRedisDbNodeLabel,
getRedisDbAlias,
sanitizeRedisDbAlias,
sanitizeRedisDbAliases,
setRedisDbAlias,
} from './redisDbAlias';
describe('redisDbAlias helpers', () => {
it('sanitizes a single alias by trimming, collapsing whitespace, and capping length', () => {
expect(sanitizeRedisDbAlias(' cache ')).toBe('cache');
expect(sanitizeRedisDbAlias('user\n sessions')).toBe('user sessions');
expect(sanitizeRedisDbAlias(' ')).toBe('');
expect(sanitizeRedisDbAlias(42)).toBe('');
expect(sanitizeRedisDbAlias('x'.repeat(200))).toHaveLength(MAX_REDIS_DB_ALIAS_LENGTH);
});
it('sanitizes the full alias map, dropping malformed and empty entries', () => {
const sanitized = sanitizeRedisDbAliases({
'conn-a': { '0': 'cache', '1': ' ', notANumber: 'x' },
'conn-b': { '0': 'sessions' },
'conn-empty': { '0': '' },
'': { '0': 'orphan' },
bogus: 'not-an-object',
});
expect(sanitized).toEqual({
'conn-a': { '0': 'cache' },
'conn-b': { '0': 'sessions' },
});
});
it('returns an empty map for non-object input', () => {
expect(sanitizeRedisDbAliases(undefined)).toEqual({});
expect(sanitizeRedisDbAliases(null)).toEqual({});
expect(sanitizeRedisDbAliases(['cache'])).toEqual({});
});
it('looks up an alias by connection id and db index', () => {
const aliases = { 'conn-a': { '0': 'cache' } };
expect(getRedisDbAlias(aliases, 'conn-a', 0)).toBe('cache');
expect(getRedisDbAlias(aliases, 'conn-a', 1)).toBe('');
expect(getRedisDbAlias(aliases, 'conn-b', 0)).toBe('');
expect(getRedisDbAlias(undefined, 'conn-a', 0)).toBe('');
});
it('keeps aliases independent across connections that share a db index', () => {
let aliases = setRedisDbAlias({}, 'conn-a', 0, 'cache');
aliases = setRedisDbAlias(aliases, 'conn-b', 0, 'sessions');
expect(getRedisDbAlias(aliases, 'conn-a', 0)).toBe('cache');
expect(getRedisDbAlias(aliases, 'conn-b', 0)).toBe('sessions');
});
it('clears an alias when set to an empty/whitespace value and prunes the connection', () => {
let aliases = setRedisDbAlias({}, 'conn-a', 0, 'cache');
aliases = setRedisDbAlias(aliases, 'conn-a', 0, ' ');
expect(getRedisDbAlias(aliases, 'conn-a', 0)).toBe('');
expect(aliases).toEqual({});
});
it('does not mutate the input map when setting an alias', () => {
const original = { 'conn-a': { '0': 'cache' } };
const next = setRedisDbAlias(original, 'conn-a', 1, 'queue');
expect(original).toEqual({ 'conn-a': { '0': 'cache' } });
expect(next).toEqual({ 'conn-a': { '0': 'cache', '1': 'queue' } });
});
it('builds the sidebar label with and without an alias', () => {
expect(buildRedisDbNodeLabel(0, 'cache')).toBe('db0 (cache)');
expect(buildRedisDbNodeLabel(3, '')).toBe('db3');
expect(buildRedisDbNodeLabel(0, ' ')).toBe('db0');
});
it('appends the key-count suffix after the alias', () => {
expect(buildRedisDbNodeLabel(0, 'cache', ' (12)')).toBe('db0 (cache) (12)');
expect(buildRedisDbNodeLabel(0, '', ' (12)')).toBe('db0 (12)');
});
});

View File

@@ -0,0 +1,144 @@
/**
* Per-database aliases for Redis connections.
*
* Redis exposes logical databases as bare numeric indices (db0..db15). When a
* user works with several connections that each use those indices for a
* different purpose, the numbers are indistinguishable in the sidebar. An alias
* map lets the user label, for example, `db0` as `cache` and have the sidebar
* render `db0 (cache)`.
*
* The map is purely a client-side display preference and is keyed by
* connection id so aliases stay independent across connections. The underlying
* Redis SELECT index is never affected.
*/
/** Aliases for a single connection, keyed by the numeric DB index. */
export type RedisConnectionDbAliasMap = Record<string, string>;
/** Alias map for every connection, keyed by connection id. */
export type RedisDbAliasMap = Record<string, RedisConnectionDbAliasMap>;
export const DEFAULT_REDIS_DB_ALIASES: RedisDbAliasMap = {};
/**
* Mirrors `MAX_SIDEBAR_PERSISTED_FILTER_LENGTH` in store.ts: a single alias is
* a short human label, so cap it to keep persisted state bounded.
*/
export const MAX_REDIS_DB_ALIAS_LENGTH = 64;
const isValidDbIndexKey = (value: string): boolean => /^\d+$/.test(value);
/** Trim, collapse newlines, and length-cap an alias. Empty -> empty string. */
export const sanitizeRedisDbAlias = (value: unknown): string => {
if (typeof value !== 'string') {
return '';
}
return value.replace(/\s+/g, ' ').trim().slice(0, MAX_REDIS_DB_ALIAS_LENGTH);
};
const sanitizeConnectionAliasMap = (value: unknown): RedisConnectionDbAliasMap => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const result: RedisConnectionDbAliasMap = {};
Object.entries(value as Record<string, unknown>).forEach(([dbIndex, alias]) => {
if (!isValidDbIndexKey(dbIndex)) {
return;
}
const sanitized = sanitizeRedisDbAlias(alias);
if (sanitized) {
result[dbIndex] = sanitized;
}
});
return result;
};
/**
* Normalize an arbitrary persisted/runtime value into a well-formed alias map,
* dropping malformed entries and empty aliases so the persisted state never
* grows unbounded or carries blank labels.
*/
export const sanitizeRedisDbAliases = (value: unknown): RedisDbAliasMap => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { ...DEFAULT_REDIS_DB_ALIASES };
}
const result: RedisDbAliasMap = {};
Object.entries(value as Record<string, unknown>).forEach(([connectionId, aliases]) => {
const trimmedId = String(connectionId).trim();
if (!trimmedId) {
return;
}
const connectionAliases = sanitizeConnectionAliasMap(aliases);
if (Object.keys(connectionAliases).length > 0) {
result[trimmedId] = connectionAliases;
}
});
return result;
};
/** Look up the alias for a given connection + DB index, or '' if none. */
export const getRedisDbAlias = (
aliases: RedisDbAliasMap | undefined,
connectionId: string,
dbIndex: number,
): string => {
if (!aliases) {
return '';
}
const connectionAliases = aliases[connectionId];
if (!connectionAliases) {
return '';
}
return sanitizeRedisDbAlias(connectionAliases[String(dbIndex)]);
};
/**
* Return a new alias map with the alias for a connection + DB index set, or
* cleared when the sanitized alias is empty. Pure: never mutates the input.
*/
export const setRedisDbAlias = (
aliases: RedisDbAliasMap | undefined,
connectionId: string,
dbIndex: number,
alias: string,
): RedisDbAliasMap => {
const base = sanitizeRedisDbAliases(aliases);
const trimmedId = String(connectionId).trim();
if (!trimmedId) {
return base;
}
const sanitized = sanitizeRedisDbAlias(alias);
const dbKey = String(dbIndex);
const nextConnectionAliases: RedisConnectionDbAliasMap = { ...(base[trimmedId] || {}) };
if (sanitized) {
nextConnectionAliases[dbKey] = sanitized;
} else {
delete nextConnectionAliases[dbKey];
}
const next: RedisDbAliasMap = { ...base };
if (Object.keys(nextConnectionAliases).length > 0) {
next[trimmedId] = nextConnectionAliases;
} else {
delete next[trimmedId];
}
return next;
};
/**
* Build the sidebar label for a Redis DB node. Returns `dbN` when there is no
* alias, and `dbN (alias)` when one is set. `suffix` carries the existing
* key-count fragment (e.g. ` (12)`) and is always appended last so the alias
* stays adjacent to the index.
*/
export const buildRedisDbNodeLabel = (
dbIndex: number,
alias: string,
suffix = '',
): string => {
const base = `db${dbIndex}`;
const sanitizedAlias = sanitizeRedisDbAlias(alias);
const labelled = sanitizedAlias ? `${base} (${sanitizedAlias})` : base;
return `${labelled}${suffix}`;
};