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 }) => {
} onClick={handleRefresh}>{tr('redis_viewer.action.refresh')}
} onClick={() => setNewKeyModalOpen(true)}>{tr('redis_viewer.action.new_key')}
+
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 = ({
} onClick={onRefresh}>{tr('redis_viewer.action.refresh')}
} onClick={onCreateKey}>{tr('redis_viewer.action.new_key')}
+