️ perf(redis-datagrid): 优化大数据场景下搜索与右键菜单响应性能

- RedisViewer 引入树节点轻量化、虚拟滚动与大 keyspace 性能模式,降低 Key 列表卡顿
- Redis 搜索按模式分级加载并增加请求乱序保护,避免搜索结果回写抖动
- Redis 后端 ScanKeys 为搜索模式增加时间预算与轮次上限,优先返回可继续分页结果
- DataGrid 稳定 Context/rowSelection/onRow 引用并增加 shouldCellUpdate,减少右键触发全表重渲染
This commit is contained in:
Syngnat
2026-02-27 17:22:38 +08:00
parent cb18bc3067
commit 5986b71c4d
5 changed files with 347 additions and 161 deletions

View File

@@ -605,7 +605,6 @@ const DataGrid: React.FC<DataGridProps> = ({
dataIndex: '',
title: '',
});
const [cellSetValueInput, setCellSetValueInput] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const pendingScrollToBottomRef = useRef(false);
@@ -671,7 +670,6 @@ const DataGrid: React.FC<DataGridProps> = ({
dataIndex,
title: titleText,
});
setCellSetValueInput(toFormText(record[dataIndex]));
}, []);
// Helper to export specific data
@@ -1409,6 +1407,18 @@ const DataGrid: React.FC<DataGridProps> = ({
const hasChanges = addedRows.length > 0 || Object.keys(modifiedRows).length > 0 || deletedRowKeys.size > 0;
const addedRowKeySet = useMemo(() => {
const next = new Set<string>();
addedRows.forEach((row) => {
const key = row?.[GONAVI_ROW_KEY];
if (key === undefined || key === null) return;
next.add(rowKeyStr(key));
});
return next;
}, [addedRows, rowKeyStr]);
const modifiedRowKeySet = useMemo(() => new Set(Object.keys(modifiedRows)), [modifiedRows]);
const handleTableChange = (pag: any, filtersArg: any, sorter: any) => {
if (isResizingRef.current) return; // Block sort if resizing
if (sorter.field) {
@@ -1560,12 +1570,6 @@ const DataGrid: React.FC<DataGridProps> = ({
setCellContextMenu(prev => ({ ...prev, visible: false }));
}, [cellContextMenu, handleCellSave]);
const handleCellSetValue = useCallback(() => {
if (!cellContextMenu.record) return;
handleCellSave({ ...cellContextMenu.record, [cellContextMenu.dataIndex]: cellSetValueInput });
setCellContextMenu(prev => ({ ...prev, visible: false }));
}, [cellContextMenu, cellSetValueInput, handleCellSave]);
const handleCellEditorSave = useCallback(() => {
if (!cellEditorMeta) return;
const apply = cellEditorApplyRef.current;
@@ -1888,6 +1892,11 @@ const DataGrid: React.FC<DataGridProps> = ({
{formatCellValue(text)}
</div>
),
shouldCellUpdate: (record: Item, prevRecord: Item) => {
const rowKeyChanged = record?.[GONAVI_ROW_KEY] !== prevRecord?.[GONAVI_ROW_KEY];
if (rowKeyChanged) return true;
return !isCellValueEqualForDiff(record?.[key], prevRecord?.[key]);
},
onHeaderCell: (column: any) => ({
width: column.width,
onResizeStart: handleResizeStart(key), // Only need start
@@ -2380,6 +2389,31 @@ const DataGrid: React.FC<DataGridProps> = ({
header: { cell: ResizableTitle }
}), []);
const dataContextValue = useMemo(() => ({
selectedRowKeysRef,
displayDataRef,
handleCopyInsert,
handleCopyJson,
handleCopyCsv,
handleExportSelected,
copyToClipboard,
tableName,
enableRowContextMenu: !canModifyData,
}), [handleCopyCsv, handleCopyInsert, handleCopyJson, handleExportSelected, copyToClipboard, tableName, canModifyData]);
const cellContextMenuValue = useMemo(() => ({
showMenu: showCellContextMenu,
handleBatchFillToSelected,
}), [showCellContextMenu, handleBatchFillToSelected]);
const rowSelectionConfig = useMemo(() => ({
selectedRowKeys,
onChange: setSelectedRowKeys,
columnWidth: selectionColumnWidth,
}), [selectedRowKeys, selectionColumnWidth]);
const rowPropsFactory = useCallback((record: any) => ({ record } as any), []);
const totalWidth = columns.reduce((sum, col) => sum + (Number(col.width) || 200), 0) + selectionColumnWidth;
const enableVirtual = mergedDisplayData.length >= 200;
const tableScrollX = useMemo(() => {
@@ -2779,8 +2813,8 @@ const DataGrid: React.FC<DataGridProps> = ({
{viewMode === 'table' ? (
<Form component={false} form={form}>
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName, enableRowContextMenu: !canModifyData }}>
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu, handleBatchFillToSelected }}>
<DataContext.Provider value={dataContextValue}>
<CellContextMenuContext.Provider value={cellContextMenuValue}>
<EditableContext.Provider value={form}>
<Table
components={tableComponents}
@@ -2792,23 +2826,21 @@ const DataGrid: React.FC<DataGridProps> = ({
scroll={{ x: tableScrollX, y: tableHeight }}
sticky={tableStickyConfig}
virtual={enableVirtual}
loading={loading}
loading={loading}
rowKey={GONAVI_ROW_KEY}
pagination={false}
onChange={handleTableChange}
bordered
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
columnWidth: selectionColumnWidth,
}}
rowSelection={rowSelectionConfig}
rowClassName={(record) => {
const k = record?.[GONAVI_ROW_KEY];
if (k !== undefined && addedRows.some(r => r?.[GONAVI_ROW_KEY] === k)) return 'row-added';
if (k !== undefined && (modifiedRows[rowKeyStr(k)] || deletedRowKeys.has(rowKeyStr(k)))) return 'row-modified'; // deleted won't show
if (k === undefined || k === null) return '';
const keyStr = rowKeyStr(k);
if (addedRowKeySet.has(keyStr)) return 'row-added';
if (modifiedRowKeySet.has(keyStr) || deletedRowKeys.has(keyStr)) return 'row-modified'; // deleted won't show
return '';
}}
onRow={(record) => ({ record } as any)}
onRow={rowPropsFactory}
/>
</EditableContext.Provider>
</CellContextMenuContext.Provider>

View File

@@ -16,6 +16,10 @@ const REDIS_TREE_KEY_TTL_WIDTH = 92;
const REDIS_TREE_HIDE_TTL_THRESHOLD = 460;
const REDIS_KEY_INITIAL_LOAD_COUNT = 2000;
const REDIS_KEY_LOAD_MORE_COUNT = 2000;
const REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT = 600;
const REDIS_KEY_SEARCH_LOAD_MORE_COUNT = 1000;
const REDIS_LARGE_KEYSPACE_THRESHOLD = 10000;
const REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS = 200;
interface RedisViewerProps {
connectionId: string;
@@ -241,36 +245,62 @@ type RedisKeyTreeGroup = {
path: string;
children: Map<string, RedisKeyTreeGroup>;
leaves: RedisKeyTreeLeaf[];
leafCount: number;
};
type RedisKeyTreeResult = {
treeData: DataNode[];
rawKeyByNodeKey: Map<string, string>;
leafNodeKeyByRawKey: Map<string, string>;
treeData: RedisTreeDataNode[];
groupKeys: string[];
};
type RedisTreeDataNode = DataNode & {
nodeType: 'group' | 'leaf';
groupName?: string;
groupLeafCount?: number;
leafLabel?: string;
rawKey?: string;
keyType?: string;
ttl?: number;
};
const buildLeafNodeKey = (rawKey: string): string => `key:${rawKey}`;
const parseRawKeyFromNodeKey = (nodeKey: React.Key): string | null => {
const keyText = String(nodeKey);
if (!keyText.startsWith('key:')) {
return null;
}
return keyText.slice(4);
};
const getRedisScanLoadCount = (pattern: string, append: boolean): number => {
const normalizedPattern = pattern.trim() || '*';
if (normalizedPattern === '*') {
return append ? REDIS_KEY_LOAD_MORE_COUNT : REDIS_KEY_INITIAL_LOAD_COUNT;
}
return append ? REDIS_KEY_SEARCH_LOAD_MORE_COUNT : REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT;
};
const normalizeKeySegment = (segment: string): string => {
return segment === '' ? EMPTY_SEGMENT_LABEL : segment;
};
const createTreeGroup = (name: string, path: string): RedisKeyTreeGroup => {
return { name, path, children: new Map(), leaves: [] };
return { name, path, children: new Map(), leaves: [], leafCount: 0 };
};
const countGroupLeafNodes = (group: RedisKeyTreeGroup): number => {
const calculateGroupLeafCount = (group: RedisKeyTreeGroup): number => {
let count = group.leaves.length;
group.children.forEach((child) => {
count += countGroupLeafNodes(child);
count += calculateGroupLeafCount(child);
});
group.leafCount = count;
return count;
};
const buildRedisKeyTree = (
keys: RedisKeyInfo[],
formatTTL: (ttl: number) => string,
getTypeColor: (type: string) => string,
showTTL: boolean
sortLeafNodes: boolean
): RedisKeyTreeResult => {
const root = createTreeGroup('__root__', '__root__');
@@ -300,105 +330,41 @@ const buildRedisKeyTree = (
current.leaves.push({ keyInfo, label: leafLabel });
});
calculateGroupLeafCount(root);
const rawKeyByNodeKey = new Map<string, string>();
const leafNodeKeyByRawKey = new Map<string, string>();
const groupKeys: string[] = [];
const toTreeNodes = (group: RedisKeyTreeGroup): DataNode[] => {
const toTreeNodes = (group: RedisKeyTreeGroup): RedisTreeDataNode[] => {
const childGroups = Array.from(group.children.values()).sort((a, b) => a.name.localeCompare(b.name));
const childLeaves = [...group.leaves].sort((a, b) => a.keyInfo.key.localeCompare(b.keyInfo.key));
const childLeaves = sortLeafNodes
? [...group.leaves].sort((a, b) => a.keyInfo.key.localeCompare(b.keyInfo.key))
: group.leaves;
const groupNodes: DataNode[] = childGroups.map((child) => {
const groupNodes: RedisTreeDataNode[] = childGroups.map((child) => {
const groupNodeKey = `group:${child.path}`;
groupKeys.push(groupNodeKey);
return {
key: groupNodeKey,
title: (
<Space size={6}>
<FolderOpenOutlined style={{ color: '#8c8c8c' }} />
<span>{child.name}</span>
<span style={{ fontSize: 12, color: '#999' }}>({countGroupLeafNodes(child)})</span>
</Space>
),
title: child.name,
nodeType: 'group',
groupName: child.name,
groupLeafCount: child.leafCount,
selectable: false,
disableCheckbox: true,
children: toTreeNodes(child),
};
});
const leafNodes: DataNode[] = childLeaves.map((leaf) => {
const nodeKey = `key:${leaf.keyInfo.key}`;
rawKeyByNodeKey.set(nodeKey, leaf.keyInfo.key);
leafNodeKeyByRawKey.set(leaf.keyInfo.key, nodeKey);
const leafNodes: RedisTreeDataNode[] = childLeaves.map((leaf) => {
return {
key: nodeKey,
key: buildLeafNodeKey(leaf.keyInfo.key),
isLeaf: true,
title: (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
width: '100%',
overflow: 'hidden',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
minWidth: 0,
flex: 1,
overflow: 'hidden',
}}
>
<KeyOutlined style={{ color: '#1677ff', flexShrink: 0 }} />
<Tooltip title={leaf.keyInfo.key}>
<span
style={{
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{leaf.label}
</span>
</Tooltip>
</div>
<Tag
color={getTypeColor(leaf.keyInfo.type)}
style={{
marginInlineEnd: 0,
width: showTTL ? REDIS_TREE_KEY_TYPE_WIDTH : REDIS_TREE_KEY_TYPE_WIDTH_NARROW,
textAlign: 'center',
flexShrink: 0
}}
>
{leaf.keyInfo.type}
</Tag>
{showTTL && (
<span
style={{
width: REDIS_TREE_KEY_TTL_WIDTH,
fontSize: 12,
color: '#999',
textAlign: 'left',
whiteSpace: 'nowrap',
flexShrink: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{formatTTL(leaf.keyInfo.ttl)}
</span>
)}
</div>
),
title: leaf.label,
nodeType: 'leaf',
leafLabel: leaf.label,
rawKey: leaf.keyInfo.key,
keyType: leaf.keyInfo.type,
ttl: leaf.keyInfo.ttl,
};
});
@@ -407,8 +373,6 @@ const buildRedisKeyTree = (
return {
treeData: toTreeNodes(root),
rawKeyByNodeKey,
leafNodeKeyByRawKey,
groupKeys,
};
};
@@ -445,11 +409,14 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
onSave: (newValue: string) => Promise<void>;
} | null>(null);
const jsonEditValueRef = useRef<string>('');
const latestLoadRequestIdRef = useRef(0);
// 面板宽度状态和 ref - 默认占据 50% 宽度
const [leftPanelWidth, setLeftPanelWidth] = useState<number | string>('50%');
const leftPanelRef = useRef<HTMLDivElement>(null);
const treeContainerRef = useRef<HTMLDivElement>(null);
const [showTreeKeyTTL, setShowTreeKeyTTL] = useState(true);
const [treeHeight, setTreeHeight] = useState(500);
const [expandedGroupKeys, setExpandedGroupKeys] = useState<string[]>([]);
const getConfig = useCallback(() => {
@@ -468,14 +435,22 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
pattern: string = '*',
fromCursor: number = 0,
append: boolean = false,
targetCount: number = REDIS_KEY_INITIAL_LOAD_COUNT
targetCount?: number
) => {
const config = getConfig();
if (!config) return;
const normalizedPattern = pattern.trim() || '*';
const effectiveTargetCount = targetCount ?? getRedisScanLoadCount(normalizedPattern, append);
const requestId = latestLoadRequestIdRef.current + 1;
latestLoadRequestIdRef.current = requestId;
setLoading(true);
try {
const res = await (window as any).go.app.App.RedisScanKeys(config, pattern, fromCursor, targetCount);
const res = await (window as any).go.app.App.RedisScanKeys(config, normalizedPattern, fromCursor, effectiveTargetCount);
if (requestId !== latestLoadRequestIdRef.current) {
return;
}
if (res.success) {
const result = res.data;
const scannedKeys = Array.isArray(result?.keys) ? result.keys : [];
@@ -496,33 +471,38 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
message.error('加载 Key 失败: ' + res.message);
}
} catch (e: any) {
if (requestId !== latestLoadRequestIdRef.current) {
return;
}
message.error('加载 Key 失败: ' + (e?.message || String(e)));
} finally {
setLoading(false);
if (requestId === latestLoadRequestIdRef.current) {
setLoading(false);
}
}
}, [getConfig]);
useEffect(() => {
loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
loadKeys(searchPattern, 0, false, getRedisScanLoadCount(searchPattern, false));
}, [redisDB]);
const handleSearch = (value: string) => {
const pattern = value.trim() || '*';
setSearchPattern(pattern);
setCursor(0);
loadKeys(pattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
loadKeys(pattern, 0, false, getRedisScanLoadCount(pattern, false));
};
const handleLoadMore = () => {
if (!hasMore || loading) {
return;
}
loadKeys(searchPattern, cursor, true, REDIS_KEY_LOAD_MORE_COUNT);
loadKeys(searchPattern, cursor, true, getRedisScanLoadCount(searchPattern, true));
};
const handleRefresh = () => {
setCursor(0);
loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
loadKeys(searchPattern, 0, false, getRedisScanLoadCount(searchPattern, false));
};
const loadKeyValue = async (key: string) => {
@@ -678,23 +658,51 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
return () => window.removeEventListener('resize', handleWindowResize);
}, []);
useEffect(() => {
const target = treeContainerRef.current;
if (!target) return;
const updateTreeHeight = (nextHeight: number) => {
if (nextHeight <= 0) return;
setTreeHeight((prev) => (prev === nextHeight ? prev : nextHeight));
};
updateTreeHeight(Math.round(target.getBoundingClientRect().height));
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver((entries) => {
const nextHeight = Math.round(entries[0]?.contentRect.height || target.getBoundingClientRect().height);
updateTreeHeight(nextHeight);
});
observer.observe(target);
return () => observer.disconnect();
}
const handleWindowResize = () => {
updateTreeHeight(Math.round(target.getBoundingClientRect().height));
};
window.addEventListener('resize', handleWindowResize);
return () => window.removeEventListener('resize', handleWindowResize);
}, []);
const isLargeKeyspace = keys.length >= REDIS_LARGE_KEYSPACE_THRESHOLD;
const keyTree = useMemo(() => {
return buildRedisKeyTree(keys, formatTTL, getTypeColor, showTreeKeyTTL);
}, [keys, showTreeKeyTTL]);
return buildRedisKeyTree(keys, !isLargeKeyspace);
}, [isLargeKeyspace, keys]);
const groupKeySet = useMemo(() => new Set(keyTree.groupKeys), [keyTree.groupKeys]);
const selectedTreeNodeKeys = useMemo(() => {
if (!selectedKey) {
return [] as string[];
}
const nodeKey = keyTree.leafNodeKeyByRawKey.get(selectedKey);
return nodeKey ? [nodeKey] : [];
}, [selectedKey, keyTree]);
return [buildLeafNodeKey(selectedKey)];
}, [selectedKey]);
const checkedTreeNodeKeys = useMemo(() => {
return selectedKeys
.map(rawKey => keyTree.leafNodeKeyByRawKey.get(rawKey))
.filter((nodeKey): nodeKey is string => Boolean(nodeKey));
}, [selectedKeys, keyTree]);
return selectedKeys.map(rawKey => buildLeafNodeKey(rawKey));
}, [selectedKeys]);
useEffect(() => {
const existingKeySet = new Set(keys.map(item => item.key));
@@ -703,16 +711,19 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
useEffect(() => {
setExpandedGroupKeys((prev) => {
const validKeys = prev.filter(nodeKey => keyTree.groupKeys.includes(nodeKey));
return validKeys;
const validKeys = prev.filter(nodeKey => groupKeySet.has(nodeKey));
if (!isLargeKeyspace) {
return validKeys;
}
return validKeys.slice(0, REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS);
});
}, [keyTree]);
}, [groupKeySet, isLargeKeyspace]);
const handleTreeSelect = (nodeKeys: React.Key[]) => {
if (nodeKeys.length === 0) {
return;
}
const rawKey = keyTree.rawKeyByNodeKey.get(String(nodeKeys[0]));
const rawKey = parseRawKeyFromNodeKey(nodeKeys[0]);
if (!rawKey) {
return;
}
@@ -722,11 +733,119 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const handleTreeCheck = (checked: React.Key[] | { checked: React.Key[]; halfChecked: React.Key[] }) => {
const checkedNodeKeys = Array.isArray(checked) ? checked : checked.checked;
const rawKeys = checkedNodeKeys
.map(nodeKey => keyTree.rawKeyByNodeKey.get(String(nodeKey)))
.map(nodeKey => parseRawKeyFromNodeKey(nodeKey))
.filter((rawKey): rawKey is string => Boolean(rawKey));
setSelectedKeys(rawKeys);
};
const renderTreeNodeTitle = useCallback((nodeData: DataNode) => {
const treeNode = nodeData as RedisTreeDataNode;
if (treeNode.nodeType === 'group') {
return (
<Space size={6}>
<FolderOpenOutlined style={{ color: '#8c8c8c' }} />
<span>{treeNode.groupName}</span>
<span style={{ fontSize: 12, color: '#999' }}>({treeNode.groupLeafCount ?? 0})</span>
</Space>
);
}
const leafLabel = treeNode.leafLabel ?? '';
const rawKey = treeNode.rawKey ?? parseRawKeyFromNodeKey(treeNode.key ?? '') ?? '';
const keyType = treeNode.keyType ?? 'unknown';
const ttl = typeof treeNode.ttl === 'number' ? treeNode.ttl : -1;
if (isLargeKeyspace) {
return (
<div style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<span>{leafLabel}</span>
<span style={{ marginLeft: 8, color: '#999', fontSize: 12 }}>[{keyType}]</span>
{showTreeKeyTTL && (
<span style={{ marginLeft: 8, color: '#999', fontSize: 12 }}>{formatTTL(ttl)}</span>
)}
</div>
);
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
width: '100%',
overflow: 'hidden',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
minWidth: 0,
flex: 1,
overflow: 'hidden',
}}
>
<KeyOutlined style={{ color: '#1677ff', flexShrink: 0 }} />
<Tooltip title={rawKey}>
<span
style={{
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{leafLabel}
</span>
</Tooltip>
</div>
<Tag
color={getTypeColor(keyType)}
style={{
marginInlineEnd: 0,
width: showTreeKeyTTL ? REDIS_TREE_KEY_TYPE_WIDTH : REDIS_TREE_KEY_TYPE_WIDTH_NARROW,
textAlign: 'center',
flexShrink: 0
}}
>
{keyType}
</Tag>
{showTreeKeyTTL && (
<span
style={{
width: REDIS_TREE_KEY_TTL_WIDTH,
fontSize: 12,
color: '#999',
textAlign: 'left',
whiteSpace: 'nowrap',
flexShrink: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{formatTTL(ttl)}
</span>
)}
</div>
);
}, [formatTTL, getTypeColor, isLargeKeyspace, showTreeKeyTTL]);
const handleTreeExpand = (nextExpandedKeys: React.Key[]) => {
const validGroupKeys = nextExpandedKeys
.map(key => String(key))
.filter(nodeKey => groupKeySet.has(nodeKey));
if (isLargeKeyspace) {
setExpandedGroupKeys(validGroupKeys.slice(0, REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS));
return;
}
setExpandedGroupKeys(validGroupKeys);
};
const renderValueEditor = () => {
if (!keyValue || !selectedKey) {
return <div style={{ padding: 20, textAlign: 'center', color: '#999' }}> Key </div>;
@@ -1769,24 +1888,34 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
</Popconfirm>
</div>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
<Spin spinning={loading} size="small">
<Tree
blockNode
showIcon={false}
checkable
checkStrictly
selectable
treeData={keyTree.treeData}
selectedKeys={selectedTreeNodeKeys}
checkedKeys={checkedTreeNodeKeys}
expandedKeys={expandedGroupKeys}
onExpand={(nextExpandedKeys) => setExpandedGroupKeys(nextExpandedKeys as string[])}
onSelect={(nodeKeys) => handleTreeSelect(nodeKeys)}
onCheck={(checked) => handleTreeCheck(checked)}
style={{ padding: '8px 6px' }}
/>
</Spin>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
{isLargeKeyspace && (
<div style={{ padding: '6px 8px', fontSize: 12, color: '#8c8c8c', borderBottom: '1px solid #f0f0f0' }}>
{REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS}
</div>
)}
<div ref={treeContainerRef} style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<Spin spinning={loading} size="small" style={{ width: '100%' }}>
<Tree
blockNode
showIcon={false}
checkable
checkStrictly
selectable
virtual
height={Math.max(treeHeight - 8, 220)}
treeData={keyTree.treeData}
titleRender={renderTreeNodeTitle}
selectedKeys={selectedTreeNodeKeys}
checkedKeys={checkedTreeNodeKeys}
expandedKeys={expandedGroupKeys}
onExpand={handleTreeExpand}
onSelect={(nodeKeys) => handleTreeSelect(nodeKeys)}
onCheck={(checked) => handleTreeCheck(checked)}
style={{ padding: '8px 6px' }}
/>
</Spin>
</div>
{hasMore && (
<div style={{ padding: 8, textAlign: 'center' }}>
<Button onClick={handleLoadMore} loading={loading} disabled={!hasMore || loading}></Button>

View File

@@ -10,10 +10,10 @@ export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
export function CheckForUpdates():Promise<connection.QueryResult>;
export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise<connection.QueryResult>;
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;
export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise<connection.QueryResult>;
export function CreateDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function DBConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;

View File

@@ -14,14 +14,14 @@ export function CheckForUpdates() {
return window['go']['app']['App']['CheckForUpdates']();
}
export function ConfigureGlobalProxy(arg1, arg2) {
return window['go']['app']['App']['ConfigureGlobalProxy'](arg1, arg2);
}
export function ConfigureDriverRuntimeDirectory(arg1) {
return window['go']['app']['App']['ConfigureDriverRuntimeDirectory'](arg1);
}
export function ConfigureGlobalProxy(arg1, arg2) {
return window['go']['app']['App']['ConfigureGlobalProxy'](arg1, arg2);
}
export function CreateDatabase(arg1, arg2) {
return window['go']['app']['App']['CreateDatabase'](arg1, arg2);
}

View File

@@ -28,6 +28,11 @@ const (
redisScanMinStepCount int64 = 200
redisScanMaxStepCount int64 = 2000
redisScanMaxRounds = 64
redisScanMaxDuration = 12 * time.Second
redisSearchMaxTargetCount int64 = 1000
redisSearchMaxStepCount int64 = 1000
redisSearchMaxRounds = 16
redisSearchMaxDuration = 3 * time.Second
)
// NewRedisClient creates a new Redis client instance
@@ -110,21 +115,41 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
return nil, fmt.Errorf("Redis 客户端未连接")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if pattern == "" {
pattern = "*"
}
isSearchPattern := pattern != "*"
targetCount := normalizeRedisScanTargetCount(count)
scanStepCount := normalizeRedisScanStepCount(targetCount)
maxRounds := redisScanMaxRounds
maxDuration := redisScanMaxDuration
if isSearchPattern {
if targetCount > redisSearchMaxTargetCount {
targetCount = redisSearchMaxTargetCount
}
if scanStepCount > redisSearchMaxStepCount {
scanStepCount = redisSearchMaxStepCount
}
maxRounds = redisSearchMaxRounds
maxDuration = redisSearchMaxDuration
}
ctx, cancel := context.WithTimeout(context.Background(), maxDuration+5*time.Second)
defer cancel()
currentCursor := cursor
round := 0
scanStartedAt := time.Now()
keys := make([]string, 0, int(targetCount))
seen := make(map[string]struct{}, int(targetCount))
for len(keys) < int(targetCount) {
if time.Since(scanStartedAt) >= maxDuration {
break
}
batch, nextCursor, err := r.client.Scan(ctx, currentCursor, pattern, scanStepCount).Result()
if err != nil {
return nil, err
@@ -143,7 +168,7 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
currentCursor = nextCursor
round++
if currentCursor == 0 || round >= redisScanMaxRounds {
if currentCursor == 0 || round >= maxRounds {
break
}
}