diff --git a/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx b/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx
index f6e8c16..4b19df0 100644
--- a/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx
+++ b/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx
@@ -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: (
+ {
+ 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: ,
+ onClick: () => openRedisDbAliasModal(node, context)
}
];
} else if (node.type === 'database') {
diff --git a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx
index dfcf128..e392c9c 100644
--- a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx
+++ b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx
@@ -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: ,
- 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: ,
+ 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));
diff --git a/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx b/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx
index 17caa5a..c110e23 100644
--- a/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx
+++ b/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx
@@ -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') {
diff --git a/frontend/src/store.ts b/frontend/src/store.ts
index a1ec64f..0d5dee3 100644
--- a/frontend/src/store.ts
+++ b/frontend/src/store.ts
@@ -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) => 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()(
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) => {
diff --git a/frontend/src/utils/redisDbAlias.test.ts b/frontend/src/utils/redisDbAlias.test.ts
new file mode 100644
index 0000000..a8856db
--- /dev/null
+++ b/frontend/src/utils/redisDbAlias.test.ts
@@ -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)');
+ });
+});
diff --git a/frontend/src/utils/redisDbAlias.ts b/frontend/src/utils/redisDbAlias.ts
new file mode 100644
index 0000000..69e8444
--- /dev/null
+++ b/frontend/src/utils/redisDbAlias.ts
@@ -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;
+
+/** Alias map for every connection, keyed by connection id. */
+export type RedisDbAliasMap = Record;
+
+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).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).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}`;
+};
diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json
index b8ce74a..069c507 100644
--- a/shared/i18n/de-DE.json
+++ b/shared/i18n/de-DE.json
@@ -1,5 +1,8 @@
{
"common.cancel": "Abbrechen",
+ "redis.db_alias.menu.set": "Alias festlegen",
+ "redis.db_alias.modal.title": "Alias für {{db}}",
+ "redis.db_alias.modal.placeholder": "z. B. cache, sessions (leer lassen zum Entfernen)",
"common.back_to_previous": "Zurück",
"common.close": "Schließen",
"common.confirm": "Bestätigen",
diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json
index 813461a..7c2917c 100644
--- a/shared/i18n/en-US.json
+++ b/shared/i18n/en-US.json
@@ -1,5 +1,8 @@
{
"common.cancel": "Cancel",
+ "redis.db_alias.menu.set": "Set alias",
+ "redis.db_alias.modal.title": "Alias for {{db}}",
+ "redis.db_alias.modal.placeholder": "e.g. cache, sessions (leave empty to clear)",
"common.back_to_previous": "Back",
"common.close": "Close",
"common.confirm": "Confirm",
diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json
index a0930ba..c828a68 100644
--- a/shared/i18n/ja-JP.json
+++ b/shared/i18n/ja-JP.json
@@ -1,5 +1,8 @@
{
"common.cancel": "キャンセル",
+ "redis.db_alias.menu.set": "エイリアスを設定",
+ "redis.db_alias.modal.title": "{{db}} のエイリアス",
+ "redis.db_alias.modal.placeholder": "例: cache、sessions(空欄で解除)",
"common.back_to_previous": "前に戻る",
"common.close": "閉じる",
"common.confirm": "確認",
diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json
index 32312c8..894efe0 100644
--- a/shared/i18n/ru-RU.json
+++ b/shared/i18n/ru-RU.json
@@ -1,5 +1,8 @@
{
"common.cancel": "Отмена",
+ "redis.db_alias.menu.set": "Задать псевдоним",
+ "redis.db_alias.modal.title": "Псевдоним для {{db}}",
+ "redis.db_alias.modal.placeholder": "напр. cache, sessions (оставьте пустым для сброса)",
"common.back_to_previous": "Назад",
"common.close": "Закрыть",
"common.confirm": "Подтвердить",
diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json
index f83998a..407c959 100644
--- a/shared/i18n/zh-CN.json
+++ b/shared/i18n/zh-CN.json
@@ -1,5 +1,8 @@
{
"common.cancel": "取消",
+ "redis.db_alias.menu.set": "设置别名",
+ "redis.db_alias.modal.title": "{{db}} 的别名",
+ "redis.db_alias.modal.placeholder": "例如:缓存、会话(留空则清除)",
"common.back_to_previous": "返回上一步",
"common.close": "关闭",
"common.confirm": "确认",
diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json
index c6bc093..791a084 100644
--- a/shared/i18n/zh-TW.json
+++ b/shared/i18n/zh-TW.json
@@ -1,5 +1,8 @@
{
"common.cancel": "取消",
+ "redis.db_alias.menu.set": "設定別名",
+ "redis.db_alias.modal.title": "{{db}} 的別名",
+ "redis.db_alias.modal.placeholder": "例如:快取、工作階段(留空則清除)",
"common.back_to_previous": "返回上一步",
"common.close": "關閉",
"common.confirm": "確認",