mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-21 14:04:01 +08:00
🐛 fix(sql-editor): 修复 Oracle 事务结束并补充 Redis 拓扑提示
- SQL 编辑器:Oracle 托管事务优先使用 transaction provider 完成提交和回滚 - Redis:拆分 Key 浏览工具栏并展示 Cluster/Sentinel 拓扑上下文 - 测试:补充 Oracle 事务结束和 Redis 拓扑头部回归用例
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
152
frontend/src/components/RedisViewerKeyToolbar.tsx
Normal file
152
frontend/src/components/RedisViewerKeyToolbar.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user