feat(data-import): 完善导入工作台与任务交互

- 表导入与数据库导入统一提供遇错停止和继续策略,并跟随主题变量

- 增加能力校验、高级解析选项、字节进度、停止状态与持久任务历史

- 防止重复启动和运行中面板卸载,完善六语言语句统计及回归测试
This commit is contained in:
Syngnat
2026-08-08 20:51:59 +08:00
parent 20d5255eb4
commit 16a601e6a4
21 changed files with 4244 additions and 195 deletions

View File

@@ -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(),

View File

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

View File

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

View File

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

View File

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

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

View 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;

View File

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

View File

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

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

View File

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

View File

@@ -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('状态:');

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

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

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

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

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

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

View File

@@ -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' && (

View File

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

View File

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