mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-06 02:21:33 +08:00
🐛 fix(tdengine): 修复旧版 TDengine 元数据查询与驱动版本选择异常
- 放开 TDengine 已安装驱动的历史版本切换入口 - 兼容低版本 SHOW TABLES FROM 语法差异 - 修复表概览加载时报 [0x2600] syntax error near - 新增后端兼容与前端交互回归测试 - Close #531
This commit is contained in:
@@ -71,7 +71,32 @@ vi.mock('antd', () => {
|
||||
<input value={value} onChange={onChange} placeholder={placeholder} />
|
||||
);
|
||||
|
||||
const Select = () => null;
|
||||
const Select = ({ value, options, disabled, loading, placeholder, onOpenChange, onChange }: any) => (
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
data-select-loading={String(loading)}
|
||||
data-select-placeholder={placeholder}
|
||||
onFocus={() => onOpenChange?.(true)}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
>
|
||||
<option value="">{placeholder || ''}</option>
|
||||
{(options || []).flatMap((item: any) => {
|
||||
if (Array.isArray(item?.options)) {
|
||||
return item.options.map((grouped: any) => (
|
||||
<option key={grouped.value} value={grouped.value}>
|
||||
{String(grouped.label || grouped.value)}
|
||||
</option>
|
||||
));
|
||||
}
|
||||
return (
|
||||
<option key={item.value} value={item.value}>
|
||||
{String(item.label || item.value)}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
);
|
||||
const Progress = () => <div data-progress="true" />;
|
||||
const Tag = ({ children }: any) => <span>{children}</span>;
|
||||
const Switch = ({ checked, onChange, disabled }: any) => (
|
||||
@@ -288,4 +313,76 @@ describe('DriverManagerModal toolbar actions', () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows switching installed TDengine drivers to a historical compatible version', async () => {
|
||||
backendApp.GetDriverStatusList.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
downloadDir: 'D:/drivers',
|
||||
drivers: [
|
||||
{
|
||||
type: 'tdengine',
|
||||
name: 'TDengine',
|
||||
builtIn: false,
|
||||
pinnedVersion: '3.7.8',
|
||||
installedVersion: '3.7.8',
|
||||
runtimeAvailable: true,
|
||||
packageInstalled: true,
|
||||
connectable: true,
|
||||
defaultDownloadUrl: 'builtin://activate/tdengine',
|
||||
message: '已启用',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
backendApp.GetDriverVersionList.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
versions: [
|
||||
{ version: '3.7.8', downloadUrl: 'builtin://activate/tdengine', recommended: true },
|
||||
{ version: '3.3.1', downloadUrl: 'builtin://activate/tdengine?channel=history&version=3.3.1' },
|
||||
],
|
||||
},
|
||||
});
|
||||
backendApp.DownloadDriverPackage.mockResolvedValue({ success: true });
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<DriverManagerModal open onClose={vi.fn()} />);
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const refreshButton = findButton(renderer!, '刷新');
|
||||
await act(async () => {
|
||||
await refreshButton.props.onClick();
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const versionSelect = renderer!.root.findByType('select');
|
||||
await act(async () => {
|
||||
versionSelect.props.onFocus();
|
||||
});
|
||||
await flushPromises();
|
||||
expect(backendApp.GetDriverVersionList).toHaveBeenCalledWith('tdengine', '');
|
||||
|
||||
const reloadedVersionSelect = renderer!.root.findByType('select');
|
||||
await act(async () => {
|
||||
reloadedVersionSelect.props.onChange({ target: { value: '3.3.1@@builtin://activate/tdengine?channel=history&version=3.3.1' } });
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const switchButtons = renderer!.root.findAll((node) => node.type === 'button' && textContent(node).includes('切换版本'));
|
||||
expect(switchButtons).toHaveLength(1);
|
||||
const switchButton = switchButtons[0];
|
||||
await act(async () => {
|
||||
await switchButton.props.onClick();
|
||||
});
|
||||
|
||||
expect(backendApp.DownloadDriverPackage).toHaveBeenCalledWith(
|
||||
'tdengine',
|
||||
'3.3.1',
|
||||
'builtin://activate/tdengine?channel=history&version=3.3.1',
|
||||
'D:/drivers',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -695,6 +695,29 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
return selectedOption?.version || row.pinnedVersion || '';
|
||||
}, [selectedVersionMap, versionMap]);
|
||||
|
||||
const resolveSelectedVersionOption = useCallback((row: DriverStatusRow) => {
|
||||
const options = versionMap[row.type] || [];
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
return (
|
||||
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
|
||||
options.find((item) => item.recommended) ||
|
||||
options[0]
|
||||
);
|
||||
}, [selectedVersionMap, versionMap]);
|
||||
|
||||
const resolveInstalledDriverVersion = useCallback((row: DriverStatusRow) => (
|
||||
String(row.installedVersion || '').trim() || String(row.pinnedVersion || '').trim()
|
||||
), []);
|
||||
|
||||
const isDriverVersionSwitchPending = useCallback((row: DriverStatusRow) => {
|
||||
if (row.builtIn || (!row.packageInstalled && !row.connectable)) {
|
||||
return false;
|
||||
}
|
||||
const selectedVersion = String(resolveSelectedVersionOption(row)?.version || '').trim();
|
||||
const installedVersion = resolveInstalledDriverVersion(row);
|
||||
return !!selectedVersion && !!installedVersion && selectedVersion !== installedVersion;
|
||||
}, [resolveInstalledDriverVersion, resolveSelectedVersionOption]);
|
||||
|
||||
const installDriver = useCallback(async (
|
||||
row: DriverStatusRow,
|
||||
actionOptions?: { silentToast?: boolean; skipRefresh?: boolean },
|
||||
@@ -714,9 +737,9 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
}
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
const selectedOption =
|
||||
versionOptions.find((item) => buildVersionOptionKey(item) === selectedKey) ||
|
||||
(row.needsUpdate ? versionOptions.find((item) => item.version === row.pinnedVersion) : undefined) ||
|
||||
(row.needsUpdate ? versionOptions.find((item) => item.recommended) : undefined) ||
|
||||
versionOptions.find((item) => buildVersionOptionKey(item) === selectedKey) ||
|
||||
versionOptions.find((item) => item.recommended) ||
|
||||
versionOptions[0];
|
||||
const selectedVersion = selectedOption?.version || row.pinnedVersion || '';
|
||||
@@ -1022,20 +1045,12 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
return <Text type="secondary">内置驱动无需安装</Text>;
|
||||
}
|
||||
|
||||
const versionLocked = row.packageInstalled || row.connectable;
|
||||
if (versionLocked) {
|
||||
const installedVersion = String(row.installedVersion || '').trim();
|
||||
const revisionHint = row.needsUpdate ? ',需重装' : '';
|
||||
return (
|
||||
<Text type="secondary" className="driver-manager-version-lock">
|
||||
{installedVersion ? `${installedVersion}(已安装${revisionHint})` : `已安装${row.needsUpdate ? ',需重装' : ''}`}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const options = versionMap[row.type] || [];
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
const selectOptions = buildVersionSelectOptions(options);
|
||||
const installedVersion = resolveInstalledDriverVersion(row);
|
||||
const versionSwitchPending = isDriverVersionSwitchPending(row);
|
||||
const selectedOption = resolveSelectedVersionOption(row);
|
||||
const mongoHint = row.type === 'mongodb'
|
||||
? 'MongoDB 4.0 请使用 1.17.x 兼容驱动;2.x 驱动要求 MongoDB 4.2+。'
|
||||
: '';
|
||||
@@ -1063,6 +1078,13 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
void loadVersionPackageSize(row, value);
|
||||
}}
|
||||
/>
|
||||
{(row.packageInstalled || row.connectable) ? (
|
||||
<Text type="secondary" className="driver-manager-small-text">
|
||||
{versionSwitchPending
|
||||
? `当前已安装 ${installedVersion || '当前版本'},已选择 ${selectedOption?.version || '目标版本'},点击“切换版本”生效`
|
||||
: `${installedVersion ? `${installedVersion}(已安装` : '已安装'}${row.needsUpdate ? ',需重装' : ''}${installedVersion ? ')' : ''}`}
|
||||
</Text>
|
||||
) : null}
|
||||
{mongoHint ? <Text type="secondary" className="driver-manager-small-text">{mongoHint}</Text> : null}
|
||||
</div>
|
||||
);
|
||||
@@ -1078,6 +1100,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
const loadingLocal = actionState.driverType === row.type && actionState.kind === 'local';
|
||||
const logs = operationLogMap[row.type] || [];
|
||||
const hasLogs = logs.length > 0;
|
||||
const versionSwitchPending = isDriverVersionSwitchPending(row);
|
||||
|
||||
if (isSlimBuildUnavailable && !row.packageInstalled) {
|
||||
return <Text type="secondary">当前精简版不可安装,请使用 Full 版</Text>;
|
||||
@@ -1087,6 +1110,10 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
<Button type="primary" icon={<DownloadOutlined />} disabled={batchBusy} loading={loadingInstallOrRemove} onClick={() => installDriver(row)}>
|
||||
重装驱动
|
||||
</Button>
|
||||
) : versionSwitchPending ? (
|
||||
<Button type="primary" icon={<DownloadOutlined />} disabled={batchBusy} loading={loadingInstallOrRemove} onClick={() => installDriver(row)}>
|
||||
切换版本
|
||||
</Button>
|
||||
) : row.connectable ? (
|
||||
<Button danger icon={<DeleteOutlined />} disabled={batchBusy} loading={loadingInstallOrRemove} onClick={() => removeDriver(row)}>
|
||||
移除
|
||||
|
||||
162
frontend/src/components/TableOverview.tdengine.test.tsx
Normal file
162
frontend/src/components/TableOverview.tdengine.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import TableOverview from './TableOverview';
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
theme: 'light',
|
||||
appearance: { uiVersion: 'legacy' as const },
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-1',
|
||||
config: {
|
||||
type: 'tdengine',
|
||||
host: '127.0.0.1',
|
||||
port: 6041,
|
||||
user: 'root',
|
||||
password: 'taosdata',
|
||||
database: 'metrics',
|
||||
useSSH: false,
|
||||
ssh: { host: '', port: 22, user: '', password: '', keyPath: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
addTab: vi.fn(),
|
||||
setActiveContext: vi.fn(),
|
||||
setAIPanelVisible: vi.fn(),
|
||||
addAIContext: vi.fn(),
|
||||
pinnedSidebarTables: [] as string[],
|
||||
setSidebarTablePinned: vi.fn(),
|
||||
}));
|
||||
|
||||
const backendApp = vi.hoisted(() => ({
|
||||
DBGetTables: vi.fn(),
|
||||
DBQuery: vi.fn(),
|
||||
DBShowCreateTable: vi.fn(),
|
||||
ExportTable: vi.fn(),
|
||||
DropTable: vi.fn(),
|
||||
RenameTable: vi.fn(),
|
||||
}));
|
||||
|
||||
const messageApi = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: typeof storeState) => any) => selector(storeState),
|
||||
buildSidebarTablePinKey: (connectionId: string, dbName: string, tableName: string, schemaName: string) =>
|
||||
`${connectionId}:${dbName}:${schemaName}:${tableName}`,
|
||||
}));
|
||||
|
||||
vi.mock('../../wailsjs/go/app/App', () => backendApp);
|
||||
vi.mock('../utils/autoFetchVisibility', () => ({
|
||||
useAutoFetchVisibility: () => true,
|
||||
}));
|
||||
vi.mock('../utils/connectionRpcConfig', () => ({
|
||||
buildRpcConnectionConfig: (config: unknown) => config,
|
||||
}));
|
||||
vi.mock('./V2TableContextMenu', () => ({
|
||||
V2TableContextMenuView: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@ant-design/icons', () => {
|
||||
const Icon = () => <span />;
|
||||
return {
|
||||
TableOutlined: Icon,
|
||||
SearchOutlined: Icon,
|
||||
ReloadOutlined: Icon,
|
||||
SortAscendingOutlined: Icon,
|
||||
DatabaseOutlined: Icon,
|
||||
ConsoleSqlOutlined: Icon,
|
||||
EditOutlined: Icon,
|
||||
CopyOutlined: Icon,
|
||||
SaveOutlined: Icon,
|
||||
DeleteOutlined: Icon,
|
||||
ExportOutlined: Icon,
|
||||
AppstoreOutlined: Icon,
|
||||
UnorderedListOutlined: Icon,
|
||||
WarningOutlined: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('antd', () => {
|
||||
const Button = ({ children, onClick, ...rest }: any) => <button type="button" onClick={onClick} {...rest}>{children}</button>;
|
||||
const Input: any = ({ value, onChange, ...rest }: any) => <input value={value} onChange={onChange} {...rest} />;
|
||||
Input.Search = ({ value, onChange, ...rest }: any) => <input value={value} onChange={onChange} {...rest} />;
|
||||
const Spin = ({ children }: any) => <div>{children}</div>;
|
||||
const Empty = ({ description }: any) => <div>{description}</div>;
|
||||
const Dropdown = ({ children }: any) => <div>{children}</div>;
|
||||
const Tooltip = ({ children }: any) => <div>{children}</div>;
|
||||
const Modal: any = ({ children }: any) => <div>{children}</div>;
|
||||
Modal.confirm = vi.fn();
|
||||
return {
|
||||
Button,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Input,
|
||||
Modal,
|
||||
Spin,
|
||||
Tooltip,
|
||||
message: messageApi,
|
||||
};
|
||||
});
|
||||
|
||||
const flushPromises = async () => {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const collectText = (node: any): string => {
|
||||
if (!node) {
|
||||
return '';
|
||||
}
|
||||
if (typeof node === 'string') {
|
||||
return node;
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map((item) => collectText(item)).join('');
|
||||
}
|
||||
return collectText(node.children || []);
|
||||
};
|
||||
|
||||
describe('TableOverview tdengine compatibility', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
backendApp.DBGetTables.mockResolvedValue({
|
||||
success: true,
|
||||
data: [
|
||||
{ Table: 'd001' },
|
||||
{ Table: 'meters' },
|
||||
],
|
||||
});
|
||||
backendApp.DBQuery.mockResolvedValue({
|
||||
success: false,
|
||||
message: '[0x2600] syntax error near',
|
||||
});
|
||||
});
|
||||
|
||||
it('loads tdengine overview rows through DBGetTables instead of direct metadata SQL', async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<TableOverview tab={{
|
||||
id: 'tab-1',
|
||||
title: '表概览 - metrics',
|
||||
type: 'table-overview',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'metrics',
|
||||
} as any} />);
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(backendApp.DBGetTables).toHaveBeenCalledWith(expect.any(Object), 'metrics');
|
||||
expect(backendApp.DBQuery).not.toHaveBeenCalled();
|
||||
expect(messageApi.error).not.toHaveBeenCalled();
|
||||
const renderedText = collectText(renderer!.toJSON());
|
||||
expect(renderedText).toContain('meters');
|
||||
expect(renderedText).toContain('d001');
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'a
|
||||
import type { MenuProps } from 'antd';
|
||||
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import { buildSidebarTablePinKey, useStore } from '../store';
|
||||
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
|
||||
import { DBGetTables, DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
|
||||
import type { TabData } from '../types';
|
||||
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
@@ -231,7 +231,7 @@ const parseTableStats = (dialect: string, rows: Record<string, any>[]): TableSta
|
||||
};
|
||||
|
||||
return {
|
||||
name: strVal(['Name', 'name', 'table_name', 'tablename', 'TABLE_NAME', 'Device', 'device']),
|
||||
name: strVal(['Name', 'name', 'table_name', 'tablename', 'TABLE_NAME', 'Table', 'table', 'Device', 'device']),
|
||||
comment: strVal(['Comment', 'table_comment', 'TABLE_COMMENT', 'comments']),
|
||||
rows: numVal(['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows']),
|
||||
dataSize: numVal(['Data_length', 'data_length', 'DATA_LENGTH', 'total_bytes']),
|
||||
@@ -290,6 +290,15 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
useSSH: connection.config.useSSH || false,
|
||||
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
|
||||
};
|
||||
if (metadataDialect === 'tdengine') {
|
||||
const res = await DBGetTables(buildRpcConnectionConfig(config) as any, tab.dbName || '');
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
setTables(parseTableStats(metadataDialect, res.data));
|
||||
} else {
|
||||
message.error('获取表信息失败: ' + (res.message || '未知错误'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const sql = buildTableStatusSQL(metadataDialect, tab.dbName || '', schemaName);
|
||||
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
|
||||
Reference in New Issue
Block a user