import Modal from "./common/ResizableDraggableModal"; import React, { useState, useEffect, useMemo, useRef } from "react"; import { Form, Select, Input, Button, message, Steps, Transfer, Card, Alert, Divider, Typography, Progress, Checkbox, Table, Drawer, Tabs, theme as antdTheme, } from "antd"; import { DatabaseOutlined, RocketOutlined, SwapOutlined, TableOutlined, } from "@ant-design/icons"; import { useStore } from "../store"; import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview, } from "../../wailsjs/go/app/App"; import { SavedConnection } from "../types"; import { EventsOn } from "../../wailsjs/runtime/runtime"; import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues, resolveTextInputSafeBackdropFilter, } from "../utils/appearance"; import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig"; import { isPostgresSchemaDialect, supportsIndependentSchemaSelection, } from "../utils/connectionDriverType"; import { resolveSqlDialect } from "../utils/sqlDialect"; import { quoteIdentPart, quoteQualifiedIdent } from "../utils/sql"; import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText, } from "./dataGridCopyInsert"; import { buildDataSyncRequest, type SourceDatasetMode, validateDataSyncSelection, } from "./dataSyncRequest"; import { t } from "../i18n"; import { useOptionalI18n } from "../i18n/provider"; import { resolveDataSyncEntryModePresentation, type DataSyncEntryMode, } from "./dataSyncEntryMode"; import { loadSchemas } from "./sidebar/sidebarMetadataLoaders"; const { Title, Text } = Typography; const { Step } = Steps; const { Option } = Select; const { TextArea } = Input; type SyncLogEvent = { jobId: string; level?: string; message?: string; ts?: number; }; type SyncProgressEvent = { jobId: string; percent?: number; current?: number; total?: number; table?: string; stage?: string; }; type SyncLogItem = { level: string; message: string; ts?: number }; type TableDiffSummary = { table: string; pkColumn?: string; canSync?: boolean; inserts?: number; updates?: number; deletes?: number; same?: number; schemaDiffCount?: number; message?: string; targetTableExists?: boolean; plannedAction?: string; warnings?: string[]; unsupportedObjects?: string[]; indexesToCreate?: number; indexesSkipped?: number; }; type TableOps = { insert: boolean; update: boolean; delete: boolean; selectedInsertPks?: string[]; selectedUpdatePks?: string[]; selectedDeletePks?: string[]; }; type WorkflowType = "sync" | "migration"; const quoteSqlIdent = (dbType: string, ident: string): string => { return quoteIdentPart(dbType, String(ident || "").trim()); }; const quoteSqlTable = (dbType: string, tableName: string): string => { return quoteQualifiedIdent(dbType, String(tableName || "").trim()); }; const toSqlLiteral = (value: any, dbType: string): string => { if (value === null || value === undefined) return "NULL"; if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL"; if (typeof value === "bigint") return value.toString(); if (typeof value === "boolean") { const t = String(dbType || "").toLowerCase(); if (t === "sqlserver") return value ? "1" : "0"; return value ? "TRUE" : "FALSE"; } if (value instanceof Date) { return `'${formatLocalDateTimeLiteral(value).replace(/'/g, "''")}'`; } if (typeof value === "string") { return `'${value.replace(/'/g, "''")}'`; } if (typeof value === "object") { try { return `'${JSON.stringify(value).replace(/'/g, "''")}'`; } catch { return `'${String(value).replace(/'/g, "''")}'`; } } return `'${String(value).replace(/'/g, "''")}'`; }; const toTypedSqlLiteral = ( value: any, dbType: string, columnType?: string, ): string => { if (typeof value === "string") { const normalized = normalizeTemporalLiteralText(value, columnType, false); return toSqlLiteral(normalized, dbType); } if (value instanceof Date) { const normalized = String(columnType || "").trim() ? formatLocalDateTimeLiteral(value) : value.toISOString(); return toSqlLiteral(normalized, dbType); } return toSqlLiteral(value, dbType); }; const resolveRedisDbIndex = (raw?: string): number => { const value = Number(String(raw || "").trim()); return Number.isInteger(value) && value >= 0 && value <= 15 ? value : 0; }; const normalizeSchemaName = (raw?: string): string => String(raw || "") .trim() .toLowerCase(); const resolveSchemaFromQualifiedTableName = (tableName: string): string => { const parts = String(tableName || "") .trim() .split(".") .map((part) => part.trim()) .filter((part) => part !== ""); if (parts.length < 2) return ""; return parts.length >= 3 ? parts[parts.length - 2] : parts[0]; }; const filterTablesBySchema = ( tables: string[], schemaName: string, ): string[] => { const normalizedSchema = normalizeSchemaName(schemaName); if (!normalizedSchema) return tables; const filtered = tables.filter((tableName) => { const rawTableName = String(tableName || "").trim(); if (!rawTableName) return false; const tableSchema = resolveSchemaFromQualifiedTableName(rawTableName); if (!tableSchema) return true; return normalizeSchemaName(tableSchema) === normalizedSchema; }); return filtered.length > 0 ? filtered : tables; }; const resolvePreferredTargetSchema = ( dialect: string, schemaNames: string[], ): string => { const preferred = isPostgresSchemaDialect(dialect) ? "public" : dialect === "sqlserver" ? "dbo" : dialect === "duckdb" ? "main" : ""; if (preferred) { const matched = schemaNames.find( (item) => item.toLowerCase() === preferred, ); if (matched) return matched; } if (schemaNames.length === 1) return schemaNames[0]; return ""; }; const isServiceNameBackedSyncConnection = (conn?: SavedConnection): boolean => { const type = String(conn?.config?.type || "") .trim() .toLowerCase(); if (type === "oracle") return true; if (type !== "oceanbase") return false; const explicitProtocol = String( (conn?.config as any)?.oceanBaseProtocol || "", ) .trim() .toLowerCase(); if (explicitProtocol === "oracle") return true; const params = new URLSearchParams( String(conn?.config?.connectionParams || ""), ); const protocol = String( params.get("protocol") || params.get("tenantMode") || "", ) .trim() .toLowerCase(); return protocol === "oracle"; }; const buildSqlPreview = ( previewData: any, tableName: string, dbType: string, ops?: TableOps, ): { sqlText: string; statementCount: number } => { if (!previewData || !tableName) return { sqlText: "", statementCount: 0 }; const tableExpr = quoteSqlTable(dbType, tableName); const pkCol = String(previewData.pkColumn || "id"); const columnTypesByLowerName = previewData?.columnTypes && typeof previewData.columnTypes === "object" ? (previewData.columnTypes as Record) : {}; const statements: string[] = []; const schemaStatements = Array.isArray(previewData.schemaStatements) ? previewData.schemaStatements .map((item: any) => String(item || "").trim()) .filter((item: string) => item.length > 0) : []; schemaStatements.forEach((statement: string) => { statements.push(statement.endsWith(";") ? statement : `${statement};`); }); const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : []; const updateRows = Array.isArray(previewData.updates) ? previewData.updates : []; const deleteRows = Array.isArray(previewData.deletes) ? previewData.deletes : []; const selectedInsert = new Set( (ops?.selectedInsertPks || []).map((v) => String(v)), ); const selectedUpdate = new Set( (ops?.selectedUpdatePks || []).map((v) => String(v)), ); const selectedDelete = new Set( (ops?.selectedDeletePks || []).map((v) => String(v)), ); if (ops?.insert !== false) { insertRows.forEach((rowWrap: any) => { const pk = String(rowWrap?.pk ?? ""); if (selectedInsert.size > 0 && !selectedInsert.has(pk)) return; const row = rowWrap?.row || {}; const columns = Object.keys(row); if (columns.length === 0) return; const colExpr = columns.map((c) => quoteSqlIdent(dbType, c)).join(", "); const valExpr = columns .map((c) => toTypedSqlLiteral( row[c], dbType, columnTypesByLowerName[String(c).toLowerCase()], ), ) .join(", "); statements.push( `INSERT INTO ${tableExpr} (${colExpr}) VALUES (${valExpr});`, ); }); } if (ops?.update !== false) { updateRows.forEach((rowWrap: any) => { const pk = String(rowWrap?.pk ?? ""); if (selectedUpdate.size > 0 && !selectedUpdate.has(pk)) return; const source = rowWrap?.source || {}; const changedColumns = Array.isArray(rowWrap?.changedColumns) ? rowWrap.changedColumns : Object.keys(source).filter((k) => k !== pkCol); const setCols = changedColumns.filter((c: string) => String(c) !== pkCol); if (setCols.length === 0) return; const setExpr = setCols .map( (c: string) => `${quoteSqlIdent(dbType, c)} = ${toTypedSqlLiteral(source[c], dbType, columnTypesByLowerName[String(c).toLowerCase()])}`, ) .join(", "); statements.push( `UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`, ); }); } if (ops?.delete) { deleteRows.forEach((rowWrap: any) => { const pk = String(rowWrap?.pk ?? ""); if (selectedDelete.size > 0 && !selectedDelete.has(pk)) return; statements.push( `DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`, ); }); } return { sqlText: statements.join("\n"), statementCount: statements.length, }; }; const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; onBack?: () => void; entryMode?: DataSyncEntryMode; embedded?: boolean; }> = ({ open, onClose, onBack, entryMode = "sync", embedded = false }) => { const i18n = useOptionalI18n(); const i18nLanguage = i18n?.language; const tr = (key: string, params?: Parameters[1]) => t(key, params, i18nLanguage); const entryPresentation = resolveDataSyncEntryModePresentation(entryMode, tr); const isSchemaCompareEntry = entryMode === "schemaCompare"; const isDataCompareEntry = entryMode === "dataCompare"; const isCompareEntry = entryPresentation.readOnly; const connections = useStore((state) => state.connections); const themeMode = useStore((state) => state.theme); const appearance = useStore((state) => state.appearance); const [currentStep, setCurrentStep] = useState(0); const [loading, setLoading] = useState(false); const { token } = antdTheme.useToken(); const darkMode = themeMode === "dark"; const resolvedAppearance = resolveAppearanceValues(appearance); const effectiveOpacity = normalizeOpacityForPlatform( resolvedAppearance.opacity, ); const disableLocalBackdropFilter = isMacLikePlatform(); // Step 1: Config const [sourceConnId, setSourceConnId] = useState(""); const [targetConnId, setTargetConnId] = useState(""); const [sourceDb, setSourceDb] = useState(""); const [targetDb, setTargetDb] = useState(""); const [sourceDbs, setSourceDbs] = useState([]); const [targetDbs, setTargetDbs] = useState([]); const [targetSchemas, setTargetSchemas] = useState([]); const [targetSchema, setTargetSchema] = useState(""); const [targetSchemaLoading, setTargetSchemaLoading] = useState(false); // Step 2: Tables const [allTables, setAllTables] = useState([]); const [selectedTables, setSelectedTables] = useState([]); const [sourceDatasetMode, setSourceDatasetMode] = useState("table"); const [sourceQuery, setSourceQuery] = useState(""); // Options const [workflowType, setWorkflowType] = useState("sync"); const [syncContent, setSyncContent] = useState<"data" | "schema" | "both">( isSchemaCompareEntry ? "schema" : "data", ); const [syncMode, setSyncMode] = useState("insert_update"); const [autoAddColumns, setAutoAddColumns] = useState(true); const [targetTableStrategy, setTargetTableStrategy] = useState< "existing_only" | "auto_create_if_missing" | "smart" >("existing_only"); const [createIndexes, setCreateIndexes] = useState(false); const [mongoCollectionName, setMongoCollectionName] = useState(""); const [showSameTables, setShowSameTables] = useState(false); const [analyzing, setAnalyzing] = useState(false); const [diffTables, setDiffTables] = useState([]); const [tableOptions, setTableOptions] = useState>( {}, ); const [previewOpen, setPreviewOpen] = useState(false); const [previewTable, setPreviewTable] = useState(""); const [previewLoading, setPreviewLoading] = useState(false); const [previewData, setPreviewData] = useState(null); // Step 3: Result const [syncResult, setSyncResult] = useState(null); const [syncing, setSyncing] = useState(false); const [syncLogs, setSyncLogs] = useState([]); const [syncProgress, setSyncProgress] = useState<{ percent: number; current: number; total: number; table: string; stage: string; }>({ percent: 0, current: 0, total: 0, table: "", stage: "", }); const jobIdRef = useRef(""); const logBoxRef = useRef(null); const autoScrollRef = useRef(true); const normalizeConnConfig = (conn: SavedConnection, database?: string) => buildRpcConnectionConfig(conn.config, { database: typeof database === "string" && !isServiceNameBackedSyncConnection(conn) ? database : conn.config.database || "", }); useEffect(() => { if (!open) return; const offLog = EventsOn("sync:log", (event: SyncLogEvent) => { if (!event || event.jobId !== jobIdRef.current) return; const msg = String(event.message || "").trim(); if (!msg) return; setSyncLogs((prev) => [ ...prev, { level: String(event.level || "info"), message: msg, ts: event.ts }, ]); }); const offProgress = EventsOn( "sync:progress", (event: SyncProgressEvent) => { if (!event || event.jobId !== jobIdRef.current) return; setSyncProgress((prev) => ({ percent: typeof event.percent === "number" ? event.percent : prev.percent, current: typeof event.current === "number" ? event.current : prev.current, total: typeof event.total === "number" ? event.total : prev.total, table: typeof event.table === "string" ? event.table : prev.table, stage: typeof event.stage === "string" ? event.stage : prev.stage, })); }, ); return () => { if (typeof offLog === "function") offLog(); if (typeof offProgress === "function") offProgress(); }; }, [open]); useEffect(() => { if (!logBoxRef.current) return; if (!autoScrollRef.current) return; logBoxRef.current.scrollTop = logBoxRef.current.scrollHeight; }, [syncLogs]); useEffect(() => { if (open) { setCurrentStep(0); setSourceConnId(""); setTargetConnId(""); setSourceDb(""); setTargetDb(""); setTargetSchema(""); setTargetSchemas([]); setTargetSchemaLoading(false); setAllTables([]); setSelectedTables([]); setSourceDatasetMode("table"); setSourceQuery(""); setWorkflowType("sync"); setSyncContent(isSchemaCompareEntry ? "schema" : "data"); setSyncMode("insert_update"); setAutoAddColumns(true); setTargetTableStrategy("existing_only"); setCreateIndexes(false); setShowSameTables(false); setAnalyzing(false); setDiffTables([]); setTableOptions({}); setPreviewOpen(false); setPreviewTable(""); setPreviewLoading(false); setPreviewData(null); setSyncResult(null); setSyncing(false); setSyncLogs([]); setSyncProgress({ percent: 0, current: 0, total: 0, table: "", stage: "", }); jobIdRef.current = ""; autoScrollRef.current = true; } }, [open, isSchemaCompareEntry]); useEffect(() => { if (isSchemaCompareEntry) { if (workflowType !== "sync") { setWorkflowType("sync"); } if (sourceDatasetMode !== "table") { setSourceDatasetMode("table"); } if (syncContent !== "schema") { setSyncContent("schema"); } if (syncMode !== "insert_update") { setSyncMode("insert_update"); } if (targetTableStrategy !== "existing_only") { setTargetTableStrategy("existing_only"); } if (createIndexes) { setCreateIndexes(false); } return; } if (isDataCompareEntry) { if (workflowType !== "sync") { setWorkflowType("sync"); } if (syncContent !== "data") { setSyncContent("data"); } if (syncMode !== "insert_update") { setSyncMode("insert_update"); } if (targetTableStrategy !== "existing_only") { setTargetTableStrategy("existing_only"); } if (createIndexes) { setCreateIndexes(false); } return; } if (workflowType === "migration") { if (syncMode === "insert_update") { setSyncMode("insert_only"); } if (syncContent === "schema") { setSyncContent("both"); } if (targetTableStrategy === "existing_only") { setTargetTableStrategy("smart"); } if (!createIndexes) { setCreateIndexes(true); } } else { if (targetTableStrategy !== "existing_only") { setTargetTableStrategy("existing_only"); } if (createIndexes) { setCreateIndexes(false); } } }, [ isSchemaCompareEntry, isDataCompareEntry, workflowType, sourceDatasetMode, syncContent, syncMode, targetTableStrategy, createIndexes, ]); useEffect(() => { if (sourceDatasetMode !== "query") return; if (workflowType !== "sync") { setWorkflowType("sync"); } if (syncContent !== "data") { setSyncContent("data"); } if (targetTableStrategy !== "existing_only") { setTargetTableStrategy("existing_only"); } if (createIndexes) { setCreateIndexes(false); } if (autoAddColumns) { setAutoAddColumns(false); } if (selectedTables.length > 1) { setSelectedTables(selectedTables.slice(0, 1)); } }, [ sourceDatasetMode, workflowType, syncContent, targetTableStrategy, createIndexes, autoAddColumns, selectedTables, ]); const handleSourceConnChange = async (connId: string) => { setSourceConnId(connId); setSourceDb(""); const conn = connections.find((c) => c.id === connId); if (conn) { setLoading(true); try { const res = await DBGetDatabases(normalizeConnConfig(conn) as any); if (res.success) { const dbRows = Array.isArray(res.data) ? res.data : []; setSourceDbs( dbRows .map((r: any) => r?.Database || r?.database || r?.username) .filter( (name: any) => typeof name === "string" && name.trim() !== "", ), ); } } catch (e: any) { message.error( tr("data_sync.message.fetch_source_databases_failed_detail", { detail: e?.message || String(e), }), ); } setLoading(false); } }; const handleTargetConnChange = async (connId: string) => { setTargetConnId(connId); setTargetDb(""); setTargetSchema(""); setTargetSchemas([]); setTargetSchemaLoading(false); const conn = connections.find((c) => c.id === connId); if (conn) { setLoading(true); try { const res = await DBGetDatabases(normalizeConnConfig(conn) as any); if (res.success) { const dbRows = Array.isArray(res.data) ? res.data : []; setTargetDbs( dbRows .map((r: any) => r?.Database || r?.database || r?.username) .filter( (name: any) => typeof name === "string" && name.trim() !== "", ), ); } } catch (e: any) { message.error( tr("data_sync.message.fetch_target_databases_failed_detail", { detail: e?.message || String(e), }), ); } setLoading(false); } }; const ensureTargetSchemaSelected = (): boolean => { if (targetSupportsSchemaSelection && !String(targetSchema || "").trim()) { message.error(tr("data_sync.message.select_target_schema")); return false; } return true; }; const nextToTables = async () => { if (!sourceConnId || !targetConnId) return message.error(tr('data_sync.message.select_connections_first')); if (!sourceDb) return message.error(tr('data_sync.message.select_source_database')); if (!targetDb) return message.error(tr('data_sync.message.select_target_database')); if (!ensureTargetSchemaSelected()) return; setLoading(true); try { const connId = isSourceQueryMode ? targetConnId : sourceConnId; const dbName = isSourceQueryMode ? targetDb : sourceDb; const conn = connections.find((c) => c.id === connId); if (conn) { const config = normalizeConnConfig(conn, dbName); const res = await DBGetTables(config as any, dbName); if (res.success) { // DBGetTables returns [{Table: "name"}, ...] const tableRows = Array.isArray(res.data) ? res.data : []; const tables = tableRows .map( (row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0], ) .filter( (name: any) => typeof name === "string" && name.trim() !== "", ); const nextTables = ( isSourceQueryMode && targetSupportsSchemaSelection && targetSchema ? filterTablesBySchema(tables as string[], targetSchema) : tables ) as string[]; setAllTables(nextTables); setSelectedTables((prev) => { const existing = prev.filter((name) => nextTables.includes(name)); if (isSourceQueryMode) { return existing.slice(0, 1); } return existing; }); setCurrentStep(1); } else { message.error( res.message ? tr("data_sync.message.fetch_tables_failed_detail", { detail: res.message, }) : tr("data_sync.message.fetch_tables_failed"), ); } } } catch (e: any) { message.error( tr("data_sync.message.fetch_tables_failed_detail", { detail: e?.message || String(e), }), ); } setLoading(false); }; const updateTableOption = ( table: string, key: keyof TableOps, value: any, ) => { setTableOptions((prev) => ({ ...prev, [table]: { ...(prev[table] || { insert: true, update: true, delete: false }), [key]: value, }, })); }; const analyzeDiff = async () => { const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent, }); if (selectionError) return message.error(tr(selectionError)); if (!sourceConnId || !targetConnId) return message.error(tr("data_sync.message.select_connections_first")); if (!sourceDb || !targetDb) return message.error(tr("data_sync.message.select_databases_first")); if (!ensureTargetSchemaSelected()) return; setLoading(true); setAnalyzing(true); setDiffTables([]); setTableOptions({}); setSyncLogs([]); const sConn = connections.find((c) => c.id === sourceConnId)!; const tConn = connections.find((c) => c.id === targetConnId)!; const jobId = `analyze-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; jobIdRef.current = jobId; autoScrollRef.current = true; setSyncProgress({ percent: 0, current: 0, total: selectedTables.length, table: "", stage: tr("data_sync.progress.stage.analyzing_diff"), }); const config = buildDataSyncRequest({ sourceConfig: normalizeConnConfig(sConn, sourceDb), targetConfig: normalizeConnConfig(tConn, targetDb), sourceDatabase: sourceDb, targetDatabase: targetDb, targetSchema, selectedTables, sourceDatasetMode, sourceQuery, syncContent, syncMode: "insert_update", autoAddColumns, targetTableStrategy, createIndexes, mongoCollectionName, jobId, }); try { const res = await DataSyncAnalyze(config as any); if (res.success) { const tables = ((res.data as any)?.tables || []) as TableDiffSummary[]; setDiffTables(tables); const init: Record = {}; tables.forEach((t) => { const can = !!t.canSync; init[t.table] = { insert: can, update: can, delete: false, selectedInsertPks: [], selectedUpdatePks: [], selectedDeletePks: [], }; }); setTableOptions(init); message.success(tr("data_sync.message.analysis_complete")); } else { message.error( res.message ? tr("data_sync.message.analysis_failed_detail", { detail: res.message, }) : tr("data_sync.message.analysis_failed"), ); } } catch (e: any) { message.error( tr("data_sync.message.analysis_failed_detail", { detail: e?.message || String(e), }), ); } setLoading(false); setAnalyzing(false); }; const openPreview = async (table: string) => { if (!table) return; if (!ensureTargetSchemaSelected()) return; const sConn = connections.find((c) => c.id === sourceConnId)!; const tConn = connections.find((c) => c.id === targetConnId)!; setPreviewOpen(true); setPreviewTable(table); setPreviewLoading(true); setPreviewData(null); const config = buildDataSyncRequest({ sourceConfig: normalizeConnConfig(sConn, sourceDb), targetConfig: normalizeConnConfig(tConn, targetDb), sourceDatabase: sourceDb, targetDatabase: targetDb, targetSchema, selectedTables, sourceDatasetMode, sourceQuery, syncContent, syncMode: "insert_update", autoAddColumns, targetTableStrategy, createIndexes, mongoCollectionName, }); try { const res = await DataSyncPreview(config as any, table, 200); if (res.success) { setPreviewData(res.data); } else { message.error( res.message ? tr("data_sync.message.preview_load_failed_detail", { detail: res.message, }) : tr("data_sync.message.preview_load_failed"), ); } } catch (e: any) { message.error( tr("data_sync.message.preview_load_failed_detail", { detail: e?.message || String(e), }), ); } setPreviewLoading(false); }; const runSync = async () => { const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent, }); if (selectionError) { message.error(tr(selectionError)); return; } if (!ensureTargetSchemaSelected()) return; if (syncContent !== "schema" && diffTables.length === 0) { message.error(tr("data_sync.message.analyze_before_sync")); return; } if (syncContent !== "schema" && syncMode === "full_overwrite") { const ok = await new Promise((resolve) => { Modal.confirm({ title: tr("data_sync.modal.full_overwrite_title"), content: tr("data_sync.modal.full_overwrite_content"), okText: tr("data_sync.modal.full_overwrite_ok"), cancelText: tr("common.cancel"), onOk: () => resolve(true), onCancel: () => resolve(false), }); }); if (!ok) return; } setLoading(true); setSyncing(true); setCurrentStep(2); setSyncResult(null); setSyncLogs([]); const sConn = connections.find((c) => c.id === sourceConnId)!; const tConn = connections.find((c) => c.id === targetConnId)!; const jobId = `sync-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; jobIdRef.current = jobId; autoScrollRef.current = true; setSyncProgress({ percent: 0, current: 0, total: selectedTables.length, table: "", stage: tr("data_sync.progress.stage.preparing"), }); const config = buildDataSyncRequest({ sourceConfig: normalizeConnConfig(sConn, sourceDb), targetConfig: normalizeConnConfig(tConn, targetDb), sourceDatabase: sourceDb, targetDatabase: targetDb, targetSchema, selectedTables, sourceDatasetMode, sourceQuery, syncContent, syncMode, autoAddColumns, targetTableStrategy, createIndexes, mongoCollectionName, tableOptions, jobId, }); try { const res = await DataSync(config as any); setSyncResult(res); if (Array.isArray(res?.logs) && res.logs.length > 0) { setSyncLogs((prev) => { if (prev.length > 0) return prev; return (res.logs as string[]).map((log) => { const msg = String(log || "").trim(); if (msg.includes("致命错误") || msg.includes("失败")) return { level: "error", message: msg }; // i18n-scan: allow-raw backend log severity markers if (msg.includes("跳过") || msg.includes("警告")) return { level: "warn", message: msg }; // i18n-scan: allow-raw backend log severity markers return { level: "info", message: msg }; }); }); } } catch (e: any) { message.error( tr("data_sync.message.sync_execution_failed_detail", { detail: e?.message || String(e), }), ); setSyncResult({ success: false, message: tr("data_sync.message.sync_execution_failed"), logs: [], }); } setLoading(false); setSyncing(false); }; const renderSyncLogItem = (item: SyncLogItem) => { const level = String(item.level || "info").toLowerCase(); const color = level === "error" ? "#ff4d4f" : level === "warn" ? "#faad14" : "#595959"; const label = level === "error" ? tr("data_sync.log.level.error") : level === "warn" ? tr("data_sync.log.level.warn") : tr("data_sync.log.level.info"); const timeText = typeof item.ts === "number" ? new Date(item.ts).toLocaleTimeString(i18nLanguage || undefined, { hour12: false, }) : ""; return (
● {label} {timeText && ( {timeText} )} {item.message}
); }; const previewSql = useMemo(() => { if (!previewData || !previewTable) return { sqlText: "", statementCount: 0 }; const targetType = String( connections.find((c) => c.id === targetConnId)?.config?.type || "", ); const ops = tableOptions[previewTable] || { insert: true, update: true, delete: false, }; return buildSqlPreview(previewData, previewTable, targetType, ops); }, [previewData, previewTable, targetConnId, connections, tableOptions]); const previewHasSchemaStatements = useMemo( () => Array.isArray(previewData?.schemaStatements) && previewData.schemaStatements.length > 0, [previewData], ); const previewSchemaWarnings = useMemo( () => Array.isArray(previewData?.schemaWarnings) ? (previewData.schemaWarnings as string[]) : [], [previewData], ); const previewHasDataDiff = useMemo( () => Number(previewData?.totalInserts || 0) + Number(previewData?.totalUpdates || 0) + Number(previewData?.totalDeletes || 0) > 0, [previewData], ); const analysisWarnings = useMemo(() => { const items: string[] = []; diffTables.forEach((table) => { (table.warnings || []).forEach((warning) => items.push(`${table.table}: ${warning}`), ); (table.unsupportedObjects || []).forEach((warning) => items.push(`${table.table}: ${warning}`), ); }); return Array.from(new Set(items)); }, [diffTables]); const isSourceQueryMode = sourceDatasetMode === "query"; const isMigrationWorkflow = !isCompareEntry && workflowType === "migration"; const sourceConn = useMemo( () => connections.find((c) => c.id === sourceConnId), [connections, sourceConnId], ); const targetConn = useMemo( () => connections.find((c) => c.id === targetConnId), [connections, targetConnId], ); const targetDialect = useMemo( () => resolveSqlDialect( targetConn?.config?.type || "", targetConn?.config?.driver || "", { oceanBaseProtocol: targetConn?.config?.oceanBaseProtocol }, ), [targetConn], ); const targetSupportsSchemaSelection = useMemo( () => supportsIndependentSchemaSelection(targetDialect), [targetDialect], ); const sourceType = String(sourceConn?.config?.type || "").toLowerCase(); const targetType = String(targetConn?.config?.type || "").toLowerCase(); const isRedisMongoKeyspaceMigration = isMigrationWorkflow && ((sourceType === "redis" && targetType === "mongodb") || (sourceType === "mongodb" && targetType === "redis")); const defaultMongoCollectionName = useMemo(() => { if (sourceType === "redis" && targetType === "mongodb") { return `redis_db_${resolveRedisDbIndex(sourceDb || sourceConn?.config?.database)}_keys`; } if (sourceType === "mongodb" && targetType === "redis") { return ( selectedTables[0] || `redis_db_${resolveRedisDbIndex(targetDb || targetConn?.config?.database)}_keys` ); } return ""; }, [ sourceType, targetType, sourceDb, targetDb, sourceConn, targetConn, selectedTables, ]); useEffect(() => { if (!targetConn || !targetDb || !targetSupportsSchemaSelection) { setTargetSchemas([]); setTargetSchema(""); setTargetSchemaLoading(false); return; } let cancelled = false; setTargetSchema(""); setTargetSchemas([]); setTargetSchemaLoading(true); (async () => { try { const result = await loadSchemas(targetConn, targetDb); if (cancelled) return; const normalizedSchemas = Array.from( new Map( (Array.isArray(result.schemas) ? result.schemas : []) .map((item) => String(item || "").trim()) .filter((item) => item !== "") .map((item) => [item.toLowerCase(), item] as const), ).values(), ); setTargetSchemas(normalizedSchemas); setTargetSchema( resolvePreferredTargetSchema(targetDialect, normalizedSchemas), ); } catch (e: any) { if (cancelled) return; setTargetSchemas([]); setTargetSchema(""); message.error( t( "data_sync.message.fetch_target_schemas_failed_detail", { detail: e?.message || String(e) }, i18nLanguage, ), ); } finally { if (!cancelled) { setTargetSchemaLoading(false); } } })(); return () => { cancelled = true; }; }, [targetConn, targetDb, targetDialect, targetSupportsSchemaSelection]); const modalPanelStyle = useMemo( () => ({ background: darkMode ? "linear-gradient(180deg, rgba(16,22,34,0.96) 0%, rgba(10,14,24,0.98) 100%)" : "linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)", border: darkMode ? "1px solid rgba(255,255,255,0.08)" : "1px solid rgba(16,24,40,0.08)", boxShadow: darkMode ? "0 24px 56px rgba(0,0,0,0.36)" : "0 18px 44px rgba(15,23,42,0.14)", backdropFilter: resolveTextInputSafeBackdropFilter( darkMode ? "blur(18px)" : "none", disableLocalBackdropFilter, ), }), [darkMode, disableLocalBackdropFilter], ); const shellCardStyle = useMemo( () => ({ borderRadius: 18, border: darkMode ? "1px solid rgba(255,255,255,0.08)" : "1px solid rgba(15,23,42,0.08)", background: darkMode ? "rgba(255,255,255,0.03)" : `rgba(255,255,255,${Math.max(effectiveOpacity, 0.88)})`, boxShadow: darkMode ? "0 12px 32px rgba(0,0,0,0.22)" : "0 10px 24px rgba(15,23,42,0.08)", overflow: "hidden", }), [darkMode, effectiveOpacity], ); const heroPanelStyle = useMemo( () => ({ padding: 18, borderRadius: 18, border: darkMode ? "1px solid rgba(255,214,102,0.12)" : "1px solid rgba(24,144,255,0.12)", background: darkMode ? "linear-gradient(135deg, rgba(255,214,102,0.10) 0%, rgba(255,255,255,0.03) 100%)" : "linear-gradient(135deg, rgba(24,144,255,0.10) 0%, rgba(255,255,255,0.95) 100%)", marginBottom: 18, }), [darkMode], ); const badgeStyle = useMemo( () => ({ display: "inline-flex", alignItems: "center", gap: 6, padding: "6px 10px", borderRadius: 999, border: darkMode ? "1px solid rgba(255,255,255,0.10)" : "1px solid rgba(15,23,42,0.08)", background: darkMode ? "rgba(255,255,255,0.04)" : "rgba(255,255,255,0.86)", color: darkMode ? "rgba(255,255,255,0.88)" : "#334155", fontSize: 12, fontWeight: 600, }), [darkMode], ); const quietPanelStyle = useMemo( () => ({ padding: 14, borderRadius: 16, border: darkMode ? "1px solid rgba(255,255,255,0.08)" : "1px solid rgba(15,23,42,0.08)", background: darkMode ? "rgba(255,255,255,0.025)" : "rgba(248,250,252,0.92)", }), [darkMode], ); const modalWorkspaceStyle = useMemo( () => ({ display: "flex", flexDirection: "column", height: "100%", minHeight: 0, }), [], ); const modalScrollableContentStyle = useMemo( () => ({ flex: 1, minHeight: 0, overflowY: "auto", overflowX: "hidden", paddingRight: 4, overscrollBehavior: "contain", }), [], ); const modalFooterBarStyle = useMemo( () => ({ marginTop: 18, display: "flex", justifyContent: "flex-end", gap: 8, paddingTop: 12, borderTop: darkMode ? "1px solid rgba(255,255,255,0.06)" : "1px solid rgba(15,23,42,0.06)", flex: "0 0 auto", }), [darkMode], ); const renderModalTitle = (title: string, description: string) => (
{isMigrationWorkflow ? : }
{title}
{description}
); const handleReturnToPrevious = () => { if (syncing) { message.warning(tr("data_sync.message.close_blocked_running")); return; } onBack?.(); }; const dataSyncContent = (
{!embedded && (
{isMigrationWorkflow ? tr("data_sync.title.migration") : isCompareEntry ? entryPresentation.heroTitle : tr("data_sync.title.sync")}
{isMigrationWorkflow ? tr("data_sync.title.migration_description") : isCompareEntry ? entryPresentation.heroDescription : tr("data_sync.title.sync_description")}
{isMigrationWorkflow ? : }{" "} {isMigrationWorkflow ? tr("data_sync.badge.migration_mode") : isCompareEntry ? entryPresentation.badgeText : tr("data_sync.badge.sync_mode")} {" "} {sourceConnId ? tr("data_sync.badge.source_selected") : tr("data_sync.badge.source_pending")} {" "} {tr("data_sync.badge.table_count", { count: selectedTables.length || 0, })}
)}
{/* STEP 1: CONFIG */} {currentStep === 0 && (
{targetSupportsSchemaSelection && ( )}
{isCompareEntry ? tr("data_sync.compare_entry.workflow_help") : tr("data_sync.help.workflow_type")}
{!isCompareEntry && ( )} {!isSchemaCompareEntry && ( )} {isSourceQueryMode && ( )} {!isCompareEntry && ( )} {!isCompareEntry && ( )} {!isCompareEntry && ( )} {isRedisMongoKeyspaceMigration && ( setMongoCollectionName(e.target.value)} placeholder={ defaultMongoCollectionName || tr("data_sync.placeholder.mongo_collection_name") } allowClear maxLength={128} /> )} {(!isCompareEntry || isSchemaCompareEntry) && ( setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode} > {isSchemaCompareEntry ? tr("data_sync.compare_entry.option.auto_add_columns") : tr("data_sync.option.auto_add_columns")} )} {!isCompareEntry && ( setCreateIndexes(e.target.checked)} disabled={ !isMigrationWorkflow || targetTableStrategy === "existing_only" || isSourceQueryMode } > {tr("data_sync.option.create_indexes")} )} {isMigrationWorkflow && targetTableStrategy !== "existing_only" && ( )} {!isCompareEntry && !isMigrationWorkflow && ( )} {syncContent !== "schema" && syncMode === "full_overwrite" && ( )}
)} {/* STEP 2: TABLES */} {currentStep === 1 && (
{!isSourceQueryMode && ( <>
{isCompareEntry ? entryPresentation.tableSelectLabel : tr("data_sync.help.select_tables")} setShowSameTables(e.target.checked)} > {tr("data_sync.option.show_same_tables")}
({ key: t, title: t }))} titles={[ tr("data_sync.transfer.source_tables"), tr("data_sync.transfer.selected_tables"), ]} targetKeys={selectedTables} onChange={(keys) => setSelectedTables(keys as string[])} render={(item) => item.title} listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: "hidden", }} locale={{ itemUnit: tr("data_sync.transfer.item_unit"), itemsUnit: tr("data_sync.transfer.items_unit"), searchPlaceholder: tr( "data_sync.transfer.search_placeholder", ), notFoundContent: tr("data_sync.transfer.empty"), }} /> )} {isSourceQueryMode && (