mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-30 10:41:23 +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
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
Reference in New Issue
Block a user