🐛 fix(sql-editor): 修复 Oracle 事务结束并补充 Redis 拓扑提示

- SQL 编辑器:Oracle 托管事务优先使用 transaction provider 完成提交和回滚

- Redis:拆分 Key 浏览工具栏并展示 Cluster/Sentinel 拓扑上下文

- 测试:补充 Oracle 事务结束和 Redis 拓扑头部回归用例
This commit is contained in:
Syngnat
2026-06-12 08:48:08 +08:00
parent 781a80e03f
commit fce50b513c
5 changed files with 327 additions and 63 deletions

View File

@@ -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(<RedisViewer connectionId="redis-1" redisDB={2} />);
});
await flushEffects();
const renderedText = collectRenderedText(renderer!.toJSON());
expect(renderedText).toContain('db2');
expect(renderedText).toContain('Cluster');
expect(renderedText).toContain('3 节点');
renderer!.unmount();
});
});

View File

@@ -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<RedisViewerProps> = ({ connectionId, redisDB }) => {
{/* Left: Key List */}
<div ref={leftPanelRef} className={isV2Ui ? 'gn-v2-redis-sidebar' : undefined} style={{ width: leftPanelWidth, minWidth: 300, display: 'flex', flexDirection: 'column', flexShrink: 0, gap: 12 }}>
<div className={isV2Ui ? 'gn-v2-redis-header' : undefined} style={{ ...workbenchCardStyle, padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, marginBottom: 12 }}>
<div>
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '.08em', color: workbenchTheme.textMuted, fontWeight: 600 }}>Key Explorer</div>
<div style={{ fontSize: 24, fontWeight: 700, color: workbenchTheme.textPrimary, marginTop: 4 }}>db{redisDB}</div>
</div>
<Tag style={mutedPillTagStyle}>{keys.length} Keys</Tag>
</div>
<Space.Compact style={{ width: '100%' }}>
<Radio.Group
value={searchMode}
onChange={handleSearchModeChange}
buttonStyle="solid"
style={{ flexShrink: 0 }}
>
<Radio.Button value="fuzzy"></Radio.Button>
<Radio.Button value="exact"></Radio.Button>
</Radio.Group>
<Search
{...noAutoCapInputProps}
style={{ flex: 1 }}
placeholder={searchMode === 'exact' ? '输入完整 Key / 命名空间精确搜索' : '搜索 Key模糊匹配'}
value={searchInput}
onChange={handleSearchInputChange}
onSearch={handleSearch}
allowClear
enterButton={<SearchOutlined />}
/>
</Space.Compact>
<div className={isV2Ui ? 'gn-v2-redis-toolbar' : undefined} style={{ marginTop: 12, display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<Space wrap size={8}>
<Button size="small" style={actionButtonStyle} icon={<ReloadOutlined />} onClick={handleRefresh}></Button>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => setNewKeyModalOpen(true)}></Button>
<Button size="small" style={primaryActionButtonStyle} onClick={handleSelectAllLoadedKeys} disabled={keys.length === 0}></Button>
<Button size="small" style={actionButtonStyle} onClick={handleClearAllSelectedKeys} disabled={selectedKeys.length === 0}></Button>
</Space>
<Popconfirm
title={`确定删除选中的 ${selectedKeys.length} 个 Key`}
onConfirm={() => handleDeleteKeys(selectedKeys)}
disabled={selectedKeys.length === 0}
>
<Button size="small" style={dangerActionButtonStyle} icon={<DeleteOutlined />} disabled={selectedKeys.length === 0}>
({selectedKeys.length})
</Button>
</Popconfirm>
</div>
<RedisViewerKeyToolbar
isV2Ui={isV2Ui}
redisDB={redisDB}
connection={connection}
keyCount={keys.length}
selectedKeyCount={selectedKeys.length}
searchMode={searchMode}
searchInput={searchInput}
mutedPillTagStyle={mutedPillTagStyle}
actionButtonStyle={actionButtonStyle}
primaryActionButtonStyle={primaryActionButtonStyle}
dangerActionButtonStyle={dangerActionButtonStyle}
textMutedColor={workbenchTheme.textMuted}
textPrimaryColor={workbenchTheme.textPrimary}
onSearchModeChange={handleSearchModeChange}
onSearchInputChange={handleSearchInputChange}
onSearch={handleSearch}
onRefresh={handleRefresh}
onCreateKey={() => setNewKeyModalOpen(true)}
onSelectAllLoadedKeys={handleSelectAllLoadedKeys}
onClearAllSelectedKeys={handleClearAllSelectedKeys}
onDeleteSelectedKeys={() => handleDeleteKeys(selectedKeys)}
/>
</div>
<div className={isV2Ui ? 'gn-v2-redis-tree-card' : undefined} style={{ ...workbenchCardStyle, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', padding: 10 }}>
{isLargeKeyspace && (

View File

@@ -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<HTMLInputElement>) => void;
onSearch: (value: string) => void;
onRefresh: () => void;
onCreateKey: () => void;
onSelectAllLoadedKeys: () => void;
onClearAllSelectedKeys: () => void;
onDeleteSelectedKeys: () => void;
};
const RedisViewerKeyToolbar: React.FC<RedisViewerKeyToolbarProps> = ({
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 (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, marginBottom: 12 }}>
<div>
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '.08em', color: textMutedColor, fontWeight: 600 }}>Key Explorer</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginTop: 4 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: textPrimaryColor }}>db{redisDB}</div>
<Tag style={mutedPillTagStyle}>{getRedisTopologyLabel(topology)}</Tag>
{topology !== 'single' && (
<Tag style={mutedPillTagStyle}>{seedAddresses.length || 1} </Tag>
)}
{sentinelMaster && (
<Tag style={mutedPillTagStyle}>master: {sentinelMaster}</Tag>
)}
</div>
</div>
<Tag style={mutedPillTagStyle}>{keyCount} Keys</Tag>
</div>
<Space.Compact style={{ width: '100%' }}>
<Radio.Group
value={searchMode}
onChange={onSearchModeChange}
buttonStyle="solid"
style={{ flexShrink: 0 }}
>
<Radio.Button value="fuzzy"></Radio.Button>
<Radio.Button value="exact"></Radio.Button>
</Radio.Group>
<Search
{...noAutoCapInputProps}
style={{ flex: 1 }}
placeholder={searchMode === 'exact' ? '输入完整 Key / 命名空间精确搜索' : '搜索 Key模糊匹配'}
value={searchInput}
onChange={onSearchInputChange}
onSearch={onSearch}
allowClear
enterButton={<SearchOutlined />}
/>
</Space.Compact>
<div className={isV2Ui ? 'gn-v2-redis-toolbar' : undefined} style={{ marginTop: 12, display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<Space wrap size={8}>
<Button size="small" style={actionButtonStyle} icon={<ReloadOutlined />} onClick={onRefresh}></Button>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={onCreateKey}></Button>
<Button size="small" style={primaryActionButtonStyle} onClick={onSelectAllLoadedKeys} disabled={keyCount === 0}></Button>
<Button size="small" style={actionButtonStyle} onClick={onClearAllSelectedKeys} disabled={selectedKeyCount === 0}></Button>
</Space>
<Popconfirm
title={`确定删除选中的 ${selectedKeyCount} 个 Key`}
onConfirm={onDeleteSelectedKeys}
disabled={selectedKeyCount === 0}
>
<Button size="small" style={dangerActionButtonStyle} icon={<DeleteOutlined />} disabled={selectedKeyCount === 0}>
({selectedKeyCount})
</Button>
</Popconfirm>
</div>
</div>
);
};
export default RedisViewerKeyToolbar;

View File

@@ -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

View File

@@ -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{