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": "確認",