🐛 fix(tdengine): 修复旧版 TDengine 元数据查询与驱动版本选择异常

- 放开 TDengine 已安装驱动的历史版本切换入口
- 兼容低版本 SHOW TABLES FROM 语法差异
- 修复表概览加载时报 [0x2600] syntax error near
- 新增后端兼容与前端交互回归测试
- Close #531
This commit is contained in:
Syngnat
2026-06-14 17:22:02 +08:00
parent 9e224d0067
commit f3e11961dc
10 changed files with 392 additions and 26 deletions

View File

@@ -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',
);
});
});

View File

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

View 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');
});
});

View File

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