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

View File

@@ -6346,6 +6346,7 @@
"redis_viewer.action.delete_key": "Key löschen",
"redis_viewer.action.delete_selected": "Auswahl löschen ({{count}})",
"redis_viewer.action.edit": "Bearbeiten",
"redis_viewer.action.load_all": "Alle laden",
"redis_viewer.action.load_more": "Mehr laden",
"redis_viewer.action.new_key": "Neu",
"redis_viewer.action.refresh": "Aktualisieren",
@@ -6374,7 +6375,7 @@
"redis_viewer.hint.binary_readonly": "Binärdaten können nicht bearbeitet werden",
"redis_viewer.hint.switch_auto_to_edit": "Zum Bearbeiten in den Auto-Modus wechseln",
"redis_viewer.label.encoding": "Kodierung: {{encoding}}",
"redis_viewer.label.keys_count": "{{count}} Schlüssel",
"redis_viewer.label.keys_count": "Geladen: {{count}} Schlüssel",
"redis_viewer.label.length": "Länge: {{count}}",
"redis_viewer.label.node_count": "{{count}} Knoten",
"redis_viewer.label.original_key": "Ursprünglicher Key: {{key}}",

View File

@@ -6346,6 +6346,7 @@
"redis_viewer.action.delete_key": "Delete Key",
"redis_viewer.action.delete_selected": "Delete selected ({{count}})",
"redis_viewer.action.edit": "Edit",
"redis_viewer.action.load_all": "Load all",
"redis_viewer.action.load_more": "Load more",
"redis_viewer.action.new_key": "New",
"redis_viewer.action.refresh": "Refresh",
@@ -6374,7 +6375,7 @@
"redis_viewer.hint.binary_readonly": "Binary data cannot be edited",
"redis_viewer.hint.switch_auto_to_edit": "Switch to Auto mode to edit",
"redis_viewer.label.encoding": "Encoding: {{encoding}}",
"redis_viewer.label.keys_count": "{{count}} Keys",
"redis_viewer.label.keys_count": "Loaded {{count}} Keys",
"redis_viewer.label.length": "Length: {{count}}",
"redis_viewer.label.node_count": "{{count}} nodes",
"redis_viewer.label.original_key": "Original Key: {{key}}",

View File

@@ -6346,6 +6346,7 @@
"redis_viewer.action.delete_key": "Key を削除",
"redis_viewer.action.delete_selected": "選択項目を削除({{count}}",
"redis_viewer.action.edit": "編集",
"redis_viewer.action.load_all": "すべて読み込む",
"redis_viewer.action.load_more": "さらに読み込む",
"redis_viewer.action.new_key": "新規",
"redis_viewer.action.refresh": "更新",
@@ -6374,7 +6375,7 @@
"redis_viewer.hint.binary_readonly": "バイナリデータは編集できません",
"redis_viewer.hint.switch_auto_to_edit": "編集するには Auto モードに切り替えてください",
"redis_viewer.label.encoding": "エンコーディング: {{encoding}}",
"redis_viewer.label.keys_count": "{{count}} 件の Key",
"redis_viewer.label.keys_count": "{{count}} 件の Key を読み込み済み",
"redis_viewer.label.length": "長さ: {{count}}",
"redis_viewer.label.node_count": "{{count}} ノード",
"redis_viewer.label.original_key": "元の Key: {{key}}",

View File

@@ -6346,6 +6346,7 @@
"redis_viewer.action.delete_key": "Удалить Key",
"redis_viewer.action.delete_selected": "Удалить выбранное ({{count}})",
"redis_viewer.action.edit": "Редактировать",
"redis_viewer.action.load_all": "Загрузить все",
"redis_viewer.action.load_more": "Загрузить еще",
"redis_viewer.action.new_key": "Создать",
"redis_viewer.action.refresh": "Обновить",
@@ -6374,7 +6375,7 @@
"redis_viewer.hint.binary_readonly": "Двоичные данные нельзя редактировать",
"redis_viewer.hint.switch_auto_to_edit": "Переключитесь в режим Auto для редактирования",
"redis_viewer.label.encoding": "Кодировка: {{encoding}}",
"redis_viewer.label.keys_count": "Keys: {{count}}",
"redis_viewer.label.keys_count": "Загружено ключей: {{count}}",
"redis_viewer.label.length": "Длина: {{count}}",
"redis_viewer.label.node_count": "Узлов: {{count}}",
"redis_viewer.label.original_key": "Исходный Key: {{key}}",

View File

@@ -6346,6 +6346,7 @@
"redis_viewer.action.delete_key": "删除 Key",
"redis_viewer.action.delete_selected": "删除选中项({{count}}",
"redis_viewer.action.edit": "编辑",
"redis_viewer.action.load_all": "加载全部",
"redis_viewer.action.load_more": "加载更多",
"redis_viewer.action.new_key": "新建",
"redis_viewer.action.refresh": "刷新",
@@ -6374,7 +6375,7 @@
"redis_viewer.hint.binary_readonly": "二进制数据无法编辑",
"redis_viewer.hint.switch_auto_to_edit": "切换到 Auto 模式后编辑",
"redis_viewer.label.encoding": "编码:{{encoding}}",
"redis_viewer.label.keys_count": "{{count}} 个 Key",
"redis_viewer.label.keys_count": "已加载 {{count}} 个 Key",
"redis_viewer.label.length": "长度:{{count}}",
"redis_viewer.label.node_count": "{{count}} 个节点",
"redis_viewer.label.original_key": "原 Key{{key}}",

View File

@@ -6346,6 +6346,7 @@
"redis_viewer.action.delete_key": "刪除 Key",
"redis_viewer.action.delete_selected": "刪除已選項目({{count}}",
"redis_viewer.action.edit": "編輯",
"redis_viewer.action.load_all": "載入全部",
"redis_viewer.action.load_more": "載入更多",
"redis_viewer.action.new_key": "新增",
"redis_viewer.action.refresh": "重新整理",
@@ -6374,7 +6375,7 @@
"redis_viewer.hint.binary_readonly": "二進位資料無法編輯",
"redis_viewer.hint.switch_auto_to_edit": "切換到 Auto 模式後即可編輯",
"redis_viewer.label.encoding": "編碼:{{encoding}}",
"redis_viewer.label.keys_count": "{{count}} 個 Key",
"redis_viewer.label.keys_count": "已載入 {{count}} 個 Key",
"redis_viewer.label.length": "長度:{{count}}",
"redis_viewer.label.node_count": "{{count}} 個節點",
"redis_viewer.label.original_key": "原 Key{{key}}",