diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index b2b963c5..bab6971c 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -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(), diff --git a/frontend/src/components/DataImportWorkbench.test.tsx b/frontend/src/components/DataImportWorkbench.test.tsx index 37ccf039..fee2ebc5 100644 --- a/frontend/src/components/DataImportWorkbench.test.tsx +++ b/frontend/src/components/DataImportWorkbench.test.tsx @@ -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) => React.createElement( - 'mock-database-import-execution-panel', - { 'data-database-import-execution-panel-mock': 'true', ...props }, - ), + default: (props: Record) => { + 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) => 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) => React.createElement('mock-select', props); const Segmented = (props: Record) => React.createElement('mock-segmented', props); const Button = ({ children, ...props }: any) => ; + const Checkbox = ({ children, ...props }: any) => React.createElement('mock-checkbox', props, children); const Alert = (props: Record) => 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 = {}) => ({ ...overrides, } as any); +class MemoryStorage implements Storage { + private readonly values = new Map(); + + 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 = {}) => { let renderer!: ReactTestRenderer; await act(async () => { @@ -98,6 +162,7 @@ const renderWorkbench = async (overrides: Record = {}) => { 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) => 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(); + 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({ diff --git a/frontend/src/components/DataImportWorkbench.tsx b/frontend/src/components/DataImportWorkbench.tsx index 745e03df..3a6e06fd 100644 --- a/frontend/src/components/DataImportWorkbench.tsx +++ b/frontend/src/components/DataImportWorkbench.tsx @@ -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>(() => { + 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({ + connectionId: '', + status: 'idle', + }); + const [capabilityRequestToken, setCapabilityRequestToken] = useState(0); const appliedPrefillRef = useRef(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) => { addTab({ @@ -153,6 +269,17 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => { }); }, [addTab]); + const updateImportPreferences = useCallback(( + scope: DataImportPreferenceScope, + patch: Partial, + ) => { + 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 (
= ({ tab }) => { {databaseError && } {importMode === 'table' && tableError && } + {capabilityReason && ( + setCapabilityRequestToken((current) => current + 1)} + > + {t('common.retry')} + + ) : undefined} + /> + )}
@@ -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')} + {capabilityAllowsImport && capabilityDetails.length > 0 ? ( +
+ {capabilityDetails.map(({ key, values }) => ( + + {t(`data_import.capability.details.${key}`)}: {values.join(', ')} + + ))} +
+ ) : null}
+ +
+ {t('data_import.workbench.error_policy.title')} + { + 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')} + + + {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')} + +
+ + {importMode === 'table' ? ( +
+ + {t('data_import.workbench.advanced.title')} + + + {t('data_import.workbench.advanced.description')} + +
+ + + + updateImportPreferences('table', { + emptyStringAsNull: event.target.checked, + })} + > + {t('data_import.workbench.advanced.empty_string_as_null')} + + + + ) : null} + {!conflictPolicySupported ? ( + + ) : !conflictKeysValid ? ( + + ) : null} +
+
+ ) : null}
@@ -666,15 +1076,17 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => { background: panelBackground, }} > - {filePath ? ( + {filePath && capabilityAllowsImport ? ( importMode === 'database' ? ( ) : ( @@ -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 }) => { /> )} +
+ +
); diff --git a/frontend/src/components/DatabaseImportExecutionPanel.test.tsx b/frontend/src/components/DatabaseImportExecutionPanel.test.tsx index 6f66eff3..5d322a87 100644 --- a/frontend/src/components/DatabaseImportExecutionPanel.test.tsx +++ b/frontend/src/components/DatabaseImportExecutionPanel.test.tsx @@ -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; +} = {}) => { 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); + }); }); diff --git a/frontend/src/components/DatabaseImportExecutionPanel.tsx b/frontend/src/components/DatabaseImportExecutionPanel.tsx index 8a814174..765541f2 100644 --- a/frontend/src/components/DatabaseImportExecutionPanel.tsx +++ b/frontend/src/components/DatabaseImportExecutionPanel.tsx @@ -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 filePath, fileSizeMB, darkMode, + continueOnError, onRunningChange, }) => { const i18n = useOptionalI18n(); @@ -69,8 +73,32 @@ const DatabaseImportExecutionPanel: React.FC 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 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 }, [ connectionConfig, connection, + continueOnError, dbName, filePath, fileSizeMB, @@ -136,6 +178,21 @@ const DatabaseImportExecutionPanel: React.FC 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 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 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 (
/>
- {state.stage || statusText} + {stageText} {t('data_import.workbench.progress.statements', { executed: state.executed, @@ -219,6 +302,15 @@ const DatabaseImportExecutionPanel: React.FC })}
+ {transferMetrics ? ( + + {transferMetrics} + + ) : null}
) : null} @@ -247,7 +339,7 @@ const DatabaseImportExecutionPanel: React.FC style={{ marginTop: 14 }} type={resultAlertType} showIcon - message={state.message} + message={
{state.message}
} /> ) : null} @@ -271,7 +363,7 @@ const DatabaseImportExecutionPanel: React.FC type="primary" icon={terminal ? : } disabled={!connectionConfig || !String(filePath || '').trim()} - onClick={() => void startImport()} + onClick={requestStartImport} > {terminal ? t('data_import.workbench.action.retry_database_import') diff --git a/frontend/src/components/ImportJobHistoryPanel.test.tsx b/frontend/src/components/ImportJobHistoryPanel.test.tsx new file mode 100644 index 00000000..773a1ba4 --- /dev/null +++ b/frontend/src/components/ImportJobHistoryPanel.test.tsx @@ -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) => React.createElement('mock-alert', props); + const Button = ({ children, ...props }: any) => ; + const Empty = (props: Record) => React.createElement('mock-empty', props); + const Text = ({ children, ...props }: any) => {children}; + 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(); + 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); + }); +}); diff --git a/frontend/src/components/ImportJobHistoryPanel.tsx b/frontend/src/components/ImportJobHistoryPanel.tsx new file mode 100644 index 00000000..937f73d3 --- /dev/null +++ b/frontend/src/components/ImportJobHistoryPanel.tsx @@ -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([ + 'completed', + 'partial', + 'failed', + 'cancelled', + 'unknown', + 'interrupted', +]); + +const pollingStatuses = new Set([ + 'preparing', + 'running', + 'stopping', +]); + +const normalizeJobs = (value: unknown): ImportJobRecord[] => { + if (!Array.isArray(value)) return []; + return value + .filter((candidate): candidate is Record => ( + 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 = ({ refreshToken = 0 }) => { + const i18n = useOptionalI18n(); + const t = i18n?.t ?? defaultTranslate; + const [jobs, setJobs] = useState([]); + const [selectedJob, setSelectedJob] = useState(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 ( +
+
+
+ {t('data_import.history.title')} + {t('data_import.history.description')} +
+ +
+ + {error ? : null} + {!loading && jobs.length === 0 ? ( + + ) : null} + +
+ {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 ( +
+
+
+ {formatJobTarget(job)} + + {t(`data_import.history.kind.${job.kind || 'table'}`)} · {formatUpdatedAt(job.updatedAt)} + +
+ {t(`data_import.history.status.${job.status || 'unknown'}`)} +
+ + {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, + })} + +
+ + {canCancel ? ( + + ) : null} + {canExport ? ( + + ) : null} + {canDelete ? ( + + ) : null} +
+ {selectedJob?.id === job.id ? ( +
+ {t('data_import.history.detail.stage', { stage: selectedJob.stage || '—' })} + {t('data_import.history.detail.job_id', { id: selectedJob.id })} + {selectedJob.message ? {selectedJob.message} : null} + {selectedJob.outcomeUnknown ? ( + + ) : null} +
+ ) : null} +
+ ); + })} +
+
+ ); +}; + +export default ImportJobHistoryPanel; diff --git a/frontend/src/components/ImportPreviewModal.test.tsx b/frontend/src/components/ImportPreviewModal.test.tsx index a6cbf208..17ee8efb 100644 --- a/frontend/src/components/ImportPreviewModal.test.tsx +++ b/frontend/src/components/ImportPreviewModal.test.tsx @@ -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) => + React.createElement("div", props, percent === undefined ? "active" : `${percent}%`), + Spin: (props: Record) => 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, ) => ( undefined}> @@ -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 () => { diff --git a/frontend/src/components/ImportPreviewModal.tsx b/frontend/src/components/ImportPreviewModal.tsx index 40172ed4..7295d9d1 100644 --- a/frontend/src/components/ImportPreviewModal.tsx +++ b/frontend/src/components/ImportPreviewModal.tsx @@ -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; 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 & { + nullToken?: string; + sheetName?: string; +}; + +const previewImportFileWithOptions = ( + AppBindings as unknown as { + PreviewImportFileWithOptions?: (filePath: string, options: ImportParserOptions) => Promise; + } +).PreviewImportFileWithOptions; + +const cancelImportJob = ( + AppBindings as unknown as { + CancelImportJob?: (jobId: string) => Promise; + } +).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 = ({ connectionId, dbName, tableName, + continueOnError = false, + importOptions, onClose, onSuccess, onImportingChange, @@ -65,6 +124,15 @@ const ImportPreviewModal: React.FC = ({ 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(null); const [targetColumns, setTargetColumns] = useState([]); @@ -80,8 +148,25 @@ const ImportPreviewModal: React.FC = ({ const stoppingRef = useRef(false); const activeImportJobIdRef = useRef(""); const previewConnectionConfigRef = useRef(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(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 = ({ 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 = ({ (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 = ({ setColumnMappings({}); setImportResult(null); setProgress(null); + latestProgressRef.current = null; try { const conn = connection; if (!conn) { @@ -169,8 +276,12 @@ const ImportPreviewModal: React.FC = ({ }, }; 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 = ({ 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 = ({ ? 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 ? ( @@ -408,7 +640,9 @@ const ImportPreviewModal: React.FC = ({ <> = ({ {t("import_preview.preview.field_list")}
= ({ 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 = ({ ? t("import_preview.status.stopping") : t("import_preview.status.importing")}
- -
- {t("import_preview.progress.processed_rows", { - current: progress.current, - total: progress.total, - })} - + {progressMode === "indeterminate" ? ( +
+ +
+ ) : ( + + )} +
+ {progress.totalRowsKnown + ? t("import_preview.progress.processed_rows", { + current: progress.current, + total: progress.total, + }) + : t("import_preview.progress.processed_rows_unknown", { + current: progress.current, + })} + {" "} {t("import_preview.progress.success_count", { count: progress.success, })} {progress.errors > 0 && ( - + {" "} {t("import_preview.progress.error_count", { count: progress.errors, })} )} + {(progress.skipped || 0) > 0 && ( + + {t("data_import.workbench.progress.skipped", { + count: progress.skipped, + })} + + )}
+ {progress.stage ? ( +
+ {t(`import_preview.stage.${progress.stage}`)} +
+ ) : null} + {progressTransferText ? ( +
+ {progressTransferText} +
+ ) : null}
)} {importResult && (
+ {importResult.executionFailed && importResult.failureMessage && ( +
{importResult.failureMessage}
+ )}
{t("import_preview.result.success_rows", { count: importResult.success, @@ -560,29 +845,46 @@ const ImportPreviewModal: React.FC = ({
{importResult.failed > 0 && (
- {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 })} +
+ )} + {Number(importResult.skipped) > 0 && ( +
+ {t("data_import.workbench.progress.skipped", { + count: importResult.skipped, })}
)} + {importResult.outcomeUnknown && ( +
{t("import_preview.result.batch_outcome_unknown")}
+ )}
} showIcon style={{ marginBottom: 16 }} /> + {importResult.errorArtifactId ? ( + + ) : null} {importResult.errorLogs && importResult.errorLogs.length > 0 && ( <>
{t("import_preview.result.error_logs")}
= ({ {log}
))} + {importResult.errorLogsOmitted > 0 && ( +
+ {t("import_preview.result.error_logs_omitted", { + count: importResult.errorLogsOmitted, + })} +
+ )} )} @@ -632,9 +941,7 @@ const ImportPreviewModal: React.FC = ({ 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} diff --git a/frontend/src/components/SQLFileExecutionWorkbench.test.tsx b/frontend/src/components/SQLFileExecutionWorkbench.test.tsx new file mode 100644 index 00000000..06d4c530 --- /dev/null +++ b/frontend/src/components/SQLFileExecutionWorkbench.test.tsx @@ -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 => ({ + 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) => 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(); + 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) => React.createElement('mock-alert', props); + const Button = ({ children, ...props }: React.PropsWithChildren>) => ( + + ); + const Empty = Object.assign( + (props: Record) => React.createElement('mock-empty', props), + { PRESENTED_IMAGE_SIMPLE: 'simple' }, + ); + const Progress = (props: Record) => React.createElement('mock-progress', props); + const Paragraph = ({ children, ...props }: React.PropsWithChildren>) => ( +

{children}

+ ); + const Text = ({ children, ...props }: React.PropsWithChildren>) => ( + {children} + ); + const Title = ({ children, ...props }: React.PropsWithChildren>) => ( +

{children}

+ ); + 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 => { + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + 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); + }); +}); diff --git a/frontend/src/components/SQLFileExecutionWorkbench.tsx b/frontend/src/components/SQLFileExecutionWorkbench.tsx index efbd0d2a..c332c94d 100644 --- a/frontend/src/components/SQLFileExecutionWorkbench.tsx +++ b/frontend/src/components/SQLFileExecutionWorkbench.tsx @@ -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 = { 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(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 ? ( + setCapabilityRequestToken((current) => current + 1)} + > + {t('common.retry')} + + ) : undefined} + /> + ) : null} +
= ({ tab }) => {
{isRunning ? ( - ) : ( )} {(state.status === 'done' || state.status === 'cancelled' || state.status === 'error') ? ( @@ -384,7 +553,7 @@ const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
{t('sidebar.sql_file_exec.workbench.label.started_at')}
-
{formatDateTime(state.startedAt)}
+
{formatDateTime(state.startedAt, locale)}
@@ -407,9 +576,10 @@ const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => { <>
@@ -418,7 +588,9 @@ const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
{t('sidebar.sql_file_exec.workbench.label.current_stage')}
- {state.stage || resolveStatusMeta(state.status).label} + + {resolveStageLabel(state.stage, state.status)} +
@@ -528,10 +700,10 @@ const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
- {entry.stage || resolveStatusMeta(entry.status).label} + {resolveStageLabel(entry.stage, entry.status)}
{entry.message ? ( -
+
{entry.message}
) : null} @@ -540,7 +712,7 @@ const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
{t('sidebar.sql_file_exec.workbench.label.started_at')} - {formatDateTime(entry.startedAt)} + {formatDateTime(entry.startedAt, locale)} {t('sidebar.sql_file_exec.workbench.label.elapsed')} {elapsed} diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 9d10e770..533212dc 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -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('状态:'); diff --git a/frontend/src/components/dataImportCapability.test.ts b/frontend/src/components/dataImportCapability.test.ts new file mode 100644 index 00000000..1b75cd5b --- /dev/null +++ b/frontend/src/components/dataImportCapability.test.ts @@ -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', + ); + }); +}); diff --git a/frontend/src/components/dataImportCapability.ts b/frontend/src/components/dataImportCapability.ts new file mode 100644 index 00000000..5759a1de --- /dev/null +++ b/frontend/src/components/dataImportCapability.ts @@ -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 }; +}; diff --git a/frontend/src/components/dataImportPreferences.test.ts b/frontend/src/components/dataImportPreferences.test.ts new file mode 100644 index 00000000..442c50c7 --- /dev/null +++ b/frontend/src/components/dataImportPreferences.test.ts @@ -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(); + + 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); + }); +}); diff --git a/frontend/src/components/dataImportPreferences.ts b/frontend/src/components/dataImportPreferences.ts new file mode 100644 index 00000000..f7b94bbb --- /dev/null +++ b/frontend/src/components/dataImportPreferences.ts @@ -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(['stop', 'skip_duplicates', 'upsert']); +const encodings = new Set(['auto', 'utf-8', 'utf-16le', 'utf-16be', 'gb18030']); +const delimiters = new Set(['auto', 'comma', 'tab', 'semicolon', 'pipe']); + +const isDataImportPreferences = (value: unknown): value is DataImportPreferences => { + if (!value || typeof value !== 'object') return false; + const candidate = value as Partial; + 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 | 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 | 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; + } +}; diff --git a/frontend/src/components/importProgressMetrics.test.ts b/frontend/src/components/importProgressMetrics.test.ts new file mode 100644 index 00000000..0edba513 --- /dev/null +++ b/frontend/src/components/importProgressMetrics.test.ts @@ -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('秒'); + }); +}); diff --git a/frontend/src/components/importProgressMetrics.ts b/frontend/src/components/importProgressMetrics.ts new file mode 100644 index 00000000..9b17338a --- /dev/null +++ b/frontend/src/components/importProgressMetrics.ts @@ -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'); +}; diff --git a/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx b/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx index 351368bc..f47dd24c 100644 --- a/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx +++ b/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx @@ -225,9 +225,9 @@ export const SQLFileExecutionProgressContent: React.FC {t('sidebar.sql_file_exec.executed_label')} {executed} - {t('sidebar.sql_file_exec.rows_separator')} + {t('sidebar.sql_file_exec.statements_separator')} 0 ? '#ff4d4f' : undefined }}>{failed} - {t('sidebar.sql_file_exec.rows_suffix')} + {t('sidebar.sql_file_exec.statements_suffix')}
{currentSQL && status === 'running' && ( diff --git a/frontend/src/components/useSQLFileExecutionRunner.test.tsx b/frontend/src/components/useSQLFileExecutionRunner.test.tsx index f604dade..9e26c4d1 100644 --- a/frontend/src/components/useSQLFileExecutionRunner.test.tsx +++ b/frontend/src/components/useSQLFileExecutionRunner.test.tsx @@ -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; + }); + }); }); diff --git a/frontend/src/components/useSQLFileExecutionRunner.ts b/frontend/src/components/useSQLFileExecutionRunner.ts index 3742a9be..2f10fa59 100644 --- a/frontend/src/components/useSQLFileExecutionRunner.ts +++ b/frontend/src/components/useSQLFileExecutionRunner.ts @@ -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 = { @@ -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(() => createInitialState()); + const [rpcInFlight, setRPCInFlight] = useState(false); const activeJobIdRef = useRef(''); + const runningJobIdRef = useRef(''); const pendingEventRef = useRef(null); const flushFrameRef = useRef(null); - const cancelRequestedRef = useRef(false); - const cancelHandlerRef = useRef<((jobId: string) => void | Promise) | null>(null); + const cancelRequestedJobIdRef = useRef(''); + const cancelHandlerRef = useRef<{ + jobId: string; + handler: (jobId: string) => void | Promise; + } | 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 ( runOptions: RunSQLFileExecutionWithProgressOptions, ): Promise => { - 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', }; }