diff --git a/frontend/src/components/DatabaseImportExecutionPanel.test.tsx b/frontend/src/components/DatabaseImportExecutionPanel.test.tsx index 5d322a87..d7a62f83 100644 --- a/frontend/src/components/DatabaseImportExecutionPanel.test.tsx +++ b/frontend/src/components/DatabaseImportExecutionPanel.test.tsx @@ -31,7 +31,8 @@ const createRunnerState = ( }); const mocks = vi.hoisted(() => ({ - importDatabaseSQL: vi.fn(), + preflightDatabaseSQLImport: vi.fn(), + importDatabaseSQLWithOptions: vi.fn(), cancelSQLFileExecution: vi.fn(), run: vi.fn(), cancel: vi.fn(), @@ -50,7 +51,8 @@ vi.mock('./common/ResizableDraggableModal', () => ({ })); vi.mock('../../wailsjs/go/app/App', () => ({ - ImportDatabaseSQL: mocks.importDatabaseSQL, + PreflightDatabaseSQLImport: mocks.preflightDatabaseSQLImport, + ImportDatabaseSQLWithOptions: mocks.importDatabaseSQLWithOptions, CancelSQLFileExecution: mocks.cancelSQLFileExecution, })); @@ -73,6 +75,8 @@ vi.mock('antd', async () => { const Alert = (props: Record) => React.createElement('mock-alert', props); const Button = ({ children, ...props }: any) => ; const Progress = (props: Record) => React.createElement('mock-progress', props); + const Radio = ({ children, ...props }: any) => React.createElement('mock-radio', props, children); + Radio.Group = ({ children, ...props }: any) => React.createElement('mock-radio-group', props, children); const Paragraph = ({ children, ...props }: any) =>

{children}

; const Text = ({ children, ...props }: any) => {children}; const Title = ({ children, ...props }: any) =>

{children}

; @@ -80,6 +84,7 @@ vi.mock('antd', async () => { Alert, Button, Progress, + Radio, Typography: { Paragraph, Text, Title }, }; }); @@ -121,7 +126,13 @@ describe('DatabaseImportExecutionPanel', () => { mocks.state = createRunnerState(); mocks.isRunning = false; mocks.lastRunOptions = null; - mocks.importDatabaseSQL.mockReset(); + mocks.preflightDatabaseSQLImport.mockReset(); + mocks.preflightDatabaseSQLImport.mockResolvedValue({ + success: true, + data: { requiresGTIDDecision: false }, + message: '', + }); + mocks.importDatabaseSQLWithOptions.mockReset(); mocks.cancelSQLFileExecution.mockReset(); mocks.reset.mockReset(); mocks.modalConfirm.mockReset(); @@ -148,13 +159,13 @@ describe('DatabaseImportExecutionPanel', () => { 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) => { + mocks.importDatabaseSQLWithOptions.mockReturnValue(new Promise((resolve) => { resolveImport = resolve; })); const onRunningChange = vi.fn(); const renderer = await renderPanel({ onRunningChange }); - expect(mocks.importDatabaseSQL).not.toHaveBeenCalled(); + expect(mocks.importDatabaseSQLWithOptions).not.toHaveBeenCalled(); const startButton = renderer.root.findByProps({ 'data-database-import-start-action': 'true', }); @@ -164,12 +175,18 @@ describe('DatabaseImportExecutionPanel', () => { await Promise.resolve(); }); - expect(mocks.importDatabaseSQL).toHaveBeenCalledWith( + expect(mocks.preflightDatabaseSQLImport).toHaveBeenCalledWith( + expect.objectContaining({ type: 'mysql' }), + 'app', + '/tmp/database.sql', + ); + expect(mocks.importDatabaseSQLWithOptions).toHaveBeenCalledWith( expect.objectContaining({ type: 'mysql' }), 'app', '/tmp/database.sql', 'database-import-job-1', false, + 'reject', ); expect(onRunningChange).toHaveBeenLastCalledWith(true); expect(renderer.root.findAllByProps({ @@ -184,7 +201,7 @@ describe('DatabaseImportExecutionPanel', () => { }); it('passes an explicit continue-on-error choice to the database import RPC', async () => { - mocks.importDatabaseSQL.mockResolvedValue({ + mocks.importDatabaseSQLWithOptions.mockResolvedValue({ success: false, data: { completed: true, failed: 1 }, message: 'completed with errors', @@ -198,18 +215,65 @@ describe('DatabaseImportExecutionPanel', () => { await Promise.resolve(); }); - expect(mocks.importDatabaseSQL).toHaveBeenCalledWith( + expect(mocks.importDatabaseSQLWithOptions).toHaveBeenCalledWith( expect.objectContaining({ type: 'mysql' }), 'app', '/tmp/database.sql', 'database-import-job-1', true, + 'reject', + ); + }); + + it('requires a GTID conflict choice before creating the import task', async () => { + mocks.preflightDatabaseSQLImport.mockResolvedValue({ + success: true, + data: { + containsMySQLGTIDPurged: true, + targetGTIDExecutedNonEmpty: true, + requiresGTIDDecision: true, + serverVersion: '8.4.3', + }, + message: '', + }); + mocks.importDatabaseSQLWithOptions.mockResolvedValue({ success: true, message: 'done' }); + const renderer = await renderPanel(); + + await act(async () => { + renderer.root.findByProps({ + 'data-database-import-start-action': 'true', + }).props.onClick(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.modalConfirm).toHaveBeenCalledOnce(); + expect(mocks.run).not.toHaveBeenCalled(); + const confirmOptions = mocks.modalConfirm.mock.calls[0][0]; + const modeSelector = confirmOptions.content.props.children.find( + (child: any) => child?.props?.['data-mysql-gtid-mode-selector'] === 'true', + ); + + await act(async () => { + modeSelector.props.onChange({ target: { value: 'reset' } }); + await confirmOptions.onOk(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.importDatabaseSQLWithOptions).toHaveBeenCalledWith( + expect.objectContaining({ type: 'mysql' }), + 'app', + '/tmp/database.sql', + 'database-import-job-1', + false, + 'reset', ); }); 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) => { + mocks.importDatabaseSQLWithOptions.mockReturnValue(new Promise((resolve) => { resolveImport = resolve; })); mocks.cancelSQLFileExecution.mockResolvedValue({ success: true }); diff --git a/frontend/src/components/DatabaseImportExecutionPanel.tsx b/frontend/src/components/DatabaseImportExecutionPanel.tsx index 765541f2..fb731de2 100644 --- a/frontend/src/components/DatabaseImportExecutionPanel.tsx +++ b/frontend/src/components/DatabaseImportExecutionPanel.tsx @@ -1,12 +1,16 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Alert, Button, Progress, Typography } from 'antd'; +import { Alert, Button, Progress, Radio, Typography } from 'antd'; import { PlayCircleOutlined, ReloadOutlined, StopOutlined, } from '@ant-design/icons'; -import { CancelSQLFileExecution, ImportDatabaseSQL } from '../../wailsjs/go/app/App'; +import { + CancelSQLFileExecution, + ImportDatabaseSQLWithOptions, + PreflightDatabaseSQLImport, +} from '../../wailsjs/go/app/App'; import { t as defaultTranslate } from '../i18n'; import { useOptionalI18n } from '../i18n/provider'; import type { SavedConnection } from '../types'; @@ -31,6 +35,61 @@ type DatabaseImportExecutionPanelProps = { onRunningChange?: (running: boolean) => void; }; +type MySQLGTIDImportMode = 'reject' | 'skip' | 'reset'; + +const requestMySQLGTIDImportMode = ( + translate: typeof defaultTranslate, +): Promise | null> => new Promise((resolve) => { + let selectedMode: Exclude = 'skip'; + let settled = false; + const settle = (value: Exclude | null) => { + if (settled) return; + settled = true; + resolve(value); + }; + + Modal.confirm({ + title: translate('data_import.workbench.gtid.title'), + width: 560, + content: ( +
+ + {translate('data_import.workbench.gtid.description')} + + { + selectedMode = event.target.value === 'reset' ? 'reset' : 'skip'; + }} + style={{ display: 'grid', gap: 12 }} + > +
+ + {translate('data_import.workbench.gtid.option.skip')} + + + {translate('data_import.workbench.gtid.option.skip_description')} + +
+
+ + {translate('data_import.workbench.gtid.option.reset')} + + + {translate('data_import.workbench.gtid.option.reset_description')} + +
+
+
+ ), + okText: translate('data_import.workbench.gtid.action.continue'), + cancelText: translate('common.cancel'), + onOk: () => settle(selectedMode), + onCancel: () => settle(null), + }); +}); + const getFileName = (filePath: string): string => { const parts = String(filePath || '').split(/[\\/]/); return parts[parts.length - 1] || filePath; @@ -58,6 +117,8 @@ const DatabaseImportExecutionPanel: React.FC const i18n = useOptionalI18n(); const t = i18n?.t ?? defaultTranslate; const [executionPending, setExecutionPending] = useState(false); + const [executionStarted, setExecutionStarted] = useState(false); + const [preflightError, setPreflightError] = useState(''); const [cancelRequested, setCancelRequested] = useState(false); const lastReportedRunningRef = useRef(null); const { @@ -69,6 +130,7 @@ const DatabaseImportExecutionPanel: React.FC } = useSQLFileExecutionRunner({ showToast: false }); const taskRunning = isRunning || executionPending; + const canCancelExecution = isRunning || executionStarted; const progressPercent = Math.max(0, Math.min(100, Number(state.percent) || 0)); const terminal = state.status === 'done' || state.status === 'cancelled' @@ -117,18 +179,42 @@ const DatabaseImportExecutionPanel: React.FC if (!approved) return; setExecutionPending(true); setCancelRequested(false); + setPreflightError(''); try { + let mysqlGTIDMode: MySQLGTIDImportMode = 'reject'; + const preflightResult = await PreflightDatabaseSQLImport( + connectionConfig as any, + String(dbName || '').trim(), + filePath, + ); + if (!preflightResult?.success) { + setPreflightError( + preflightResult?.message || t('data_import.workbench.gtid.preflight_failed'), + ); + return; + } + const preflightData = preflightResult.data as { + requiresGTIDDecision?: unknown; + } | undefined; + if (preflightData?.requiresGTIDDecision === true) { + const selectedMode = await requestMySQLGTIDImportMode(t); + if (!selectedMode) return; + mysqlGTIDMode = selectedMode; + } + + setExecutionStarted(true); await runSQLFileExecutionWithProgress({ title: getFileName(filePath), filePath, fileSizeMB, run: async (jobId) => { - const result = await ImportDatabaseSQL( + const result = await ImportDatabaseSQLWithOptions( connectionConfig as any, String(dbName || '').trim(), filePath, jobId, continueOnError, + mysqlGTIDMode, ); // Reaching EOF with recorded statement errors is a completed import, // not a transport/fatal failure. Preserve the counters and render it @@ -148,6 +234,7 @@ const DatabaseImportExecutionPanel: React.FC } catch { // The shared runner already records and displays the RPC error state. } finally { + setExecutionStarted(false); setExecutionPending(false); } }, [ @@ -163,14 +250,14 @@ const DatabaseImportExecutionPanel: React.FC ]); const requestCancel = useCallback(async () => { - if (!taskRunning || cancelRequested) return; + if (!canCancelExecution || cancelRequested) return; setCancelRequested(true); try { await cancelExecution(); } catch { setCancelRequested(false); } - }, [cancelExecution, cancelRequested, taskRunning]); + }, [canCancelExecution, cancelExecution, cancelRequested]); const resetProgress = useCallback(() => { if (taskRunning) return; @@ -251,6 +338,14 @@ const DatabaseImportExecutionPanel: React.FC ? t('data_import.workbench.notice.continue_on_error') : t('data_import.workbench.notice.stop_on_error')} /> + {preflightError ? ( + + ) : null} ) : null}
- {taskRunning ? ( + {canCancelExecution ? (