diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 6cfec4f..f1903f8 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -605,7 +605,6 @@ const DataGrid: React.FC = ({ dataIndex: '', title: '', }); - const [cellSetValueInput, setCellSetValueInput] = useState(''); const containerRef = useRef(null); const pendingScrollToBottomRef = useRef(false); @@ -671,7 +670,6 @@ const DataGrid: React.FC = ({ dataIndex, title: titleText, }); - setCellSetValueInput(toFormText(record[dataIndex])); }, []); // Helper to export specific data @@ -1409,6 +1407,18 @@ const DataGrid: React.FC = ({ const hasChanges = addedRows.length > 0 || Object.keys(modifiedRows).length > 0 || deletedRowKeys.size > 0; + const addedRowKeySet = useMemo(() => { + const next = new Set(); + 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 = ({ 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 = ({ {formatCellValue(text)} ), + 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 = ({ 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 = ({ {viewMode === 'table' ? (
- - + + = ({ 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} /> diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index d998950..0aa3750 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -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; leaves: RedisKeyTreeLeaf[]; + leafCount: number; }; type RedisKeyTreeResult = { - treeData: DataNode[]; - rawKeyByNodeKey: Map; - leafNodeKeyByRawKey: Map; + 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(); - const leafNodeKeyByRawKey = new Map(); 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: ( - - - {child.name} - ({countGroupLeafNodes(child)}) - - ), + 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: ( -
-
- - - - {leaf.label} - - -
- - {leaf.keyInfo.type} - - {showTTL && ( - - {formatTTL(leaf.keyInfo.ttl)} - - )} -
- ), + 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 = ({ connectionId, redisDB }) => { onSave: (newValue: string) => Promise; } | null>(null); const jsonEditValueRef = useRef(''); + const latestLoadRequestIdRef = useRef(0); // 面板宽度状态和 ref - 默认占据 50% 宽度 const [leftPanelWidth, setLeftPanelWidth] = useState('50%'); const leftPanelRef = useRef(null); + const treeContainerRef = useRef(null); const [showTreeKeyTTL, setShowTreeKeyTTL] = useState(true); + const [treeHeight, setTreeHeight] = useState(500); const [expandedGroupKeys, setExpandedGroupKeys] = useState([]); const getConfig = useCallback(() => { @@ -468,14 +435,22 @@ const RedisViewer: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 ( + + + {treeNode.groupName} + ({treeNode.groupLeafCount ?? 0}) + + ); + } + + 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 ( +
+ {leafLabel} + [{keyType}] + {showTreeKeyTTL && ( + {formatTTL(ttl)} + )} +
+ ); + } + + return ( +
+
+ + + + {leafLabel} + + +
+ + {keyType} + + {showTreeKeyTTL && ( + + {formatTTL(ttl)} + + )} +
+ ); + }, [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
选择一个 Key 查看详情
; @@ -1769,24 +1888,34 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { -
- - setExpandedGroupKeys(nextExpandedKeys as string[])} - onSelect={(nodeKeys) => handleTreeSelect(nodeKeys)} - onCheck={(checked) => handleTreeCheck(checked)} - style={{ padding: '8px 6px' }} - /> - +
+ {isLargeKeyspace && ( +
+ 已启用大数据量性能模式(简化节点渲染,最多保留 {REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS} 个展开分组) +
+ )} +
+ + handleTreeSelect(nodeKeys)} + onCheck={(checked) => handleTreeCheck(checked)} + style={{ padding: '8px 6px' }} + /> + +
{hasMore && (
diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 00e3a00..b2edb2f 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -10,10 +10,10 @@ export function CheckDriverNetworkStatus():Promise; export function CheckForUpdates():Promise; -export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise; - export function ConfigureDriverRuntimeDirectory(arg1:string):Promise; +export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise; + export function CreateDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise; export function DBConnect(arg1:connection.ConnectionConfig):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index f872dea..6dba529 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -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); } diff --git a/internal/redis/redis_impl.go b/internal/redis/redis_impl.go index 03ee844..50df382 100644 --- a/internal/redis/redis_impl.go +++ b/internal/redis/redis_impl.go @@ -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 } }