{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
+
+
+
+ 模糊
+ 精确
+
+ }
+ />
+
+
+
+ } onClick={onRefresh}>刷新
+ } onClick={onCreateKey}>新建
+
+
+
+
+ } disabled={selectedKeyCount === 0}>
+ 删除选中({selectedKeyCount})
+
+
+
+
+ );
+};
+
+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{