mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-24 07:09:50 +08:00
@@ -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}');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user