diff --git a/frontend/src/components/RedisViewer.i18n.test.ts b/frontend/src/components/RedisViewer.i18n.test.ts index 5b17559..ce49ed7 100644 --- a/frontend/src/components/RedisViewer.i18n.test.ts +++ b/frontend/src/components/RedisViewer.i18n.test.ts @@ -52,6 +52,7 @@ describe('RedisViewer i18n', () => { '刷新', '新建', '全选全部', + '加载全部', '取消全选', '确定删除选中的', '删除选中', @@ -63,6 +64,7 @@ describe('RedisViewer i18n', () => { expect(keyToolbarSource).toContain("tr('redis_viewer.title.key_explorer'"); expect(keyToolbarSource).toContain("tr('redis_viewer.label.keys_count'"); expect(keyToolbarSource).toContain("tr('redis_viewer.label.node_count'"); + expect(keyToolbarSource).toContain("tr('redis_viewer.action.load_all'"); expect(keyToolbarSource).toContain("tr('redis_viewer.confirm.delete_selected'"); expect(keyToolbarSource).toContain("tr('redis_viewer.action.delete_selected'"); expect(keyToolbarSource).toContain('master: {sentinelMaster}'); diff --git a/frontend/src/components/RedisViewer.interaction.test.tsx b/frontend/src/components/RedisViewer.interaction.test.tsx index 341510a..c866c09 100644 --- a/frontend/src/components/RedisViewer.interaction.test.tsx +++ b/frontend/src/components/RedisViewer.interaction.test.tsx @@ -137,6 +137,22 @@ const collectRenderedText = (node: any): string => { return ''; }; +const findButtonByText = (renderer: ReactTestRenderer, text: string) => { + return renderer.root.findAllByType('button').find((node) => collectRenderedText(node.props.children).includes(text)); +}; + +const countLeafNodes = (nodes: any[]): number => { + return nodes.reduce((total, node) => { + if (!node || typeof node !== 'object') { + return total; + } + if (node.nodeType === 'leaf') { + return total + 1; + } + return total + countLeafNodes(Array.isArray(node.children) ? node.children : []); + }, 0); +}; + describe('RedisViewer tree interactions', () => { beforeEach(() => { vi.clearAllMocks(); @@ -238,7 +254,66 @@ describe('RedisViewer tree interactions', () => { const renderedText = collectRenderedText(renderer!.toJSON()); expect(renderedText).toContain('db2'); expect(renderedText).toContain('Cluster'); - expect(renderedText).toContain('3 节点'); + expect(renderedText).toContain('3 nodes'); + + renderer!.unmount(); + }); + + it('loads every key page when the load-all action is clicked', async () => { + redisBackend.RedisScanKeys.mockReset(); + redisBackend.RedisScanKeys + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '1', + keys: [ + { key: 'app:user:1', type: 'string', ttl: -1 }, + { key: 'app:user:2', type: 'string', ttl: -1 }, + ], + }, + }) + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '1', + keys: [ + { key: 'app:user:1', type: 'string', ttl: -1 }, + { key: 'app:user:2', type: 'string', ttl: -1 }, + ], + }, + }) + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '0', + keys: [ + { key: 'app:user:3', type: 'string', ttl: -1 }, + ], + }, + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushEffects(); + + const loadAllButton = findButtonByText(renderer!, 'Load all'); + expect(loadAllButton).toBeTruthy(); + + await act(async () => { + loadAllButton!.props.onClick?.(); + }); + await flushEffects(); + + expect(redisBackend.RedisScanKeys).toHaveBeenCalledTimes(3); + expect(redisBackend.RedisScanKeys.mock.calls[1]?.[2]).toBe('0'); + expect(redisBackend.RedisScanKeys.mock.calls[2]?.[2]).toBe('1'); + + expect(countLeafNodes(antdState.treeProps.treeData)).toBe(3); + + const renderedText = collectRenderedText(renderer!.toJSON()); + expect(renderedText).toContain('Loaded 3 Keys'); renderer!.unmount(); }); diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index 24e42c0..38e454d 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -150,6 +150,13 @@ const isRedisKeyGoneErrorMessage = (messageText: string): boolean => { const normalizeToolbarText = (value: unknown): string => String(value || '').trim(); +const mergeRedisKeyInfoLists = (existing: RedisKeyInfo[], incoming: RedisKeyInfo[]): RedisKeyInfo[] => { + const keyMap = new Map(); + existing.forEach((item) => keyMap.set(item.key, item)); + incoming.forEach((item) => keyMap.set(item.key, item)); + return Array.from(keyMap.values()); +}; + const resolveRedisTopology = (connection?: { config?: { topology?: string; hosts?: string[] } }): 'single' | 'replica' | 'cluster' | 'sentinel' => { const topology = normalizeToolbarText(connection?.config?.topology).toLowerCase(); if (topology === 'replica') return 'replica'; @@ -215,6 +222,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const [searchMode, setSearchMode] = useState('fuzzy'); const [cursor, setCursor] = useState('0'); const [hasMore, setHasMore] = useState(false); + const [loadingAllKeys, setLoadingAllKeys] = useState(false); const [selectedKey, setSelectedKey] = useState(null); const [keyValue, setKeyValue] = useState(null); const [valueLoading, setValueLoading] = useState(false); @@ -333,6 +341,28 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }; }, [connection, redisDB]); + const scanRedisKeysPage = useCallback(async ( + config: Record, + pattern: string, + fromCursor: string, + targetCount: number + ): Promise<{ scannedKeys: RedisKeyInfo[]; nextCursor: string }> => { + const res = await (window as any).go.app.App.RedisScanKeys( + buildRpcConnectionConfig(config), + pattern, + fromCursor, + targetCount + ); + if (!res?.success) { + throw new Error(String(res?.message || 'Unknown error')); + } + const result = res.data; + return { + scannedKeys: Array.isArray(result?.keys) ? result.keys : [], + nextCursor: normalizeRedisCursor(result?.cursor), + }; + }, []); + const loadKeys = useCallback(async ( pattern: string = '*', fromCursor: string = '0', @@ -349,29 +379,17 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { setLoading(true); try { - const res = await (window as any).go.app.App.RedisScanKeys(buildRpcConnectionConfig(config), normalizedPattern, fromCursor, effectiveTargetCount); + const { scannedKeys, nextCursor } = await scanRedisKeysPage(config, normalizedPattern, fromCursor, effectiveTargetCount); if (requestId !== latestLoadRequestIdRef.current) { return; } - if (res.success) { - const result = res.data; - const scannedKeys = Array.isArray(result?.keys) ? result.keys : []; - const nextCursor = normalizeRedisCursor(result?.cursor); - if (append) { - setKeys(prev => { - const keyMap = new Map(); - prev.forEach(item => keyMap.set(item.key, item)); - scannedKeys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item)); - return Array.from(keyMap.values()); - }); - } else { - setKeys(scannedKeys); - } - setCursor(nextCursor); - setHasMore(nextCursor !== '0'); + if (append) { + setKeys(prev => mergeRedisKeyInfoLists(prev, scannedKeys)); } else { - message.error(tr('redis_viewer.message.load_keys_failed', { detail: res.message })); + setKeys(scannedKeys); } + setCursor(nextCursor); + setHasMore(nextCursor !== '0'); } catch (e: any) { if (requestId !== latestLoadRequestIdRef.current) { return; @@ -382,7 +400,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { setLoading(false); } } - }, [getConfig, tr]); + }, [getConfig, scanRedisKeysPage, tr]); useEffect(() => { loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false)); @@ -424,6 +442,53 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { loadKeys(searchPattern, cursor, true, getRedisScanLoadCount(searchPattern, true)); }; + const handleLoadAllKeys = useCallback(async () => { + const config = getConfig(); + if (!config || loading || !hasMore) { + return; + } + + const normalizedPattern = searchPattern.trim() || '*'; + const batchSize = getRedisScanLoadCount(normalizedPattern, true); + const requestId = latestLoadRequestIdRef.current + 1; + latestLoadRequestIdRef.current = requestId; + + setLoading(true); + setLoadingAllKeys(true); + try { + let nextCursor = '0'; + const keyMap = new Map(); + + do { + const { scannedKeys, nextCursor: scannedCursor } = await scanRedisKeysPage( + config, + normalizedPattern, + nextCursor, + batchSize + ); + if (requestId !== latestLoadRequestIdRef.current) { + return; + } + scannedKeys.forEach((item) => keyMap.set(item.key, item)); + nextCursor = scannedCursor; + } while (nextCursor !== '0'); + + setKeys(Array.from(keyMap.values())); + setCursor('0'); + setHasMore(false); + } catch (e: any) { + if (requestId !== latestLoadRequestIdRef.current) { + return; + } + message.error(tr('redis_viewer.message.load_keys_failed', { detail: e?.message || String(e) })); + } finally { + if (requestId === latestLoadRequestIdRef.current) { + setLoading(false); + setLoadingAllKeys(false); + } + } + }, [getConfig, hasMore, loading, scanRedisKeysPage, searchPattern, tr]); + const handleRefresh = () => { setCursor('0'); loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false)); @@ -1928,6 +1993,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { + void; onCreateKey: () => void; onSelectAllLoadedKeys: () => void; + onLoadAllKeys: () => void; onClearAllSelectedKeys: () => void; onDeleteSelectedKeys: () => void; }; @@ -73,6 +76,8 @@ const RedisViewerKeyToolbar: React.FC = ({ selectedKeyCount, searchMode, searchInput, + canLoadAll = false, + loadingAllKeys = false, mutedPillTagStyle, actionButtonStyle, primaryActionButtonStyle, @@ -85,6 +90,7 @@ const RedisViewerKeyToolbar: React.FC = ({ onRefresh, onCreateKey, onSelectAllLoadedKeys, + onLoadAllKeys, onClearAllSelectedKeys, onDeleteSelectedKeys, }) => { @@ -141,6 +147,7 @@ const RedisViewerKeyToolbar: React.FC = ({ +