feat(redis-viewer): 新增加载全部 Key 入口

Fixes #603
This commit is contained in:
Syngnat
2026-06-29 20:17:01 +08:00
parent b434247838
commit 61c2e524b4
10 changed files with 182 additions and 26 deletions

View File

@@ -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}');

View File

@@ -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(<RedisViewer connectionId="redis-1" redisDB={0} />);
});
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();
});

View File

@@ -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<string, RedisKeyInfo>();
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<RedisViewerProps> = ({ connectionId, redisDB }) => {
const [searchMode, setSearchMode] = useState<RedisSearchMode>('fuzzy');
const [cursor, setCursor] = useState<string>('0');
const [hasMore, setHasMore] = useState(false);
const [loadingAllKeys, setLoadingAllKeys] = useState(false);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [keyValue, setKeyValue] = useState<RedisValue | null>(null);
const [valueLoading, setValueLoading] = useState(false);
@@ -333,6 +341,28 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
}, [connection, redisDB]);
const scanRedisKeysPage = useCallback(async (
config: Record<string, any>,
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<RedisViewerProps> = ({ 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<string, RedisKeyInfo>();
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<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ 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<string, RedisKeyInfo>();
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<RedisViewerProps> = ({ connectionId, redisDB }) => {
<Button size="small" style={actionButtonStyle} icon={<ReloadOutlined />} onClick={handleRefresh}>{tr('redis_viewer.action.refresh')}</Button>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => setNewKeyModalOpen(true)}>{tr('redis_viewer.action.new_key')}</Button>
<Button size="small" style={primaryActionButtonStyle} onClick={handleSelectAllLoadedKeys} disabled={keys.length === 0}>{tr('redis_viewer.action.select_all_loaded')}</Button>
<Button size="small" style={actionButtonStyle} onClick={handleLoadAllKeys} disabled={!hasMore || loading} loading={loadingAllKeys}>{tr('redis_viewer.action.load_all')}</Button>
<Button size="small" style={actionButtonStyle} onClick={handleClearAllSelectedKeys} disabled={selectedKeys.length === 0}>{tr('redis_viewer.action.clear_selection')}</Button>
</Space>
<Popconfirm

View File

@@ -49,6 +49,8 @@ type RedisViewerKeyToolbarProps = {
selectedKeyCount: number;
searchMode: RedisSearchMode;
searchInput: string;
canLoadAll?: boolean;
loadingAllKeys?: boolean;
mutedPillTagStyle: React.CSSProperties;
actionButtonStyle: React.CSSProperties;
primaryActionButtonStyle: React.CSSProperties;
@@ -61,6 +63,7 @@ type RedisViewerKeyToolbarProps = {
onRefresh: () => void;
onCreateKey: () => void;
onSelectAllLoadedKeys: () => void;
onLoadAllKeys: () => void;
onClearAllSelectedKeys: () => void;
onDeleteSelectedKeys: () => void;
};
@@ -73,6 +76,8 @@ const RedisViewerKeyToolbar: React.FC<RedisViewerKeyToolbarProps> = ({
selectedKeyCount,
searchMode,
searchInput,
canLoadAll = false,
loadingAllKeys = false,
mutedPillTagStyle,
actionButtonStyle,
primaryActionButtonStyle,
@@ -85,6 +90,7 @@ const RedisViewerKeyToolbar: React.FC<RedisViewerKeyToolbarProps> = ({
onRefresh,
onCreateKey,
onSelectAllLoadedKeys,
onLoadAllKeys,
onClearAllSelectedKeys,
onDeleteSelectedKeys,
}) => {
@@ -141,6 +147,7 @@ const RedisViewerKeyToolbar: React.FC<RedisViewerKeyToolbarProps> = ({
<Button size="small" style={actionButtonStyle} icon={<ReloadOutlined />} onClick={onRefresh}>{tr('redis_viewer.action.refresh')}</Button>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={onCreateKey}>{tr('redis_viewer.action.new_key')}</Button>
<Button size="small" style={primaryActionButtonStyle} onClick={onSelectAllLoadedKeys} disabled={keyCount === 0}>{tr('redis_viewer.action.select_all_loaded')}</Button>
<Button size="small" style={actionButtonStyle} onClick={onLoadAllKeys} disabled={!canLoadAll} loading={loadingAllKeys}>{tr('redis_viewer.action.load_all')}</Button>
<Button size="small" style={actionButtonStyle} onClick={onClearAllSelectedKeys} disabled={selectedKeyCount === 0}>{tr('redis_viewer.action.clear_selection')}</Button>
</Space>
<Popconfirm