diff --git a/frontend/src/components/RedisViewer.interaction.test.tsx b/frontend/src/components/RedisViewer.interaction.test.tsx index bf9f5bd..341510a 100644 --- a/frontend/src/components/RedisViewer.interaction.test.tsx +++ b/frontend/src/components/RedisViewer.interaction.test.tsx @@ -129,10 +129,31 @@ const flushEffects = async () => { }); }; +const collectRenderedText = (node: any): string => { + if (node == null || typeof node === 'boolean') return ''; + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(collectRenderedText).join(''); + if (Array.isArray(node.children)) return node.children.map(collectRenderedText).join(''); + return ''; +}; + describe('RedisViewer tree interactions', () => { beforeEach(() => { vi.clearAllMocks(); antdState.treeProps = null; + storeState.connections = [ + { + id: 'redis-1', + name: 'redis', + config: { + type: 'redis', + host: '127.0.0.1', + port: 6379, + password: '', + database: '', + }, + }, + ]; redisBackend.RedisScanKeys.mockResolvedValue({ success: true, data: { @@ -190,4 +211,35 @@ describe('RedisViewer tree interactions', () => { renderer!.unmount(); }); + + it('shows Redis Cluster topology context in the key explorer header', async () => { + storeState.connections = [ + { + id: 'redis-1', + name: 'redis-cluster', + config: { + type: 'redis', + host: '10.0.0.1', + port: 6379, + hosts: ['10.0.0.2:6379', '10.0.0.3:6379'], + topology: 'cluster', + password: '', + database: '', + } as any, + }, + ]; + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushEffects(); + + const renderedText = collectRenderedText(renderer!.toJSON()); + expect(renderedText).toContain('db2'); + expect(renderedText).toContain('Cluster'); + expect(renderedText).toContain('3 节点'); + + renderer!.unmount(); + }); }); diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index 3c358be..70b6066 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { createPortal } from 'react-dom'; import { Table, Input, Button, Space, Tag, Tree, Spin, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd'; import type { RadioChangeEvent } from 'antd'; -import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined, RightOutlined, DownOutlined } from '@ant-design/icons'; +import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined, RightOutlined, DownOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { RedisKeyInfo, RedisValue, StreamEntry } from '../types'; import Editor from './MonacoEditor'; @@ -30,8 +30,7 @@ import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme'; import { noAutoCapInputProps } from '../utils/inputAutoCap'; import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput, type RedisSearchMode } from '../utils/redisSearchPattern'; import { decodeRedisUtf8Value, formatRedisStringValue, toHexDisplay } from '../utils/redisValueDisplay'; - -const { Search } = Input; +import RedisViewerKeyToolbar from './RedisViewerKeyToolbar'; const REDIS_TREE_KEY_TYPE_WIDTH = 92; const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84; @@ -1849,51 +1848,29 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { {/* Left: Key List */}
-
-
-
Key Explorer
-
db{redisDB}
-
- {keys.length} Keys -
- - - 模糊 - 精确 - - } - /> - -
- - - - - - - handleDeleteKeys(selectedKeys)} - disabled={selectedKeys.length === 0} - > - - -
+ setNewKeyModalOpen(true)} + onSelectAllLoadedKeys={handleSelectAllLoadedKeys} + onClearAllSelectedKeys={handleClearAllSelectedKeys} + onDeleteSelectedKeys={() => handleDeleteKeys(selectedKeys)} + />
{isLargeKeyspace && ( diff --git a/frontend/src/components/RedisViewerKeyToolbar.tsx b/frontend/src/components/RedisViewerKeyToolbar.tsx new file mode 100644 index 0000000..26e82b8 --- /dev/null +++ b/frontend/src/components/RedisViewerKeyToolbar.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { Button, Input, Popconfirm, Radio, Space, Tag } from 'antd'; +import type { RadioChangeEvent } from 'antd'; +import { DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; + +import type { SavedConnection } from '../types'; +import { noAutoCapInputProps } from '../utils/inputAutoCap'; +import type { RedisSearchMode } from '../utils/redisSearchPattern'; + +const { Search } = Input; + +const normalizeText = (value: unknown): string => String(value || '').trim(); + +const normalizeRedisTopology = (connection?: SavedConnection): 'single' | 'cluster' | 'sentinel' => { + const topology = normalizeText(connection?.config?.topology).toLowerCase(); + if (topology === 'sentinel') return 'sentinel'; + if (topology === 'cluster') return 'cluster'; + const extraHosts = Array.isArray(connection?.config?.hosts) ? connection.config.hosts.filter(Boolean) : []; + return extraHosts.length > 0 ? 'cluster' : 'single'; +}; + +const buildRedisSeedAddresses = (connection?: SavedConnection): string[] => { + if (!connection) return []; + const config = connection.config || {}; + const port = Number.isFinite(Number(config.port)) ? Number(config.port) : 6379; + const primary = normalizeText(config.host) ? `${normalizeText(config.host)}:${port}` : ''; + const extraHosts = Array.isArray(config.hosts) + ? config.hosts.map((host) => normalizeText(host)).filter(Boolean) + : []; + return [primary, ...extraHosts].filter(Boolean); +}; + +const getRedisTopologyLabel = (topology: 'single' | 'cluster' | 'sentinel'): string => { + if (topology === 'cluster') return 'Cluster'; + if (topology === 'sentinel') return 'Sentinel'; + return '单机'; +}; + +type RedisViewerKeyToolbarProps = { + isV2Ui: boolean; + redisDB: number; + connection?: SavedConnection; + keyCount: number; + selectedKeyCount: number; + searchMode: RedisSearchMode; + searchInput: string; + mutedPillTagStyle: React.CSSProperties; + actionButtonStyle: React.CSSProperties; + primaryActionButtonStyle: React.CSSProperties; + dangerActionButtonStyle: React.CSSProperties; + textMutedColor: string; + textPrimaryColor: string; + onSearchModeChange: (event: RadioChangeEvent) => void; + onSearchInputChange: (event: React.ChangeEvent) => void; + onSearch: (value: string) => void; + onRefresh: () => void; + onCreateKey: () => void; + onSelectAllLoadedKeys: () => void; + onClearAllSelectedKeys: () => void; + onDeleteSelectedKeys: () => void; +}; + +const RedisViewerKeyToolbar: React.FC = ({ + isV2Ui, + redisDB, + connection, + keyCount, + selectedKeyCount, + searchMode, + searchInput, + mutedPillTagStyle, + actionButtonStyle, + primaryActionButtonStyle, + dangerActionButtonStyle, + textMutedColor, + textPrimaryColor, + onSearchModeChange, + onSearchInputChange, + onSearch, + onRefresh, + onCreateKey, + onSelectAllLoadedKeys, + onClearAllSelectedKeys, + onDeleteSelectedKeys, +}) => { + const topology = normalizeRedisTopology(connection); + const seedAddresses = buildRedisSeedAddresses(connection); + const sentinelMaster = topology === 'sentinel' + ? normalizeText(connection?.config?.redisSentinelMaster) + : ''; + + return ( +
+
+
+
Key Explorer
+
+
db{redisDB}
+ {getRedisTopologyLabel(topology)} + {topology !== 'single' && ( + {seedAddresses.length || 1} 节点 + )} + {sentinelMaster && ( + master: {sentinelMaster} + )} +
+
+ {keyCount} Keys +
+ + + 模糊 + 精确 + + } + /> + +
+ + + + + + + + + +
+
+ ); +}; + +export default RedisViewerKeyToolbar; diff --git a/internal/app/methods_db_multi_test.go b/internal/app/methods_db_multi_test.go index 8ede51d..1d3b97c 100644 --- a/internal/app/methods_db_multi_test.go +++ b/internal/app/methods_db_multi_test.go @@ -683,6 +683,89 @@ func TestDBQueryMultiTransactionalUsesImplicitSessionTransactionForOracle(t *tes } } +func TestDBQueryMultiTransactionalOraclePrefersTransactionProviderForFinish(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + t.Cleanup(func() { + newDatabaseFunc = originalNewDatabaseFunc + }) + + for _, tt := range []struct { + name string + finish func(*App, string) connection.QueryResult + wantCommitCalls int + wantRollbackCalls int + }{ + { + name: "commit", + finish: func(app *App, transactionID string) connection.QueryResult { + return app.DBCommitTransaction(transactionID) + }, + wantCommitCalls: 1, + }, + { + name: "rollback", + finish: func(app *App, transactionID string) connection.QueryResult { + return app.DBRollbackTransaction(transactionID) + }, + wantRollbackCalls: 1, + }, + } { + t.Run(tt.name, func(t *testing.T) { + stmt := "UPDATE users SET name = 'new' WHERE id = 1" + fakeDB := &fakeTransactionalDB{ + fakeBatchWriteDB: fakeBatchWriteDB{ + execAffected: map[string]int64{ + stmt: 1, + }, + execErr: map[string]error{ + "COMMIT": errors.New("oracle commit rows affected unavailable"), + "ROLLBACK": errors.New("oracle rollback rows affected unavailable"), + }, + }, + } + newDatabaseFunc = func(dbType string) (db.Database, error) { + return fakeDB, nil + } + + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + config := connection.ConnectionConfig{Type: "oracle", Host: "127.0.0.1", Port: 1521, User: "app"} + + result := app.DBQueryMultiTransactional(config, "ORCLPDB1", stmt, "oracle-provider-finish-"+tt.name) + if !result.Success { + t.Fatalf("expected Oracle transactional query success, got failure: %s", result.Message) + } + if result.TransactionID == "" || !result.TransactionPending { + t.Fatalf("expected pending transaction metadata, got id=%q pending=%v", result.TransactionID, result.TransactionPending) + } + if fakeDB.session != nil { + t.Fatal("expected Oracle to use transaction provider instead of plain session provider") + } + if fakeDB.txSession == nil { + t.Fatal("expected Oracle to open a transaction provider session") + } + + finishResult := tt.finish(app, result.TransactionID) + if !finishResult.Success { + t.Fatalf("expected Oracle transaction %s success through transaction provider, got failure: %s", tt.name, finishResult.Message) + } + if fakeDB.txSession.commitCalls != tt.wantCommitCalls { + t.Fatalf("expected commitCalls=%d, got %d", tt.wantCommitCalls, fakeDB.txSession.commitCalls) + } + if fakeDB.txSession.rollbackCalls != tt.wantRollbackCalls { + t.Fatalf("expected rollbackCalls=%d, got %d", tt.wantRollbackCalls, fakeDB.txSession.rollbackCalls) + } + if !fakeDB.txSession.closed { + t.Fatal("expected transaction provider session to close after finish") + } + for _, query := range fakeDB.execQueries { + if query == "COMMIT" || query == "ROLLBACK" { + t.Fatalf("expected finish to avoid plain ExecContext(%q), got exec queries %#v", query, fakeDB.execQueries) + } + } + }) + } +} + func TestDBQueryMultiTransactionalUsesOracleImplicitSessionForOceanBaseOracleProtocol(t *testing.T) { originalNewDatabaseFunc := newDatabaseFunc originalVerifyDriverAgentRevisionFunc := verifyDriverAgentRevisionFunc diff --git a/internal/app/methods_db_transaction.go b/internal/app/methods_db_transaction.go index ba6c5d6..a3c39ef 100644 --- a/internal/app/methods_db_transaction.go +++ b/internal/app/methods_db_transaction.go @@ -68,21 +68,7 @@ func (a *App) DBQueryMultiTransactional(config connection.ConnectionConfig, dbNa transactionCancel context.CancelFunc startTextTransaction bool ) - if implicitTextTransaction { - provider, ok := dbInst.(db.SessionExecerProvider) - if !ok { - return connection.QueryResult{ - Success: false, - Message: fmt.Sprintf("当前数据源(%s)不支持 SQL 编辑器托管事务", transactionDBType), - QueryID: queryID, - } - } - sessionExecer, err = provider.OpenSessionExecer(ctx) - if err != nil { - logger.Error(err, "DBQueryMultiTransactional 打开隐式事务会话失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query)) - return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} - } - } else if provider, ok := dbInst.(db.TransactionExecerProvider); ok { + if provider, ok := dbInst.(db.TransactionExecerProvider); ok { // database/sql rolls back a BeginTx transaction when its context is cancelled. // SQL editor transactions must outlive the execution RPC and be ended only by // explicit commit, rollback, or shutdown cleanup. @@ -96,6 +82,20 @@ func (a *App) DBQueryMultiTransactional(config connection.ConnectionConfig, dbNa } sessionExecer = transactionExecer transactor = transactionExecer + } else if implicitTextTransaction { + provider, ok := dbInst.(db.SessionExecerProvider) + if !ok { + return connection.QueryResult{ + Success: false, + Message: fmt.Sprintf("当前数据源(%s)不支持 SQL 编辑器托管事务", transactionDBType), + QueryID: queryID, + } + } + sessionExecer, err = provider.OpenSessionExecer(ctx) + if err != nil { + logger.Error(err, "DBQueryMultiTransactional 打开隐式事务会话失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query)) + return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} + } } else { if !hasTextTransaction { return connection.QueryResult{