mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-09 16:23:27 +08:00
✨ feat(data-import): 完善导入工作台与任务交互
- 表导入与数据库导入统一提供遇错停止和继续策略,并跟随主题变量 - 增加能力校验、高级解析选项、字节进度、停止状态与持久任务历史 - 防止重复启动和运行中面板卸载,完善六语言语句统计及回归测试
This commit is contained in:
@@ -92,6 +92,8 @@ vi.mock('../store', () => ({
|
||||
|
||||
vi.mock('../../wailsjs/go/app/App', () => ({
|
||||
ImportData: vi.fn(),
|
||||
PreviewImportFileWithOptions: vi.fn(),
|
||||
CancelImportJob: vi.fn(),
|
||||
ExportTable: vi.fn(),
|
||||
ExportData: vi.fn(),
|
||||
ExportQuery: vi.fn(),
|
||||
|
||||
@@ -3,8 +3,10 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DataImportWorkbench from './DataImportWorkbench';
|
||||
import { DEFAULT_DATA_IMPORT_PREFERENCES } from './dataImportPreferences';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dataImportCapability: vi.fn(),
|
||||
dbGetDatabases: vi.fn(),
|
||||
dbGetTables: vi.fn(),
|
||||
importData: vi.fn(),
|
||||
@@ -24,6 +26,7 @@ vi.mock('../store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../wailsjs/go/app/App', () => ({
|
||||
DataImportCapability: mocks.dataImportCapability,
|
||||
DBGetDatabases: mocks.dbGetDatabases,
|
||||
DBGetTables: mocks.dbGetTables,
|
||||
ImportData: mocks.importData,
|
||||
@@ -31,10 +34,18 @@ vi.mock('../../wailsjs/go/app/App', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('./DatabaseImportExecutionPanel', () => ({
|
||||
default: (props: Record<string, unknown>) => React.createElement(
|
||||
'mock-database-import-execution-panel',
|
||||
{ 'data-database-import-execution-panel-mock': 'true', ...props },
|
||||
),
|
||||
default: (props: Record<string, unknown>) => {
|
||||
const [runnerStatus, setRunnerStatus] = React.useState('idle');
|
||||
return React.createElement(
|
||||
'mock-database-import-execution-panel',
|
||||
{
|
||||
'data-database-import-execution-panel-mock': 'true',
|
||||
'data-mock-runner-status': runnerStatus,
|
||||
onMockRunnerStatusChange: setRunnerStatus,
|
||||
...props,
|
||||
},
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./ImportPreviewModal', () => ({
|
||||
@@ -44,11 +55,19 @@ vi.mock('./ImportPreviewModal', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./ImportJobHistoryPanel', () => ({
|
||||
default: (props: Record<string, unknown>) => React.createElement(
|
||||
'mock-import-job-history',
|
||||
{ 'data-import-job-history-mock': 'true', ...props },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('antd', async () => {
|
||||
const React = await import('react');
|
||||
const Select = (props: Record<string, unknown>) => React.createElement('mock-select', props);
|
||||
const Segmented = (props: Record<string, unknown>) => React.createElement('mock-segmented', props);
|
||||
const Button = ({ children, ...props }: any) => <button {...props}>{children}</button>;
|
||||
const Checkbox = ({ children, ...props }: any) => React.createElement('mock-checkbox', props, children);
|
||||
const Alert = (props: Record<string, unknown>) => React.createElement('mock-alert', props);
|
||||
const Empty = ({ description, ...props }: any) => React.createElement('mock-empty', props, description);
|
||||
Empty.PRESENTED_IMAGE_SIMPLE = 'simple';
|
||||
@@ -57,6 +76,7 @@ vi.mock('antd', async () => {
|
||||
return {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Empty,
|
||||
Segmented,
|
||||
Select,
|
||||
@@ -85,6 +105,50 @@ const createTab = (overrides: Record<string, unknown> = {}) => ({
|
||||
...overrides,
|
||||
} as any);
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
get length() { return this.values.size; }
|
||||
clear() { this.values.clear(); }
|
||||
getItem(key: string) { return this.values.get(key) ?? null; }
|
||||
key(index: number) { return Array.from(this.values.keys())[index] ?? null; }
|
||||
removeItem(key: string) { this.values.delete(key); }
|
||||
setItem(key: string, value: string) { this.values.set(key, value); }
|
||||
}
|
||||
|
||||
const createImportCapability = (
|
||||
tableSupported = true,
|
||||
tableReason = '',
|
||||
sqlFileSupported = true,
|
||||
sqlFileReason = '',
|
||||
) => ({
|
||||
databaseType: 'mysql',
|
||||
tableImport: {
|
||||
supported: tableSupported,
|
||||
reason: tableReason,
|
||||
requiresPinnedSession: false,
|
||||
supportsTransactionalBatch: tableSupported,
|
||||
supportsContinue: tableSupported,
|
||||
supportedConflictPolicies: tableSupported ? ['stop', 'skip_duplicates', 'upsert'] : [],
|
||||
supportedFormats: tableSupported ? ['csv', 'json', 'xlsx'] : [],
|
||||
supportedEncodings: tableSupported ? ['utf-8'] : [],
|
||||
supportedCompressions: [],
|
||||
supportedClientDirectives: [],
|
||||
},
|
||||
sqlFileImport: {
|
||||
supported: sqlFileSupported,
|
||||
reason: sqlFileReason,
|
||||
requiresPinnedSession: true,
|
||||
supportsTransactionalBatch: sqlFileSupported,
|
||||
supportsContinue: sqlFileSupported,
|
||||
supportedConflictPolicies: [],
|
||||
supportedFormats: sqlFileSupported ? ['sql'] : [],
|
||||
supportedEncodings: sqlFileSupported ? ['utf-8', 'utf-16le', 'utf-16be'] : [],
|
||||
supportedCompressions: sqlFileSupported ? ['gzip'] : [],
|
||||
supportedClientDirectives: sqlFileSupported ? ['delimiter'] : [],
|
||||
},
|
||||
});
|
||||
|
||||
const renderWorkbench = async (overrides: Record<string, unknown> = {}) => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
@@ -98,6 +162,7 @@ const renderWorkbench = async (overrides: Record<string, unknown> = {}) => {
|
||||
|
||||
describe('DataImportWorkbench', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', new MemoryStorage());
|
||||
mocks.storeState.theme = 'light';
|
||||
mocks.storeState.connections = [
|
||||
{
|
||||
@@ -156,6 +221,512 @@ describe('DataImportWorkbench', () => {
|
||||
mocks.messageError.mockReset();
|
||||
mocks.messageSuccess.mockReset();
|
||||
mocks.addTab.mockReset();
|
||||
mocks.dataImportCapability.mockReset();
|
||||
mocks.dataImportCapability.mockResolvedValue(createImportCapability());
|
||||
});
|
||||
|
||||
it('persists independent table and database policies and passes table parser options to preview', async () => {
|
||||
const renderer = await renderWorkbench();
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-continue-on-error': 'true',
|
||||
}).props.onChange({ target: { checked: true } });
|
||||
renderer.root.findByProps({
|
||||
'data-import-option-encoding': 'true',
|
||||
}).props.onChange('gb18030');
|
||||
renderer.root.findByProps({
|
||||
'data-import-option-delimiter': 'true',
|
||||
}).props.onChange('tab');
|
||||
renderer.root.findByProps({
|
||||
'data-import-option-header-row': 'true',
|
||||
}).props.onChange({ target: { value: '3' } });
|
||||
renderer.root.findByProps({
|
||||
'data-import-option-null-token': 'true',
|
||||
}).props.onChange({ target: { value: '\\N' } });
|
||||
renderer.root.findByProps({
|
||||
'data-import-option-empty-string-as-null': 'true',
|
||||
}).props.onChange({ target: { checked: true } });
|
||||
renderer.root.findByProps({
|
||||
'data-import-option-sheet-name': 'true',
|
||||
}).props.onChange({ target: { value: 'Sheet2' } });
|
||||
renderer.root.findByProps({
|
||||
'data-import-option-conflict-policy': 'true',
|
||||
}).props.onChange('upsert');
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-option-conflict-keys': 'true',
|
||||
}).props.onChange({ target: { value: 'id, tenant_id, id' } });
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-preview-mock': 'true',
|
||||
}).props.importOptions).toMatchObject({
|
||||
continueOnError: true,
|
||||
encoding: 'gb18030',
|
||||
delimiter: 'tab',
|
||||
headerRow: 3,
|
||||
nullToken: '\\N',
|
||||
emptyStringAsNull: true,
|
||||
sheetName: 'Sheet2',
|
||||
conflictPolicy: 'upsert',
|
||||
conflictKeyColumns: ['id', 'tenant_id'],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
const restored = await renderWorkbench();
|
||||
expect(restored.root.findByProps({
|
||||
'data-import-continue-on-error': 'true',
|
||||
}).props.checked).toBe(true);
|
||||
expect(restored.root.findByProps({
|
||||
'data-import-option-encoding': 'true',
|
||||
}).props.value).toBe('gb18030');
|
||||
|
||||
await act(async () => {
|
||||
restored.root.findByProps({
|
||||
'data-import-mode-selector': 'true',
|
||||
}).props.onChange('database');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(restored.root.findByProps({
|
||||
'data-import-continue-on-error': 'true',
|
||||
}).props.checked).toBe(false);
|
||||
expect(restored.root.findAllByProps({
|
||||
'data-import-advanced-options': 'true',
|
||||
})).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps the controlled conflict key input aligned with normalized submitted columns', async () => {
|
||||
const renderer = await renderWorkbench();
|
||||
const overlongColumn = 'x'.repeat(300);
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-option-conflict-policy': 'true',
|
||||
}).props.onChange('upsert');
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-option-conflict-keys': 'true',
|
||||
}).props.onChange({ target: { value: ` id, ${overlongColumn}, ID ` } });
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-option-conflict-keys': 'true',
|
||||
}).props.value).toBe(`id, ${'x'.repeat(255)}`);
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-preview-mock': 'true',
|
||||
}).props.importOptions.conflictKeyColumns).toEqual(['id', 'x'.repeat(255)]);
|
||||
});
|
||||
|
||||
it('renders import job history without exposing a resume action', async () => {
|
||||
const renderer = await renderWorkbench();
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-job-history-mock': 'true',
|
||||
})).toBeDefined();
|
||||
expect(renderer.root.findAllByProps({
|
||||
'data-import-history-resume-action': true,
|
||||
})).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refreshes durable history when an import starts and when it finishes', async () => {
|
||||
const renderer = await renderWorkbench();
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-job-history-mock': 'true',
|
||||
}).props.refreshToken).toBe(0);
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const preview = renderer.root.findByProps({ 'data-import-preview-mock': 'true' });
|
||||
|
||||
await act(async () => {
|
||||
preview.props.onImportingChange(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-job-history-mock': 'true',
|
||||
}).props.refreshToken).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
preview.props.onImportingChange(false);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-job-history-mock': 'true',
|
||||
}).props.refreshToken).toBe(2);
|
||||
});
|
||||
|
||||
it('fails closed when a persisted conflict policy is unsupported by the backend capability', async () => {
|
||||
globalThis.localStorage.setItem(
|
||||
'gonavi:data-import-preferences:v1:table',
|
||||
JSON.stringify({
|
||||
...DEFAULT_DATA_IMPORT_PREFERENCES,
|
||||
conflictPolicy: 'upsert',
|
||||
conflictKeyColumns: ['id'],
|
||||
}),
|
||||
);
|
||||
const capability = createImportCapability();
|
||||
capability.tableImport.supportedConflictPolicies = ['stop'];
|
||||
mocks.dataImportCapability.mockResolvedValue(capability);
|
||||
|
||||
const renderer = await renderWorkbench();
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.disabled).toBe(true);
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-conflict-policy-error': 'unsupported',
|
||||
})).toBeDefined();
|
||||
const policyOptions = renderer.root.findByProps({
|
||||
'data-import-option-conflict-policy': 'true',
|
||||
}).props.options;
|
||||
expect(policyOptions.find((option: any) => option.value === 'upsert').disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('loads the backend capability for the selected connection before enabling file selection', async () => {
|
||||
const renderer = await renderWorkbench();
|
||||
|
||||
expect(mocks.dataImportCapability).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'mysql' }),
|
||||
);
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the file formats, encodings, compression and client directives supported by the backend', async () => {
|
||||
const renderer = await renderWorkbench();
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-mode-selector': 'true',
|
||||
}).props.onChange('database');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-capability-detail': 'formats',
|
||||
}).props.children.join('')).toContain('sql');
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-capability-detail': 'encodings',
|
||||
}).props.children.join('')).toContain('utf-16le');
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-capability-detail': 'compressions',
|
||||
}).props.children.join('')).toContain('gzip');
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-capability-detail': 'directives',
|
||||
}).props.children.join('')).toContain('delimiter');
|
||||
});
|
||||
|
||||
it('blocks the current mode and shows the backend reason when it is unsupported', async () => {
|
||||
mocks.dataImportCapability.mockResolvedValueOnce(
|
||||
createImportCapability(false, 'table_import_runtime_unavailable'),
|
||||
);
|
||||
|
||||
const renderer = await renderWorkbench();
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.disabled).toBe(true);
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-continue-on-error': 'true',
|
||||
}).props.disabled).toBe(true);
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-capability-alert': 'true',
|
||||
}).props['data-import-capability-reason']).toBe('table_import_runtime_unavailable');
|
||||
});
|
||||
|
||||
it('fails closed and shows a recoverable reason when the capability RPC fails', async () => {
|
||||
mocks.dataImportCapability.mockRejectedValueOnce(new Error('backend offline'));
|
||||
|
||||
const renderer = await renderWorkbench();
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.disabled).toBe(true);
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-capability-alert': 'true',
|
||||
}).props['data-import-capability-reason']).toBe('rpc_failed');
|
||||
});
|
||||
|
||||
it('retries a failed capability request while keeping import actions closed', async () => {
|
||||
let resolveRetry!: (value: ReturnType<typeof createImportCapability>) => void;
|
||||
mocks.dataImportCapability
|
||||
.mockRejectedValueOnce(new Error('backend offline'))
|
||||
.mockReturnValueOnce(new Promise((resolve) => {
|
||||
resolveRetry = resolve;
|
||||
}));
|
||||
|
||||
const renderer = await renderWorkbench();
|
||||
const capabilityAlert = renderer.root.findByProps({
|
||||
'data-import-capability-alert': 'true',
|
||||
});
|
||||
const retryAction = capabilityAlert.props.action;
|
||||
|
||||
expect(retryAction.props['data-import-capability-retry']).toBe('true');
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
retryAction.props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.dataImportCapability).toHaveBeenCalledTimes(2);
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-capability-alert': 'true',
|
||||
}).props['data-import-capability-reason']).toBe('loading');
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveRetry(createImportCapability());
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(renderer.root.findAllByProps({
|
||||
'data-import-capability-alert': 'true',
|
||||
})).toHaveLength(0);
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed when the capability binding throws synchronously', async () => {
|
||||
mocks.dataImportCapability.mockImplementationOnce(() => {
|
||||
throw new Error('binding unavailable');
|
||||
});
|
||||
|
||||
const renderer = await renderWorkbench();
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.disabled).toBe(true);
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-capability-alert': 'true',
|
||||
}).props['data-import-capability-reason']).toBe('rpc_failed');
|
||||
});
|
||||
|
||||
it('keeps file selection closed while the capability is loading', async () => {
|
||||
mocks.dataImportCapability.mockReturnValueOnce(new Promise(() => {}));
|
||||
|
||||
const renderer = await renderWorkbench();
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.disabled).toBe(true);
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-capability-alert': 'true',
|
||||
}).props['data-import-capability-reason']).toBe('loading');
|
||||
});
|
||||
|
||||
it('ignores a stale capability response after the selected connection changes', async () => {
|
||||
mocks.storeState.connections.push({
|
||||
id: 'conn-2',
|
||||
name: 'Analytics PostgreSQL',
|
||||
config: {
|
||||
type: 'postgres',
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
database: 'app',
|
||||
},
|
||||
includeDatabases: ['app'],
|
||||
});
|
||||
let resolveFirst!: (value: unknown) => void;
|
||||
mocks.dataImportCapability
|
||||
.mockReturnValueOnce(new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}))
|
||||
.mockResolvedValueOnce(
|
||||
createImportCapability(false, 'table_import_runtime_unavailable'),
|
||||
);
|
||||
const renderer = await renderWorkbench();
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-target-field': 'connection',
|
||||
}).props.onChange('conn-2');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-capability-alert': 'true',
|
||||
}).props['data-import-capability-reason']).toBe('table_import_runtime_unavailable');
|
||||
|
||||
await act(async () => {
|
||||
resolveFirst(createImportCapability());
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-target-field': 'connection',
|
||||
}).props.value).toBe('conn-2');
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-capability-alert': 'true',
|
||||
}).props['data-import-capability-reason']).toBe('table_import_runtime_unavailable');
|
||||
});
|
||||
|
||||
it('does not pre-filter database imports with the SQL export capability heuristic', async () => {
|
||||
const renderer = await renderWorkbench({
|
||||
dataImportMode: 'database',
|
||||
dataImportLaunchKey: 'database-capability-filter',
|
||||
tableName: undefined,
|
||||
});
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-target-field': 'connection',
|
||||
}).props.options.map((option: any) => option.value)).toEqual([
|
||||
'conn-1',
|
||||
'redis-1',
|
||||
'mongo-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the SQL-file capability when database import mode is active', async () => {
|
||||
mocks.dataImportCapability.mockResolvedValueOnce(
|
||||
createImportCapability(true, '', false, 'pinned_session_unavailable'),
|
||||
);
|
||||
|
||||
const renderer = await renderWorkbench({
|
||||
dataImportMode: 'database',
|
||||
dataImportLaunchKey: 'database-sql-capability',
|
||||
tableName: undefined,
|
||||
});
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.disabled).toBe(true);
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-capability-alert': 'true',
|
||||
}).props['data-import-capability-reason']).toBe('pinned_session_unavailable');
|
||||
});
|
||||
|
||||
it('uses the shared theme tokens for the workbench surfaces', async () => {
|
||||
const renderer = await renderWorkbench();
|
||||
const workbench = renderer.root.findByProps({
|
||||
'data-data-import-workbench': 'true',
|
||||
});
|
||||
const header = renderer.root.findByType('header');
|
||||
const target = renderer.root.findByProps({
|
||||
'data-data-import-target-config': 'true',
|
||||
});
|
||||
const preview = renderer.root.findByProps({
|
||||
'data-data-import-preview-panel': 'true',
|
||||
});
|
||||
|
||||
expect(workbench.props.style.background).toContain('var(--gn-bg-panel-2');
|
||||
expect(header.props.style.background).toContain('var(--gn-bg-panel');
|
||||
expect(header.props.style.borderBottom).toContain('var(--gn-br-1');
|
||||
expect(target.props.style.background).toContain('var(--gn-bg-panel');
|
||||
expect(target.props.style.border).toContain('var(--gn-br-1');
|
||||
expect(preview.props.style.background).toContain('var(--gn-bg-panel');
|
||||
expect(preview.props.style.border).toContain('var(--gn-br-1');
|
||||
});
|
||||
|
||||
it('shows the error policy in both import modes and carries it into execution', async () => {
|
||||
const tableRenderer = await renderWorkbench();
|
||||
const tableCheckbox = tableRenderer.root.findByProps({
|
||||
'data-import-continue-on-error': 'true',
|
||||
});
|
||||
expect(tableCheckbox.props).toMatchObject({ checked: false, disabled: false });
|
||||
|
||||
await act(async () => {
|
||||
tableCheckbox.props.onChange({ target: { checked: true } });
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
tableRenderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const preview = tableRenderer.root.findByProps({
|
||||
'data-import-preview-mock': 'true',
|
||||
});
|
||||
expect(preview.props.continueOnError).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
preview.props.onImportingChange(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(tableRenderer.root.findByProps({
|
||||
'data-import-continue-on-error': 'true',
|
||||
}).props.disabled).toBe(true);
|
||||
|
||||
const renderer = await renderWorkbench({
|
||||
dataImportMode: 'database',
|
||||
dataImportLaunchKey: 'database-launch-1',
|
||||
tableName: undefined,
|
||||
});
|
||||
const checkbox = renderer.root.findByProps({
|
||||
'data-import-continue-on-error': 'true',
|
||||
});
|
||||
expect(checkbox.props).toMatchObject({ checked: false, disabled: false });
|
||||
|
||||
await act(async () => {
|
||||
checkbox.props.onChange({ target: { checked: true } });
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-continue-on-error': 'true',
|
||||
}).props.checked).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const executionPanel = renderer.root.findByProps({
|
||||
'data-database-import-execution-panel-mock': 'true',
|
||||
});
|
||||
expect(executionPanel.props.continueOnError).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
executionPanel.props.onRunningChange(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-continue-on-error': 'true',
|
||||
}).props.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('filters non-relational and protected connections while loading the prefilled target', async () => {
|
||||
@@ -302,7 +873,7 @@ describe('DataImportWorkbench', () => {
|
||||
'data-import-target-field': 'connection',
|
||||
});
|
||||
|
||||
expect(connectionSelect.props.options.map((option: any) => option.value)).toEqual(['conn-1']);
|
||||
expect(connectionSelect.props.options.map((option: any) => option.value)).toEqual(['conn-1', 'redis-1']);
|
||||
expect(renderer.root.findAllByProps({ 'data-import-target-field': 'table' })).toHaveLength(0);
|
||||
expect(mocks.dbGetTables).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -409,6 +980,53 @@ describe('DataImportWorkbench', () => {
|
||||
expect(executionPanel.props.connectionConfig).toEqual(expect.objectContaining({ type: 'mysql' }));
|
||||
});
|
||||
|
||||
it('resets the database execution state when a different SQL file is selected', async () => {
|
||||
const renderer = await renderWorkbench({
|
||||
dataImportMode: 'database',
|
||||
dataImportLaunchKey: 'database-launch-1',
|
||||
tableName: undefined,
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const firstExecutionPanel = renderer.root.findByProps({
|
||||
'data-database-import-execution-panel-mock': 'true',
|
||||
});
|
||||
await act(async () => {
|
||||
firstExecutionPanel.props.onMockRunnerStatusChange('error');
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(renderer.root.findByProps({
|
||||
'data-database-import-execution-panel-mock': 'true',
|
||||
}).props['data-mock-runner-status']).toBe('error');
|
||||
|
||||
mocks.selectSQLFileForExecution.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: { filePath: '/tmp/replacement.sql', fileSizeMB: '2.5' },
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const replacementExecutionPanel = renderer.root.findByProps({
|
||||
'data-database-import-execution-panel-mock': 'true',
|
||||
});
|
||||
expect(replacementExecutionPanel.props).toMatchObject({
|
||||
filePath: '/tmp/replacement.sql',
|
||||
fileSizeMB: '2.5',
|
||||
'data-mock-runner-status': 'idle',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows selecting a database SQL file without a default database', async () => {
|
||||
const renderer = await renderWorkbench({
|
||||
dataImportMode: 'database',
|
||||
@@ -653,6 +1271,52 @@ describe('DataImportWorkbench', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('keeps an active database import mounted while its connection record changes', async () => {
|
||||
const tab = createTab({
|
||||
dataImportMode: 'database',
|
||||
dataImportLaunchKey: 'database-connection-refresh',
|
||||
tableName: undefined,
|
||||
});
|
||||
const renderer = await renderWorkbench(tab);
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-select-file-action': 'true',
|
||||
}).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const executionPanel = renderer.root.findByProps({
|
||||
'data-database-import-execution-panel-mock': 'true',
|
||||
});
|
||||
await act(async () => {
|
||||
executionPanel.props.onRunningChange(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
const capabilityCallsBeforeRefresh = mocks.dataImportCapability.mock.calls.length;
|
||||
const databaseCallsBeforeRefresh = mocks.dbGetDatabases.mock.calls.length;
|
||||
mocks.dataImportCapability.mockImplementation(() => new Promise(() => undefined));
|
||||
mocks.storeState.connections = mocks.storeState.connections.map((connection) => (
|
||||
connection.id === 'conn-1'
|
||||
? { ...connection, config: { ...connection.config, host: '127.0.0.1' } }
|
||||
: connection
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
renderer.update(<DataImportWorkbench tab={tab} />);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-database-import-execution-panel-mock': 'true',
|
||||
}).props).toMatchObject({
|
||||
dbName: 'app',
|
||||
filePath: '/tmp/full-backup.sql',
|
||||
});
|
||||
expect(mocks.dataImportCapability).toHaveBeenCalledTimes(capabilityCallsBeforeRefresh);
|
||||
expect(mocks.dbGetDatabases).toHaveBeenCalledTimes(databaseCallsBeforeRefresh);
|
||||
});
|
||||
|
||||
it('does not replace an active import target when the stable tab is reopened', async () => {
|
||||
const renderer = await renderWorkbench();
|
||||
const selectFileButton = renderer.root.findByProps({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Alert, Button, Empty, Segmented, Select, Typography, message } from 'antd';
|
||||
import { Alert, Button, Checkbox, Empty, Segmented, Select, Typography, message } from 'antd';
|
||||
import {
|
||||
DatabaseOutlined,
|
||||
FileAddOutlined,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import {
|
||||
DataImportCapability as LoadDataImportCapability,
|
||||
DBGetDatabases,
|
||||
DBGetTables,
|
||||
ImportData,
|
||||
@@ -29,7 +30,19 @@ import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { normalizeTableNamesFromMetadataRows } from '../utils/tableMetadataRows';
|
||||
import type { DataImportMode } from '../utils/dataImportTab';
|
||||
import DatabaseImportExecutionPanel from './DatabaseImportExecutionPanel';
|
||||
import ImportJobHistoryPanel from './ImportJobHistoryPanel';
|
||||
import ImportPreviewModal from './ImportPreviewModal';
|
||||
import {
|
||||
resolveDataImportCapabilityReasonKey,
|
||||
resolveDataImportModeCapability,
|
||||
type DataImportCapabilityDTO,
|
||||
} from './dataImportCapability';
|
||||
import {
|
||||
loadDataImportPreferences,
|
||||
saveDataImportPreferences,
|
||||
type DataImportPreferenceScope,
|
||||
type DataImportPreferences,
|
||||
} from './dataImportPreferences';
|
||||
import './DataImportWorkbench.css';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
@@ -46,6 +59,12 @@ type SelectOption = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
type CapabilityLoadState = {
|
||||
connectionId: string;
|
||||
status: 'idle' | 'loading' | 'ready' | 'error';
|
||||
value?: DataImportCapabilityDTO;
|
||||
};
|
||||
|
||||
const normalizeConnectionConfig = (connection: SavedConnection) => ({
|
||||
...connection.config,
|
||||
port: Number(connection.config.port),
|
||||
@@ -79,17 +98,48 @@ const getFileName = (filePath: string): string => {
|
||||
return parts[parts.length - 1] || filePath;
|
||||
};
|
||||
|
||||
const resolvePreferenceStorage = (): Storage | null => {
|
||||
try {
|
||||
return typeof globalThis.localStorage === 'undefined' ? null : globalThis.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseConflictKeyColumns = (value: string): string[] => (
|
||||
String(value || '')
|
||||
.split(',')
|
||||
.map((column) => column.trim().slice(0, 255))
|
||||
.filter(Boolean)
|
||||
.filter((column, index, columns) => (
|
||||
columns.findIndex((candidate) => candidate.toLowerCase() === column.toLowerCase()) === index
|
||||
))
|
||||
.slice(0, 64)
|
||||
);
|
||||
|
||||
const normalizeConflictKeyColumnsInput = (value: string): {
|
||||
columns: string[];
|
||||
displayValue: string;
|
||||
} => {
|
||||
const inputValue = String(value || '');
|
||||
const columns = parseConflictKeyColumns(inputValue);
|
||||
const trailingSeparator = inputValue.match(/,\s*$/)?.[0] || '';
|
||||
return {
|
||||
columns,
|
||||
displayValue: `${columns.join(', ')}${columns.length < 64 ? trailingSeparator : ''}`,
|
||||
};
|
||||
};
|
||||
|
||||
const isEligibleImportConnection = (
|
||||
connection: SavedConnection,
|
||||
mode: DataImportMode,
|
||||
): boolean => {
|
||||
const capabilities = getDataSourceCapabilities(connection.config);
|
||||
if (mode === 'table') {
|
||||
const capabilities = getDataSourceCapabilities(connection.config);
|
||||
return capabilities.supportsCopyInsert
|
||||
&& !isConnectionDataImportRestricted(connection.config);
|
||||
}
|
||||
return capabilities.supportsSqlQueryExport
|
||||
&& !isConnectionDataImportRestricted(connection.config)
|
||||
return !isConnectionDataImportRestricted(connection.config)
|
||||
&& !isConnectionStructureEditRestricted(connection.config)
|
||||
&& !isConnectionScriptExecutionRestricted(connection.config);
|
||||
};
|
||||
@@ -127,11 +177,28 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [selectingFile, setSelectingFile] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [preferencesByMode, setPreferencesByMode] = useState<Record<DataImportPreferenceScope, DataImportPreferences>>(() => {
|
||||
const storage = resolvePreferenceStorage();
|
||||
return {
|
||||
table: loadDataImportPreferences(storage, 'table'),
|
||||
database: loadDataImportPreferences(storage, 'database'),
|
||||
};
|
||||
});
|
||||
const [conflictKeyColumnsInput, setConflictKeyColumnsInput] = useState(
|
||||
() => preferencesByMode.table.conflictKeyColumns.join(', '),
|
||||
);
|
||||
const [historyRefreshToken, setHistoryRefreshToken] = useState(0);
|
||||
const [databaseError, setDatabaseError] = useState('');
|
||||
const [tableError, setTableError] = useState('');
|
||||
const [capabilityState, setCapabilityState] = useState<CapabilityLoadState>({
|
||||
connectionId: '',
|
||||
status: 'idle',
|
||||
});
|
||||
const [capabilityRequestToken, setCapabilityRequestToken] = useState(0);
|
||||
const appliedPrefillRef = useRef<string | null>(null);
|
||||
const fileSelectionRequestRef = useRef(0);
|
||||
const tabRef = useRef(tab);
|
||||
const wasImportingRef = useRef(false);
|
||||
tabRef.current = tab;
|
||||
|
||||
const selectedConnection = useMemo(
|
||||
@@ -143,6 +210,55 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
[selectedConnection],
|
||||
);
|
||||
const targetLocked = Boolean(filePath) || importing;
|
||||
const activeCapability = capabilityState.connectionId === selectedConnectionId
|
||||
&& capabilityState.status === 'ready'
|
||||
? capabilityState.value
|
||||
: undefined;
|
||||
const modeCapability = resolveDataImportModeCapability(
|
||||
activeCapability,
|
||||
importMode === 'database' ? 'sqlFile' : 'table',
|
||||
);
|
||||
const capabilityAllowsImport = capabilityState.connectionId === selectedConnectionId
|
||||
&& capabilityState.status === 'ready'
|
||||
&& modeCapability.supported;
|
||||
const activePreferences = preferencesByMode[importMode];
|
||||
const continueOnError = capabilityAllowsImport
|
||||
&& modeCapability.supportsContinue
|
||||
&& activePreferences.continueOnError;
|
||||
const supportedConflictPolicies = modeCapability.supportedConflictPolicies || [];
|
||||
const conflictPolicySupported = importMode !== 'table'
|
||||
|| !capabilityAllowsImport
|
||||
|| supportedConflictPolicies.includes(activePreferences.conflictPolicy);
|
||||
const conflictKeysValid = activePreferences.conflictPolicy !== 'upsert'
|
||||
|| activePreferences.conflictKeyColumns.length > 0;
|
||||
const tableImportOptionsValid = conflictPolicySupported && conflictKeysValid;
|
||||
const capabilityReason = capabilityState.connectionId === selectedConnectionId
|
||||
? capabilityState.status === 'loading'
|
||||
? 'loading'
|
||||
: capabilityState.status === 'error'
|
||||
? 'rpc_failed'
|
||||
: capabilityState.status === 'ready' && !modeCapability.supported
|
||||
? (modeCapability.reason || 'capability_unavailable')
|
||||
: ''
|
||||
: '';
|
||||
const capabilityMessageKey = capabilityReason === 'loading'
|
||||
? 'data_import.capability.loading'
|
||||
: capabilityReason === 'rpc_failed'
|
||||
? 'data_import.capability.rpc_failed'
|
||||
: capabilityReason
|
||||
? resolveDataImportCapabilityReasonKey(capabilityReason)
|
||||
: '';
|
||||
const capabilityDetails = [
|
||||
{ key: 'formats', values: modeCapability.supportedFormats },
|
||||
{ key: 'encodings', values: modeCapability.supportedEncodings },
|
||||
{ key: 'compressions', values: modeCapability.supportedCompressions },
|
||||
{ key: 'directives', values: modeCapability.supportedClientDirectives },
|
||||
].map(({ key, values }) => ({
|
||||
key,
|
||||
values: Array.isArray(values)
|
||||
? Array.from(new Set(values.map((value) => String(value || '').trim()).filter(Boolean)))
|
||||
: [],
|
||||
})).filter(({ values }) => values.length > 0);
|
||||
|
||||
const syncWorkbenchTab = useCallback((patch: Partial<TabData>) => {
|
||||
addTab({
|
||||
@@ -153,6 +269,17 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
});
|
||||
}, [addTab]);
|
||||
|
||||
const updateImportPreferences = useCallback((
|
||||
scope: DataImportPreferenceScope,
|
||||
patch: Partial<DataImportPreferences>,
|
||||
) => {
|
||||
setPreferencesByMode((current) => {
|
||||
const nextPreferences = { ...current[scope], ...patch };
|
||||
saveDataImportPreferences(resolvePreferenceStorage(), scope, nextPreferences);
|
||||
return { ...current, [scope]: nextPreferences };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const invalidateFileSelection = useCallback(() => {
|
||||
fileSelectionRequestRef.current += 1;
|
||||
setSelectingFile(false);
|
||||
@@ -218,6 +345,36 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (importing) return undefined;
|
||||
if (!selectedConnectionConfig || !selectedConnectionId) {
|
||||
setCapabilityState({ connectionId: '', status: 'idle' });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setCapabilityState({ connectionId: selectedConnectionId, status: 'loading' });
|
||||
void Promise.resolve()
|
||||
.then(() => LoadDataImportCapability(buildRpcConnectionConfig(selectedConnectionConfig) as any))
|
||||
.then((capability) => {
|
||||
if (!active) return;
|
||||
setCapabilityState({
|
||||
connectionId: selectedConnectionId,
|
||||
status: 'ready',
|
||||
value: capability as unknown as DataImportCapabilityDTO,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
setCapabilityState({ connectionId: selectedConnectionId, status: 'error' });
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [capabilityRequestToken, importing, selectedConnectionConfig, selectedConnectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (importing) return undefined;
|
||||
if (!selectedConnectionConfig || !selectedConnection) {
|
||||
setDatabaseOptions([]);
|
||||
setLoadingDatabases(false);
|
||||
@@ -285,9 +442,10 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [invalidateFileSelection, selectedConnection, selectedConnectionConfig, t]);
|
||||
}, [importing, invalidateFileSelection, selectedConnection, selectedConnectionConfig, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (importing) return undefined;
|
||||
if (importMode !== 'table' || !selectedConnectionConfig || !selectedDbName) {
|
||||
setTableOptions([]);
|
||||
setLoadingTables(false);
|
||||
@@ -331,7 +489,7 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [importMode, invalidateFileSelection, selectedConnectionConfig, selectedDbName, t]);
|
||||
}, [importMode, importing, invalidateFileSelection, selectedConnectionConfig, selectedDbName, t]);
|
||||
|
||||
const clearSelectedFile = () => {
|
||||
invalidateFileSelection();
|
||||
@@ -401,6 +559,10 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
};
|
||||
|
||||
const handleImportingChange = useCallback((nextImporting: boolean) => {
|
||||
if (wasImportingRef.current !== nextImporting) {
|
||||
setHistoryRefreshToken((current) => current + 1);
|
||||
}
|
||||
wasImportingRef.current = nextImporting;
|
||||
setImporting(nextImporting);
|
||||
syncWorkbenchTab({
|
||||
connectionId: selectedConnectionId,
|
||||
@@ -412,7 +574,8 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}, [importMode, selectedConnectionId, selectedDbName, selectedTableName, syncWorkbenchTab]);
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
if (!selectedConnectionConfig || !selectedConnection || loadingDatabases || loadingTables) return;
|
||||
if (!capabilityAllowsImport || !selectedConnectionConfig || !selectedConnection || loadingDatabases || loadingTables) return;
|
||||
if (importMode === 'table' && !tableImportOptionsValid) return;
|
||||
if (importMode === 'table' && (!selectedDbName || !selectedTableName)) return;
|
||||
if (selectedDbName && !isDatabaseVisible(selectedConnection, selectedDbName)) return;
|
||||
const requestId = fileSelectionRequestRef.current + 1;
|
||||
@@ -450,12 +613,14 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const shellBackground = darkMode ? '#101319' : '#f5f7fb';
|
||||
const panelBackground = darkMode ? '#161b22' : '#ffffff';
|
||||
const panelBorder = darkMode
|
||||
? '1px solid rgba(255,255,255,0.08)'
|
||||
: '1px solid rgba(15,23,42,0.08)';
|
||||
const selectedFileBackground = darkMode ? 'rgba(255,255,255,0.04)' : '#f8fafc';
|
||||
const shellBackground = `var(--gn-bg-panel-2, ${darkMode ? '#101319' : '#f5f7fb'})`;
|
||||
const panelBackground = `var(--gn-bg-panel, ${darkMode ? '#161b22' : '#ffffff'})`;
|
||||
const panelBorder = `1px solid var(--gn-br-1, ${darkMode
|
||||
? 'rgba(255,255,255,0.08)'
|
||||
: 'rgba(15,23,42,0.08)'})`;
|
||||
const selectedFileBackground = `var(--gn-bg-subtle, var(--gn-bg-panel-2, ${darkMode
|
||||
? 'rgba(255,255,255,0.04)'
|
||||
: '#f8fafc'}))`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -599,6 +764,25 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
{databaseError && <Alert type="error" showIcon message={databaseError} />}
|
||||
{importMode === 'table' && tableError && <Alert type="error" showIcon message={tableError} />}
|
||||
{capabilityReason && (
|
||||
<Alert
|
||||
data-import-capability-alert="true"
|
||||
data-import-capability-reason={capabilityReason}
|
||||
type={capabilityReason === 'loading' ? 'info' : 'error'}
|
||||
showIcon
|
||||
message={t(capabilityMessageKey)}
|
||||
action={capabilityReason === 'rpc_failed' ? (
|
||||
<Button
|
||||
data-import-capability-retry="true"
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => setCapabilityRequestToken((current) => current + 1)}
|
||||
>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<Text type="secondary">
|
||||
@@ -631,9 +815,11 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
loading={selectingFile}
|
||||
disabled={
|
||||
importing
|
||||
|| !capabilityAllowsImport
|
||||
|| !selectedConnectionConfig
|
||||
|| loadingDatabases
|
||||
|| loadingTables
|
||||
|| (importMode === 'table' && !tableImportOptionsValid)
|
||||
|| (importMode === 'table' && (!selectedDbName || !selectedTableName))
|
||||
}
|
||||
onClick={() => void handleSelectFile()}
|
||||
@@ -651,7 +837,231 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
? t('data_import.workbench.helper.sql_file')
|
||||
: t('data_import.workbench.helper.file_formats')}
|
||||
</Text>
|
||||
{capabilityAllowsImport && capabilityDetails.length > 0 ? (
|
||||
<div
|
||||
data-import-capability-details="true"
|
||||
style={{ display: 'grid', gap: 3 }}
|
||||
>
|
||||
{capabilityDetails.map(({ key, values }) => (
|
||||
<Text
|
||||
key={key}
|
||||
type="secondary"
|
||||
style={{ fontSize: 12 }}
|
||||
data-import-capability-detail={key}
|
||||
>
|
||||
{t(`data_import.capability.details.${key}`)}: {values.join(', ')}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-import-error-policy="true"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
padding: '12px 14px',
|
||||
border: panelBorder,
|
||||
borderRadius: 8,
|
||||
background: selectedFileBackground,
|
||||
}}
|
||||
>
|
||||
<Text strong>{t('data_import.workbench.error_policy.title')}</Text>
|
||||
<Checkbox
|
||||
data-import-continue-on-error="true"
|
||||
checked={continueOnError}
|
||||
disabled={importing || !capabilityAllowsImport || !modeCapability.supportsContinue}
|
||||
onChange={(event) => {
|
||||
if (capabilityAllowsImport && modeCapability.supportsContinue) {
|
||||
updateImportPreferences(importMode, {
|
||||
continueOnError: event.target.checked,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{importMode === 'database'
|
||||
? t('data_import.workbench.error_policy.continue')
|
||||
: t('data_import.workbench.error_policy.continue_table')}
|
||||
</Checkbox>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{importMode === 'database'
|
||||
? continueOnError
|
||||
? t('data_import.workbench.error_policy.continue_description')
|
||||
: t('data_import.workbench.error_policy.stop_description')
|
||||
: continueOnError
|
||||
? t('data_import.workbench.error_policy.continue_table_description')
|
||||
: t('data_import.workbench.error_policy.stop_table_description')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{importMode === 'table' ? (
|
||||
<details
|
||||
data-import-advanced-options="true"
|
||||
style={{
|
||||
padding: '12px 14px',
|
||||
border: panelBorder,
|
||||
borderRadius: 8,
|
||||
background: selectedFileBackground,
|
||||
}}
|
||||
>
|
||||
<summary style={{ cursor: importing ? 'default' : 'pointer', fontWeight: 600 }}>
|
||||
{t('data_import.workbench.advanced.title')}
|
||||
</summary>
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
|
||||
{t('data_import.workbench.advanced.description')}
|
||||
</Text>
|
||||
<div style={{ display: 'grid', gap: 12, marginTop: 12 }}>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<Text type="secondary">{t('data_import.workbench.advanced.encoding')}</Text>
|
||||
<Select
|
||||
data-import-option-encoding="true"
|
||||
value={activePreferences.encoding}
|
||||
disabled={importing}
|
||||
options={[
|
||||
{ value: 'auto', label: t('data_import.workbench.advanced.encoding.auto') },
|
||||
{ value: 'utf-8', label: t('data_import.workbench.advanced.encoding.utf8') },
|
||||
{ value: 'utf-16le', label: t('data_import.workbench.advanced.encoding.utf16le') },
|
||||
{ value: 'utf-16be', label: t('data_import.workbench.advanced.encoding.utf16be') },
|
||||
{ value: 'gb18030', label: t('data_import.workbench.advanced.encoding.gb18030') },
|
||||
]}
|
||||
onChange={(encoding) => updateImportPreferences('table', { encoding })}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<Text type="secondary">{t('data_import.workbench.advanced.delimiter')}</Text>
|
||||
<Select
|
||||
data-import-option-delimiter="true"
|
||||
value={activePreferences.delimiter}
|
||||
disabled={importing}
|
||||
options={[
|
||||
{ value: 'auto', label: t('data_import.workbench.advanced.delimiter.auto') },
|
||||
{ value: 'comma', label: t('data_import.workbench.advanced.delimiter.comma') },
|
||||
{ value: 'tab', label: t('data_import.workbench.advanced.delimiter.tab') },
|
||||
{ value: 'semicolon', label: t('data_import.workbench.advanced.delimiter.semicolon') },
|
||||
{ value: 'pipe', label: t('data_import.workbench.advanced.delimiter.pipe') },
|
||||
]}
|
||||
onChange={(delimiter) => updateImportPreferences('table', { delimiter })}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<Text type="secondary">{t('data_import.workbench.advanced.header_row')}</Text>
|
||||
<input
|
||||
data-import-option-header-row="true"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1_000_000}
|
||||
value={activePreferences.headerRow}
|
||||
disabled={importing}
|
||||
onChange={(event) => {
|
||||
const headerRow = Math.min(
|
||||
1_000_000,
|
||||
Math.max(1, Math.trunc(Number(event.target.value) || 1)),
|
||||
);
|
||||
updateImportPreferences('table', { headerRow });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<Text type="secondary">{t('data_import.workbench.advanced.null_token')}</Text>
|
||||
<input
|
||||
data-import-option-null-token="true"
|
||||
type="text"
|
||||
maxLength={64}
|
||||
value={activePreferences.nullToken}
|
||||
disabled={importing}
|
||||
onChange={(event) => updateImportPreferences('table', {
|
||||
nullToken: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<Checkbox
|
||||
data-import-option-empty-string-as-null="true"
|
||||
checked={activePreferences.emptyStringAsNull}
|
||||
disabled={importing}
|
||||
onChange={(event) => updateImportPreferences('table', {
|
||||
emptyStringAsNull: event.target.checked,
|
||||
})}
|
||||
>
|
||||
{t('data_import.workbench.advanced.empty_string_as_null')}
|
||||
</Checkbox>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<Text type="secondary">{t('data_import.workbench.advanced.sheet_name')}</Text>
|
||||
<input
|
||||
data-import-option-sheet-name="true"
|
||||
type="text"
|
||||
maxLength={255}
|
||||
value={activePreferences.sheetName}
|
||||
disabled={importing}
|
||||
onChange={(event) => updateImportPreferences('table', {
|
||||
sheetName: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<Text type="secondary">{t('data_import.workbench.advanced.conflict_policy')}</Text>
|
||||
<Select
|
||||
data-import-option-conflict-policy="true"
|
||||
value={activePreferences.conflictPolicy}
|
||||
disabled={importing || !capabilityAllowsImport}
|
||||
options={[
|
||||
{
|
||||
value: 'stop',
|
||||
label: t('data_import.workbench.advanced.conflict.stop'),
|
||||
disabled: !supportedConflictPolicies.includes('stop'),
|
||||
},
|
||||
{
|
||||
value: 'skip_duplicates',
|
||||
label: t('data_import.workbench.advanced.conflict.skip_duplicates'),
|
||||
disabled: !supportedConflictPolicies.includes('skip_duplicates'),
|
||||
},
|
||||
{
|
||||
value: 'upsert',
|
||||
label: t('data_import.workbench.advanced.conflict.upsert'),
|
||||
disabled: !supportedConflictPolicies.includes('upsert'),
|
||||
},
|
||||
]}
|
||||
onChange={(conflictPolicy) => updateImportPreferences('table', { conflictPolicy })}
|
||||
/>
|
||||
</label>
|
||||
{activePreferences.conflictPolicy === 'upsert' ? (
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<Text type="secondary">{t('data_import.workbench.advanced.conflict_keys')}</Text>
|
||||
<input
|
||||
data-import-option-conflict-keys="true"
|
||||
type="text"
|
||||
maxLength={16_384}
|
||||
value={conflictKeyColumnsInput}
|
||||
disabled={importing || !capabilityAllowsImport || !supportedConflictPolicies.includes('upsert')}
|
||||
placeholder={t('data_import.workbench.advanced.conflict_keys_placeholder')}
|
||||
onChange={(event) => {
|
||||
const normalized = normalizeConflictKeyColumnsInput(event.target.value);
|
||||
setConflictKeyColumnsInput(normalized.displayValue);
|
||||
updateImportPreferences('table', {
|
||||
conflictKeyColumns: normalized.columns,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{!conflictPolicySupported ? (
|
||||
<Alert
|
||||
data-import-conflict-policy-error="unsupported"
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('data_import.workbench.advanced.conflict_unsupported')}
|
||||
/>
|
||||
) : !conflictKeysValid ? (
|
||||
<Alert
|
||||
data-import-conflict-policy-error="keys_required"
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('data_import.workbench.advanced.conflict_keys_required')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -666,15 +1076,17 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
background: panelBackground,
|
||||
}}
|
||||
>
|
||||
{filePath ? (
|
||||
{filePath && capabilityAllowsImport ? (
|
||||
importMode === 'database' ? (
|
||||
<DatabaseImportExecutionPanel
|
||||
key={JSON.stringify([selectedConnectionId, selectedDbName, filePath])}
|
||||
connection={selectedConnection}
|
||||
connectionConfig={selectedConnectionConfig}
|
||||
dbName={selectedDbName}
|
||||
filePath={filePath}
|
||||
fileSizeMB={fileSizeMB}
|
||||
darkMode={darkMode}
|
||||
continueOnError={continueOnError}
|
||||
onRunningChange={handleImportingChange}
|
||||
/>
|
||||
) : (
|
||||
@@ -685,6 +1097,8 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
connectionId={selectedConnectionId}
|
||||
dbName={selectedDbName}
|
||||
tableName={selectedTableName}
|
||||
continueOnError={continueOnError}
|
||||
importOptions={activePreferences}
|
||||
onClose={clearSelectedFile}
|
||||
onImportingChange={handleImportingChange}
|
||||
onSuccess={() => {
|
||||
@@ -712,6 +1126,9 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<ImportJobHistoryPanel refreshToken={historyRefreshToken} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SQLFileExecutionState } from './useSQLFileExecutionRunner';
|
||||
import { setCurrentLanguage } from '../i18n';
|
||||
import DatabaseImportExecutionPanel from './DatabaseImportExecutionPanel';
|
||||
|
||||
const createRunnerState = (
|
||||
@@ -20,6 +21,10 @@ const createRunnerState = (
|
||||
failed: 0,
|
||||
total: 0,
|
||||
percent: 0,
|
||||
bytesRead: 0,
|
||||
totalBytes: 0,
|
||||
bytesPerSecond: 0,
|
||||
etaSeconds: 0,
|
||||
currentSQL: '',
|
||||
message: '',
|
||||
...overrides,
|
||||
@@ -31,6 +36,7 @@ const mocks = vi.hoisted(() => ({
|
||||
run: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
modalConfirm: vi.fn(),
|
||||
state: null as SQLFileExecutionState | null,
|
||||
isRunning: false,
|
||||
lastRunOptions: null as null | {
|
||||
@@ -39,6 +45,10 @@ const mocks = vi.hoisted(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./common/ResizableDraggableModal', () => ({
|
||||
default: { confirm: mocks.modalConfirm },
|
||||
}));
|
||||
|
||||
vi.mock('../../wailsjs/go/app/App', () => ({
|
||||
ImportDatabaseSQL: mocks.importDatabaseSQL,
|
||||
CancelSQLFileExecution: mocks.cancelSQLFileExecution,
|
||||
@@ -80,7 +90,13 @@ vi.mock('@ant-design/icons', () => ({
|
||||
StopOutlined: () => React.createElement('mock-icon', { name: 'stop' }),
|
||||
}));
|
||||
|
||||
const renderPanel = async (onRunningChange = vi.fn()) => {
|
||||
const renderPanel = async ({
|
||||
continueOnError = false,
|
||||
onRunningChange = vi.fn(),
|
||||
}: {
|
||||
continueOnError?: boolean;
|
||||
onRunningChange?: ReturnType<typeof vi.fn>;
|
||||
} = {}) => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
@@ -90,6 +106,7 @@ const renderPanel = async (onRunningChange = vi.fn()) => {
|
||||
filePath="/tmp/database.sql"
|
||||
fileSizeMB="12.5"
|
||||
darkMode={false}
|
||||
continueOnError={continueOnError}
|
||||
onRunningChange={onRunningChange}
|
||||
/>,
|
||||
);
|
||||
@@ -100,12 +117,14 @@ const renderPanel = async (onRunningChange = vi.fn()) => {
|
||||
|
||||
describe('DatabaseImportExecutionPanel', () => {
|
||||
beforeEach(() => {
|
||||
setCurrentLanguage('en-US');
|
||||
mocks.state = createRunnerState();
|
||||
mocks.isRunning = false;
|
||||
mocks.lastRunOptions = null;
|
||||
mocks.importDatabaseSQL.mockReset();
|
||||
mocks.cancelSQLFileExecution.mockReset();
|
||||
mocks.reset.mockReset();
|
||||
mocks.modalConfirm.mockReset();
|
||||
mocks.run.mockReset();
|
||||
mocks.cancel.mockReset();
|
||||
mocks.run.mockImplementation(async (options: any) => {
|
||||
@@ -117,13 +136,23 @@ describe('DatabaseImportExecutionPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses shared theme tokens for its inset execution surface', async () => {
|
||||
const renderer = await renderPanel();
|
||||
const statusCard = renderer.root.findByProps({
|
||||
'data-database-import-status-card': 'true',
|
||||
});
|
||||
|
||||
expect(statusCard.props.style.background).toContain('var(--gn-bg-panel-2');
|
||||
expect(statusCard.props.style.border).toContain('var(--gn-br-1');
|
||||
});
|
||||
|
||||
it('waits for an explicit start action and reports the full RPC lifetime as running', async () => {
|
||||
let resolveImport!: (value: { success: boolean; message: string }) => void;
|
||||
mocks.importDatabaseSQL.mockReturnValue(new Promise((resolve) => {
|
||||
resolveImport = resolve;
|
||||
}));
|
||||
const onRunningChange = vi.fn();
|
||||
const renderer = await renderPanel(onRunningChange);
|
||||
const renderer = await renderPanel({ onRunningChange });
|
||||
|
||||
expect(mocks.importDatabaseSQL).not.toHaveBeenCalled();
|
||||
const startButton = renderer.root.findByProps({
|
||||
@@ -140,12 +169,12 @@ describe('DatabaseImportExecutionPanel', () => {
|
||||
'app',
|
||||
'/tmp/database.sql',
|
||||
'database-import-job-1',
|
||||
false,
|
||||
);
|
||||
expect(onRunningChange).toHaveBeenLastCalledWith(true);
|
||||
expect(renderer.root.findAllByProps({
|
||||
'data-database-import-cancel-action': 'true',
|
||||
}).length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
resolveImport({ success: true, message: 'done' });
|
||||
await Promise.resolve();
|
||||
@@ -154,6 +183,30 @@ describe('DatabaseImportExecutionPanel', () => {
|
||||
expect(onRunningChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('passes an explicit continue-on-error choice to the database import RPC', async () => {
|
||||
mocks.importDatabaseSQL.mockResolvedValue({
|
||||
success: false,
|
||||
data: { completed: true, failed: 1 },
|
||||
message: 'completed with errors',
|
||||
});
|
||||
const renderer = await renderPanel({ continueOnError: true });
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-database-import-start-action': 'true',
|
||||
}).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.importDatabaseSQL).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'mysql' }),
|
||||
'app',
|
||||
'/tmp/database.sql',
|
||||
'database-import-job-1',
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels the active SQL import with the runner job id', async () => {
|
||||
let resolveImport!: (value: { success: boolean; message: string }) => void;
|
||||
mocks.importDatabaseSQL.mockReturnValue(new Promise((resolve) => {
|
||||
@@ -206,14 +259,91 @@ describe('DatabaseImportExecutionPanel', () => {
|
||||
expect(renderer.root.findByProps({
|
||||
'data-database-import-progress': 'true',
|
||||
}).props.percent).toBe(status === 'done' ? 100 : 96);
|
||||
expect(renderer.root.findByProps({
|
||||
const resultAlert = renderer.root.findByProps({
|
||||
'data-database-import-result': 'true',
|
||||
}).props).toMatchObject({
|
||||
type: alertType,
|
||||
message,
|
||||
});
|
||||
expect(resultAlert.props.type).toBe(alertType);
|
||||
expect(resultAlert.props.message.props.children).toBe(message);
|
||||
expect(renderer.root.findAllByProps({
|
||||
'data-database-import-current-sql': 'true',
|
||||
})).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders a completed continue-on-error run as a warning instead of a fatal failure', async () => {
|
||||
mocks.state = createRunnerState({
|
||||
jobId: 'database-import-job-1',
|
||||
status: 'done',
|
||||
stage: 'done',
|
||||
filePath: '/tmp/database.sql',
|
||||
executed: 24,
|
||||
failed: 1,
|
||||
total: 25,
|
||||
percent: 100,
|
||||
message: 'completed with errors',
|
||||
});
|
||||
const renderer = await renderPanel();
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-database-import-result': 'true',
|
||||
}).props.type).toBe('warning');
|
||||
expect(renderer.root.findByProps({
|
||||
'data-database-import-progress': 'true',
|
||||
}).props).toMatchObject({
|
||||
percent: 100,
|
||||
status: 'normal',
|
||||
strokeColor: 'var(--gn-warn, #faad14)',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders byte progress, throughput and ETA for a large SQL source', async () => {
|
||||
mocks.state = createRunnerState({
|
||||
jobId: 'database-import-job-1',
|
||||
status: 'running',
|
||||
stage: 'preflight',
|
||||
bytesRead: 10 * 1024 * 1024,
|
||||
totalBytes: 20 * 1024 * 1024,
|
||||
bytesPerSecond: 1024 * 1024,
|
||||
etaSeconds: 10,
|
||||
percent: 50,
|
||||
});
|
||||
const renderer = await renderPanel();
|
||||
|
||||
const metrics = renderer.root.findByProps({
|
||||
'data-database-import-transfer-metrics': 'true',
|
||||
});
|
||||
expect(String(metrics.props.children)).toContain('10.0 MB / 20.0 MB');
|
||||
expect(String(metrics.props.children)).toContain('1.0 MB/s');
|
||||
expect(String(metrics.props.children)).toContain('10s');
|
||||
expect(renderer.root.findByProps({
|
||||
'data-database-import-stage': 'true',
|
||||
}).props.children).toBe('Running preflight checks');
|
||||
});
|
||||
|
||||
it('requires a second confirmation before rerunning the whole SQL file', async () => {
|
||||
mocks.state = createRunnerState({
|
||||
jobId: 'database-import-job-1',
|
||||
status: 'done',
|
||||
stage: 'done',
|
||||
filePath: '/tmp/database.sql',
|
||||
percent: 100,
|
||||
});
|
||||
const renderer = await renderPanel();
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-database-import-start-action': 'true',
|
||||
}).props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.modalConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.run).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
await mocks.modalConfirm.mock.calls[0][0].onOk();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ import { t as defaultTranslate } from '../i18n';
|
||||
import { useOptionalI18n } from '../i18n/provider';
|
||||
import type { SavedConnection } from '../types';
|
||||
import { confirmProductionRisk } from '../utils/productionRiskConfirm';
|
||||
import { formatImportBytes, formatImportDuration } from './importProgressMetrics';
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
import {
|
||||
useSQLFileExecutionRunner,
|
||||
type SQLFileExecutionRunnerStatus,
|
||||
@@ -25,6 +27,7 @@ type DatabaseImportExecutionPanelProps = {
|
||||
filePath: string;
|
||||
fileSizeMB?: string;
|
||||
darkMode: boolean;
|
||||
continueOnError: boolean;
|
||||
onRunningChange?: (running: boolean) => void;
|
||||
};
|
||||
|
||||
@@ -38,7 +41,7 @@ const resolveProgressStatus = (
|
||||
): 'active' | 'success' | 'exception' | 'normal' => {
|
||||
if (status === 'done') return 'success';
|
||||
if (status === 'error') return 'exception';
|
||||
if (status === 'start' || status === 'running') return 'active';
|
||||
if (status === 'start' || status === 'running' || status === 'stopping') return 'active';
|
||||
return 'normal';
|
||||
};
|
||||
|
||||
@@ -49,6 +52,7 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
|
||||
filePath,
|
||||
fileSizeMB,
|
||||
darkMode,
|
||||
continueOnError,
|
||||
onRunningChange,
|
||||
}) => {
|
||||
const i18n = useOptionalI18n();
|
||||
@@ -69,8 +73,32 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
|
||||
const terminal = state.status === 'done'
|
||||
|| state.status === 'cancelled'
|
||||
|| state.status === 'error';
|
||||
const subtleBackground = darkMode ? 'rgba(255,255,255,0.04)' : '#f8fafc';
|
||||
const dividerColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)';
|
||||
const completedWithErrors = state.status === 'done' && state.failed > 0;
|
||||
const subtleBackground = `var(--gn-bg-subtle, var(--gn-bg-panel-2, ${darkMode
|
||||
? 'rgba(255,255,255,0.04)'
|
||||
: '#f8fafc'}))`;
|
||||
const dividerColor = `var(--gn-br-1, ${darkMode
|
||||
? 'rgba(255,255,255,0.08)'
|
||||
: 'rgba(15,23,42,0.08)'})`;
|
||||
const warningColor = 'var(--gn-warn, #faad14)';
|
||||
const transferMetrics = useMemo(() => {
|
||||
if (state.bytesRead <= 0 && state.totalBytes <= 0) return '';
|
||||
const details = [t('data_import.workbench.progress.bytes', {
|
||||
processed: formatImportBytes(state.bytesRead),
|
||||
total: state.totalBytes > 0 ? formatImportBytes(state.totalBytes) : '—',
|
||||
})];
|
||||
if (state.bytesPerSecond > 0) {
|
||||
details.push(t('data_import.workbench.progress.throughput', {
|
||||
rate: formatImportBytes(state.bytesPerSecond),
|
||||
}));
|
||||
}
|
||||
if (state.etaSeconds > 0) {
|
||||
details.push(t('data_import.workbench.progress.eta', {
|
||||
duration: formatImportDuration(state.etaSeconds, i18n?.language),
|
||||
}));
|
||||
}
|
||||
return details.join(' · ');
|
||||
}, [i18n?.language, state.bytesPerSecond, state.bytesRead, state.etaSeconds, state.totalBytes, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastReportedRunningRef.current === taskRunning) return;
|
||||
@@ -94,14 +122,27 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
|
||||
title: getFileName(filePath),
|
||||
filePath,
|
||||
fileSizeMB,
|
||||
run: (jobId) => ImportDatabaseSQL(
|
||||
connectionConfig as any,
|
||||
String(dbName || '').trim(),
|
||||
filePath,
|
||||
jobId,
|
||||
),
|
||||
run: async (jobId) => {
|
||||
const result = await ImportDatabaseSQL(
|
||||
connectionConfig as any,
|
||||
String(dbName || '').trim(),
|
||||
filePath,
|
||||
jobId,
|
||||
continueOnError,
|
||||
);
|
||||
// Reaching EOF with recorded statement errors is a completed import,
|
||||
// not a transport/fatal failure. Preserve the counters and render it
|
||||
// as a warning result instead of offering a misleading fatal retry.
|
||||
if (continueOnError && result.data?.completed === true) {
|
||||
return { ...result, success: true };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
cancel: async (jobId) => {
|
||||
await CancelSQLFileExecution(jobId);
|
||||
const result = await CancelSQLFileExecution(jobId);
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.message || t('import_preview.error.stop_failed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
@@ -112,6 +153,7 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
|
||||
}, [
|
||||
connectionConfig,
|
||||
connection,
|
||||
continueOnError,
|
||||
dbName,
|
||||
filePath,
|
||||
fileSizeMB,
|
||||
@@ -136,6 +178,21 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
|
||||
reset();
|
||||
}, [reset, taskRunning]);
|
||||
|
||||
const requestStartImport = useCallback(() => {
|
||||
if (!terminal) {
|
||||
void startImport();
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: t('data_import.workbench.confirm.rerun_title'),
|
||||
content: t('data_import.workbench.confirm.rerun_content'),
|
||||
okText: t('data_import.workbench.action.retry_database_import'),
|
||||
cancelText: t('common.cancel'),
|
||||
okButtonProps: { danger: true },
|
||||
onOk: startImport,
|
||||
});
|
||||
}, [startImport, t, terminal]);
|
||||
|
||||
const statusText = useMemo(() => {
|
||||
if (cancelRequested && taskRunning) return t('data_import.workbench.state.cancelling');
|
||||
switch (state.status) {
|
||||
@@ -143,7 +200,9 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
|
||||
case 'running':
|
||||
return t('data_import.workbench.state.running');
|
||||
case 'done':
|
||||
return t('data_import.workbench.state.completed');
|
||||
return state.failed > 0
|
||||
? t('data_import.workbench.state.completed_with_errors')
|
||||
: t('data_import.workbench.state.completed');
|
||||
case 'error':
|
||||
return t('data_import.workbench.state.failed');
|
||||
case 'cancelled':
|
||||
@@ -151,13 +210,34 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
|
||||
default:
|
||||
return t('data_import.workbench.state.ready_sql_title');
|
||||
}
|
||||
}, [cancelRequested, state.status, t, taskRunning]);
|
||||
}, [cancelRequested, state.failed, state.status, t, taskRunning]);
|
||||
|
||||
const stageText = useMemo(() => {
|
||||
switch (state.stage) {
|
||||
case 'prepare':
|
||||
return t('import_preview.stage.prepare');
|
||||
case 'preflight':
|
||||
return t('import_preview.stage.preflight');
|
||||
case 'read':
|
||||
return t('import_preview.stage.read');
|
||||
case 'parse':
|
||||
return t('import_preview.stage.parse');
|
||||
case 'write':
|
||||
return t('import_preview.stage.write');
|
||||
case 'finalize':
|
||||
return t('import_preview.stage.finalize');
|
||||
default:
|
||||
return state.stage || statusText;
|
||||
}
|
||||
}, [state.stage, statusText, t]);
|
||||
|
||||
const resultAlertType = state.status === 'error'
|
||||
? 'error'
|
||||
: state.status === 'cancelled'
|
||||
? 'warning'
|
||||
: 'success';
|
||||
: completedWithErrors
|
||||
? 'warning'
|
||||
: 'success';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -167,7 +247,9 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('data_import.workbench.notice.partial_execution')}
|
||||
message={continueOnError
|
||||
? t('data_import.workbench.notice.continue_on_error')
|
||||
: t('data_import.workbench.notice.stop_on_error')}
|
||||
/>
|
||||
<Alert
|
||||
type="info"
|
||||
@@ -176,6 +258,7 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
|
||||
/>
|
||||
|
||||
<div
|
||||
data-database-import-status-card="true"
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
@@ -206,11 +289,11 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
|
||||
<Progress
|
||||
data-database-import-progress="true"
|
||||
percent={Math.round(progressPercent)}
|
||||
status={resolveProgressStatus(state.status)}
|
||||
strokeColor={state.status === 'cancelled' ? '#faad14' : undefined}
|
||||
status={completedWithErrors ? 'normal' : resolveProgressStatus(state.status)}
|
||||
strokeColor={state.status === 'cancelled' || completedWithErrors ? warningColor : undefined}
|
||||
/>
|
||||
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Text type="secondary">{state.stage || statusText}</Text>
|
||||
<Text data-database-import-stage="true" type="secondary">{stageText}</Text>
|
||||
<Text type="secondary">
|
||||
{t('data_import.workbench.progress.statements', {
|
||||
executed: state.executed,
|
||||
@@ -219,6 +302,15 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
{transferMetrics ? (
|
||||
<Text
|
||||
data-database-import-transfer-metrics="true"
|
||||
type="secondary"
|
||||
style={{ display: 'block', marginTop: 6, fontSize: 12 }}
|
||||
>
|
||||
{transferMetrics}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -247,7 +339,7 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
|
||||
style={{ marginTop: 14 }}
|
||||
type={resultAlertType}
|
||||
showIcon
|
||||
message={state.message}
|
||||
message={<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{state.message}</div>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -271,7 +363,7 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
|
||||
type="primary"
|
||||
icon={terminal ? <ReloadOutlined /> : <PlayCircleOutlined />}
|
||||
disabled={!connectionConfig || !String(filePath || '').trim()}
|
||||
onClick={() => void startImport()}
|
||||
onClick={requestStartImport}
|
||||
>
|
||||
{terminal
|
||||
? t('data_import.workbench.action.retry_database_import')
|
||||
|
||||
255
frontend/src/components/ImportJobHistoryPanel.test.tsx
Normal file
255
frontend/src/components/ImportJobHistoryPanel.test.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import ImportJobHistoryPanel from './ImportJobHistoryPanel';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listImportJobs: vi.fn(),
|
||||
getImportJob: vi.fn(),
|
||||
deleteImportJob: vi.fn(),
|
||||
cancelImportJob: vi.fn(),
|
||||
exportImportErrorRows: vi.fn(),
|
||||
modalConfirm: vi.fn(),
|
||||
messageError: vi.fn(),
|
||||
messageSuccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../wailsjs/go/app/App', () => ({
|
||||
ListImportJobs: mocks.listImportJobs,
|
||||
GetImportJob: mocks.getImportJob,
|
||||
DeleteImportJob: mocks.deleteImportJob,
|
||||
CancelImportJob: mocks.cancelImportJob,
|
||||
ExportImportErrorRows: mocks.exportImportErrorRows,
|
||||
}));
|
||||
|
||||
vi.mock('./common/ResizableDraggableModal', () => ({
|
||||
default: { confirm: mocks.modalConfirm },
|
||||
}));
|
||||
|
||||
vi.mock('antd', async () => {
|
||||
const React = await import('react');
|
||||
const Alert = (props: Record<string, unknown>) => React.createElement('mock-alert', props);
|
||||
const Button = ({ children, ...props }: any) => <button {...props}>{children}</button>;
|
||||
const Empty = (props: Record<string, unknown>) => React.createElement('mock-empty', props);
|
||||
const Text = ({ children, ...props }: any) => <span {...props}>{children}</span>;
|
||||
return {
|
||||
Alert,
|
||||
Button,
|
||||
Empty,
|
||||
Typography: { Text },
|
||||
message: {
|
||||
error: mocks.messageError,
|
||||
success: mocks.messageSuccess,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@ant-design/icons', () => ({
|
||||
DeleteOutlined: () => React.createElement('mock-icon', { name: 'delete' }),
|
||||
DownloadOutlined: () => React.createElement('mock-icon', { name: 'download' }),
|
||||
EyeOutlined: () => React.createElement('mock-icon', { name: 'eye' }),
|
||||
ReloadOutlined: () => React.createElement('mock-icon', { name: 'reload' }),
|
||||
StopOutlined: () => React.createElement('mock-icon', { name: 'stop' }),
|
||||
}));
|
||||
|
||||
const failedJob = {
|
||||
id: 'import-failed-1',
|
||||
kind: 'table',
|
||||
status: 'failed',
|
||||
stage: 'failed',
|
||||
databaseName: 'app',
|
||||
tableName: 'users',
|
||||
current: 12,
|
||||
succeeded: 11,
|
||||
failed: 1,
|
||||
skipped: 2,
|
||||
errorArtifactId: 'artifact-1',
|
||||
message: 'duplicate key',
|
||||
updatedAt: 1_700_000_000_000,
|
||||
};
|
||||
|
||||
const runningJob = {
|
||||
id: 'import-running-1',
|
||||
kind: 'sql',
|
||||
status: 'running',
|
||||
stage: 'executing',
|
||||
databaseName: 'app',
|
||||
current: 4,
|
||||
succeeded: 4,
|
||||
failed: 0,
|
||||
updatedAt: 1_700_000_001_000,
|
||||
};
|
||||
|
||||
let renderedHistories: ReactTestRenderer[] = [];
|
||||
|
||||
const renderHistory = async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<ImportJobHistoryPanel refreshToken={0} />);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
renderedHistories.push(renderer);
|
||||
return renderer;
|
||||
};
|
||||
|
||||
describe('ImportJobHistoryPanel', () => {
|
||||
beforeEach(() => {
|
||||
mocks.listImportJobs.mockReset();
|
||||
mocks.listImportJobs.mockResolvedValue({ success: true, data: [runningJob, failedJob] });
|
||||
mocks.getImportJob.mockReset();
|
||||
mocks.getImportJob.mockResolvedValue({ success: true, data: failedJob });
|
||||
mocks.deleteImportJob.mockReset();
|
||||
mocks.deleteImportJob.mockResolvedValue({ success: true });
|
||||
mocks.cancelImportJob.mockReset();
|
||||
mocks.cancelImportJob.mockResolvedValue({ success: true });
|
||||
mocks.exportImportErrorRows.mockReset();
|
||||
mocks.exportImportErrorRows.mockResolvedValue({ success: true });
|
||||
mocks.modalConfirm.mockReset();
|
||||
mocks.messageError.mockReset();
|
||||
mocks.messageSuccess.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
renderedHistories.forEach((renderer) => renderer.unmount());
|
||||
});
|
||||
renderedHistories = [];
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('lists jobs and exposes only safe supported actions', async () => {
|
||||
const renderer = await renderHistory();
|
||||
|
||||
expect(renderer.root.findAllByProps({ 'data-import-history-job': true })).toHaveLength(2);
|
||||
expect(renderer.root.findAllByProps({ 'data-import-history-resume-action': true })).toHaveLength(0);
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-history-cancel-action': 'import-running-1',
|
||||
})).toBeDefined();
|
||||
expect(renderer.root.findAllByProps({
|
||||
'data-import-history-delete-action': 'import-running-1',
|
||||
})).toHaveLength(0);
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-history-delete-action': 'import-failed-1',
|
||||
})).toBeDefined();
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-history-export-action': 'import-failed-1',
|
||||
})).toBeDefined();
|
||||
expect(String(renderer.root.findByProps({
|
||||
'data-import-history-progress': 'import-failed-1',
|
||||
}).props.children)).toContain('2');
|
||||
});
|
||||
|
||||
it('cancels a running durable import from history', async () => {
|
||||
const renderer = await renderHistory();
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-history-cancel-action': 'import-running-1',
|
||||
}).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.cancelImportJob).toHaveBeenCalledWith('import-running-1');
|
||||
expect(mocks.listImportJobs).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('polls a running import until its durable status becomes terminal', async () => {
|
||||
vi.useFakeTimers();
|
||||
const completedJob = { ...runningJob, status: 'completed', stage: 'completed', current: 8, succeeded: 8 };
|
||||
mocks.listImportJobs
|
||||
.mockResolvedValueOnce({ success: true, data: [runningJob, failedJob] })
|
||||
.mockResolvedValueOnce({ success: true, data: [completedJob, failedJob] });
|
||||
const renderer = await renderHistory();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mocks.listImportJobs).toHaveBeenCalledTimes(2);
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-history-delete-action': 'import-running-1',
|
||||
})).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mocks.listImportJobs).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('polls a stopping import until its durable status becomes terminal', async () => {
|
||||
vi.useFakeTimers();
|
||||
const stoppingJob = { ...runningJob, status: 'stopping', stage: 'stopping' };
|
||||
const completedJob = { ...runningJob, status: 'completed', stage: 'completed', current: 8, succeeded: 8 };
|
||||
mocks.listImportJobs
|
||||
.mockResolvedValueOnce({ success: true, data: [runningJob, failedJob] })
|
||||
.mockResolvedValueOnce({ success: true, data: [stoppingJob, failedJob] })
|
||||
.mockResolvedValueOnce({ success: true, data: [completedJob, failedJob] });
|
||||
const renderer = await renderHistory();
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-history-cancel-action': 'import-running-1',
|
||||
}).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mocks.listImportJobs).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mocks.listImportJobs).toHaveBeenCalledTimes(3);
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-history-delete-action': 'import-running-1',
|
||||
})).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mocks.listImportJobs).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('loads details, exports rejected rows and confirms terminal job deletion', async () => {
|
||||
const renderer = await renderHistory();
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-history-details-action': 'import-failed-1',
|
||||
}).props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mocks.getImportJob).toHaveBeenCalledWith('import-failed-1');
|
||||
expect(renderer.root.findByProps({
|
||||
'data-import-history-details': 'import-failed-1',
|
||||
})).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-import-history-export-action': 'import-failed-1',
|
||||
}).props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mocks.exportImportErrorRows).toHaveBeenCalledWith('artifact-1');
|
||||
|
||||
renderer.root.findByProps({
|
||||
'data-import-history-delete-action': 'import-failed-1',
|
||||
}).props.onClick();
|
||||
expect(mocks.modalConfirm).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await mocks.modalConfirm.mock.calls[0][0].onOk();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mocks.deleteImportJob).toHaveBeenCalledWith('import-failed-1');
|
||||
expect(mocks.listImportJobs).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
399
frontend/src/components/ImportJobHistoryPanel.tsx
Normal file
399
frontend/src/components/ImportJobHistoryPanel.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Button, Empty, Typography, message } from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
ReloadOutlined,
|
||||
StopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import {
|
||||
CancelImportJob,
|
||||
DeleteImportJob,
|
||||
ExportImportErrorRows,
|
||||
GetImportJob,
|
||||
ListImportJobs,
|
||||
} from '../../wailsjs/go/app/App';
|
||||
import { t as defaultTranslate } from '../i18n';
|
||||
import { useOptionalI18n } from '../i18n/provider';
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type ImportJobStatus =
|
||||
| 'preparing'
|
||||
| 'running'
|
||||
| 'stopping'
|
||||
| 'completed'
|
||||
| 'partial'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'unknown'
|
||||
| 'interrupted';
|
||||
|
||||
type ImportJobRecord = {
|
||||
id: string;
|
||||
kind: 'table' | 'sql' | string;
|
||||
status: ImportJobStatus | string;
|
||||
stage?: string;
|
||||
connectionId?: string;
|
||||
databaseName?: string;
|
||||
tableName?: string;
|
||||
current?: number;
|
||||
total?: number;
|
||||
succeeded?: number;
|
||||
failed?: number;
|
||||
skipped?: number;
|
||||
bytesRead?: number;
|
||||
outcomeUnknown?: boolean;
|
||||
errorArtifactId?: string;
|
||||
message?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
||||
type ImportJobHistoryPanelProps = {
|
||||
refreshToken?: number;
|
||||
};
|
||||
|
||||
const terminalStatuses = new Set<ImportJobStatus>([
|
||||
'completed',
|
||||
'partial',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'unknown',
|
||||
'interrupted',
|
||||
]);
|
||||
|
||||
const pollingStatuses = new Set<ImportJobStatus>([
|
||||
'preparing',
|
||||
'running',
|
||||
'stopping',
|
||||
]);
|
||||
|
||||
const normalizeJobs = (value: unknown): ImportJobRecord[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter((candidate): candidate is Record<string, unknown> => (
|
||||
Boolean(candidate)
|
||||
&& typeof candidate === 'object'
|
||||
&& typeof candidate.id === 'string'
|
||||
&& candidate.id.trim().length > 0
|
||||
))
|
||||
.slice(0, 50)
|
||||
.map((candidate) => ({
|
||||
id: String(candidate.id).trim(),
|
||||
kind: String(candidate.kind || ''),
|
||||
status: String(candidate.status || 'unknown'),
|
||||
stage: String(candidate.stage || ''),
|
||||
connectionId: String(candidate.connectionId || ''),
|
||||
databaseName: String(candidate.databaseName || ''),
|
||||
tableName: String(candidate.tableName || ''),
|
||||
current: Number(candidate.current) || 0,
|
||||
total: Number(candidate.total) || 0,
|
||||
succeeded: Number(candidate.succeeded) || 0,
|
||||
skipped: Number(candidate.skipped) || 0,
|
||||
failed: Number(candidate.failed) || 0,
|
||||
bytesRead: Number(candidate.bytesRead) || 0,
|
||||
outcomeUnknown: candidate.outcomeUnknown === true,
|
||||
errorArtifactId: String(candidate.errorArtifactId || ''),
|
||||
message: String(candidate.message || ''),
|
||||
createdAt: Number(candidate.createdAt) || 0,
|
||||
updatedAt: Number(candidate.updatedAt) || 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const formatJobTarget = (job: ImportJobRecord): string => (
|
||||
[job.databaseName, job.tableName].map((value) => String(value || '').trim()).filter(Boolean).join(' / ')
|
||||
|| '—'
|
||||
);
|
||||
|
||||
const formatUpdatedAt = (value: unknown): string => {
|
||||
const timestamp = Number(value);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return '—';
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
|
||||
const ImportJobHistoryPanel: React.FC<ImportJobHistoryPanelProps> = ({ refreshToken = 0 }) => {
|
||||
const i18n = useOptionalI18n();
|
||||
const t = i18n?.t ?? defaultTranslate;
|
||||
const [jobs, setJobs] = useState<ImportJobRecord[]>([]);
|
||||
const [selectedJob, setSelectedJob] = useState<ImportJobRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [pendingAction, setPendingAction] = useState('');
|
||||
const requestRef = useRef(0);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
const requestID = requestRef.current + 1;
|
||||
requestRef.current = requestID;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await ListImportJobs();
|
||||
if (requestRef.current !== requestID) return;
|
||||
if (!result?.success) {
|
||||
setJobs([]);
|
||||
setError(result?.message || t('data_import.history.error.load_failed'));
|
||||
return;
|
||||
}
|
||||
setJobs(normalizeJobs(result.data));
|
||||
} catch (loadError: any) {
|
||||
if (requestRef.current !== requestID) return;
|
||||
setJobs([]);
|
||||
setError(t('data_import.history.error.load_failed_detail', {
|
||||
detail: loadError?.message || String(loadError),
|
||||
}));
|
||||
} finally {
|
||||
if (requestRef.current === requestID) setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadJobs();
|
||||
return () => {
|
||||
requestRef.current += 1;
|
||||
};
|
||||
}, [loadJobs, refreshToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobs.some((job) => pollingStatuses.has(job.status as ImportJobStatus))) return undefined;
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
void loadJobs();
|
||||
}, 1_000);
|
||||
return () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
};
|
||||
}, [jobs, loadJobs]);
|
||||
|
||||
const loadDetails = async (jobID: string) => {
|
||||
setPendingAction(`details:${jobID}`);
|
||||
try {
|
||||
const result = await GetImportJob(jobID);
|
||||
if (!result?.success || !result.data) {
|
||||
void message.error(result?.message || t('data_import.history.error.details_failed'));
|
||||
return;
|
||||
}
|
||||
const [job] = normalizeJobs([result.data]);
|
||||
if (job) setSelectedJob(job);
|
||||
} catch (detailsError: any) {
|
||||
void message.error(t('data_import.history.error.details_failed_detail', {
|
||||
detail: detailsError?.message || String(detailsError),
|
||||
}));
|
||||
} finally {
|
||||
setPendingAction('');
|
||||
}
|
||||
};
|
||||
|
||||
const exportRejectedRows = async (job: ImportJobRecord) => {
|
||||
const artifactID = String(job.errorArtifactId || '').trim();
|
||||
if (!artifactID) return;
|
||||
setPendingAction(`export:${job.id}`);
|
||||
try {
|
||||
const result = await ExportImportErrorRows(artifactID);
|
||||
if (!result?.success) {
|
||||
void message.error(result?.message || t('data_import.history.error.export_failed'));
|
||||
return;
|
||||
}
|
||||
void message.success(t('data_import.history.message.exported'));
|
||||
} catch (exportError: any) {
|
||||
void message.error(t('data_import.history.error.export_failed_detail', {
|
||||
detail: exportError?.message || String(exportError),
|
||||
}));
|
||||
} finally {
|
||||
setPendingAction('');
|
||||
}
|
||||
};
|
||||
|
||||
const cancelJob = async (job: ImportJobRecord) => {
|
||||
if (job.status !== 'preparing' && job.status !== 'running') return;
|
||||
setPendingAction(`cancel:${job.id}`);
|
||||
try {
|
||||
const result = await CancelImportJob(job.id);
|
||||
if (!result?.success) {
|
||||
void message.error(result?.message || t('import_preview.error.stop_failed'));
|
||||
return;
|
||||
}
|
||||
await loadJobs();
|
||||
} catch (cancelError: any) {
|
||||
void message.error(t('import_preview.error.stop_failed_detail', {
|
||||
detail: cancelError?.message || String(cancelError),
|
||||
}));
|
||||
} finally {
|
||||
setPendingAction('');
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (job: ImportJobRecord) => {
|
||||
if (!terminalStatuses.has(job.status as ImportJobStatus)) return;
|
||||
Modal.confirm({
|
||||
title: t('data_import.history.confirm.delete_title'),
|
||||
content: t('data_import.history.confirm.delete_content'),
|
||||
okText: t('data_import.history.action.delete'),
|
||||
cancelText: t('common.cancel'),
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
setPendingAction(`delete:${job.id}`);
|
||||
try {
|
||||
const result = await DeleteImportJob(job.id);
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.message || t('data_import.history.error.delete_failed'));
|
||||
}
|
||||
setSelectedJob((current) => (current?.id === job.id ? null : current));
|
||||
await loadJobs();
|
||||
void message.success(t('data_import.history.message.deleted'));
|
||||
} catch (deleteError: any) {
|
||||
void message.error(t('data_import.history.error.delete_failed_detail', {
|
||||
detail: deleteError?.message || String(deleteError),
|
||||
}));
|
||||
throw deleteError;
|
||||
} finally {
|
||||
setPendingAction('');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
data-import-history-panel="true"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 12,
|
||||
padding: 20,
|
||||
border: '1px solid var(--gn-br-1, rgba(15,23,42,0.08))',
|
||||
borderRadius: 8,
|
||||
background: 'var(--gn-bg-panel, #fff)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gap: 2 }}>
|
||||
<Text strong>{t('data_import.history.title')}</Text>
|
||||
<Text type="secondary">{t('data_import.history.description')}</Text>
|
||||
</div>
|
||||
<Button
|
||||
data-import-history-refresh-action="true"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={loading}
|
||||
onClick={() => void loadJobs()}
|
||||
>
|
||||
{t('data_import.history.action.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? <Alert type="error" showIcon message={error} /> : null}
|
||||
{!loading && jobs.length === 0 ? (
|
||||
<Empty description={t('data_import.history.empty')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : null}
|
||||
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{jobs.map((job) => {
|
||||
const canDelete = terminalStatuses.has(job.status as ImportJobStatus);
|
||||
const canCancel = job.status === 'preparing' || job.status === 'running';
|
||||
const hasArtifact = Boolean(String(job.errorArtifactId || '').trim());
|
||||
const canExport = canDelete && hasArtifact;
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
data-import-history-job={true}
|
||||
data-import-history-job-id={job.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
padding: '12px 14px',
|
||||
borderRadius: 8,
|
||||
background: 'var(--gn-bg-subtle, var(--gn-bg-panel-2, #f8fafc))',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text strong>{formatJobTarget(job)}</Text>
|
||||
<Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||
{t(`data_import.history.kind.${job.kind || 'table'}`)} · {formatUpdatedAt(job.updatedAt)}
|
||||
</Text>
|
||||
</div>
|
||||
<Text>{t(`data_import.history.status.${job.status || 'unknown'}`)}</Text>
|
||||
</div>
|
||||
<Text data-import-history-progress={job.id} type="secondary">
|
||||
{t('data_import.history.progress', {
|
||||
current: Number(job.current) || 0,
|
||||
success: Number(job.succeeded) || 0,
|
||||
failed: Number(job.failed) || 0,
|
||||
skipped: Number(job.skipped) || 0,
|
||||
})}
|
||||
</Text>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
data-import-history-details-action={job.id}
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
loading={pendingAction === `details:${job.id}`}
|
||||
onClick={() => void loadDetails(job.id)}
|
||||
>
|
||||
{t('data_import.history.action.details')}
|
||||
</Button>
|
||||
{canCancel ? (
|
||||
<Button
|
||||
data-import-history-cancel-action={job.id}
|
||||
size="small"
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
loading={pendingAction === `cancel:${job.id}`}
|
||||
onClick={() => void cancelJob(job)}
|
||||
>
|
||||
{t('import_preview.action.stop')}
|
||||
</Button>
|
||||
) : null}
|
||||
{canExport ? (
|
||||
<Button
|
||||
data-import-history-export-action={job.id}
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={pendingAction === `export:${job.id}`}
|
||||
onClick={() => void exportRejectedRows(job)}
|
||||
>
|
||||
{t('data_import.history.action.export_errors')}
|
||||
</Button>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<Button
|
||||
data-import-history-delete-action={job.id}
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={pendingAction === `delete:${job.id}`}
|
||||
onClick={() => confirmDelete(job)}
|
||||
>
|
||||
{t('data_import.history.action.delete')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedJob?.id === job.id ? (
|
||||
<div
|
||||
data-import-history-details={job.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 4,
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--gn-br-1, rgba(15,23,42,0.08))',
|
||||
}}
|
||||
>
|
||||
<Text>{t('data_import.history.detail.stage', { stage: selectedJob.stage || '—' })}</Text>
|
||||
<Text>{t('data_import.history.detail.job_id', { id: selectedJob.id })}</Text>
|
||||
{selectedJob.message ? <Text type="secondary">{selectedJob.message}</Text> : null}
|
||||
{selectedJob.outcomeUnknown ? (
|
||||
<Alert type="warning" showIcon message={t('data_import.history.detail.outcome_unknown')} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportJobHistoryPanel;
|
||||
@@ -4,12 +4,14 @@ import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { I18nProvider } from "../i18n/provider";
|
||||
import ImportPreviewModal from "./ImportPreviewModal";
|
||||
import type { DataImportPreferences } from "./dataImportPreferences";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
previewImportFile: vi.fn(),
|
||||
dbGetColumns: vi.fn(),
|
||||
importDataWithProgressOptions: vi.fn(),
|
||||
cancelQuery: vi.fn(),
|
||||
exportImportErrorRows: vi.fn(),
|
||||
cancelImportJob: vi.fn(),
|
||||
progressHandler: null as ((data: any) => void) | null,
|
||||
eventsOn: vi.fn((_event: string, handler: (data: any) => void) => {
|
||||
mocks.progressHandler = handler;
|
||||
@@ -45,9 +47,11 @@ vi.mock("../i18n/runtime", () => ({
|
||||
|
||||
vi.mock("../../wailsjs/go/app/App", () => ({
|
||||
PreviewImportFile: mocks.previewImportFile,
|
||||
PreviewImportFileWithOptions: mocks.previewImportFile,
|
||||
DBGetColumns: mocks.dbGetColumns,
|
||||
ImportDataWithProgressOptions: mocks.importDataWithProgressOptions,
|
||||
CancelQuery: mocks.cancelQuery,
|
||||
ExportImportErrorRows: mocks.exportImportErrorRows,
|
||||
CancelImportJob: mocks.cancelImportJob,
|
||||
}));
|
||||
|
||||
vi.mock("../../wailsjs/runtime/runtime", () => ({
|
||||
@@ -106,8 +110,9 @@ vi.mock("antd", async () => {
|
||||
message?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
}) => React.createElement("div", null, message, description),
|
||||
Progress: ({ percent }: { percent: number }) =>
|
||||
React.createElement("div", null, `${percent}%`),
|
||||
Progress: ({ percent, ...props }: { percent?: number } & Record<string, unknown>) =>
|
||||
React.createElement("div", props, percent === undefined ? "active" : `${percent}%`),
|
||||
Spin: (props: Record<string, unknown>) => React.createElement("mock-spin", props),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
@@ -160,6 +165,8 @@ const textContent = (node: any): string => {
|
||||
const createImportPreviewTree = (
|
||||
filePath = "D:/imports/users.csv",
|
||||
presentation: "modal" | "embedded" = "modal",
|
||||
continueOnError?: boolean,
|
||||
importOptions?: DataImportPreferences,
|
||||
) => (
|
||||
<I18nProvider preference="en-US" onPreferenceChange={() => undefined}>
|
||||
<ImportPreviewModal
|
||||
@@ -169,6 +176,8 @@ const createImportPreviewTree = (
|
||||
connectionId="conn-1"
|
||||
dbName="app"
|
||||
tableName="users"
|
||||
continueOnError={continueOnError}
|
||||
importOptions={importOptions}
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
@@ -222,8 +231,10 @@ describe("ImportPreviewModal i18n", () => {
|
||||
],
|
||||
});
|
||||
mocks.importDataWithProgressOptions.mockReset();
|
||||
mocks.cancelQuery.mockReset();
|
||||
mocks.cancelQuery.mockResolvedValue({ success: true });
|
||||
mocks.exportImportErrorRows.mockReset();
|
||||
mocks.exportImportErrorRows.mockResolvedValue({ success: true });
|
||||
mocks.cancelImportJob.mockReset();
|
||||
mocks.cancelImportJob.mockResolvedValue({ success: true });
|
||||
mocks.progressHandler = null;
|
||||
mocks.eventsOn.mockClear();
|
||||
mocks.eventsOff.mockClear();
|
||||
@@ -261,6 +272,19 @@ describe("ImportPreviewModal i18n", () => {
|
||||
expect(renderedText).toContain("alice");
|
||||
});
|
||||
|
||||
it("uses shared theme tokens inside the embedded workbench preview", async () => {
|
||||
const renderer = await renderImportPreview("D:/imports/users.csv", "embedded");
|
||||
const sourceColumns = renderer.root.findByProps({
|
||||
"data-import-preview-source-columns": "true",
|
||||
});
|
||||
const footer = renderer.root.findByProps({
|
||||
"data-import-preview-embedded-footer": "true",
|
||||
});
|
||||
|
||||
expect(sourceColumns.props.style.background).toContain("var(--gn-bg-subtle");
|
||||
expect(footer.props.style.borderTop).toContain("var(--gn-br-1");
|
||||
});
|
||||
|
||||
it("keeps preview total when progress events omit total rows", async () => {
|
||||
let resolveImport!: (value: any) => void;
|
||||
mocks.importDataWithProgressOptions.mockImplementation(
|
||||
@@ -315,7 +339,7 @@ describe("ImportPreviewModal i18n", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps file headers to database fields and submits only selected mappings", async () => {
|
||||
it("maps file headers to database fields and submits the selected error policy", async () => {
|
||||
mocks.importDataWithProgressOptions.mockResolvedValue({
|
||||
success: true,
|
||||
data: { success: 12, failed: 0, total: 12, errorLogs: [] },
|
||||
@@ -347,10 +371,11 @@ describe("ImportPreviewModal i18n", () => {
|
||||
"app",
|
||||
"users",
|
||||
"D:/imports/users.csv",
|
||||
{
|
||||
expect.objectContaining({
|
||||
columnMappings: { id: "ID", user_name: "username" },
|
||||
continueOnError: false,
|
||||
jobId: expect.stringMatching(/^import-/),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(mocks.dbGetColumns).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "mysql" }),
|
||||
@@ -359,6 +384,356 @@ describe("ImportPreviewModal i18n", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the same parser options for preview and import", async () => {
|
||||
const importOptions: DataImportPreferences = {
|
||||
continueOnError: true,
|
||||
encoding: "gb18030",
|
||||
delimiter: "tab",
|
||||
headerRow: 2,
|
||||
nullToken: "\\N",
|
||||
emptyStringAsNull: true,
|
||||
sheetName: " Sheet2 ",
|
||||
conflictPolicy: "upsert",
|
||||
conflictKeyColumns: ["id"],
|
||||
};
|
||||
mocks.importDataWithProgressOptions.mockResolvedValue({
|
||||
success: true,
|
||||
data: { success: 12, failed: 0, total: 12, errorLogs: [] },
|
||||
});
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(createImportPreviewTree(
|
||||
"D:/imports/users.csv",
|
||||
"embedded",
|
||||
true,
|
||||
importOptions,
|
||||
));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const expectedParserOptions = expect.objectContaining({
|
||||
continueOnError: true,
|
||||
encoding: "gb18030",
|
||||
delimiter: "tab",
|
||||
headerRow: 2,
|
||||
nullToken: "\\N",
|
||||
emptyStringAsNull: true,
|
||||
sheetName: " Sheet2 ",
|
||||
conflictPolicy: "upsert",
|
||||
conflictKeyColumns: ["id"],
|
||||
});
|
||||
expect(mocks.previewImportFile).toHaveBeenCalledWith(
|
||||
"D:/imports/users.csv",
|
||||
expectedParserOptions,
|
||||
);
|
||||
|
||||
const startButton = renderer.root.findAllByType("button")
|
||||
.find((node) => textContent(node.props.children) === "Start import");
|
||||
await act(async () => {
|
||||
startButton?.props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mocks.importDataWithProgressOptions.mock.calls[0][4]).toEqual(expectedParserOptions);
|
||||
});
|
||||
|
||||
it("blocks upsert until every conflict key is included in the target mappings", async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(createImportPreviewTree(
|
||||
"D:/imports/users.csv",
|
||||
"embedded",
|
||||
false,
|
||||
{
|
||||
continueOnError: false,
|
||||
encoding: "auto",
|
||||
delimiter: "auto",
|
||||
headerRow: 1,
|
||||
nullToken: "",
|
||||
emptyStringAsNull: false,
|
||||
sheetName: "",
|
||||
conflictPolicy: "upsert",
|
||||
conflictKeyColumns: ["email"],
|
||||
},
|
||||
));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const startButton = renderer.root.findAllByType("button")
|
||||
.find((node) => textContent(node.props.children) === "Start import");
|
||||
expect(startButton?.props.disabled).toBe(true);
|
||||
expect(textContent(renderer.toJSON())).toContain(
|
||||
"Conflict key columns must be included in the selected mappings: email",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses source bytes for progress when the total row count is unknown", async () => {
|
||||
mocks.previewImportFile.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
columns: ["id"],
|
||||
totalRows: 5,
|
||||
totalRowsKnown: false,
|
||||
fileSize: 20 * 1024 * 1024,
|
||||
sourceIdentity: { token: "source-v1" },
|
||||
previewRows: [{ id: 1 }],
|
||||
},
|
||||
});
|
||||
let resolveImport!: (value: any) => void;
|
||||
mocks.importDataWithProgressOptions.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveImport = resolve;
|
||||
}));
|
||||
const renderer = await renderImportPreview();
|
||||
const previewText = textContent(renderer.toJSON());
|
||||
expect(previewText).toContain("Showing 5 sample rows; total row count was not scanned. 1 field");
|
||||
expect(previewText).not.toContain("5 rows and 1 field");
|
||||
const startButton = renderer.root.findAllByType("button")
|
||||
.find((node) => textContent(node.props.children) === "Start import");
|
||||
|
||||
await act(async () => {
|
||||
startButton?.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const options = mocks.importDataWithProgressOptions.mock.calls[0][4];
|
||||
expect(options.sourceIdentityToken).toBe("source-v1");
|
||||
await act(async () => {
|
||||
mocks.progressHandler?.({
|
||||
jobId: options.jobId,
|
||||
current: 3,
|
||||
total: 0,
|
||||
totalRowsKnown: false,
|
||||
success: 3,
|
||||
errors: 0,
|
||||
skipped: 2,
|
||||
bytesRead: 10 * 1024 * 1024,
|
||||
totalBytes: 20 * 1024 * 1024,
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const byteProgress = renderer.root.findByProps({ "data-import-progress-mode": "bytes" });
|
||||
expect(textContent(byteProgress)).toBe("50%");
|
||||
expect(renderer.root.findAllByProps({ "data-import-progress-indeterminate": "true" })).toHaveLength(0);
|
||||
const renderedText = textContent(renderer.toJSON());
|
||||
expect(renderedText).toContain("Processed 3 rows");
|
||||
expect(renderedText).toContain("Skipped 2 rows");
|
||||
expect(renderedText).not.toContain("Processed 3 / 5 rows");
|
||||
const successMetric = renderer.root.findByProps({ "data-import-progress-success": "true" });
|
||||
expect(successMetric.props.style.color).toContain("var(--gn-status-connected");
|
||||
|
||||
await act(async () => {
|
||||
resolveImport({ success: true, data: { success: 3, failed: 0, total: 3 } });
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a real indeterminate indicator until a parser reports measurable progress", async () => {
|
||||
mocks.previewImportFile.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
columns: ["id"],
|
||||
totalRows: 5,
|
||||
totalRowsKnown: false,
|
||||
fileSize: 20 * 1024 * 1024,
|
||||
previewRows: [{ id: 1 }],
|
||||
},
|
||||
});
|
||||
let resolveImport!: (value: any) => void;
|
||||
mocks.importDataWithProgressOptions.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveImport = resolve;
|
||||
}));
|
||||
const renderer = await renderImportPreview("D:/imports/users.xlsx");
|
||||
const startButton = renderer.root.findAllByType("button")
|
||||
.find((node) => textContent(node.props.children) === "Start import");
|
||||
|
||||
await act(async () => {
|
||||
startButton?.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const options = mocks.importDataWithProgressOptions.mock.calls[0][4];
|
||||
await act(async () => {
|
||||
mocks.progressHandler?.({
|
||||
jobId: options.jobId,
|
||||
current: 3,
|
||||
total: 0,
|
||||
totalRowsKnown: false,
|
||||
success: 3,
|
||||
errors: 0,
|
||||
bytesRead: 0,
|
||||
totalBytes: 20 * 1024 * 1024,
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(renderer.root.findByProps({ "data-import-progress-mode": "indeterminate" })).toBeDefined();
|
||||
expect(renderer.root.findAll((node) => String(node.type) === "mock-spin")).toHaveLength(1);
|
||||
expect(renderer.root.findAllByProps({ "data-import-progress-mode": "bytes" })).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
resolveImport({ success: true, data: { success: 3, failed: 0, total: 3 } });
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it("starts only one import when the action is triggered twice before React rerenders", async () => {
|
||||
let resolveImport!: (value: any) => void;
|
||||
mocks.importDataWithProgressOptions.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveImport = resolve;
|
||||
}));
|
||||
const renderer = await renderImportPreview();
|
||||
const startButton = renderer.root.findAllByType("button")
|
||||
.find((node) => textContent(node.props.children) === "Start import");
|
||||
|
||||
await act(async () => {
|
||||
void startButton?.props.onClick();
|
||||
void startButton?.props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mocks.importDataWithProgressOptions).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveImport({ success: true, data: { success: 12, failed: 0, total: 12, errorLogs: [] } });
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it("locks retry behind an unknown outcome when the import RPC response is lost", async () => {
|
||||
mocks.importDataWithProgressOptions.mockRejectedValue(new Error("transport response lost"));
|
||||
const renderer = await renderImportPreview();
|
||||
const startButton = renderer.root.findAllByType("button")
|
||||
.find((node) => textContent(node.props.children) === "Start import");
|
||||
|
||||
await act(async () => {
|
||||
await startButton?.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const renderedText = textContent(renderer.toJSON());
|
||||
expect(renderedText).toContain("Import failed: transport response lost");
|
||||
expect(renderedText).toContain("may have been partially written");
|
||||
expect(renderer.root.findAllByType("button")
|
||||
.some((node) => textContent(node.props.children) === "Start import")).toBe(false);
|
||||
});
|
||||
|
||||
it("submits continue-on-error when the workbench enables it", async () => {
|
||||
mocks.importDataWithProgressOptions.mockResolvedValue({
|
||||
success: true,
|
||||
data: { success: 11, failed: 1, total: 12, errorLogs: ["Row 2: duplicate key"] },
|
||||
});
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(createImportPreviewTree("D:/imports/users.csv", "embedded", true));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const button = renderer.root
|
||||
.findAllByType("button")
|
||||
.find((node) => textContent(node.props.children) === "Start import");
|
||||
await act(async () => {
|
||||
button?.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.importDataWithProgressOptions.mock.calls[0][4]).toEqual(expect.objectContaining({
|
||||
continueOnError: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it("renders a fail-fast partial result without offering an implicit replay", async () => {
|
||||
mocks.importDataWithProgressOptions.mockResolvedValue({
|
||||
success: false,
|
||||
message: "Table import stopped on error",
|
||||
data: {
|
||||
success: 1000,
|
||||
failed: 21,
|
||||
total: 2000,
|
||||
errorLogs: ["Rows 1001-2000: duplicate key"],
|
||||
errorLogsOmitted: 20,
|
||||
stoppedOnError: true,
|
||||
outcomeUnknown: true,
|
||||
},
|
||||
});
|
||||
const renderer = await renderImportPreview();
|
||||
const button = renderer.root
|
||||
.findAllByType("button")
|
||||
.find((node) => textContent(node.props.children) === "Start import");
|
||||
|
||||
await act(async () => {
|
||||
button?.props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const renderedText = textContent(renderer.toJSON());
|
||||
expect(renderedText).toContain("Import stopped on error");
|
||||
expect(renderedText).toContain("The failed batch may have been partially written");
|
||||
expect(renderedText).toContain("Rows 1001-2000: duplicate key");
|
||||
expect(renderedText).toContain("20 more error details are not shown");
|
||||
});
|
||||
|
||||
it("exports rejected rows through the managed artifact id", async () => {
|
||||
mocks.importDataWithProgressOptions.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
success: 11,
|
||||
failed: 1,
|
||||
total: 12,
|
||||
errorArtifactId: "artifact-v1",
|
||||
errorLogs: ["Row 2: duplicate key"],
|
||||
},
|
||||
});
|
||||
const renderer = await renderImportPreview();
|
||||
const startButton = renderer.root.findAllByType("button")
|
||||
.find((node) => textContent(node.props.children) === "Start import");
|
||||
await act(async () => {
|
||||
startButton?.props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const exportButton = renderer.root.findAllByType("button")
|
||||
.find((node) => textContent(node.props.children) === "Export rejected rows");
|
||||
expect(exportButton).toBeDefined();
|
||||
await act(async () => {
|
||||
exportButton?.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mocks.exportImportErrorRows).toHaveBeenCalledWith("artifact-v1");
|
||||
});
|
||||
|
||||
it("renders an ordinary partial failure as failed instead of completed", async () => {
|
||||
mocks.importDataWithProgressOptions.mockResolvedValue({
|
||||
success: false,
|
||||
message: "Malformed CSV at row 2",
|
||||
data: {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
total: 0,
|
||||
errorLogs: [],
|
||||
stoppedOnError: false,
|
||||
outcomeUnknown: false,
|
||||
},
|
||||
});
|
||||
const renderer = await renderImportPreview();
|
||||
const button = renderer.root
|
||||
.findAllByType("button")
|
||||
.find((node) => textContent(node.props.children) === "Start import");
|
||||
|
||||
await act(async () => {
|
||||
button?.props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const renderedText = textContent(renderer.toJSON());
|
||||
expect(renderedText).toContain("Import failed");
|
||||
expect(renderedText).toContain("Malformed CSV at row 2");
|
||||
expect(renderedText).not.toContain("Import completed");
|
||||
});
|
||||
|
||||
it("disables import until at least one source column is mapped", async () => {
|
||||
mocks.previewImportFile.mockResolvedValue({
|
||||
success: true,
|
||||
@@ -483,6 +858,15 @@ describe("ImportPreviewModal i18n", () => {
|
||||
expect(mocks.previewImportFile).toHaveBeenCalledTimes(1);
|
||||
expect(textContent(renderer.toJSON())).toContain("Failed 1 rows");
|
||||
expect(textContent(renderer.toJSON())).toContain("Row 12: duplicate key");
|
||||
const errorLogTitle = renderer.root.findByProps({
|
||||
"data-import-preview-error-log-title": "true",
|
||||
});
|
||||
const errorLogPanel = renderer.root.findByProps({
|
||||
"data-import-preview-error-log-panel": "true",
|
||||
});
|
||||
expect(errorLogTitle.props.style.color).toContain("var(--gn-danger");
|
||||
expect(errorLogPanel.props.style.background).toContain("var(--gn-warn-soft");
|
||||
expect(errorLogPanel.props.style.border).toContain("var(--gn-warn");
|
||||
});
|
||||
|
||||
it("stops an active import by its job id and preserves the partial result", async () => {
|
||||
@@ -513,8 +897,8 @@ describe("ImportPreviewModal i18n", () => {
|
||||
stopButton?.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mocks.cancelQuery).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.cancelQuery).toHaveBeenCalledWith(importJobId);
|
||||
expect(mocks.cancelImportJob).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.cancelImportJob).toHaveBeenCalledWith(importJobId);
|
||||
|
||||
await act(async () => {
|
||||
resolveImport({
|
||||
@@ -546,7 +930,7 @@ describe("ImportPreviewModal i18n", () => {
|
||||
resolveImport = resolve;
|
||||
}),
|
||||
);
|
||||
mocks.cancelQuery.mockImplementation(
|
||||
mocks.cancelImportJob.mockImplementation(
|
||||
() => new Promise((resolve) => {
|
||||
resolveCancel = resolve;
|
||||
}),
|
||||
@@ -593,7 +977,7 @@ describe("ImportPreviewModal i18n", () => {
|
||||
resolveImport = resolve;
|
||||
}),
|
||||
);
|
||||
mocks.cancelQuery.mockResolvedValue({ success: false, message: "No running query" });
|
||||
mocks.cancelImportJob.mockResolvedValue({ success: false, message: "No running query" });
|
||||
const renderer = await renderImportPreview();
|
||||
const startButton = renderer.root
|
||||
.findAllByType("button")
|
||||
@@ -613,7 +997,7 @@ describe("ImportPreviewModal i18n", () => {
|
||||
});
|
||||
expect(textContent(renderer.toJSON())).toContain("No running query");
|
||||
|
||||
mocks.cancelQuery.mockResolvedValue({ success: true });
|
||||
mocks.cancelImportJob.mockResolvedValue({ success: true });
|
||||
const retryStopButton = renderer.root
|
||||
.findAllByType("button")
|
||||
.find((node) => textContent(node.props.children) === "Stop import");
|
||||
@@ -621,7 +1005,7 @@ describe("ImportPreviewModal i18n", () => {
|
||||
retryStopButton?.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mocks.cancelQuery).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.cancelImportJob).toHaveBeenCalledTimes(2);
|
||||
expect(textContent(renderer.toJSON())).not.toContain("No running query");
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Table, Alert, Progress, Button, Space, Select } from 'antd';
|
||||
import { Table, Alert, Progress, Button, Space, Select, Spin } from 'antd';
|
||||
import { CheckCircleOutlined, CloseCircleOutlined, StopOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
CancelQuery,
|
||||
DBGetColumns,
|
||||
PreviewImportFile,
|
||||
ExportImportErrorRows,
|
||||
ImportDataWithProgressOptions,
|
||||
} from "../../wailsjs/go/app/App";
|
||||
import * as AppBindings from "../../wailsjs/go/app/App";
|
||||
import { EventsOn } from "../../wailsjs/runtime/runtime";
|
||||
import { useStore } from "../store";
|
||||
import { t as defaultTranslate } from "../i18n";
|
||||
@@ -15,12 +15,19 @@ import { useOptionalI18n } from "../i18n/provider";
|
||||
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
|
||||
import { getColumnDefinitionName } from "../utils/columnDefinition";
|
||||
import { confirmProductionRisk } from "../utils/productionRiskConfirm";
|
||||
import { calculateImportTransferMetrics, formatImportBytes, formatImportDuration } from "./importProgressMetrics";
|
||||
import {
|
||||
DEFAULT_DATA_IMPORT_PREFERENCES,
|
||||
type DataImportPreferences,
|
||||
} from "./dataImportPreferences";
|
||||
interface ImportPreviewModalProps {
|
||||
visible: boolean;
|
||||
filePath: string;
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
tableName: string;
|
||||
continueOnError?: boolean;
|
||||
importOptions?: DataImportPreferences;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void | Promise<void>;
|
||||
onImportingChange?: (importing: boolean) => void;
|
||||
@@ -30,16 +37,66 @@ interface ImportPreviewModalProps {
|
||||
interface PreviewData {
|
||||
columns: string[];
|
||||
totalRows: number;
|
||||
totalRowsKnown: boolean;
|
||||
fileSize: number;
|
||||
sourceIdentityToken: string;
|
||||
previewRows: any[];
|
||||
}
|
||||
|
||||
type ImportParserOptions = Omit<DataImportPreferences, "nullToken" | "sheetName"> & {
|
||||
nullToken?: string;
|
||||
sheetName?: string;
|
||||
};
|
||||
|
||||
const previewImportFileWithOptions = (
|
||||
AppBindings as unknown as {
|
||||
PreviewImportFileWithOptions?: (filePath: string, options: ImportParserOptions) => Promise<any>;
|
||||
}
|
||||
).PreviewImportFileWithOptions;
|
||||
|
||||
const cancelImportJob = (
|
||||
AppBindings as unknown as {
|
||||
CancelImportJob?: (jobId: string) => Promise<any>;
|
||||
}
|
||||
).CancelImportJob;
|
||||
|
||||
const buildImportParserOptions = (
|
||||
importOptions: DataImportPreferences | undefined,
|
||||
continueOnError: boolean,
|
||||
): ImportParserOptions => {
|
||||
const normalized = {
|
||||
...DEFAULT_DATA_IMPORT_PREFERENCES,
|
||||
...importOptions,
|
||||
continueOnError,
|
||||
};
|
||||
return {
|
||||
continueOnError: normalized.continueOnError,
|
||||
conflictPolicy: normalized.conflictPolicy,
|
||||
conflictKeyColumns: Array.from(new Set(
|
||||
normalized.conflictKeyColumns.map((column) => column.trim()).filter(Boolean),
|
||||
)),
|
||||
encoding: normalized.encoding,
|
||||
delimiter: normalized.delimiter,
|
||||
headerRow: normalized.headerRow,
|
||||
emptyStringAsNull: normalized.emptyStringAsNull,
|
||||
...(normalized.nullToken !== "" ? { nullToken: normalized.nullToken } : {}),
|
||||
...(normalized.sheetName !== "" ? { sheetName: normalized.sheetName } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
interface ImportProgress {
|
||||
jobId?: string;
|
||||
current: number;
|
||||
total: number;
|
||||
success: number;
|
||||
errors: number;
|
||||
skipped?: number;
|
||||
totalRowsKnown?: boolean;
|
||||
bytesRead?: number;
|
||||
totalBytes?: number;
|
||||
bytesPerSecond?: number;
|
||||
etaSeconds?: number;
|
||||
stage?: string;
|
||||
}
|
||||
|
||||
const createImportJobId = (): string => {
|
||||
@@ -55,6 +112,8 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
connectionId,
|
||||
dbName,
|
||||
tableName,
|
||||
continueOnError = false,
|
||||
importOptions,
|
||||
onClose,
|
||||
onSuccess,
|
||||
onImportingChange,
|
||||
@@ -65,6 +124,15 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
const connections = useStore((state) => state.connections);
|
||||
const darkMode = useStore((state) => state.theme === "dark");
|
||||
const connection = connections.find((item) => item.id === connectionId);
|
||||
const parserOptions = buildImportParserOptions(importOptions, continueOnError);
|
||||
const parserOptionsKey = JSON.stringify({
|
||||
encoding: parserOptions.encoding,
|
||||
delimiter: parserOptions.delimiter,
|
||||
headerRow: parserOptions.headerRow,
|
||||
nullToken: parserOptions.nullToken,
|
||||
emptyStringAsNull: parserOptions.emptyStringAsNull,
|
||||
sheetName: parserOptions.sheetName,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [previewData, setPreviewData] = useState<PreviewData | null>(null);
|
||||
const [targetColumns, setTargetColumns] = useState<string[]>([]);
|
||||
@@ -80,8 +148,25 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
const stoppingRef = useRef(false);
|
||||
const activeImportJobIdRef = useRef("");
|
||||
const previewConnectionConfigRef = useRef<any>(null);
|
||||
const secondaryTextColor = darkMode ? "rgba(255,255,255,0.65)" : "rgba(0,0,0,0.45)";
|
||||
const mappingFieldBackground = darkMode ? "rgba(255,255,255,0.06)" : "#f5f5f5";
|
||||
const importStartedAtRef = useRef(0);
|
||||
const latestProgressRef = useRef<ImportProgress | null>(null);
|
||||
const secondaryTextColor = `var(--gn-fg-3, ${darkMode
|
||||
? "rgba(255,255,255,0.65)"
|
||||
: "rgba(0,0,0,0.45)"})`;
|
||||
const mappingHeaderColor = `var(--gn-fg-2, ${darkMode
|
||||
? "rgba(255,255,255,0.85)"
|
||||
: "rgba(0,0,0,0.65)"})`;
|
||||
const mappingFieldBackground = `var(--gn-bg-subtle, var(--gn-bg-panel-2, ${darkMode
|
||||
? "rgba(255,255,255,0.06)"
|
||||
: "#f5f5f5"}))`;
|
||||
const dividerColor = `var(--gn-br-1, ${darkMode
|
||||
? "rgba(255,255,255,0.08)"
|
||||
: "rgba(15,23,42,0.08)"})`;
|
||||
const dangerColor = `var(--gn-danger, ${darkMode ? "#ff7875" : "#ff4d4f"})`;
|
||||
const warningSoftBackground = `var(--gn-warn-soft, ${darkMode
|
||||
? "rgba(250,173,20,0.16)"
|
||||
: "#fff1f0"})`;
|
||||
const warningBorderColor = `var(--gn-warn, ${darkMode ? "#d89614" : "#ffccc7"})`;
|
||||
|
||||
useEffect(() => {
|
||||
if (importingRef.current) return undefined;
|
||||
@@ -95,7 +180,7 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
previewRequestRef.current += 1;
|
||||
}
|
||||
};
|
||||
}, [visible, filePath, connectionId, dbName, tableName, connection]);
|
||||
}, [visible, filePath, connectionId, dbName, tableName, connection, parserOptionsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (importing) {
|
||||
@@ -104,18 +189,39 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
(data: ImportProgress) => {
|
||||
if (!data || data.jobId !== activeImportJobIdRef.current) return;
|
||||
setProgress((prev) => {
|
||||
const fallbackTotal = prev?.total || previewData?.totalRows || 0;
|
||||
const totalRowsKnown = prev?.totalRowsKnown === true
|
||||
? true
|
||||
: (data.totalRowsKnown ?? previewData?.totalRowsKnown ?? false);
|
||||
const fallbackTotal = totalRowsKnown
|
||||
? (prev?.total || previewData?.totalRows || 0)
|
||||
: 0;
|
||||
const nextTotal =
|
||||
typeof data.total === "number" && data.total > 0
|
||||
totalRowsKnown && typeof data.total === "number" && data.total > 0
|
||||
? data.total
|
||||
: fallbackTotal;
|
||||
return {
|
||||
const bytesRead = Math.max(0, Math.trunc(Number(data.bytesRead ?? prev?.bytesRead) || 0));
|
||||
const totalBytes = Math.max(0, Math.trunc(Number(data.totalBytes ?? prev?.totalBytes ?? previewData?.fileSize) || 0));
|
||||
const transferMetrics = calculateImportTransferMetrics({
|
||||
startedAt: importStartedAtRef.current,
|
||||
now: Date.now(),
|
||||
bytesRead,
|
||||
totalBytes,
|
||||
});
|
||||
const nextProgress = {
|
||||
current: data.current ?? prev?.current ?? 0,
|
||||
total: nextTotal,
|
||||
success: data.success ?? prev?.success ?? 0,
|
||||
errors: data.errors ?? prev?.errors ?? 0,
|
||||
totalRowsKnown: data.totalRowsKnown ?? nextTotal > 0,
|
||||
skipped: data.skipped ?? prev?.skipped ?? 0,
|
||||
totalRowsKnown,
|
||||
bytesRead,
|
||||
totalBytes,
|
||||
bytesPerSecond: transferMetrics.bytesPerSecond,
|
||||
etaSeconds: transferMetrics.etaSeconds,
|
||||
stage: data.stage || prev?.stage || "",
|
||||
};
|
||||
latestProgressRef.current = nextProgress;
|
||||
return nextProgress;
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -147,6 +253,7 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
setColumnMappings({});
|
||||
setImportResult(null);
|
||||
setProgress(null);
|
||||
latestProgressRef.current = null;
|
||||
try {
|
||||
const conn = connection;
|
||||
if (!conn) {
|
||||
@@ -169,8 +276,12 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
},
|
||||
};
|
||||
const rpcConfig = buildRpcConnectionConfig(config) as any;
|
||||
if (typeof previewImportFileWithOptions !== "function") {
|
||||
setError(t("data_import.capability.reason.capability_unavailable"));
|
||||
return;
|
||||
}
|
||||
const [previewRes, columnsRes] = await Promise.all([
|
||||
PreviewImportFile(filePath),
|
||||
previewImportFileWithOptions(filePath, parserOptions),
|
||||
DBGetColumns(rpcConfig, dbName, tableName),
|
||||
]);
|
||||
if (previewRequestRef.current !== requestId) return;
|
||||
@@ -205,9 +316,14 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
nextMappings[sourceColumn] = exactTarget || (insensitiveTargets.length === 1 ? insensitiveTargets[0] : "");
|
||||
});
|
||||
|
||||
const previewTotalRows = Math.max(0, Number(previewRes.data.totalRows) || 0);
|
||||
setPreviewData({
|
||||
columns: sourceColumns,
|
||||
totalRows: previewRes.data.totalRows || 0,
|
||||
totalRows: previewTotalRows,
|
||||
totalRowsKnown: previewRes.data.totalRowsKnown === true
|
||||
|| (previewRes.data.totalRowsKnown == null && previewTotalRows > 0),
|
||||
fileSize: Math.max(0, Number(previewRes.data.fileSize) || 0),
|
||||
sourceIdentityToken: String(previewRes.data.sourceIdentity?.token || "").trim(),
|
||||
previewRows: previewRes.data.previewRows || [],
|
||||
});
|
||||
setTargetColumns(nextTargetColumns);
|
||||
@@ -231,16 +347,32 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
? new Set(previewData.columns).size !== previewData.columns.length
|
||||
: false;
|
||||
const hasDuplicateTargetColumns = new Set(mappedTargetColumns).size !== mappedTargetColumns.length;
|
||||
const mappingValidationError = hasDuplicateSourceColumns
|
||||
const normalizedMappedTargetColumns = new Set(
|
||||
mappedTargetColumns.map((column) => column.trim().toLowerCase()),
|
||||
);
|
||||
const unmappedConflictKeys = parserOptions.conflictPolicy === "upsert"
|
||||
? parserOptions.conflictKeyColumns.filter((column) => (
|
||||
!normalizedMappedTargetColumns.has(column.trim().toLowerCase())
|
||||
))
|
||||
: [];
|
||||
const importOptionsValidationError = parserOptions.conflictPolicy === "upsert"
|
||||
&& parserOptions.conflictKeyColumns.length === 0
|
||||
? t("data_import.workbench.advanced.conflict_keys_required")
|
||||
: unmappedConflictKeys.length > 0
|
||||
? t("data_import.workbench.advanced.conflict_keys_not_mapped", {
|
||||
columns: unmappedConflictKeys.join(", "),
|
||||
})
|
||||
: null;
|
||||
const mappingValidationError = importOptionsValidationError || (hasDuplicateSourceColumns
|
||||
? t("import_preview.mapping.validation.duplicate_source")
|
||||
: hasDuplicateTargetColumns
|
||||
? t("import_preview.mapping.validation.duplicate_target")
|
||||
: mappedTargetColumns.length === 0
|
||||
? t("import_preview.mapping.validation.required")
|
||||
: null;
|
||||
: null);
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!previewData || mappingValidationError) return;
|
||||
if (!previewData || mappingValidationError || importingRef.current) return;
|
||||
|
||||
const approved = await confirmProductionRisk({
|
||||
connection,
|
||||
@@ -248,7 +380,7 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
target: [dbName, tableName].filter(Boolean).join(" / "),
|
||||
translate: t,
|
||||
});
|
||||
if (!approved) return;
|
||||
if (!approved || importingRef.current) return;
|
||||
|
||||
const importRequestId = importRequestRef.current + 1;
|
||||
const importJobId = createImportJobId();
|
||||
@@ -256,15 +388,25 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
importingRef.current = true;
|
||||
stoppingRef.current = false;
|
||||
activeImportJobIdRef.current = importJobId;
|
||||
importStartedAtRef.current = Date.now();
|
||||
setImporting(true);
|
||||
setStopping(false);
|
||||
setError(null);
|
||||
setProgress({
|
||||
const initialProgress: ImportProgress = {
|
||||
current: 0,
|
||||
total: previewData.totalRows,
|
||||
success: 0,
|
||||
errors: 0,
|
||||
});
|
||||
skipped: 0,
|
||||
totalRowsKnown: previewData.totalRowsKnown,
|
||||
bytesRead: 0,
|
||||
totalBytes: previewData.fileSize,
|
||||
bytesPerSecond: 0,
|
||||
etaSeconds: 0,
|
||||
stage: "prepare",
|
||||
};
|
||||
latestProgressRef.current = initialProgress;
|
||||
setProgress(initialProgress);
|
||||
setImportResult(null);
|
||||
|
||||
try {
|
||||
@@ -282,33 +424,72 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
dbName,
|
||||
tableName,
|
||||
filePath,
|
||||
{ columnMappings: selectedMappings, jobId: importJobId },
|
||||
{
|
||||
...parserOptions,
|
||||
columnMappings: selectedMappings,
|
||||
jobId: importJobId,
|
||||
...(previewData.sourceIdentityToken
|
||||
? { sourceIdentityToken: previewData.sourceIdentityToken }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
if (importRequestRef.current !== importRequestId) return;
|
||||
|
||||
setError(null);
|
||||
if (res.data?.cancelled) {
|
||||
setImportResult(res.data);
|
||||
} else if (res.data?.stoppedOnError) {
|
||||
setImportResult(res.data);
|
||||
} else if (res.success && res.data) {
|
||||
setImportResult(res.data);
|
||||
if (res.data.failed === 0) {
|
||||
await onSuccess();
|
||||
}
|
||||
} else {
|
||||
setError(res.message || t("import_preview.error.import_failed"));
|
||||
const failureMessage = res.message || t("import_preview.error.import_failed");
|
||||
if (res.data) {
|
||||
setImportResult({
|
||||
...res.data,
|
||||
executionFailed: true,
|
||||
failureMessage,
|
||||
});
|
||||
} else {
|
||||
const latestProgress = latestProgressRef.current;
|
||||
setImportResult({
|
||||
success: latestProgress?.success || 0,
|
||||
skipped: latestProgress?.skipped || 0,
|
||||
failed: latestProgress?.errors || 0,
|
||||
total: latestProgress?.current || 0,
|
||||
errorLogs: [],
|
||||
executionFailed: true,
|
||||
failureMessage,
|
||||
outcomeUnknown: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (importRequestRef.current !== importRequestId) return;
|
||||
setError(
|
||||
t("import_preview.error.import_failed_detail", {
|
||||
detail: String(e?.message || e),
|
||||
}),
|
||||
);
|
||||
const failureMessage = t("import_preview.error.import_failed_detail", {
|
||||
detail: String(e?.message || e),
|
||||
});
|
||||
const latestProgress = latestProgressRef.current;
|
||||
setError(null);
|
||||
setImportResult({
|
||||
success: latestProgress?.success || 0,
|
||||
skipped: latestProgress?.skipped || 0,
|
||||
failed: latestProgress?.errors || 0,
|
||||
total: latestProgress?.current || 0,
|
||||
errorLogs: [],
|
||||
executionFailed: true,
|
||||
failureMessage,
|
||||
outcomeUnknown: true,
|
||||
});
|
||||
} finally {
|
||||
if (importRequestRef.current === importRequestId) {
|
||||
importingRef.current = false;
|
||||
stoppingRef.current = false;
|
||||
activeImportJobIdRef.current = "";
|
||||
importStartedAtRef.current = 0;
|
||||
setImporting(false);
|
||||
setStopping(false);
|
||||
}
|
||||
@@ -323,7 +504,10 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
setStopping(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await CancelQuery(importJobId);
|
||||
if (typeof cancelImportJob !== "function") {
|
||||
throw new Error(t("import_preview.error.stop_failed"));
|
||||
}
|
||||
const res = await cancelImportJob(importJobId);
|
||||
if (!importingRef.current || activeImportJobIdRef.current !== importJobId) {
|
||||
return;
|
||||
}
|
||||
@@ -353,10 +537,58 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
width: 150,
|
||||
})) || [];
|
||||
|
||||
const progressPercent =
|
||||
progress && progress.total > 0
|
||||
? Math.round((progress.current / progress.total) * 100)
|
||||
: 0;
|
||||
const rowProgressKnown = Boolean(progress?.totalRowsKnown && progress.total > 0);
|
||||
const byteProgressKnown = Boolean(
|
||||
!rowProgressKnown
|
||||
&& progress
|
||||
&& Number(progress.bytesRead) > 0
|
||||
&& Number(progress.totalBytes) > 0,
|
||||
);
|
||||
const progressMode = rowProgressKnown
|
||||
? "rows"
|
||||
: byteProgressKnown
|
||||
? "bytes"
|
||||
: "indeterminate";
|
||||
const progressPercent = Math.max(0, Math.min(100, Math.round(
|
||||
rowProgressKnown
|
||||
? ((progress?.current || 0) / (progress?.total || 1)) * 100
|
||||
: byteProgressKnown
|
||||
? ((progress?.bytesRead || 0) / (progress?.totalBytes || 1)) * 100
|
||||
: 0,
|
||||
)));
|
||||
|
||||
const progressTransferText = progress && (progress.bytesRead || progress.totalBytes)
|
||||
? [
|
||||
t("data_import.workbench.progress.bytes", {
|
||||
processed: formatImportBytes(progress.bytesRead || 0),
|
||||
total: progress.totalBytes ? formatImportBytes(progress.totalBytes) : "—",
|
||||
}),
|
||||
progress.bytesPerSecond
|
||||
? t("data_import.workbench.progress.throughput", { rate: formatImportBytes(progress.bytesPerSecond) })
|
||||
: "",
|
||||
progress.etaSeconds
|
||||
? t("data_import.workbench.progress.eta", {
|
||||
duration: formatImportDuration(progress.etaSeconds, i18n?.language),
|
||||
})
|
||||
: "",
|
||||
].filter(Boolean).join(" · ")
|
||||
: "";
|
||||
|
||||
const handleExportRejectedRows = async () => {
|
||||
const artifactID = String(importResult?.errorArtifactId || "").trim();
|
||||
if (!artifactID) return;
|
||||
setError(null);
|
||||
try {
|
||||
const result = await ExportImportErrorRows(artifactID);
|
||||
if (!result.success) {
|
||||
setError(result.message || t("import_preview.error.export_rejected_rows_failed"));
|
||||
}
|
||||
} catch (exportError: any) {
|
||||
setError(t("import_preview.error.export_rejected_rows_failed_detail", {
|
||||
detail: String(exportError?.message || exportError),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const footer = importResult ? (
|
||||
<Space>
|
||||
@@ -408,7 +640,9 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
message={t("import_preview.preview.summary", {
|
||||
message={t(previewData.totalRowsKnown
|
||||
? "import_preview.preview.summary"
|
||||
: "import_preview.preview.summary_sample", {
|
||||
rows: previewData.totalRows,
|
||||
columns: previewData.columns.length,
|
||||
})}
|
||||
@@ -420,6 +654,7 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
{t("import_preview.preview.field_list")}
|
||||
</div>
|
||||
<div
|
||||
data-import-preview-source-columns="true"
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 8,
|
||||
@@ -442,7 +677,7 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)",
|
||||
gap: 8,
|
||||
marginBottom: 6,
|
||||
color: darkMode ? "rgba(255,255,255,0.85)" : "rgba(0,0,0,0.65)",
|
||||
color: mappingHeaderColor,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
@@ -520,39 +755,89 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
? t("import_preview.status.stopping")
|
||||
: t("import_preview.status.importing")}
|
||||
</div>
|
||||
<Progress percent={progressPercent} status="active" />
|
||||
<div style={{ marginTop: 16, textAlign: "center", color: "#666" }}>
|
||||
{t("import_preview.progress.processed_rows", {
|
||||
current: progress.current,
|
||||
total: progress.total,
|
||||
})}
|
||||
<span style={{ marginLeft: 16, color: "#52c41a" }}>
|
||||
{progressMode === "indeterminate" ? (
|
||||
<div
|
||||
data-import-progress-mode="indeterminate"
|
||||
data-import-progress-indeterminate="true"
|
||||
style={{ display: "flex", justifyContent: "center", padding: "8px 0" }}
|
||||
>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<Progress
|
||||
data-import-progress-mode={progressMode}
|
||||
percent={progressPercent}
|
||||
showInfo
|
||||
status="active"
|
||||
/>
|
||||
)}
|
||||
<div style={{ marginTop: 16, textAlign: "center", color: secondaryTextColor }}>
|
||||
{progress.totalRowsKnown
|
||||
? t("import_preview.progress.processed_rows", {
|
||||
current: progress.current,
|
||||
total: progress.total,
|
||||
})
|
||||
: t("import_preview.progress.processed_rows_unknown", {
|
||||
current: progress.current,
|
||||
})}
|
||||
<span
|
||||
data-import-progress-success="true"
|
||||
style={{ marginLeft: 16, color: "var(--gn-status-connected, #52c41a)" }}
|
||||
>
|
||||
<CheckCircleOutlined />{" "}
|
||||
{t("import_preview.progress.success_count", {
|
||||
count: progress.success,
|
||||
})}
|
||||
</span>
|
||||
{progress.errors > 0 && (
|
||||
<span style={{ marginLeft: 16, color: "#ff4d4f" }}>
|
||||
<span style={{ marginLeft: 16, color: dangerColor }}>
|
||||
<CloseCircleOutlined />{" "}
|
||||
{t("import_preview.progress.error_count", {
|
||||
count: progress.errors,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{(progress.skipped || 0) > 0 && (
|
||||
<span data-import-progress-skipped="true" style={{ marginLeft: 16, color: secondaryTextColor }}>
|
||||
{t("data_import.workbench.progress.skipped", {
|
||||
count: progress.skipped,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{progress.stage ? (
|
||||
<div style={{ marginTop: 8, textAlign: "center", color: secondaryTextColor }}>
|
||||
{t(`import_preview.stage.${progress.stage}`)}
|
||||
</div>
|
||||
) : null}
|
||||
{progressTransferText ? (
|
||||
<div style={{ marginTop: 8, textAlign: "center", color: secondaryTextColor, fontSize: 12 }}>
|
||||
{progressTransferText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult && (
|
||||
<div style={{ padding: 20 }}>
|
||||
<Alert
|
||||
type={!importResult.cancelled && importResult.failed === 0 ? "success" : "warning"}
|
||||
message={importResult.cancelled
|
||||
? t("import_preview.result.stopped")
|
||||
: t("import_preview.result.completed")}
|
||||
type={importResult.executionFailed
|
||||
? "error"
|
||||
: !importResult.cancelled && !importResult.stoppedOnError && importResult.failed === 0
|
||||
? "success"
|
||||
: "warning"}
|
||||
message={importResult.executionFailed
|
||||
? t("import_preview.error.import_failed")
|
||||
: importResult.cancelled
|
||||
? t("import_preview.result.stopped")
|
||||
: importResult.stoppedOnError
|
||||
? t("import_preview.result.stopped_on_error")
|
||||
: t("import_preview.result.completed")}
|
||||
description={
|
||||
<div>
|
||||
{importResult.executionFailed && importResult.failureMessage && (
|
||||
<div>{importResult.failureMessage}</div>
|
||||
)}
|
||||
<div>
|
||||
{t("import_preview.result.success_rows", {
|
||||
count: importResult.success,
|
||||
@@ -560,29 +845,46 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
</div>
|
||||
{importResult.failed > 0 && (
|
||||
<div>
|
||||
{t("import_preview.result.failed_rows", {
|
||||
count: importResult.failed,
|
||||
{importResult.outcomeUnknown
|
||||
? t("import_preview.result.error_count", { count: importResult.failed })
|
||||
: t("import_preview.result.failed_rows", { count: importResult.failed })}
|
||||
</div>
|
||||
)}
|
||||
{Number(importResult.skipped) > 0 && (
|
||||
<div data-import-result-skipped="true">
|
||||
{t("data_import.workbench.progress.skipped", {
|
||||
count: importResult.skipped,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{importResult.outcomeUnknown && (
|
||||
<div>{t("import_preview.result.batch_outcome_unknown")}</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
{importResult.errorArtifactId ? (
|
||||
<Button onClick={() => void handleExportRejectedRows()}>
|
||||
{t("import_preview.action.export_rejected_rows")}
|
||||
</Button>
|
||||
) : null}
|
||||
{importResult.errorLogs && importResult.errorLogs.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
style={{ marginBottom: 8, fontWeight: 600, color: "#ff4d4f" }}
|
||||
data-import-preview-error-log-title="true"
|
||||
style={{ marginBottom: 8, fontWeight: 600, color: dangerColor }}
|
||||
>
|
||||
{t("import_preview.result.error_logs")}
|
||||
</div>
|
||||
<div
|
||||
data-import-preview-error-log-panel="true"
|
||||
style={{
|
||||
maxHeight: 300,
|
||||
overflow: "auto",
|
||||
background: "#fff1f0",
|
||||
border: "1px solid #ffccc7",
|
||||
background: warningSoftBackground,
|
||||
border: `1px solid ${warningBorderColor}`,
|
||||
borderRadius: 4,
|
||||
padding: 12,
|
||||
fontSize: 12,
|
||||
@@ -594,6 +896,13 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
{importResult.errorLogsOmitted > 0 && (
|
||||
<div>
|
||||
{t("import_preview.result.error_logs_omitted", {
|
||||
count: importResult.errorLogsOmitted,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -632,9 +941,7 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
justifyContent: "flex-end",
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTop: darkMode
|
||||
? "1px solid rgba(255,255,255,0.08)"
|
||||
: "1px solid rgba(15,23,42,0.08)",
|
||||
borderTop: `1px solid ${dividerColor}`,
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
|
||||
441
frontend/src/components/SQLFileExecutionWorkbench.test.tsx
Normal file
441
frontend/src/components/SQLFileExecutionWorkbench.test.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TabData } from '../types';
|
||||
import type { SQLFileExecutionState } from './useSQLFileExecutionRunner';
|
||||
import SQLFileExecutionWorkbench from './SQLFileExecutionWorkbench';
|
||||
|
||||
const createRunnerState = (
|
||||
overrides: Partial<SQLFileExecutionState> = {},
|
||||
): SQLFileExecutionState => ({
|
||||
jobId: '',
|
||||
title: '',
|
||||
filePath: '',
|
||||
fileSizeMB: '',
|
||||
startedAt: 0,
|
||||
finishedAt: 0,
|
||||
status: 'idle',
|
||||
stage: '',
|
||||
executed: 0,
|
||||
failed: 0,
|
||||
total: 0,
|
||||
percent: 0,
|
||||
bytesRead: 0,
|
||||
totalBytes: 0,
|
||||
bytesPerSecond: 0,
|
||||
etaSeconds: 0,
|
||||
currentSQL: '',
|
||||
message: '',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeSQLFile: vi.fn(),
|
||||
importDatabaseSQL: vi.fn(),
|
||||
dataImportCapability: vi.fn(),
|
||||
cancelSQLFileExecution: vi.fn(),
|
||||
confirmProductionRisk: vi.fn(),
|
||||
run: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
modalConfirm: vi.fn(),
|
||||
state: null as SQLFileExecutionState | null,
|
||||
isRunning: false,
|
||||
}));
|
||||
|
||||
vi.mock('./common/ResizableDraggableModal', () => ({
|
||||
default: { confirm: mocks.modalConfirm },
|
||||
}));
|
||||
|
||||
vi.mock('../../wailsjs/go/app/App', () => ({
|
||||
ExecuteSQLFile: mocks.executeSQLFile,
|
||||
ImportDatabaseSQL: mocks.importDatabaseSQL,
|
||||
DataImportCapability: mocks.dataImportCapability,
|
||||
CancelSQLFileExecution: mocks.cancelSQLFileExecution,
|
||||
}));
|
||||
|
||||
vi.mock('../utils/productionRiskConfirm', () => ({
|
||||
confirmProductionRisk: mocks.confirmProductionRisk,
|
||||
}));
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
|
||||
theme: 'light',
|
||||
connections: [{
|
||||
id: 'conn-1',
|
||||
name: 'Local MySQL',
|
||||
environmentType: 'production',
|
||||
config: {
|
||||
type: 'mysql',
|
||||
host: '127.0.0.1',
|
||||
port: 3306,
|
||||
user: 'root',
|
||||
},
|
||||
}],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../i18n', () => ({
|
||||
t: (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock('../utils/connectionRpcConfig', () => ({
|
||||
buildRpcConnectionConfig: () => ({ type: 'mysql', host: '127.0.0.1', port: 3306 }),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/tabDisplay', () => ({
|
||||
resolveConnectionHostSummary: () => '127.0.0.1:3306',
|
||||
}));
|
||||
|
||||
vi.mock('./useSQLFileExecutionRunner', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./useSQLFileExecutionRunner')>();
|
||||
return {
|
||||
...actual,
|
||||
useSQLFileExecutionRunner: () => ({
|
||||
state: mocks.state,
|
||||
reset: mocks.reset,
|
||||
cancelExecution: mocks.cancel,
|
||||
runSQLFileExecutionWithProgress: mocks.run,
|
||||
isRunning: mocks.isRunning,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('antd', async () => {
|
||||
const React = await import('react');
|
||||
const Alert = (props: Record<string, unknown>) => React.createElement('mock-alert', props);
|
||||
const Button = ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<button {...props}>{children}</button>
|
||||
);
|
||||
const Empty = Object.assign(
|
||||
(props: Record<string, unknown>) => React.createElement('mock-empty', props),
|
||||
{ PRESENTED_IMAGE_SIMPLE: 'simple' },
|
||||
);
|
||||
const Progress = (props: Record<string, unknown>) => React.createElement('mock-progress', props);
|
||||
const Paragraph = ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<p {...props}>{children}</p>
|
||||
);
|
||||
const Text = ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<span {...props}>{children}</span>
|
||||
);
|
||||
const Title = ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<h3 {...props}>{children}</h3>
|
||||
);
|
||||
return {
|
||||
Alert,
|
||||
Button,
|
||||
Empty,
|
||||
Progress,
|
||||
Typography: { Paragraph, Text, Title },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@ant-design/icons', () => ({
|
||||
ClockCircleOutlined: () => React.createElement('mock-icon', { name: 'clock' }),
|
||||
FileTextOutlined: () => React.createElement('mock-icon', { name: 'file' }),
|
||||
ReloadOutlined: () => React.createElement('mock-icon', { name: 'reload' }),
|
||||
StopOutlined: () => React.createElement('mock-icon', { name: 'stop' }),
|
||||
}));
|
||||
|
||||
const tab: TabData = {
|
||||
id: 'sql-file-execution-1',
|
||||
title: 'seed.sql',
|
||||
type: 'sql-file-execution',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'app',
|
||||
filePath: 'C:\\data\\seed.sql',
|
||||
sqlFileExecutionFileSizeMB: '12.5',
|
||||
};
|
||||
|
||||
const renderWorkbench = async (renderTab: TabData = tab): Promise<ReactTestRenderer> => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<SQLFileExecutionWorkbench tab={renderTab} />);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
return renderer;
|
||||
};
|
||||
|
||||
const findRunButton = (renderer: ReactTestRenderer) => renderer.root.findByProps({
|
||||
'data-sql-file-execution-run-action': 'true',
|
||||
});
|
||||
|
||||
describe('SQLFileExecutionWorkbench', () => {
|
||||
beforeEach(() => {
|
||||
mocks.state = createRunnerState();
|
||||
mocks.isRunning = false;
|
||||
mocks.executeSQLFile.mockReset();
|
||||
mocks.importDatabaseSQL.mockReset();
|
||||
mocks.importDatabaseSQL.mockResolvedValue({ success: true, message: '', data: {} });
|
||||
mocks.dataImportCapability.mockReset();
|
||||
mocks.dataImportCapability.mockResolvedValue({
|
||||
databaseType: 'mysql',
|
||||
tableImport: { supported: true },
|
||||
sqlFileImport: { supported: true, reason: '', supportsContinue: true },
|
||||
});
|
||||
mocks.cancelSQLFileExecution.mockReset();
|
||||
mocks.confirmProductionRisk.mockReset();
|
||||
mocks.confirmProductionRisk.mockResolvedValue(true);
|
||||
mocks.run.mockReset();
|
||||
mocks.cancel.mockReset();
|
||||
mocks.reset.mockReset();
|
||||
mocks.modalConfirm.mockReset();
|
||||
});
|
||||
|
||||
it('requires confirmation before rerunning a terminal SQL file execution', async () => {
|
||||
mocks.state = createRunnerState({
|
||||
jobId: 'sql-file-job-1',
|
||||
status: 'done',
|
||||
stage: 'done',
|
||||
filePath: tab.filePath,
|
||||
percent: 100,
|
||||
});
|
||||
const renderer = await renderWorkbench();
|
||||
const runButton = findRunButton(renderer);
|
||||
|
||||
expect(runButton).toBeDefined();
|
||||
await act(async () => {
|
||||
runButton?.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.modalConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: 'data_import.workbench.confirm.rerun_title',
|
||||
content: 'data_import.workbench.confirm.rerun_content',
|
||||
okText: 'data_import.workbench.action.retry_database_import',
|
||||
}));
|
||||
expect(mocks.run).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
await mocks.modalConfirm.mock.calls[0][0].onOk();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('starts the first manual execution without a rerun confirmation', async () => {
|
||||
const renderer = await renderWorkbench();
|
||||
|
||||
await act(async () => {
|
||||
findRunButton(renderer).props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.modalConfirm).not.toHaveBeenCalled();
|
||||
expect(mocks.confirmProductionRisk).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('auto-starts a new request without a rerun confirmation', async () => {
|
||||
await renderWorkbench({
|
||||
...tab,
|
||||
sqlFileExecutionRequestKey: 'request-1',
|
||||
});
|
||||
|
||||
expect(mocks.modalConfirm).not.toHaveBeenCalled();
|
||||
expect(mocks.confirmProductionRisk).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses the guarded database import API with fail-fast as the default', async () => {
|
||||
const renderer = await renderWorkbench();
|
||||
|
||||
await act(async () => {
|
||||
findRunButton(renderer).props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const runnerOptions = mocks.run.mock.calls[0][0];
|
||||
await runnerOptions.run('sql-file-safe-job');
|
||||
|
||||
expect(mocks.importDatabaseSQL).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'mysql' }),
|
||||
'app',
|
||||
tab.filePath,
|
||||
'sql-file-safe-job',
|
||||
false,
|
||||
);
|
||||
expect(mocks.executeSQLFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start when production confirmation is declined', async () => {
|
||||
mocks.confirmProductionRisk.mockResolvedValue(false);
|
||||
const renderer = await renderWorkbench();
|
||||
|
||||
await act(async () => {
|
||||
findRunButton(renderer).props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.confirmProductionRisk).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed when SQL file import capability is unsupported', async () => {
|
||||
mocks.dataImportCapability.mockResolvedValue({
|
||||
databaseType: 'mysql',
|
||||
tableImport: { supported: true },
|
||||
sqlFileImport: { supported: false, reason: 'pinned_session_unavailable' },
|
||||
});
|
||||
const renderer = await renderWorkbench({
|
||||
...tab,
|
||||
sqlFileExecutionRequestKey: 'unsupported-request',
|
||||
});
|
||||
|
||||
expect(mocks.run).not.toHaveBeenCalled();
|
||||
expect(findRunButton(renderer).props.disabled).toBe(true);
|
||||
expect(renderer.root.findByProps({
|
||||
'data-sql-file-execution-capability-alert': 'true',
|
||||
}).props.message).toBe('data_import.capability.reason.pinned_session_unavailable');
|
||||
});
|
||||
|
||||
it('retries a failed capability request before enabling execution', async () => {
|
||||
mocks.dataImportCapability
|
||||
.mockRejectedValueOnce(new Error('runtime unavailable'))
|
||||
.mockResolvedValueOnce({
|
||||
databaseType: 'mysql',
|
||||
tableImport: { supported: true },
|
||||
sqlFileImport: { supported: true, reason: '', supportsContinue: true },
|
||||
});
|
||||
const renderer = await renderWorkbench();
|
||||
const alert = renderer.root.findByProps({
|
||||
'data-sql-file-execution-capability-alert': 'true',
|
||||
});
|
||||
|
||||
expect(alert.props['data-sql-file-execution-capability-reason']).toBe('rpc_failed');
|
||||
expect(findRunButton(renderer).props.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
alert.props.action.props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.dataImportCapability).toHaveBeenCalledTimes(2);
|
||||
expect(renderer.root.findAllByProps({
|
||||
'data-sql-file-execution-capability-alert': 'true',
|
||||
})).toHaveLength(0);
|
||||
expect(findRunButton(renderer).props.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('uses shared theme tokens for workbench surfaces, borders and text', async () => {
|
||||
const renderer = await renderWorkbench();
|
||||
const workbench = renderer.root.findByProps({
|
||||
'data-sql-file-execution-workbench': 'true',
|
||||
});
|
||||
const sections = renderer.root.findAllByType('section');
|
||||
const actionsInset = renderer.root.findAll((node) => (
|
||||
node.type === 'div'
|
||||
&& node.props.style?.marginTop === 'auto'
|
||||
&& node.props.style?.padding === 14
|
||||
))[0];
|
||||
const helper = renderer.root.findAll((node) => (
|
||||
node.type === 'div'
|
||||
&& node.props.children === 'sidebar.sql_file_exec.workbench.helper.auto_run'
|
||||
))[0];
|
||||
|
||||
expect(workbench.props.style.background).toContain('var(--gn-bg-panel-2');
|
||||
expect(workbench.props.style.color).toContain('var(--gn-fg-1');
|
||||
expect(sections).toHaveLength(3);
|
||||
for (const section of sections) {
|
||||
expect(section.props.style.background).toContain('var(--gn-bg-panel');
|
||||
expect(section.props.style.border).toContain('var(--gn-br-1');
|
||||
}
|
||||
expect(actionsInset.props.style.background).toContain('var(--gn-bg-subtle');
|
||||
expect(actionsInset.props.style.border).toContain('var(--gn-br-1');
|
||||
expect(helper.props.style.color).toContain('var(--gn-fg-3');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['idle', '--gn-bg-subtle', '--gn-fg-2', '--gn-br-2'],
|
||||
['start', '--gn-info-soft', '--gn-info', '--gn-info'],
|
||||
['done', '--gn-status-connected', '--gn-status-connected', '--gn-status-connected'],
|
||||
['cancelled', '--gn-warn-soft', '--gn-warn', '--gn-warn'],
|
||||
['error', '--gn-danger', '--gn-danger', '--gn-danger'],
|
||||
] as const)('uses semantic theme tokens for the %s status pill', async (
|
||||
status,
|
||||
backgroundToken,
|
||||
textToken,
|
||||
borderToken,
|
||||
) => {
|
||||
mocks.state = createRunnerState({
|
||||
jobId: status === 'idle' ? '' : `sql-file-job-${status}`,
|
||||
status,
|
||||
});
|
||||
const renderer = await renderWorkbench();
|
||||
const pill = renderer.root.find((node) => (
|
||||
node.type === 'span'
|
||||
&& node.props.style?.borderRadius === 999
|
||||
));
|
||||
|
||||
expect(pill.props.style.background).toContain(backgroundToken);
|
||||
expect(pill.props.style.color).toContain(textToken);
|
||||
expect(pill.props.style.border).toContain(borderToken);
|
||||
});
|
||||
|
||||
it.each(['preflight', 'parse', 'write'])('localizes the raw %s stage before rendering it', async (stage) => {
|
||||
mocks.state = createRunnerState({
|
||||
jobId: `sql-file-job-${stage}`,
|
||||
status: 'running',
|
||||
stage,
|
||||
});
|
||||
const renderer = await renderWorkbench();
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-sql-file-execution-current-stage': 'true',
|
||||
}).props.children).toBe(`import_preview.stage.${stage}`);
|
||||
});
|
||||
|
||||
it('keeps an already localized or unknown terminal stage unchanged', async () => {
|
||||
mocks.state = createRunnerState({
|
||||
jobId: 'sql-file-job-done',
|
||||
status: 'done',
|
||||
stage: '执行完成',
|
||||
});
|
||||
const renderer = await renderWorkbench();
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-sql-file-execution-current-stage': 'true',
|
||||
}).props.children).toBe('执行完成');
|
||||
});
|
||||
|
||||
it('uses the warning theme token for cancelled progress', async () => {
|
||||
mocks.state = createRunnerState({
|
||||
jobId: 'sql-file-job-cancelled',
|
||||
status: 'cancelled',
|
||||
stage: 'cancelled',
|
||||
percent: 42,
|
||||
});
|
||||
const renderer = await renderWorkbench();
|
||||
const progress = renderer.root.findByProps({
|
||||
'data-sql-file-execution-progress': 'true',
|
||||
});
|
||||
|
||||
expect(progress.props.strokeColor).toBe('var(--gn-warn, #faad14)');
|
||||
});
|
||||
|
||||
it('labels SQL execution counters as statements rather than rows', async () => {
|
||||
mocks.state = createRunnerState({
|
||||
jobId: 'sql-file-job-statements',
|
||||
status: 'running',
|
||||
stage: 'write',
|
||||
executed: 12,
|
||||
failed: 2,
|
||||
total: 20,
|
||||
percent: 60,
|
||||
});
|
||||
const renderer = await renderWorkbench();
|
||||
const containsText = (value: string) => renderer.root.findAll((node) => (
|
||||
node.children.some((child) => typeof child === 'string' && child.includes(value))
|
||||
)).length > 0;
|
||||
|
||||
expect(containsText('sidebar.sql_file_exec.statements_separator')).toBe(true);
|
||||
expect(containsText('sidebar.sql_file_exec.statements_suffix')).toBe(true);
|
||||
expect(containsText('sidebar.sql_file_exec.rows_separator')).toBe(false);
|
||||
expect(containsText('sidebar.sql_file_exec.rows_suffix')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -7,32 +7,54 @@ import {
|
||||
StopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { ExecuteSQLFile, CancelSQLFileExecution } from '../../wailsjs/go/app/App';
|
||||
import {
|
||||
CancelSQLFileExecution,
|
||||
DataImportCapability as LoadDataImportCapability,
|
||||
ImportDatabaseSQL,
|
||||
} from '../../wailsjs/go/app/App';
|
||||
import { useStore } from '../store';
|
||||
import type { TabData } from '../types';
|
||||
import { t } from '../i18n';
|
||||
import { t as defaultTranslate } from '../i18n';
|
||||
import { useOptionalI18n } from '../i18n/provider';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { confirmProductionRisk } from '../utils/productionRiskConfirm';
|
||||
import { resolveConnectionHostSummary } from '../utils/tabDisplay';
|
||||
import { formatExportElapsed, resolveExportElapsedMs } from '../utils/exportProgress';
|
||||
import { resolveDataImportCapabilityReasonKey } from './dataImportCapability';
|
||||
import {
|
||||
useSQLFileExecutionRunner,
|
||||
type SQLFileExecutionRunnerStatus,
|
||||
type SQLFileExecutionState,
|
||||
} from './useSQLFileExecutionRunner';
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
const t = defaultTranslate;
|
||||
|
||||
type SQLFileExecutionHistoryEntry = SQLFileExecutionState & {
|
||||
requestKey: string;
|
||||
};
|
||||
|
||||
const EMPTY_HISTORY: SQLFileExecutionHistoryEntry[] = [];
|
||||
const LOCALIZED_IMPORT_STAGES = new Set([
|
||||
'prepare',
|
||||
'preflight',
|
||||
'read',
|
||||
'parse',
|
||||
'write',
|
||||
'finalize',
|
||||
]);
|
||||
|
||||
const formatDateTime = (timestamp: number): string => {
|
||||
const formatDateTime = (timestamp: number, locale: string): string => {
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return '-';
|
||||
}
|
||||
return new Date(timestamp).toLocaleString('zh-CN', { hour12: false });
|
||||
return new Date(timestamp).toLocaleString(locale, { hour12: false });
|
||||
};
|
||||
|
||||
const getFileName = (filePath: string): string => {
|
||||
const parts = String(filePath || '').split(/[\\/]/);
|
||||
return parts[parts.length - 1] || filePath;
|
||||
};
|
||||
|
||||
const resolveStatusMeta = (status: SQLFileExecutionRunnerStatus): {
|
||||
@@ -44,39 +66,45 @@ const resolveStatusMeta = (status: SQLFileExecutionRunnerStatus): {
|
||||
const meta: Record<SQLFileExecutionRunnerStatus, { label: string; border: string; bg: string; text: string }> = {
|
||||
idle: {
|
||||
label: t('sidebar.sql_file_exec.workbench.empty.not_started'),
|
||||
border: 'rgba(148, 163, 184, 0.35)',
|
||||
bg: 'rgba(148, 163, 184, 0.12)',
|
||||
text: '#475467',
|
||||
border: 'var(--gn-br-2, rgba(148, 163, 184, 0.35))',
|
||||
bg: 'var(--gn-bg-subtle, rgba(148, 163, 184, 0.12))',
|
||||
text: 'var(--gn-fg-2, #475467)',
|
||||
},
|
||||
start: {
|
||||
label: t('sidebar.sql_file_exec.workbench.stage.preparing'),
|
||||
border: 'rgba(59, 130, 246, 0.3)',
|
||||
bg: 'rgba(59, 130, 246, 0.12)',
|
||||
text: '#1d4ed8',
|
||||
border: 'color-mix(in srgb, var(--gn-info, #3b82f6) 30%, transparent)',
|
||||
bg: 'var(--gn-info-soft, rgba(59, 130, 246, 0.12))',
|
||||
text: 'var(--gn-info, #1d4ed8)',
|
||||
},
|
||||
running: {
|
||||
label: t('sidebar.sql_file_exec.status.running'),
|
||||
border: 'rgba(16, 185, 129, 0.3)',
|
||||
bg: 'rgba(16, 185, 129, 0.14)',
|
||||
text: '#047857',
|
||||
border: 'color-mix(in srgb, var(--gn-status-connected, #10b981) 30%, transparent)',
|
||||
bg: 'color-mix(in srgb, var(--gn-status-connected, #10b981) 14%, transparent)',
|
||||
text: 'var(--gn-status-connected, #047857)',
|
||||
},
|
||||
stopping: {
|
||||
label: t('sidebar.sql_file_exec.status.stopping'),
|
||||
border: 'color-mix(in srgb, var(--gn-warn, #f97316) 30%, transparent)',
|
||||
bg: 'var(--gn-warn-soft, rgba(249, 115, 22, 0.12))',
|
||||
text: 'var(--gn-warn, #c2410c)',
|
||||
},
|
||||
done: {
|
||||
label: t('sidebar.sql_file_exec.status.done'),
|
||||
border: 'rgba(34, 197, 94, 0.3)',
|
||||
bg: 'rgba(34, 197, 94, 0.14)',
|
||||
text: '#15803d',
|
||||
border: 'color-mix(in srgb, var(--gn-status-connected, #22c55e) 30%, transparent)',
|
||||
bg: 'color-mix(in srgb, var(--gn-status-connected, #22c55e) 14%, transparent)',
|
||||
text: 'var(--gn-status-connected, #15803d)',
|
||||
},
|
||||
cancelled: {
|
||||
label: t('sidebar.sql_file_exec.status.cancelled'),
|
||||
border: 'rgba(249, 115, 22, 0.3)',
|
||||
bg: 'rgba(249, 115, 22, 0.12)',
|
||||
text: '#c2410c',
|
||||
border: 'color-mix(in srgb, var(--gn-warn, #f97316) 30%, transparent)',
|
||||
bg: 'var(--gn-warn-soft, rgba(249, 115, 22, 0.12))',
|
||||
text: 'var(--gn-warn, #c2410c)',
|
||||
},
|
||||
error: {
|
||||
label: t('sidebar.sql_file_exec.status.error'),
|
||||
border: 'rgba(239, 68, 68, 0.32)',
|
||||
bg: 'rgba(239, 68, 68, 0.12)',
|
||||
text: '#dc2626',
|
||||
border: 'color-mix(in srgb, var(--gn-danger, #ef4444) 32%, transparent)',
|
||||
bg: 'color-mix(in srgb, var(--gn-danger, #ef4444) 12%, transparent)',
|
||||
text: 'var(--gn-danger, #dc2626)',
|
||||
},
|
||||
};
|
||||
return meta[status];
|
||||
@@ -106,29 +134,55 @@ const renderStatusPill = (status: SQLFileExecutionRunnerStatus) => {
|
||||
};
|
||||
|
||||
const formatExecutionSummary = (executed: number, failed: number): string =>
|
||||
`${t('sidebar.sql_file_exec.executed_label')}${executed.toLocaleString()}${t('sidebar.sql_file_exec.rows_separator')}${failed.toLocaleString()}${t('sidebar.sql_file_exec.rows_suffix')}`;
|
||||
`${t('sidebar.sql_file_exec.executed_label')}${executed.toLocaleString()}${t('sidebar.sql_file_exec.statements_separator')}${failed.toLocaleString()}${t('sidebar.sql_file_exec.statements_suffix')}`;
|
||||
|
||||
const resolveProgressStatus = (status: SQLFileExecutionRunnerStatus): 'active' | 'success' | 'exception' | 'normal' => {
|
||||
if (status === 'done') return 'success';
|
||||
if (status === 'error') return 'exception';
|
||||
if (status === 'start' || status === 'running') return 'active';
|
||||
if (status === 'start' || status === 'running' || status === 'stopping') return 'active';
|
||||
return 'normal';
|
||||
};
|
||||
|
||||
const resolveStageLabel = (
|
||||
stage: string,
|
||||
status: SQLFileExecutionRunnerStatus,
|
||||
): string => {
|
||||
const normalizedStage = String(stage || '').trim();
|
||||
if (LOCALIZED_IMPORT_STAGES.has(normalizedStage)) {
|
||||
return t(`import_preview.stage.${normalizedStage}`);
|
||||
}
|
||||
return normalizedStage || resolveStatusMeta(status).label;
|
||||
};
|
||||
|
||||
const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const i18n = useOptionalI18n();
|
||||
const t = i18n?.t ?? defaultTranslate;
|
||||
const locale = i18n?.language ?? 'zh-CN';
|
||||
const connections = useStore((state) => state.connections);
|
||||
const theme = useStore((state) => state.theme);
|
||||
const [nowTick, setNowTick] = useState(() => Date.now());
|
||||
const [historyEntries, setHistoryEntries] = useState<SQLFileExecutionHistoryEntry[]>(EMPTY_HISTORY);
|
||||
const [capabilityRequestToken, setCapabilityRequestToken] = useState(0);
|
||||
const [capabilityState, setCapabilityState] = useState<{
|
||||
status: 'idle' | 'loading' | 'ready' | 'error';
|
||||
supported: boolean;
|
||||
reason: string;
|
||||
}>({ status: 'idle', supported: false, reason: '' });
|
||||
const lastRequestKeyRef = useRef('');
|
||||
const darkMode = theme === 'dark';
|
||||
const shellBg = darkMode ? '#101319' : '#f5f7fb';
|
||||
const panelBg = darkMode ? '#161b22' : '#ffffff';
|
||||
const panelBorder = darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.08)';
|
||||
const dividerColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)';
|
||||
const headingColor = darkMode ? 'rgba(255,255,255,0.96)' : '#101828';
|
||||
const secondaryTextColor = darkMode ? 'rgba(255,255,255,0.68)' : '#667085';
|
||||
const subtleBg = darkMode ? 'rgba(255,255,255,0.04)' : '#f8fafc';
|
||||
const shellBg = `var(--gn-bg-panel-2, ${darkMode ? '#101319' : '#f5f7fb'})`;
|
||||
const panelBg = `var(--gn-bg-panel, ${darkMode ? '#161b22' : '#ffffff'})`;
|
||||
const panelBorder = `1px solid var(--gn-br-1, ${darkMode
|
||||
? 'rgba(255,255,255,0.08)'
|
||||
: 'rgba(15,23,42,0.08)'})`;
|
||||
const dividerColor = `var(--gn-br-1, ${darkMode
|
||||
? 'rgba(255,255,255,0.08)'
|
||||
: 'rgba(15,23,42,0.08)'})`;
|
||||
const headingColor = `var(--gn-fg-1, ${darkMode ? 'rgba(255,255,255,0.96)' : '#101828'})`;
|
||||
const secondaryTextColor = `var(--gn-fg-3, ${darkMode ? 'rgba(255,255,255,0.68)' : '#667085'})`;
|
||||
const subtleBg = `var(--gn-bg-subtle, var(--gn-bg-panel-2, ${darkMode
|
||||
? 'rgba(255,255,255,0.04)'
|
||||
: '#f8fafc'}))`;
|
||||
const connection = useMemo(
|
||||
() => connections.find((item) => item.id === String(tab.connectionId || '').trim()),
|
||||
[connections, tab.connectionId],
|
||||
@@ -137,6 +191,10 @@ const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
() => (connection ? buildRpcConnectionConfig(connection.config) : null),
|
||||
[connection],
|
||||
);
|
||||
const connectionConfigKey = useMemo(
|
||||
() => JSON.stringify(connectionConfig || null),
|
||||
[connectionConfig],
|
||||
);
|
||||
const hostSummary = useMemo(
|
||||
() => resolveConnectionHostSummary(connection?.config),
|
||||
[connection?.config],
|
||||
@@ -144,6 +202,52 @@ const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const { state, reset, cancelExecution, runSQLFileExecutionWithProgress, isRunning } = useSQLFileExecutionRunner({
|
||||
showToast: true,
|
||||
});
|
||||
const terminal = state.status === 'done'
|
||||
|| state.status === 'cancelled'
|
||||
|| state.status === 'error';
|
||||
const capabilityAllowsExecution = capabilityState.status === 'ready'
|
||||
&& capabilityState.supported;
|
||||
const capabilityReason = capabilityState.status === 'loading'
|
||||
? 'loading'
|
||||
: capabilityState.status === 'error'
|
||||
? 'rpc_failed'
|
||||
: capabilityState.status === 'ready' && !capabilityState.supported
|
||||
? (capabilityState.reason || 'capability_unavailable')
|
||||
: '';
|
||||
const capabilityMessageKey = capabilityReason === 'loading'
|
||||
? 'data_import.capability.loading'
|
||||
: capabilityReason === 'rpc_failed'
|
||||
? 'data_import.capability.rpc_failed'
|
||||
: capabilityReason
|
||||
? resolveDataImportCapabilityReasonKey(capabilityReason)
|
||||
: '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectionConfig) {
|
||||
setCapabilityState({ status: 'idle', supported: false, reason: '' });
|
||||
return undefined;
|
||||
}
|
||||
let active = true;
|
||||
setCapabilityState({ status: 'loading', supported: false, reason: '' });
|
||||
void Promise.resolve()
|
||||
.then(() => LoadDataImportCapability(connectionConfig as any))
|
||||
.then((capability) => {
|
||||
if (!active) return;
|
||||
const sqlFileImport = capability?.sqlFileImport;
|
||||
setCapabilityState({
|
||||
status: 'ready',
|
||||
supported: sqlFileImport?.supported === true,
|
||||
reason: String(sqlFileImport?.reason || ''),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
setCapabilityState({ status: 'error', supported: false, reason: 'capability_unavailable' });
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [capabilityRequestToken, connectionConfigKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.startedAt || state.finishedAt > 0) {
|
||||
@@ -175,38 +279,72 @@ const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
const startExecution = React.useCallback(async () => {
|
||||
const filePath = String(tab.filePath || '').trim();
|
||||
if (!connectionConfig || !filePath) {
|
||||
if (!connectionConfig || !filePath || !capabilityAllowsExecution) {
|
||||
return;
|
||||
}
|
||||
const approved = await confirmProductionRisk({
|
||||
connection,
|
||||
action: t('connection.production_risk.action.execute_sql'),
|
||||
target: [tab.dbName, getFileName(filePath)].filter(Boolean).join(' / '),
|
||||
translate: t,
|
||||
});
|
||||
if (!approved) return;
|
||||
await runSQLFileExecutionWithProgress({
|
||||
title: tab.title || t('sidebar.sql_file_exec.title'),
|
||||
filePath,
|
||||
fileSizeMB: tab.sqlFileExecutionFileSizeMB,
|
||||
run: (jobId) => ExecuteSQLFile(connectionConfig as any, tab.dbName || '', filePath, jobId),
|
||||
cancel: (jobId) => {
|
||||
CancelSQLFileExecution(jobId);
|
||||
run: (jobId) => ImportDatabaseSQL(
|
||||
connectionConfig as any,
|
||||
tab.dbName || '',
|
||||
filePath,
|
||||
jobId,
|
||||
false,
|
||||
),
|
||||
cancel: async (jobId) => {
|
||||
const result = await CancelSQLFileExecution(jobId);
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.message || t('import_preview.error.stop_failed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [
|
||||
capabilityAllowsExecution,
|
||||
connection,
|
||||
connectionConfig,
|
||||
runSQLFileExecutionWithProgress,
|
||||
tab.dbName,
|
||||
tab.filePath,
|
||||
tab.sqlFileExecutionFileSizeMB,
|
||||
tab.title,
|
||||
t,
|
||||
]);
|
||||
|
||||
const requestStartExecution = React.useCallback(() => {
|
||||
if (!terminal) {
|
||||
void startExecution();
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: t('data_import.workbench.confirm.rerun_title'),
|
||||
content: t('data_import.workbench.confirm.rerun_content'),
|
||||
okText: t('data_import.workbench.action.retry_database_import'),
|
||||
cancelText: t('common.cancel'),
|
||||
okButtonProps: { danger: true },
|
||||
onOk: startExecution,
|
||||
});
|
||||
}, [startExecution, terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
const requestKey = String(tab.sqlFileExecutionRequestKey || '').trim();
|
||||
if (!requestKey || requestKey === lastRequestKeyRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!connectionConfig || !String(tab.filePath || '').trim()) {
|
||||
if (!connectionConfig || !String(tab.filePath || '').trim() || !capabilityAllowsExecution) {
|
||||
return;
|
||||
}
|
||||
lastRequestKeyRef.current = requestKey;
|
||||
void startExecution();
|
||||
}, [connectionConfig, startExecution, tab.filePath, tab.sqlFileExecutionRequestKey]);
|
||||
}, [capabilityAllowsExecution, connectionConfig, startExecution, tab.filePath, tab.sqlFileExecutionRequestKey]);
|
||||
|
||||
const currentElapsedMs = useMemo(
|
||||
() => resolveExportElapsedMs(state.startedAt, state.finishedAt, nowTick),
|
||||
@@ -294,6 +432,26 @@ const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{capabilityReason ? (
|
||||
<Alert
|
||||
data-sql-file-execution-capability-alert="true"
|
||||
data-sql-file-execution-capability-reason={capabilityReason}
|
||||
type={capabilityReason === 'loading' ? 'info' : 'error'}
|
||||
showIcon
|
||||
message={t(capabilityMessageKey)}
|
||||
action={capabilityReason === 'rpc_failed' ? (
|
||||
<Button
|
||||
data-sql-file-execution-capability-retry="true"
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => setCapabilityRequestToken((current) => current + 1)}
|
||||
>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
) : undefined}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
@@ -311,19 +469,30 @@ const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{isRunning ? (
|
||||
<Button danger icon={<StopOutlined />} onClick={() => void cancelExecution()}>
|
||||
<Button
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
loading={state.status === 'stopping'}
|
||||
disabled={state.status === 'stopping'}
|
||||
onClick={() => {
|
||||
void cancelExecution().catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
{t('sidebar.sql_file_exec.cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
data-sql-file-execution-run-action="true"
|
||||
type="primary"
|
||||
icon={state.status === 'idle' ? <FileTextOutlined /> : <ReloadOutlined />}
|
||||
disabled={!connectionConfig || !String(tab.filePath || '').trim()}
|
||||
onClick={() => {
|
||||
void startExecution();
|
||||
}}
|
||||
disabled={!connectionConfig
|
||||
|| !String(tab.filePath || '').trim()
|
||||
|| !capabilityAllowsExecution}
|
||||
onClick={requestStartExecution}
|
||||
>
|
||||
{t('sidebar.sql_file_exec.workbench.action.run_again')}
|
||||
{terminal
|
||||
? t('data_import.workbench.action.retry_database_import')
|
||||
: t('sidebar.sql_file_exec.workbench.action.run_again')}
|
||||
</Button>
|
||||
)}
|
||||
{(state.status === 'done' || state.status === 'cancelled' || state.status === 'error') ? (
|
||||
@@ -384,7 +553,7 @@ const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 4 }}>
|
||||
{t('sidebar.sql_file_exec.workbench.label.started_at')}
|
||||
</div>
|
||||
<div style={{ color: headingColor, fontWeight: 600 }}>{formatDateTime(state.startedAt)}</div>
|
||||
<div style={{ color: headingColor, fontWeight: 600 }}>{formatDateTime(state.startedAt, locale)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 4 }}>
|
||||
@@ -407,9 +576,10 @@ const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
<>
|
||||
<div>
|
||||
<Progress
|
||||
data-sql-file-execution-progress="true"
|
||||
percent={Math.round(progressPercent)}
|
||||
status={resolveProgressStatus(state.status)}
|
||||
strokeColor={state.status === 'cancelled' ? '#faad14' : undefined}
|
||||
strokeColor={state.status === 'cancelled' ? 'var(--gn-warn, #faad14)' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -418,7 +588,9 @@ const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 6 }}>
|
||||
{t('sidebar.sql_file_exec.workbench.label.current_stage')}
|
||||
</div>
|
||||
<Text>{state.stage || resolveStatusMeta(state.status).label}</Text>
|
||||
<Text data-sql-file-execution-current-stage="true">
|
||||
{resolveStageLabel(state.stage, state.status)}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: secondaryTextColor, marginBottom: 6 }}>
|
||||
@@ -528,10 +700,10 @@ const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: headingColor }}>
|
||||
{entry.stage || resolveStatusMeta(entry.status).label}
|
||||
{resolveStageLabel(entry.stage, entry.status)}
|
||||
</div>
|
||||
{entry.message ? (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: entry.status === 'error' ? '#dc2626' : secondaryTextColor, whiteSpace: 'pre-wrap' }}>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: entry.status === 'error' ? 'var(--gn-danger, #dc2626)' : secondaryTextColor, whiteSpace: 'pre-wrap' }}>
|
||||
{entry.message}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -540,7 +712,7 @@ const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '84px minmax(0, 1fr)', rowGap: 6, columnGap: 10 }}>
|
||||
<Text type="secondary">{t('sidebar.sql_file_exec.workbench.label.started_at')}</Text>
|
||||
<Text>{formatDateTime(entry.startedAt)}</Text>
|
||||
<Text>{formatDateTime(entry.startedAt, locale)}</Text>
|
||||
|
||||
<Text type="secondary">{t('sidebar.sql_file_exec.workbench.label.elapsed')}</Text>
|
||||
<Text>{elapsed}</Text>
|
||||
|
||||
@@ -2619,7 +2619,7 @@ describe('Sidebar locate toolbar', () => {
|
||||
expect(runningMarkup).toContain('Status:');
|
||||
expect(runningMarkup).toContain('Running');
|
||||
expect(runningMarkup).toContain('Executed:');
|
||||
expect(runningMarkup).toContain('rows | Failed:');
|
||||
expect(runningMarkup).toContain('statements | Failed:');
|
||||
expect(runningMarkup).toContain('SELECT * FROM users');
|
||||
expect(runningMarkup).not.toContain('文件大小:');
|
||||
expect(runningMarkup).not.toContain('状态:');
|
||||
|
||||
104
frontend/src/components/dataImportCapability.test.ts
Normal file
104
frontend/src/components/dataImportCapability.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
resolveDataImportCapabilityReasonKey,
|
||||
resolveDataImportModeCapability,
|
||||
type DataImportCapabilityDTO,
|
||||
} from './dataImportCapability';
|
||||
|
||||
describe('resolveDataImportModeCapability', () => {
|
||||
it('honors a backend SQL-file veto even for MySQL', () => {
|
||||
const capability: DataImportCapabilityDTO = {
|
||||
databaseType: 'mysql',
|
||||
tableImport: {
|
||||
supported: true,
|
||||
reason: '',
|
||||
requiresPinnedSession: false,
|
||||
supportsTransactionalBatch: true,
|
||||
supportsContinue: true,
|
||||
supportedConflictPolicies: ['stop'],
|
||||
supportedFormats: ['csv'],
|
||||
supportedEncodings: ['utf-8'],
|
||||
supportedCompressions: [],
|
||||
supportedClientDirectives: [],
|
||||
},
|
||||
sqlFileImport: {
|
||||
supported: false,
|
||||
reason: 'pinned_session_unavailable',
|
||||
requiresPinnedSession: true,
|
||||
supportsTransactionalBatch: false,
|
||||
supportsContinue: false,
|
||||
supportedConflictPolicies: [],
|
||||
supportedFormats: [],
|
||||
supportedEncodings: [],
|
||||
supportedCompressions: [],
|
||||
supportedClientDirectives: [],
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDataImportModeCapability(capability, 'sqlFile')).toEqual(
|
||||
capability.sqlFileImport,
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed when the backend capability DTO is unavailable', () => {
|
||||
expect(resolveDataImportModeCapability(undefined, 'sqlFile')).toEqual({
|
||||
supported: false,
|
||||
reason: 'capability_unavailable',
|
||||
requiresPinnedSession: true,
|
||||
supportsTransactionalBatch: false,
|
||||
supportsContinue: false,
|
||||
supportedConflictPolicies: [],
|
||||
supportedFormats: [],
|
||||
supportedEncodings: [],
|
||||
supportedCompressions: [],
|
||||
supportedClientDirectives: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when the backend returns an incomplete mode capability', () => {
|
||||
expect(resolveDataImportModeCapability({} as DataImportCapabilityDTO, 'table')).toEqual({
|
||||
supported: false,
|
||||
reason: 'capability_unavailable',
|
||||
requiresPinnedSession: false,
|
||||
supportsTransactionalBatch: false,
|
||||
supportsContinue: false,
|
||||
supportedConflictPolicies: [],
|
||||
supportedFormats: [],
|
||||
supportedEncodings: [],
|
||||
supportedCompressions: [],
|
||||
supportedClientDirectives: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for missing or unknown conflict policies', () => {
|
||||
const capability = {
|
||||
databaseType: 'mysql',
|
||||
tableImport: {
|
||||
supported: true,
|
||||
reason: '',
|
||||
requiresPinnedSession: false,
|
||||
supportsTransactionalBatch: true,
|
||||
supportsContinue: true,
|
||||
supportedFormats: ['csv'],
|
||||
supportedEncodings: ['utf-8'],
|
||||
supportedCompressions: [],
|
||||
supportedClientDirectives: [],
|
||||
supportedConflictPolicies: ['stop', 'overwrite_everything'],
|
||||
},
|
||||
} as unknown as DataImportCapabilityDTO;
|
||||
|
||||
expect(resolveDataImportModeCapability(capability, 'table').supportedConflictPolicies).toEqual([
|
||||
'stop',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps backend reason codes to bounded translation keys', () => {
|
||||
expect(resolveDataImportCapabilityReasonKey('pinned_session_unavailable')).toBe(
|
||||
'data_import.capability.reason.pinned_session_unavailable',
|
||||
);
|
||||
expect(resolveDataImportCapabilityReasonKey('future_backend_reason')).toBe(
|
||||
'data_import.capability.reason.unsupported',
|
||||
);
|
||||
});
|
||||
});
|
||||
72
frontend/src/components/dataImportCapability.ts
Normal file
72
frontend/src/components/dataImportCapability.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export type DataImportMode = 'table' | 'sqlFile';
|
||||
|
||||
export type DataImportModeCapabilityDTO = {
|
||||
supported: boolean;
|
||||
reason: string;
|
||||
requiresPinnedSession: boolean;
|
||||
supportsTransactionalBatch: boolean;
|
||||
supportsContinue: boolean;
|
||||
supportedConflictPolicies: string[];
|
||||
supportedFormats: string[];
|
||||
supportedEncodings: string[];
|
||||
supportedCompressions: string[];
|
||||
supportedClientDirectives: string[];
|
||||
};
|
||||
|
||||
export type DataImportCapabilityDTO = {
|
||||
databaseType: string;
|
||||
tableImport: DataImportModeCapabilityDTO;
|
||||
sqlFileImport: DataImportModeCapabilityDTO;
|
||||
};
|
||||
|
||||
const KNOWN_DATA_IMPORT_REASON_CODES = new Set([
|
||||
'capability_unavailable',
|
||||
'data_import_restricted',
|
||||
'database_runtime_unavailable',
|
||||
'database_type_unsupported',
|
||||
'pinned_session_unavailable',
|
||||
'sql_file_import_restricted',
|
||||
'table_import_runtime_unavailable',
|
||||
]);
|
||||
|
||||
export const resolveDataImportCapabilityReasonKey = (reason: string): string => {
|
||||
const normalized = String(reason || '').trim();
|
||||
if (!KNOWN_DATA_IMPORT_REASON_CODES.has(normalized)) {
|
||||
return 'data_import.capability.reason.unsupported';
|
||||
}
|
||||
return `data_import.capability.reason.${normalized}`;
|
||||
};
|
||||
|
||||
const unavailableModeCapability = (mode: DataImportMode): DataImportModeCapabilityDTO => ({
|
||||
supported: false,
|
||||
reason: 'capability_unavailable',
|
||||
requiresPinnedSession: mode === 'sqlFile',
|
||||
supportsTransactionalBatch: false,
|
||||
supportsContinue: false,
|
||||
supportedConflictPolicies: [],
|
||||
supportedFormats: [],
|
||||
supportedEncodings: [],
|
||||
supportedCompressions: [],
|
||||
supportedClientDirectives: [],
|
||||
});
|
||||
|
||||
export const resolveDataImportModeCapability = (
|
||||
capability: DataImportCapabilityDTO | null | undefined,
|
||||
mode: DataImportMode,
|
||||
): DataImportModeCapabilityDTO => {
|
||||
if (!capability) {
|
||||
return unavailableModeCapability(mode);
|
||||
}
|
||||
const modeCapability = mode === 'table'
|
||||
? capability.tableImport
|
||||
: capability.sqlFileImport;
|
||||
if (!modeCapability || typeof modeCapability.supported !== 'boolean') {
|
||||
return unavailableModeCapability(mode);
|
||||
}
|
||||
const supportedConflictPolicies = Array.isArray(modeCapability.supportedConflictPolicies)
|
||||
? modeCapability.supportedConflictPolicies.filter((policy) => (
|
||||
policy === 'stop' || policy === 'skip_duplicates' || policy === 'upsert'
|
||||
))
|
||||
: [];
|
||||
return { ...modeCapability, supportedConflictPolicies };
|
||||
};
|
||||
48
frontend/src/components/dataImportPreferences.test.ts
Normal file
48
frontend/src/components/dataImportPreferences.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_DATA_IMPORT_PREFERENCES,
|
||||
loadDataImportPreferences,
|
||||
saveDataImportPreferences,
|
||||
type DataImportPreferences,
|
||||
} from './dataImportPreferences';
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
get length() { return this.values.size; }
|
||||
clear() { this.values.clear(); }
|
||||
getItem(key: string) { return this.values.get(key) ?? null; }
|
||||
key(index: number) { return Array.from(this.values.keys())[index] ?? null; }
|
||||
removeItem(key: string) { this.values.delete(key); }
|
||||
setItem(key: string, value: string) { this.values.set(key, value); }
|
||||
}
|
||||
|
||||
describe('dataImportPreferences', () => {
|
||||
it('persists validated table import settings without inheriting unsafe defaults', () => {
|
||||
const storage = new MemoryStorage();
|
||||
const preferences: DataImportPreferences = {
|
||||
...DEFAULT_DATA_IMPORT_PREFERENCES,
|
||||
continueOnError: true,
|
||||
conflictPolicy: 'skip_duplicates',
|
||||
conflictKeyColumns: ['id'],
|
||||
encoding: 'gb18030',
|
||||
delimiter: 'tab',
|
||||
nullToken: '\\N',
|
||||
};
|
||||
|
||||
saveDataImportPreferences(storage, 'table', preferences);
|
||||
expect(loadDataImportPreferences(storage, 'table')).toEqual(preferences);
|
||||
expect(loadDataImportPreferences(storage, 'database')).toEqual(DEFAULT_DATA_IMPORT_PREFERENCES);
|
||||
});
|
||||
|
||||
it('falls back safely when persisted values are malformed', () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem('gonavi:data-import-preferences:v1:table', JSON.stringify({
|
||||
continueOnError: 'yes',
|
||||
conflictPolicy: 'overwrite_everything',
|
||||
headerRow: -10,
|
||||
}));
|
||||
|
||||
expect(loadDataImportPreferences(storage, 'table')).toEqual(DEFAULT_DATA_IMPORT_PREFERENCES);
|
||||
});
|
||||
});
|
||||
93
frontend/src/components/dataImportPreferences.ts
Normal file
93
frontend/src/components/dataImportPreferences.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export type DataImportPreferenceScope = 'table' | 'database';
|
||||
export type DataImportConflictPolicy = 'stop' | 'skip_duplicates' | 'upsert';
|
||||
export type DataImportEncoding = 'auto' | 'utf-8' | 'utf-16le' | 'utf-16be' | 'gb18030';
|
||||
export type DataImportDelimiter = 'auto' | 'comma' | 'tab' | 'semicolon' | 'pipe';
|
||||
|
||||
export interface DataImportPreferences {
|
||||
continueOnError: boolean;
|
||||
conflictPolicy: DataImportConflictPolicy;
|
||||
conflictKeyColumns: string[];
|
||||
encoding: DataImportEncoding;
|
||||
delimiter: DataImportDelimiter;
|
||||
headerRow: number;
|
||||
nullToken: string;
|
||||
emptyStringAsNull: boolean;
|
||||
sheetName: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_DATA_IMPORT_PREFERENCES: DataImportPreferences = Object.freeze({
|
||||
continueOnError: false,
|
||||
conflictPolicy: 'stop',
|
||||
conflictKeyColumns: [],
|
||||
encoding: 'auto',
|
||||
delimiter: 'auto',
|
||||
headerRow: 1,
|
||||
nullToken: '',
|
||||
emptyStringAsNull: false,
|
||||
sheetName: '',
|
||||
});
|
||||
|
||||
const STORAGE_PREFIX = 'gonavi:data-import-preferences:v1:';
|
||||
const conflictPolicies = new Set<DataImportConflictPolicy>(['stop', 'skip_duplicates', 'upsert']);
|
||||
const encodings = new Set<DataImportEncoding>(['auto', 'utf-8', 'utf-16le', 'utf-16be', 'gb18030']);
|
||||
const delimiters = new Set<DataImportDelimiter>(['auto', 'comma', 'tab', 'semicolon', 'pipe']);
|
||||
|
||||
const isDataImportPreferences = (value: unknown): value is DataImportPreferences => {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const candidate = value as Partial<DataImportPreferences>;
|
||||
return typeof candidate.continueOnError === 'boolean'
|
||||
&& conflictPolicies.has(candidate.conflictPolicy as DataImportConflictPolicy)
|
||||
&& Array.isArray(candidate.conflictKeyColumns)
|
||||
&& candidate.conflictKeyColumns.length <= 64
|
||||
&& candidate.conflictKeyColumns.every((column) => (
|
||||
typeof column === 'string'
|
||||
&& column.trim().length > 0
|
||||
&& column === column.trim()
|
||||
&& column.length <= 255
|
||||
))
|
||||
&& new Set(candidate.conflictKeyColumns.map((column) => column.toLowerCase())).size
|
||||
=== candidate.conflictKeyColumns.length
|
||||
&& encodings.has(candidate.encoding as DataImportEncoding)
|
||||
&& delimiters.has(candidate.delimiter as DataImportDelimiter)
|
||||
&& Number.isInteger(candidate.headerRow)
|
||||
&& Number(candidate.headerRow) >= 1
|
||||
&& Number(candidate.headerRow) <= 1_000_000
|
||||
&& typeof candidate.nullToken === 'string'
|
||||
&& candidate.nullToken.length <= 64
|
||||
&& typeof candidate.emptyStringAsNull === 'boolean'
|
||||
&& typeof candidate.sheetName === 'string'
|
||||
&& candidate.sheetName.length <= 255;
|
||||
};
|
||||
|
||||
const storageKey = (scope: DataImportPreferenceScope) => `${STORAGE_PREFIX}${scope}`;
|
||||
|
||||
export const loadDataImportPreferences = (
|
||||
storage: Pick<Storage, 'getItem'> | null | undefined,
|
||||
scope: DataImportPreferenceScope,
|
||||
): DataImportPreferences => {
|
||||
if (!storage) return { ...DEFAULT_DATA_IMPORT_PREFERENCES };
|
||||
try {
|
||||
const raw = storage.getItem(storageKey(scope));
|
||||
if (!raw) return { ...DEFAULT_DATA_IMPORT_PREFERENCES };
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isDataImportPreferences(parsed)
|
||||
? { ...parsed }
|
||||
: { ...DEFAULT_DATA_IMPORT_PREFERENCES };
|
||||
} catch {
|
||||
return { ...DEFAULT_DATA_IMPORT_PREFERENCES };
|
||||
}
|
||||
};
|
||||
|
||||
export const saveDataImportPreferences = (
|
||||
storage: Pick<Storage, 'setItem'> | null | undefined,
|
||||
scope: DataImportPreferenceScope,
|
||||
preferences: DataImportPreferences,
|
||||
): boolean => {
|
||||
if (!storage || !isDataImportPreferences(preferences)) return false;
|
||||
try {
|
||||
storage.setItem(storageKey(scope), JSON.stringify(preferences));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
32
frontend/src/components/importProgressMetrics.test.ts
Normal file
32
frontend/src/components/importProgressMetrics.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { calculateImportTransferMetrics, formatImportBytes, formatImportDuration } from './importProgressMetrics';
|
||||
|
||||
describe('importProgressMetrics', () => {
|
||||
it('calculates a stable average throughput and ETA', () => {
|
||||
expect(calculateImportTransferMetrics({
|
||||
startedAt: 8_000,
|
||||
now: 18_000,
|
||||
bytesRead: 10 * 1024 * 1024,
|
||||
totalBytes: 20 * 1024 * 1024,
|
||||
})).toEqual({
|
||||
bytesPerSecond: 1024 * 1024,
|
||||
etaSeconds: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not invent an ETA for unknown or stalled transfers', () => {
|
||||
expect(calculateImportTransferMetrics({ startedAt: 0, now: 5_000, bytesRead: 10, totalBytes: 100 }))
|
||||
.toEqual({ bytesPerSecond: 0, etaSeconds: 0 });
|
||||
expect(calculateImportTransferMetrics({ startedAt: 1_000, now: 2_000, bytesRead: 10, totalBytes: 0 }))
|
||||
.toEqual({ bytesPerSecond: 10, etaSeconds: 0 });
|
||||
});
|
||||
|
||||
it('formats byte counts and durations compactly', () => {
|
||||
expect(formatImportBytes(1536)).toBe('1.5 KB');
|
||||
expect(formatImportBytes(5 * 1024 * 1024)).toBe('5.0 MB');
|
||||
expect(formatImportDuration(65)).toBe('1m 5s');
|
||||
expect(formatImportDuration(65, 'zh-CN')).toContain('分钟');
|
||||
expect(formatImportDuration(65, 'zh-CN')).toContain('秒');
|
||||
});
|
||||
});
|
||||
65
frontend/src/components/importProgressMetrics.ts
Normal file
65
frontend/src/components/importProgressMetrics.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
type ImportTransferMetricInput = {
|
||||
startedAt: number;
|
||||
now: number;
|
||||
bytesRead: number;
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
export const calculateImportTransferMetrics = ({
|
||||
startedAt,
|
||||
now,
|
||||
bytesRead,
|
||||
totalBytes,
|
||||
}: ImportTransferMetricInput): { bytesPerSecond: number; etaSeconds: number } => {
|
||||
const safeStartedAt = Number.isFinite(startedAt) && startedAt > 0 ? startedAt : 0;
|
||||
const safeNow = Number.isFinite(now) ? now : 0;
|
||||
const safeBytesRead = Number.isFinite(bytesRead) && bytesRead > 0 ? Math.trunc(bytesRead) : 0;
|
||||
const safeTotalBytes = Number.isFinite(totalBytes) && totalBytes > 0 ? Math.trunc(totalBytes) : 0;
|
||||
const elapsedSeconds = safeStartedAt > 0 ? Math.max(0, (safeNow - safeStartedAt) / 1000) : 0;
|
||||
const bytesPerSecond = elapsedSeconds > 0 ? Math.round(safeBytesRead / elapsedSeconds) : 0;
|
||||
const etaSeconds = bytesPerSecond > 0 && safeTotalBytes > safeBytesRead
|
||||
? Math.round((safeTotalBytes - safeBytesRead) / bytesPerSecond)
|
||||
: 0;
|
||||
return { bytesPerSecond, etaSeconds };
|
||||
};
|
||||
|
||||
export const formatImportBytes = (value: number): string => {
|
||||
const bytes = Number.isFinite(value) && value > 0 ? value : 0;
|
||||
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${Math.trunc(bytes)} B`;
|
||||
};
|
||||
|
||||
const formatImportDurationUnit = (
|
||||
value: number,
|
||||
unit: 'hour' | 'minute' | 'second',
|
||||
locale: string,
|
||||
): string => {
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'unit',
|
||||
unit,
|
||||
unitDisplay: 'narrow',
|
||||
}).format(value);
|
||||
} catch {
|
||||
const suffix = unit === 'hour' ? 'h' : unit === 'minute' ? 'm' : 's';
|
||||
return `${value}${suffix}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatImportDuration = (value: number, locale?: string): string => {
|
||||
const seconds = Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
|
||||
const formatUnit = (unitValue: number, unit: 'hour' | 'minute' | 'second'): string => (
|
||||
locale ? formatImportDurationUnit(unitValue, unit, locale) : `${unitValue}${unit[0]}`
|
||||
);
|
||||
if (seconds >= 3600) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${formatUnit(hours, 'hour')} ${formatUnit(minutes, 'minute')}`;
|
||||
}
|
||||
if (seconds >= 60) {
|
||||
return `${formatUnit(Math.floor(seconds / 60), 'minute')} ${formatUnit(seconds % 60, 'second')}`;
|
||||
}
|
||||
return formatUnit(seconds, 'second');
|
||||
};
|
||||
@@ -225,9 +225,9 @@ export const SQLFileExecutionProgressContent: React.FC<SQLFileExecutionProgressS
|
||||
<div>
|
||||
{t('sidebar.sql_file_exec.executed_label')}
|
||||
<strong style={{ color: '#52c41a' }}>{executed}</strong>
|
||||
{t('sidebar.sql_file_exec.rows_separator')}
|
||||
{t('sidebar.sql_file_exec.statements_separator')}
|
||||
<strong style={{ color: failed > 0 ? '#ff4d4f' : undefined }}>{failed}</strong>
|
||||
{t('sidebar.sql_file_exec.rows_suffix')}
|
||||
{t('sidebar.sql_file_exec.statements_suffix')}
|
||||
</div>
|
||||
</div>
|
||||
{currentSQL && status === 'running' && (
|
||||
|
||||
@@ -80,6 +80,133 @@ describe('useSQLFileExecutionRunner', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('starts only one backend execution when called twice before React commits running state', async () => {
|
||||
renderRunner();
|
||||
|
||||
let resolveFirstRun!: (value: { success: boolean; message: string }) => void;
|
||||
const pendingFirstRun = new Promise<{ success: boolean; message: string }>((resolve) => {
|
||||
resolveFirstRun = resolve;
|
||||
});
|
||||
const firstRun = vi.fn(async () => pendingFirstRun);
|
||||
const secondRun = vi.fn(async () => ({ success: true, message: 'must not run' }));
|
||||
let firstPromise: Promise<{ success: boolean; message: string } | null> | null = null;
|
||||
let secondResult: { success: boolean; message: string } | null | undefined;
|
||||
|
||||
await act(async () => {
|
||||
firstPromise = runner?.runSQLFileExecutionWithProgress({
|
||||
title: 'first.sql',
|
||||
filePath: 'D:/sql/first.sql',
|
||||
run: firstRun,
|
||||
}) || null;
|
||||
secondResult = await runner?.runSQLFileExecutionWithProgress({
|
||||
title: 'second.sql',
|
||||
filePath: 'D:/sql/second.sql',
|
||||
run: secondRun,
|
||||
});
|
||||
});
|
||||
|
||||
expect(secondResult).toBeNull();
|
||||
expect(firstRun).toHaveBeenCalledTimes(1);
|
||||
expect(secondRun).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveFirstRun({ success: true, message: 'done' });
|
||||
await firstPromise;
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps reset and rerun locked until the backend RPC settles after a terminal progress event', async () => {
|
||||
renderRunner();
|
||||
|
||||
let resolveFirstRun!: (value: { success: boolean; message: string }) => void;
|
||||
const pendingFirstRun = new Promise<{ success: boolean; message: string }>((resolve) => {
|
||||
resolveFirstRun = resolve;
|
||||
});
|
||||
const firstRun = vi.fn(async () => pendingFirstRun);
|
||||
const secondRun = vi.fn(async () => ({ success: true, message: 'must not run' }));
|
||||
let firstPromise: Promise<{ success: boolean; message: string } | null> | null = null;
|
||||
|
||||
await act(async () => {
|
||||
firstPromise = runner?.runSQLFileExecutionWithProgress({
|
||||
title: 'first.sql',
|
||||
filePath: 'D:/sql/first.sql',
|
||||
run: firstRun,
|
||||
}) || null;
|
||||
await Promise.resolve();
|
||||
});
|
||||
const firstJobId = runner?.state.jobId || '';
|
||||
|
||||
act(() => {
|
||||
runtimeApi.emitProgress({ jobId: firstJobId, status: 'done', percent: 100 });
|
||||
vi.advanceTimersByTime(20);
|
||||
});
|
||||
expect(runner?.state.status).toBe('done');
|
||||
expect(runner?.isRunning).toBe(true);
|
||||
|
||||
act(() => {
|
||||
runner?.reset();
|
||||
});
|
||||
expect(runner?.state.jobId).toBe(firstJobId);
|
||||
|
||||
let secondResult: { success: boolean; message: string } | null | undefined;
|
||||
await act(async () => {
|
||||
secondResult = await runner?.runSQLFileExecutionWithProgress({
|
||||
title: 'second.sql',
|
||||
filePath: 'D:/sql/second.sql',
|
||||
run: secondRun,
|
||||
});
|
||||
});
|
||||
expect(secondResult).toBeNull();
|
||||
expect(secondRun).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveFirstRun({ success: true, message: 'done' });
|
||||
await firstPromise;
|
||||
});
|
||||
expect(runner?.isRunning).toBe(false);
|
||||
|
||||
act(() => {
|
||||
runner?.reset();
|
||||
});
|
||||
expect(runner?.state.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('keeps the active task cancellable when reset is requested before the RPC settles', async () => {
|
||||
renderRunner();
|
||||
|
||||
let resolveRun!: (value: { success: boolean; message: string }) => void;
|
||||
const pendingRun = new Promise<{ success: boolean; message: string }>((resolve) => {
|
||||
resolveRun = resolve;
|
||||
});
|
||||
const cancel = vi.fn(async () => undefined);
|
||||
let runPromise: Promise<{ success: boolean; message: string } | null> | null = null;
|
||||
await act(async () => {
|
||||
runPromise = runner?.runSQLFileExecutionWithProgress({
|
||||
title: 'active.sql',
|
||||
filePath: 'D:/sql/active.sql',
|
||||
run: async () => pendingRun,
|
||||
cancel,
|
||||
}) || null;
|
||||
await Promise.resolve();
|
||||
});
|
||||
const jobId = runner?.state.jobId || '';
|
||||
|
||||
act(() => {
|
||||
runner?.reset();
|
||||
});
|
||||
expect(runner?.state.jobId).toBe(jobId);
|
||||
await act(async () => {
|
||||
await runner?.cancelExecution();
|
||||
});
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(cancel).toHaveBeenCalledWith(jobId);
|
||||
|
||||
await act(async () => {
|
||||
resolveRun({ success: false, message: '\u5df2\u53d6\u6d88' });
|
||||
await runPromise;
|
||||
});
|
||||
});
|
||||
|
||||
it('starts timing after backend progress arrives and keeps execution details in sync', async () => {
|
||||
renderRunner();
|
||||
|
||||
@@ -138,7 +265,7 @@ describe('useSQLFileExecutionRunner', () => {
|
||||
expect(runner?.state.message).toBe('执行完成');
|
||||
});
|
||||
|
||||
it('marks the task cancelled when cancelExecution is requested', async () => {
|
||||
it('keeps the task stopping after cancel acknowledgement until a terminal signal arrives', async () => {
|
||||
renderRunner();
|
||||
|
||||
let resolveRun!: (value: { success: boolean; message: string }) => void;
|
||||
@@ -166,7 +293,8 @@ describe('useSQLFileExecutionRunner', () => {
|
||||
});
|
||||
|
||||
expect(cancelSpy).toHaveBeenCalledWith(jobId);
|
||||
expect(runner?.state.status).toBe('cancelled');
|
||||
expect(runner?.state.status).toBe('stopping');
|
||||
expect(runner?.isRunning).toBe(true);
|
||||
|
||||
act(() => {
|
||||
runtimeApi.emitProgress({
|
||||
@@ -177,7 +305,7 @@ describe('useSQLFileExecutionRunner', () => {
|
||||
});
|
||||
vi.advanceTimersByTime(20);
|
||||
});
|
||||
expect(runner?.state.status).toBe('cancelled');
|
||||
expect(runner?.state.status).toBe('stopping');
|
||||
|
||||
now = 6_000;
|
||||
act(() => {
|
||||
@@ -201,6 +329,69 @@ describe('useSQLFileExecutionRunner', () => {
|
||||
expect(runner?.state.finishedAt).toBe(6_000);
|
||||
});
|
||||
|
||||
it('uses the structured cancelled result independently of the active language', async () => {
|
||||
setCurrentLanguage('en-US');
|
||||
renderRunner();
|
||||
|
||||
await act(async () => {
|
||||
await runner?.runSQLFileExecutionWithProgress({
|
||||
title: 'cancelled.sql',
|
||||
filePath: 'D:/sql/cancelled.sql',
|
||||
run: async () => ({
|
||||
success: false,
|
||||
message: 'Execution cancelled',
|
||||
data: { cancelled: true },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
expect(runner?.state.status).toBe('cancelled');
|
||||
expect(runner?.state.message).toBe('Execution cancelled');
|
||||
});
|
||||
|
||||
it('does not rerun after cancel acknowledgement and lets a completed RPC win the race', async () => {
|
||||
renderRunner();
|
||||
|
||||
let resolveRun!: (value: { success: boolean; message: string }) => void;
|
||||
const pendingRun = new Promise<{ success: boolean; message: string }>((resolve) => {
|
||||
resolveRun = resolve;
|
||||
});
|
||||
let runPromise: Promise<{ success: boolean; message: string } | null> | null = null;
|
||||
await act(async () => {
|
||||
runPromise = runner?.runSQLFileExecutionWithProgress({
|
||||
title: 'cancel-race.sql',
|
||||
filePath: 'D:/sql/cancel-race.sql',
|
||||
run: async () => pendingRun,
|
||||
cancel: async () => undefined,
|
||||
}) || null;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await runner?.cancelExecution();
|
||||
});
|
||||
const rerun = vi.fn().mockResolvedValue({ success: true, message: 'unexpected rerun' });
|
||||
let rerunResult: { success: boolean; message: string } | null | undefined;
|
||||
await act(async () => {
|
||||
rerunResult = await runner?.runSQLFileExecutionWithProgress({
|
||||
title: 'cancel-race.sql',
|
||||
filePath: 'D:/sql/cancel-race.sql',
|
||||
run: rerun,
|
||||
});
|
||||
});
|
||||
expect(rerunResult).toBeNull();
|
||||
expect(rerun).not.toHaveBeenCalled();
|
||||
|
||||
now = 7_000;
|
||||
await act(async () => {
|
||||
resolveRun({ success: true, message: 'completed before cancellation took effect' });
|
||||
await runPromise;
|
||||
});
|
||||
expect(runner?.state.status).toBe('done');
|
||||
expect(runner?.state.finishedAt).toBe(7_000);
|
||||
expect(runner?.isRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a running task below 100 percent until completion', async () => {
|
||||
renderRunner();
|
||||
|
||||
@@ -321,4 +512,84 @@ describe('useSQLFileExecutionRunner', () => {
|
||||
expect(runner?.state.failed).toBe(1);
|
||||
expect(runner?.state.percent).toBe(100);
|
||||
});
|
||||
|
||||
it('keeps the complete RPC summary when a queued terminal error event flushes later', async () => {
|
||||
renderRunner();
|
||||
|
||||
let resolveRun!: (value: { success: boolean; message: string }) => void;
|
||||
const pendingRun = new Promise<{ success: boolean; message: string }>((resolve) => {
|
||||
resolveRun = resolve;
|
||||
});
|
||||
|
||||
let runPromise: Promise<{ success: boolean; message: string } | null> | null = null;
|
||||
await act(async () => {
|
||||
runPromise = runner?.runSQLFileExecutionWithProgress({
|
||||
title: 'queued-error.sql',
|
||||
filePath: 'D:/sql/queued-error.sql',
|
||||
run: async () => pendingRun,
|
||||
}) || null;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
runtimeApi.emitProgress({
|
||||
jobId: runner?.state.jobId,
|
||||
status: 'error',
|
||||
executed: 8,
|
||||
failed: 1,
|
||||
percent: 42,
|
||||
error: 'raw statement error',
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
resolveRun({ success: false, message: 'complete stop-on-error summary' });
|
||||
await runPromise;
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(20);
|
||||
});
|
||||
|
||||
expect(runner?.state.status).toBe('error');
|
||||
expect(runner?.state.message).toBe('complete stop-on-error summary');
|
||||
});
|
||||
|
||||
it('tracks source bytes, throughput and ETA for large SQL files', async () => {
|
||||
renderRunner();
|
||||
|
||||
let resolveRun!: (value: { success: boolean; message: string }) => void;
|
||||
const pendingRun = new Promise<{ success: boolean; message: string }>((resolve) => {
|
||||
resolveRun = resolve;
|
||||
});
|
||||
let runPromise: Promise<{ success: boolean; message: string } | null> | null = null;
|
||||
await act(async () => {
|
||||
runPromise = runner?.runSQLFileExecutionWithProgress({
|
||||
title: 'large.sql',
|
||||
filePath: 'D:/sql/large.sql',
|
||||
run: async () => pendingRun,
|
||||
}) || null;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const jobId = runner?.state.jobId || '';
|
||||
now = 8_000;
|
||||
act(() => {
|
||||
runtimeApi.emitProgress({ jobId, status: 'running', bytesRead: 0, totalBytes: 20 * 1024 * 1024 });
|
||||
vi.advanceTimersByTime(20);
|
||||
});
|
||||
now = 18_000;
|
||||
act(() => {
|
||||
runtimeApi.emitProgress({ jobId, status: 'running', bytesRead: 10 * 1024 * 1024, totalBytes: 20 * 1024 * 1024 });
|
||||
vi.advanceTimersByTime(20);
|
||||
});
|
||||
|
||||
expect(runner?.state.bytesRead).toBe(10 * 1024 * 1024);
|
||||
expect(runner?.state.totalBytes).toBe(20 * 1024 * 1024);
|
||||
expect(runner?.state.bytesPerSecond).toBe(1024 * 1024);
|
||||
expect(runner?.state.etaSeconds).toBe(10);
|
||||
|
||||
await act(async () => {
|
||||
resolveRun({ success: true, message: 'done' });
|
||||
await runPromise;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { message } from 'antd';
|
||||
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { t } from '../i18n';
|
||||
import { calculateImportTransferMetrics } from './importProgressMetrics';
|
||||
|
||||
export type SQLFileExecutionProgressEvent = {
|
||||
jobId: string;
|
||||
@@ -11,6 +12,9 @@ export type SQLFileExecutionProgressEvent = {
|
||||
failed?: number;
|
||||
total?: number;
|
||||
percent?: number;
|
||||
bytesRead?: number;
|
||||
totalBytes?: number;
|
||||
stage?: string;
|
||||
currentSQL?: string;
|
||||
error?: string;
|
||||
};
|
||||
@@ -19,6 +23,7 @@ export type SQLFileExecutionRunnerStatus =
|
||||
| 'idle'
|
||||
| 'start'
|
||||
| 'running'
|
||||
| 'stopping'
|
||||
| 'done'
|
||||
| 'cancelled'
|
||||
| 'error';
|
||||
@@ -36,6 +41,10 @@ export type SQLFileExecutionState = {
|
||||
failed: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
bytesRead: number;
|
||||
totalBytes: number;
|
||||
bytesPerSecond: number;
|
||||
etaSeconds: number;
|
||||
currentSQL: string;
|
||||
message: string;
|
||||
};
|
||||
@@ -43,6 +52,7 @@ export type SQLFileExecutionState = {
|
||||
export type SQLFileExecutionRunResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
export type RunSQLFileExecutionWithProgressOptions<T extends SQLFileExecutionRunResult> = {
|
||||
@@ -70,6 +80,10 @@ const createInitialState = (): SQLFileExecutionState => ({
|
||||
failed: 0,
|
||||
total: 0,
|
||||
percent: 0,
|
||||
bytesRead: 0,
|
||||
totalBytes: 0,
|
||||
bytesPerSecond: 0,
|
||||
etaSeconds: 0,
|
||||
currentSQL: '',
|
||||
message: '',
|
||||
});
|
||||
@@ -87,14 +101,24 @@ const buildSQLFileExecutionJobId = (): string =>
|
||||
|
||||
const EXECUTION_CANCELED_MESSAGE = '\u5df2\u53d6\u6d88';
|
||||
|
||||
const isStructuredCancelledResult = (result: SQLFileExecutionRunResult): boolean => {
|
||||
const data = result?.data;
|
||||
return Boolean(data && typeof data === 'object' && (data as { cancelled?: unknown }).cancelled === true);
|
||||
};
|
||||
|
||||
export function useSQLFileExecutionRunner(options?: UseSQLFileExecutionRunnerOptions) {
|
||||
const showToast = options?.showToast !== false;
|
||||
const [state, setState] = useState<SQLFileExecutionState>(() => createInitialState());
|
||||
const [rpcInFlight, setRPCInFlight] = useState(false);
|
||||
const activeJobIdRef = useRef('');
|
||||
const runningJobIdRef = useRef('');
|
||||
const pendingEventRef = useRef<SQLFileExecutionProgressEvent | null>(null);
|
||||
const flushFrameRef = useRef<number | null>(null);
|
||||
const cancelRequestedRef = useRef(false);
|
||||
const cancelHandlerRef = useRef<((jobId: string) => void | Promise<void>) | null>(null);
|
||||
const cancelRequestedJobIdRef = useRef('');
|
||||
const cancelHandlerRef = useRef<{
|
||||
jobId: string;
|
||||
handler: (jobId: string) => void | Promise<void>;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const flushPendingEvent = () => {
|
||||
@@ -118,31 +142,59 @@ export function useSQLFileExecutionRunner(options?: UseSQLFileExecutionRunnerOpt
|
||||
// The RPC result is authoritative once it has settled. A terminal
|
||||
// progress event delayed by the animation-frame throttle may still
|
||||
// contribute final counters/percent, but must not rewrite that result.
|
||||
const nextStatus = wasTerminal && reportedIsTerminal ? prev.status : reportedStatus;
|
||||
const nextStatus = wasTerminal && reportedIsTerminal
|
||||
? prev.status
|
||||
: prev.status === 'stopping' && !reportedIsTerminal
|
||||
? 'stopping'
|
||||
: reportedStatus;
|
||||
const nextStartedAt = prev.startedAt || Date.now();
|
||||
const isTerminal = nextStatus === 'done' || nextStatus === 'cancelled' || nextStatus === 'error';
|
||||
const reportedPercent = Math.max(0, Math.min(100, Number(event.percent ?? prev.percent) || 0));
|
||||
const nextPercent = reportedStatus === 'done' || nextStatus === 'done'
|
||||
? 100
|
||||
: Math.min(99, reportedPercent);
|
||||
const nextBytesRead = normalizeCount(event.bytesRead ?? prev.bytesRead);
|
||||
const nextTotalBytes = normalizeCount(event.totalBytes ?? prev.totalBytes);
|
||||
const transferMetrics = calculateImportTransferMetrics({
|
||||
startedAt: nextStartedAt,
|
||||
now: Date.now(),
|
||||
bytesRead: nextBytesRead,
|
||||
totalBytes: nextTotalBytes,
|
||||
});
|
||||
const preserveSettledMessage = wasTerminal
|
||||
&& reportedIsTerminal
|
||||
&& typeof prev.message === 'string'
|
||||
&& Boolean(prev.message.trim());
|
||||
return {
|
||||
...prev,
|
||||
startedAt: nextStartedAt,
|
||||
finishedAt: isTerminal ? (prev.finishedAt || Date.now()) : prev.finishedAt,
|
||||
status: nextStatus,
|
||||
stage: nextStatus === 'cancelled'
|
||||
? t('sidebar.sql_file_exec.status.cancelled')
|
||||
: nextStatus === 'error'
|
||||
? t('sidebar.sql_file_exec.status.error')
|
||||
: nextStatus === 'done'
|
||||
? t('sidebar.sql_file_exec.status.done')
|
||||
: t('sidebar.sql_file_exec.status.running'),
|
||||
stage: nextStatus === 'stopping'
|
||||
? t('sidebar.sql_file_exec.status.stopping')
|
||||
: typeof event.stage === 'string' && event.stage.trim() && !isTerminal
|
||||
? event.stage.trim()
|
||||
: nextStatus === 'cancelled'
|
||||
? t('sidebar.sql_file_exec.status.cancelled')
|
||||
: nextStatus === 'error'
|
||||
? t('sidebar.sql_file_exec.status.error')
|
||||
: nextStatus === 'done'
|
||||
? t('sidebar.sql_file_exec.status.done')
|
||||
: t('sidebar.sql_file_exec.status.running'),
|
||||
executed: normalizeCount(event.executed ?? prev.executed),
|
||||
failed: normalizeCount(event.failed ?? prev.failed),
|
||||
total: normalizeCount(event.total ?? prev.total),
|
||||
percent: nextPercent,
|
||||
bytesRead: nextBytesRead,
|
||||
totalBytes: nextTotalBytes,
|
||||
bytesPerSecond: transferMetrics.bytesPerSecond,
|
||||
etaSeconds: transferMetrics.etaSeconds,
|
||||
currentSQL: typeof event.currentSQL === 'string' ? event.currentSQL : prev.currentSQL,
|
||||
message: typeof event.error === 'string' && event.error.trim() ? event.error : prev.message,
|
||||
message: preserveSettledMessage
|
||||
? prev.message
|
||||
: typeof event.error === 'string' && event.error.trim()
|
||||
? event.error
|
||||
: prev.message,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -183,35 +235,56 @@ export function useSQLFileExecutionRunner(options?: UseSQLFileExecutionRunnerOpt
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (runningJobIdRef.current) {
|
||||
return;
|
||||
}
|
||||
activeJobIdRef.current = '';
|
||||
runningJobIdRef.current = '';
|
||||
pendingEventRef.current = null;
|
||||
cancelRequestedRef.current = false;
|
||||
cancelRequestedJobIdRef.current = '';
|
||||
cancelHandlerRef.current = null;
|
||||
setState(createInitialState());
|
||||
}, []);
|
||||
|
||||
const cancelExecution = useCallback(async () => {
|
||||
const jobId = activeJobIdRef.current;
|
||||
if (!jobId || !cancelHandlerRef.current) {
|
||||
const cancelRegistration = cancelHandlerRef.current;
|
||||
if (!jobId || !cancelRegistration || cancelRegistration.jobId !== jobId) {
|
||||
return;
|
||||
}
|
||||
cancelRequestedRef.current = true;
|
||||
await cancelHandlerRef.current(jobId);
|
||||
if (cancelRequestedJobIdRef.current === jobId) {
|
||||
return;
|
||||
}
|
||||
cancelRequestedJobIdRef.current = jobId;
|
||||
try {
|
||||
await cancelRegistration.handler(jobId);
|
||||
} catch (error: any) {
|
||||
if (cancelRequestedJobIdRef.current === jobId) {
|
||||
cancelRequestedJobIdRef.current = '';
|
||||
}
|
||||
if (showToast) {
|
||||
void message.error(error?.message || String(error));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
setState((prev) => (
|
||||
prev.jobId !== jobId
|
||||
|| prev.status === 'done'
|
||||
|| prev.status === 'cancelled'
|
||||
|| prev.status === 'error'
|
||||
? prev
|
||||
: {
|
||||
...prev,
|
||||
status: 'cancelled',
|
||||
stage: t('sidebar.sql_file_exec.status.cancelled'),
|
||||
status: 'stopping',
|
||||
stage: t('sidebar.sql_file_exec.status.stopping'),
|
||||
}
|
||||
));
|
||||
}, []);
|
||||
}, [showToast]);
|
||||
|
||||
const runSQLFileExecutionWithProgress = useCallback(async <T extends SQLFileExecutionRunResult,>(
|
||||
runOptions: RunSQLFileExecutionWithProgressOptions<T>,
|
||||
): Promise<T | null> => {
|
||||
if (state.status === 'start' || state.status === 'running') {
|
||||
if (runningJobIdRef.current) {
|
||||
if (showToast) {
|
||||
void message.warning(t('sidebar.sql_file_exec.message.already_running'));
|
||||
}
|
||||
@@ -219,9 +292,13 @@ export function useSQLFileExecutionRunner(options?: UseSQLFileExecutionRunnerOpt
|
||||
}
|
||||
|
||||
const jobId = buildSQLFileExecutionJobId();
|
||||
runningJobIdRef.current = jobId;
|
||||
setRPCInFlight(true);
|
||||
activeJobIdRef.current = jobId;
|
||||
cancelRequestedRef.current = false;
|
||||
cancelHandlerRef.current = runOptions.cancel || null;
|
||||
cancelRequestedJobIdRef.current = '';
|
||||
cancelHandlerRef.current = runOptions.cancel
|
||||
? { jobId, handler: runOptions.cancel }
|
||||
: null;
|
||||
setState({
|
||||
jobId,
|
||||
title: String(runOptions.title || '').trim(),
|
||||
@@ -235,6 +312,10 @@ export function useSQLFileExecutionRunner(options?: UseSQLFileExecutionRunnerOpt
|
||||
failed: 0,
|
||||
total: 0,
|
||||
percent: 0,
|
||||
bytesRead: 0,
|
||||
totalBytes: 0,
|
||||
bytesPerSecond: 0,
|
||||
etaSeconds: 0,
|
||||
currentSQL: '',
|
||||
message: '',
|
||||
});
|
||||
@@ -245,7 +326,9 @@ export function useSQLFileExecutionRunner(options?: UseSQLFileExecutionRunnerOpt
|
||||
if (prev.jobId !== jobId) {
|
||||
return prev;
|
||||
}
|
||||
const canceled = cancelRequestedRef.current || prev.status === 'cancelled' || result.message === EXECUTION_CANCELED_MESSAGE;
|
||||
const canceled = prev.status === 'cancelled'
|
||||
|| isStructuredCancelledResult(result)
|
||||
|| result.message === EXECUTION_CANCELED_MESSAGE;
|
||||
const nextStatus: SQLFileExecutionRunnerStatus = canceled
|
||||
? 'cancelled'
|
||||
: result.success
|
||||
@@ -269,7 +352,7 @@ export function useSQLFileExecutionRunner(options?: UseSQLFileExecutionRunnerOpt
|
||||
});
|
||||
|
||||
if (showToast) {
|
||||
if (cancelRequestedRef.current || result.message === EXECUTION_CANCELED_MESSAGE) {
|
||||
if (isStructuredCancelledResult(result) || result.message === EXECUTION_CANCELED_MESSAGE) {
|
||||
void message.info(t('sidebar.sql_file_exec.status.cancelled'));
|
||||
} else if (result.success) {
|
||||
void message.success(t('sidebar.sql_file_exec.status.done'));
|
||||
@@ -288,25 +371,43 @@ export function useSQLFileExecutionRunner(options?: UseSQLFileExecutionRunnerOpt
|
||||
...prev,
|
||||
startedAt: prev.startedAt || Date.now(),
|
||||
finishedAt: prev.finishedAt || Date.now(),
|
||||
status: cancelRequestedRef.current ? 'cancelled' : 'error',
|
||||
stage: cancelRequestedRef.current
|
||||
status: prev.status === 'cancelled' || errorMessage === EXECUTION_CANCELED_MESSAGE ? 'cancelled' : 'error',
|
||||
stage: prev.status === 'cancelled' || errorMessage === EXECUTION_CANCELED_MESSAGE
|
||||
? t('sidebar.sql_file_exec.status.cancelled')
|
||||
: t('sidebar.sql_file_exec.status.error'),
|
||||
message: errorMessage,
|
||||
};
|
||||
});
|
||||
if (showToast) {
|
||||
void message.error(errorMessage);
|
||||
if (errorMessage === EXECUTION_CANCELED_MESSAGE) {
|
||||
void message.info(t('sidebar.sql_file_exec.status.cancelled'));
|
||||
} else {
|
||||
void message.error(errorMessage);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (runningJobIdRef.current === jobId) {
|
||||
runningJobIdRef.current = '';
|
||||
setRPCInFlight(false);
|
||||
}
|
||||
if (cancelRequestedJobIdRef.current === jobId) {
|
||||
cancelRequestedJobIdRef.current = '';
|
||||
}
|
||||
if (cancelHandlerRef.current?.jobId === jobId) {
|
||||
cancelHandlerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [showToast, state.status]);
|
||||
}, [showToast]);
|
||||
|
||||
return {
|
||||
state,
|
||||
reset,
|
||||
cancelExecution,
|
||||
runSQLFileExecutionWithProgress,
|
||||
isRunning: state.status === 'start' || state.status === 'running',
|
||||
isRunning: rpcInFlight
|
||||
|| state.status === 'start'
|
||||
|| state.status === 'running'
|
||||
|| state.status === 'stopping',
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user