diff --git a/frontend/src/components/DataSyncWorkbench.test.tsx b/frontend/src/components/DataSyncWorkbench.test.tsx index 7f81c138..3710f87e 100644 --- a/frontend/src/components/DataSyncWorkbench.test.tsx +++ b/frontend/src/components/DataSyncWorkbench.test.tsx @@ -13,19 +13,21 @@ vi.mock('../store', () => ({ selector({ closeTab }), })); -vi.mock('./DataSyncModal', () => ({ - default: ({ embedded, entryMode, taskKey, onClose }: { - embedded?: boolean; - entryMode?: string; - taskKey?: string; +vi.mock('./data-sync', () => ({ + createDataSyncTaskDraft: (input: Record) => input, + createWailsDataSyncWorkbenchGateway: () => ({ kind: 'test-gateway' }), + DataSyncWorkbenchShell: ({ initialTasks, locale, onClose }: { + initialTasks: Array>; + locale?: string; onClose: () => void; }) => ( + ) : null} + + ); +}; + +export const DataSyncEndpointSelector: React.FC<{ + role: 'source' | 'target'; + title: string; + endpoint: DataSyncEndpointRef; + connections: DataSyncMetadataResult; + databases: DataSyncMetadataResult; + t: DataSyncWorkbenchTranslate; + onConnectionChange: (connection: DataSyncSavedConnectionView | null) => void; + onDatabaseChange: (database: string) => void; + onSchemaChange: (schema: string) => void; +}> = ({ + role, + title, + endpoint, + connections, + databases, + t, + onConnectionChange, + onDatabaseChange, + onSchemaChange, +}) => { + const selectableConnections = connections.items.filter((connection) => + role === 'source' ? connection.readable : connection.writable, + ); + const currentConnection = connections.items.find( + (connection) => connection.id === endpoint.connectionId, + ); + const currentConnectionSelectable = selectableConnections.some( + (connection) => connection.id === endpoint.connectionId, + ); + const currentDatabaseKnown = databases.items.some( + (database) => database.name === endpoint.database, + ); + + return ( +
+ {title} +
+ + + + +
+ + + {connections.status === 'ready' && selectableConnections.length === 0 ? ( +

+ {t('metadata.no_eligible_connections')} +

+ ) : null} + {databases.status === 'ready' && endpoint.connectionId && databases.items.length === 0 ? ( +

+ {t('metadata.no_databases')} +

+ ) : null} +
+ ); +}; diff --git a/frontend/src/components/data-sync/DataSyncFieldMappingEditor.tsx b/frontend/src/components/data-sync/DataSyncFieldMappingEditor.tsx new file mode 100644 index 00000000..26e07312 --- /dev/null +++ b/frontend/src/components/data-sync/DataSyncFieldMappingEditor.tsx @@ -0,0 +1,304 @@ +import React from 'react'; + +import { + autoMatchDataSyncFields, + type DataSyncFieldMapping, + type DataSyncFieldMetadata, + type DataSyncTableMapping, + type DataSyncEndpointRef, +} from './model'; +import type { DataSyncWorkbenchTranslate } from './text'; +import type { DataSyncWorkbenchGateway } from './gateway'; +import { + useDataSyncFields, + type DataSyncMetadataResult, +} from './useDataSyncMetadata'; + +const SUPPORTED_TRANSFORMS = [ + '', + 'trim', + 'lower', + 'upper', + 'string', + 'int64', + 'bool', + 'date', + 'timestamp', + 'json', +] as const; + +const nextFieldMappingId = (mapping: DataSyncTableMapping): string => { + let sequence = mapping.fields.length + 1; + while (mapping.fields.some((field) => field.id === `${mapping.id}:field:${sequence}`)) { + sequence += 1; + } + return `${mapping.id}:field:${sequence}`; +}; + +const metadataFor = ( + items: DataSyncFieldMetadata[], + name: string, +): DataSyncFieldMetadata | undefined => + items.find((field) => field.name.toLowerCase() === name.toLowerCase()); + +const FieldMetadataState: React.FC<{ + side: 'source' | 'target'; + state: DataSyncMetadataResult; + t: DataSyncWorkbenchTranslate; +}> = ({ side, state, t }) => ( + + {t(`mapping.${side}`)}:{' '} + {state.status === 'loading' + ? t('metadata.loading_fields') + : state.status === 'error' + ? t('metadata.load_failed') + : state.status === 'idle' + ? t('metadata.object_required') + : t('metadata.fields_count', { count: state.items.length })} + {state.status === 'error' ? ( + + ) : null} + +); + +export const DataSyncFieldMappingEditor: React.FC<{ + gateway: DataSyncWorkbenchGateway; + source: DataSyncEndpointRef; + target: DataSyncEndpointRef; + mapping: DataSyncTableMapping; + t: DataSyncWorkbenchTranslate; + onChange: (mapping: DataSyncTableMapping) => void; + onClose: () => void; +}> = ({ gateway, source, target, mapping, t, onChange, onClose }) => { + const sourceFields = useDataSyncFields(gateway, source, mapping.sourceObject); + const targetFields = useDataSyncFields(gateway, target, mapping.targetObject); + + const patchField = (field: DataSyncFieldMapping) => { + onChange({ + ...mapping, + fields: mapping.fields.map((item) => (item.id === field.id ? field : item)), + }); + }; + + const addField = () => { + const usedSource = new Set(mapping.fields.map((field) => field.sourceField)); + const usedTarget = new Set(mapping.fields.map((field) => field.targetField)); + const sourceField = sourceFields.items.find((field) => !usedSource.has(field.name)); + const matchingTarget = sourceField + ? metadataFor(targetFields.items, sourceField.name) + : undefined; + const targetField = + matchingTarget || targetFields.items.find((field) => !usedTarget.has(field.name)); + onChange({ + ...mapping, + fields: [ + ...mapping.fields, + { + id: nextFieldMappingId(mapping), + sourceField: sourceField?.name || '', + targetField: targetField?.name || '', + sourceType: sourceField?.type || '', + targetType: targetField?.type || '', + transform: '', + nullable: targetField?.nullable ?? true, + }, + ], + }); + }; + + return ( +
+
+
+

{t('field_mapping.title')}

+

+ {mapping.sourceObject} → {mapping.targetObject} +

+
+ +
+
+ + + + + +
+ {mapping.fields.length === 0 ? ( +
+ {t('field_mapping.empty_title')} + {t('field_mapping.empty_desc')} +
+ ) : ( +
+ + + + + + + + + + + + {mapping.fields.map((field) => ( + + + + + + + + ))} + +
{t('field_mapping.source')}{t('field_mapping.target')}{t('field_mapping.transform')}{t('field_mapping.type')} + + {t('field_mapping.remove')} + +
+ + + + + + + patchField({ + ...field, + transformArgument: event.target.value, + }) + } + /> + + + {field.sourceType || '—'} → {field.targetType || '—'} + + + +
+
+ )} +
+ ); +}; diff --git a/frontend/src/components/data-sync/DataSyncMappingTable.tsx b/frontend/src/components/data-sync/DataSyncMappingTable.tsx new file mode 100644 index 00000000..0f53835b --- /dev/null +++ b/frontend/src/components/data-sync/DataSyncMappingTable.tsx @@ -0,0 +1,486 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +import { DataSyncObjectPicker } from './DataSyncObjectPicker'; +import type { + DataSyncObjectMetadata, + DataSyncTableMapping, + DataSyncTaskKind, +} from './model'; +import type { DataSyncWorkbenchTranslate } from './text'; +import type { DataSyncMetadataResult } from './useDataSyncMetadata'; + +const normalizeName = (value: string): string => value.trim().toLowerCase(); + +type MappingTargetStatus = 'exists' | 'create' | 'missing'; + +const mappingReady = ( + mapping: DataSyncTableMapping, + taskKind: DataSyncTaskKind, + targetState: MappingTargetStatus, +): boolean => + Boolean( + (taskKind === 'querySink' || mapping.sourceObject.trim()) && + mapping.targetObject.trim() && + (mapping.targetMode !== 'existing_only' || targetState === 'exists') && + (!['reconcile', 'cdc'].includes(taskKind) || mapping.keyColumns.length > 0) && + (taskKind !== 'cdc' || mapping.fields.length > 0), + ); + +const ObjectMetadataStatus: React.FC<{ + side: 'source' | 'target'; + state: DataSyncMetadataResult; + t: DataSyncWorkbenchTranslate; +}> = ({ side, state, t }) => ( +
+ {t(`mapping.${side}`)} + + {state.status === 'loading' + ? t('metadata.loading_objects') + : state.status === 'error' + ? t('metadata.load_failed') + : state.status === 'idle' + ? t('metadata.endpoint_required') + : t('metadata.objects_count', { count: state.items.length })} + + {state.status === 'error' ? ( + + ) : null} +
+); + +const DataSyncObjectCombobox: React.FC<{ + id: string; + side: 'source' | 'target'; + value: string; + options: DataSyncObjectMetadata[]; + disabled: boolean; + allowCustom: boolean; + t: DataSyncWorkbenchTranslate; + onChange: (value: string) => void; +}> = ({ id, side, value, options, disabled, allowCustom, t, onChange }) => { + const [open, setOpen] = useState(false); + const [showAll, setShowAll] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const listId = `gn-data-sync-object-list-${id.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + const filtered = useMemo(() => { + const needle = showAll ? '' : normalizeName(value); + return options + .filter((object) => side === 'source' || object.kind !== 'view') + .filter((object) => !needle || normalizeName(object.name).includes(needle)) + .slice(0, 100); + }, [options, showAll, side, value]); + const exactMatch = options.some( + (object) => normalizeName(object.name) === normalizeName(value), + ); + + useEffect(() => { + setActiveIndex(-1); + }, [filtered.length, open, showAll, value]); + + return ( +
+ = 0 ? `${listId}-option-${activeIndex}` : undefined + } + value={value} + placeholder={t(`mapping.${side}_placeholder`)} + disabled={disabled} + autoComplete="off" + onFocus={() => { + setOpen(true); + setShowAll(false); + }} + onBlur={() => globalThis.setTimeout(() => setOpen(false), 0)} + onChange={(event) => { + setShowAll(false); + setOpen(true); + onChange(event.target.value); + }} + onKeyDown={(event) => { + if (event.key === 'Escape') setOpen(false); + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + setOpen(true); + setActiveIndex((current) => { + if (filtered.length === 0) return -1; + const direction = event.key === 'ArrowDown' ? 1 : -1; + if (current < 0) return direction > 0 ? 0 : filtered.length - 1; + return (current + direction + filtered.length) % filtered.length; + }); + } + if (event.key === 'Enter' && activeIndex >= 0 && filtered[activeIndex]) { + event.preventDefault(); + onChange(filtered[activeIndex].name); + setOpen(false); + } + }} + /> + + {open ? ( +
+ {filtered.map((object, optionIndex) => ( + + ))} + {filtered.length === 0 ? ( + allowCustom && value.trim() ? ( +
+ {t('mapping.will_create_named', { name: value.trim() })} +
+ ) : ( +
+ {t('mapping.no_matching_objects')} +
+ ) + ) : null} + {allowCustom && value.trim() && !exactMatch && filtered.length > 0 ? ( +
+ {t('mapping.will_create_named', { name: value.trim() })} +
+ ) : null} +
+ ) : null} +
+ ); +}; + +const targetStatus = ( + mapping: DataSyncTableMapping, + targetObjects: DataSyncObjectMetadata[], +): MappingTargetStatus => { + const exists = targetObjects.some( + (object) => + object.kind !== 'view' && + normalizeName(object.name) === normalizeName(mapping.targetObject), + ); + if (exists) return 'exists'; + if (mapping.targetObject.trim() && mapping.targetMode === 'create_or_reuse') { + return 'create'; + } + return 'missing'; +}; + +export const DataSyncMappingTable: React.FC<{ + mappings: DataSyncTableMapping[]; + taskKind: DataSyncTaskKind; + sourceObjects: DataSyncMetadataResult; + targetObjects: DataSyncMetadataResult; + disabled?: boolean; + t: DataSyncWorkbenchTranslate; + onAdd: () => void; + onAddMany: (sourceNames: string[]) => void; + onChange: (mapping: DataSyncTableMapping) => void; + onRemove: (mappingId: string) => void; + onInspectFields?: (mappingId: string) => void; +}> = ({ + mappings, + taskKind, + sourceObjects, + targetObjects, + disabled = false, + t, + onAdd, + onAddMany, + onChange, + onRemove, + onInspectFields, +}) => { + const [pickerOpen, setPickerOpen] = useState(false); + const [expandedMappingIds, setExpandedMappingIds] = useState>( + new Set(), + ); + const querySink = taskKind === 'querySink'; + const canPickSources = + !querySink && + sourceObjects.status === 'ready' && + targetObjects.status === 'ready' && + sourceObjects.items.length > 0; + + return ( +
+
+
+

{t('mapping.title')}

+

{t(querySink ? 'mapping.query_help' : 'mapping.help')}

+
+ +
+ +
+ + +
+ + mapping.sourceObject)} + disabled={disabled} + t={t} + onClose={() => setPickerOpen(false)} + onConfirm={onAddMany} + /> + + {mappings.length === 0 ? ( +
+ {t('mapping.empty_title')} +

{t('mapping.empty_desc')}

+ {!querySink ? ( + + ) : null} +
+ ) : ( +
+ {mappings.map((mapping, index) => { + const targetState = targetStatus(mapping, targetObjects.items); + const ready = mappingReady(mapping, taskKind, targetState); + const detailsOpen = + !ready || expandedMappingIds.has(mapping.id); + return ( +
+
+ +
+ {t('mapping.source')} + {querySink ? ( + + {t('mapping.query_result_source')} + + ) : ( + + onChange({ ...mapping, sourceObject, keyColumns: [], fields: [] }) + } + /> + )} +
+ +
+ {t('mapping.target')} + + onChange({ ...mapping, targetObject, fields: [] }) + } + /> +
+
+ + {t(`mapping.target_state.${targetState}`)} + + + {t(ready ? 'mapping.ready' : 'mapping.needs_attention')} + +
+
+ + +
+
+ + {detailsOpen ? ( +
+ + +
+ {t('mapping.fields')} + + + {t( + taskKind === 'cdc' + ? 'mapping.fields_cdc_help' + : 'mapping.fields_automatic_help', + )} + +
+
+ ) : null} +
+ ); + })} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/data-sync/DataSyncMetadataSelectors.test.tsx b/frontend/src/components/data-sync/DataSyncMetadataSelectors.test.tsx new file mode 100644 index 00000000..044593c7 --- /dev/null +++ b/frontend/src/components/data-sync/DataSyncMetadataSelectors.test.tsx @@ -0,0 +1,382 @@ +import React from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it } from 'vitest'; + +import { + createStaticDataSyncWorkbenchGateway, + dataSyncEndpointMetadataKey, + dataSyncObjectMetadataKey, + type DataSyncWorkbenchGateway, +} from './gateway'; +import { + createDataSyncTableMapping, + createDataSyncTaskDraft, + reviseDataSyncTask, + type DataSyncDatabaseMetadata, +} from './model'; +import { DataSyncWorkbenchShell } from './DataSyncWorkbenchShell'; + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +}; + +const flush = async () => { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +}; + +const stageButton = (renderer: TestRenderer.ReactTestRenderer, label: string) => + renderer.root + .findAllByType('button') + .find((button) => button.children.includes(label))!; + +describe('data sync metadata selectors', () => { + it('keeps an empty controlled value without exposing prompt rows in the menu', async () => { + const task = createDataSyncTaskDraft({ + id: 'empty-endpoint-options', + kind: 'reconcile', + }); + const gateway = createStaticDataSyncWorkbenchGateway({ + tasks: [task], + savedConnections: [ + { id: 'source', name: 'Source', type: 'mysql', readable: true, writable: true }, + ], + }); + const renderer = TestRenderer.create( + , + ); + await flush(); + + const sourceEndpoint = renderer.root.findByProps({ + 'data-endpoint-role': 'source', + }); + const connection = sourceEndpoint.findByProps({ + 'data-endpoint-control': 'connection', + }); + const database = sourceEndpoint.findByProps({ + 'data-endpoint-control': 'database', + }); + const connectionPrompt = connection + .findAllByType('option') + .find((option) => option.props.value === '')!; + const databasePrompt = database + .findAllByType('option') + .find((option) => option.props.value === '')!; + + expect(connection.props.value).toBe(''); + expect(database.props.value).toBe(''); + expect(connectionPrompt.props).toEqual( + expect.objectContaining({ disabled: true, hidden: true }), + ); + expect(databasePrompt.props).toEqual( + expect.objectContaining({ disabled: true, hidden: true }), + ); + }); + + it('does not expose empty prompt rows after a connection and database are selected', async () => { + const draft = createDataSyncTaskDraft({ + id: 'selected-endpoint-options', + kind: 'reconcile', + }); + const task = reviseDataSyncTask(draft, { + source: { + connectionId: 'source', + connectionName: 'Source', + type: 'mysql', + database: 'sales', + schema: '', + }, + target: { + connectionId: 'target', + connectionName: 'Target', + type: 'postgresql', + database: 'warehouse', + schema: 'public', + }, + }); + const gateway = createStaticDataSyncWorkbenchGateway({ + tasks: [task], + savedConnections: [ + { id: 'source', name: 'Source', type: 'mysql', readable: true, writable: true }, + { id: 'target', name: 'Target', type: 'postgresql', readable: true, writable: true }, + ], + databasesByConnection: { + source: [{ name: 'sales' }], + target: [{ name: 'warehouse' }], + }, + }); + const renderer = TestRenderer.create( + , + ); + await flush(); + + const sourceEndpoint = renderer.root.findByProps({ + 'data-endpoint-role': 'source', + }); + const connectionValues = sourceEndpoint + .findByProps({ 'data-endpoint-control': 'connection' }) + .findAllByType('option') + .map((option) => option.props.value); + const databaseValues = sourceEndpoint + .findByProps({ 'data-endpoint-control': 'database' }) + .findAllByType('option') + .map((option) => option.props.value); + + expect(connectionValues).not.toContain(''); + expect(databaseValues).not.toContain(''); + }); + + it('ignores stale database responses and clears source-side descendants', async () => { + const draft = createDataSyncTaskDraft({ + id: 'stale-task', + kind: 'reconcile', + name: 'Stale response test', + }); + const task = reviseDataSyncTask(draft, { + source: { + connectionId: 'source-a', + connectionName: 'Source A', + type: 'mysql', + database: 'a-db', + schema: 'sales', + }, + target: { + connectionId: 'target', + connectionName: 'Target', + type: 'postgresql', + database: 'target-db', + schema: 'public', + }, + mappings: [ + { + ...createDataSyncTableMapping('stale-map', 'orders', 'fact_orders'), + keyColumns: ['id'], + fields: [ + { + id: 'field-id', + sourceField: 'id', + targetField: 'id', + sourceType: 'bigint', + targetType: 'int8', + transform: 'CAST({value} AS BIGINT)', + nullable: false, + }, + ], + }, + ], + }); + const sourceA = deferred(); + const sourceB = deferred(); + const baseGateway = createStaticDataSyncWorkbenchGateway({ + tasks: [task], + savedConnections: [ + { id: 'source-a', name: 'Source A', type: 'mysql', readable: true, writable: true }, + { id: 'source-b', name: 'Source B', type: 'oracle', readable: true, writable: true }, + { id: 'target', name: 'Target', type: 'postgresql', readable: true, writable: true }, + ], + }); + const gateway: DataSyncWorkbenchGateway = { + ...baseGateway, + listDatabases: (connectionId) => { + if (connectionId === 'source-a') return sourceA.promise; + if (connectionId === 'source-b') return sourceB.promise; + return Promise.resolve([{ name: 'target-db' }]); + }, + }; + const renderer = TestRenderer.create( + , + ); + await flush(); + + const sourceEndpoint = renderer.root.findByProps({ 'data-endpoint-role': 'source' }); + const connectionSelect = sourceEndpoint.findByProps({ + 'data-endpoint-control': 'connection', + }); + act(() => { + connectionSelect.props.onChange({ target: { value: 'source-b' } }); + }); + await flush(); + + await act(async () => { + sourceB.resolve([{ name: 'b-db' }]); + await sourceB.promise; + }); + const databaseSelect = renderer.root + .findByProps({ 'data-endpoint-role': 'source' }) + .findByProps({ 'data-endpoint-control': 'database' }); + expect( + databaseSelect.findAllByType('option').map((option) => option.props.value), + ).toContain('b-db'); + + await act(async () => { + sourceA.resolve([{ name: 'late-a-db' }]); + await sourceA.promise; + }); + expect( + renderer.root + .findByProps({ 'data-endpoint-role': 'source' }) + .findByProps({ 'data-endpoint-control': 'database' }) + .findAllByType('option') + .map((option) => option.props.value), + ).toEqual(['', 'b-db']); + + act(() => stageButton(renderer, 'Choose data').props.onClick()); + const sourceObjectInput = renderer.root.findByProps({ 'data-object-side': 'source' }); + expect(sourceObjectInput.props.value).toBe(''); + expect(renderer.root.findByProps({ 'data-dirty': 'true' })).toBeTruthy(); + }); + + it('auto-matches gateway fields and keeps transforms editable', async () => { + const draft = createDataSyncTaskDraft({ + id: 'field-task', + kind: 'migration', + name: 'Field mapping test', + }); + const source = { + connectionId: 'source', + connectionName: 'Source', + type: 'mysql', + database: 'sales', + schema: '', + }; + const target = { + connectionId: 'target', + connectionName: 'Target', + type: 'postgresql', + database: 'warehouse', + schema: 'public', + }; + const task = reviseDataSyncTask(draft, { + source, + target, + mappings: [createDataSyncTableMapping('field-map', 'orders', 'fact_orders')], + }); + const gateway = createStaticDataSyncWorkbenchGateway({ + tasks: [task], + savedConnections: [ + { id: 'source', name: 'Source', type: 'mysql', readable: true, writable: true }, + { id: 'target', name: 'Target', type: 'postgresql', readable: true, writable: true }, + ], + databasesByConnection: { + source: [{ name: 'sales' }], + target: [{ name: 'warehouse' }], + }, + objectsByEndpoint: { + [dataSyncEndpointMetadataKey(source)]: [{ name: 'orders', kind: 'table' }], + [dataSyncEndpointMetadataKey(target)]: [{ name: 'fact_orders', kind: 'table' }], + }, + fieldsByObject: { + [dataSyncObjectMetadataKey(source, 'orders')]: [ + { name: 'id', type: 'bigint', nullable: false, ordinal: 1, key: true }, + { name: 'amount', type: 'decimal', nullable: false, ordinal: 2, key: false }, + ], + [dataSyncObjectMetadataKey(target, 'fact_orders')]: [ + { name: 'ID', type: 'int8', nullable: false, ordinal: 1, key: true }, + { name: 'amount', type: 'numeric', nullable: true, ordinal: 2, key: false }, + ], + }, + }); + const renderer = TestRenderer.create( + , + ); + await flush(); + act(() => stageButton(renderer, 'Choose data').props.onClick()); + await flush(); + + act(() => stageButton(renderer, 'Edit exception').props.onClick()); + act(() => stageButton(renderer, 'Automatic same-name fields').props.onClick()); + await flush(); + act(() => stageButton(renderer, 'Match same names').props.onClick()); + + expect(renderer.root.findAllByProps({ 'data-field-control': 'transform' })).toHaveLength(2); + const transform = renderer.root.findAllByProps({ + 'data-field-control': 'transform', + })[1]; + act(() => { + transform.props.onChange({ target: { value: 'upper' } }); + }); + + expect( + renderer.root.findAllByProps({ 'data-field-control': 'transform' })[1].props.value, + ).toBe('upper'); + expect(stageButton(renderer, '2 fields')).toBeTruthy(); + }); + + it('surfaces metadata errors and retries without discarding the draft endpoint', async () => { + const draft = createDataSyncTaskDraft({ + id: 'retry-task', + kind: 'migration', + name: 'Retry metadata', + }); + const task = reviseDataSyncTask(draft, { + source: { + connectionId: 'broken-source', + connectionName: 'Broken source', + type: 'mysql', + database: 'remembered-db', + schema: '', + }, + }); + let attempts = 0; + const baseGateway = createStaticDataSyncWorkbenchGateway({ + tasks: [task], + savedConnections: [ + { + id: 'broken-source', + name: 'Broken source', + type: 'mysql', + readable: true, + writable: true, + }, + ], + }); + const gateway: DataSyncWorkbenchGateway = { + ...baseGateway, + listDatabases: async (connectionId) => { + if (connectionId !== 'broken-source') return []; + attempts += 1; + if (attempts === 1) throw new Error('temporary metadata outage'); + return [{ name: 'recovered-db' }]; + }, + }; + const renderer = TestRenderer.create( + , + ); + await flush(); + + const errorState = renderer.root.findByProps({ + 'data-metadata-scope': 'source-databases', + 'data-status': 'error', + }); + expect( + renderer.root + .findByProps({ 'data-endpoint-role': 'source' }) + .findByProps({ 'data-endpoint-control': 'database' }).props.value, + ).toBe('remembered-db'); + + act(() => { + errorState + .findAllByType('button') + .find((button) => button.children.includes('Retry'))! + .props.onClick(); + }); + await flush(); + + expect( + renderer.root + .findByProps({ 'data-endpoint-role': 'source' }) + .findByProps({ 'data-endpoint-control': 'database' }) + .findAllByType('option') + .map((option) => option.props.value), + ).toContain('recovered-db'); + expect(attempts).toBe(2); + }); +}); diff --git a/frontend/src/components/data-sync/DataSyncMultiObjectSelection.test.tsx b/frontend/src/components/data-sync/DataSyncMultiObjectSelection.test.tsx new file mode 100644 index 00000000..dcfc6afb --- /dev/null +++ b/frontend/src/components/data-sync/DataSyncMultiObjectSelection.test.tsx @@ -0,0 +1,328 @@ +import React from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it } from 'vitest'; + +import { + createStaticDataSyncWorkbenchGateway, + dataSyncEndpointMetadataKey, + dataSyncObjectMetadataKey, + type DataSyncWorkbenchGateway, +} from './gateway'; +import { + createDataSyncTableMapping, + createDataSyncTaskDraft, + reviseDataSyncTask, + type DataSyncFieldMetadata, + type DataSyncObjectMetadata, +} from './model'; +import { DataSyncWorkbenchShell } from './DataSyncWorkbenchShell'; + +const flush = async () => { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +}; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +}; + +const buttonWithText = ( + renderer: TestRenderer.ReactTestRenderer, + text: string, +) => + renderer.root + .findAllByType('button') + .find((button) => button.children.includes(text))!; + +describe('data sync multi-object selection', () => { + it('adds all current source tables, matches targets, and detects keys in one action', async () => { + const draft = createDataSyncTaskDraft({ + id: 'multi-table-task', + kind: 'reconcile', + name: 'Migrate selected tables', + }); + const source = { + connectionId: 'source', + connectionName: 'Source', + type: 'mysql', + database: 'sales', + schema: '', + }; + const target = { + connectionId: 'target', + connectionName: 'Target', + type: 'postgresql', + database: 'warehouse', + schema: '', + }; + const task = reviseDataSyncTask(draft, { source, target }); + const names = ['admin_users', 'messages', 'push_records']; + const fieldsByObject = Object.fromEntries( + names.map((name) => [ + dataSyncObjectMetadataKey(source, name), + [ + { name: 'id', type: 'bigint', nullable: false, ordinal: 1, key: true }, + { name: 'payload', type: 'text', nullable: true, ordinal: 2, key: false }, + ], + ]), + ); + const gateway = createStaticDataSyncWorkbenchGateway({ + tasks: [task], + savedConnections: [ + { id: 'source', name: 'Source', type: 'mysql', readable: true, writable: true }, + { id: 'target', name: 'Target', type: 'postgresql', readable: true, writable: true }, + ], + objectsByEndpoint: { + [dataSyncEndpointMetadataKey(source)]: names.map((name) => ({ + name, + kind: 'table' as const, + })), + [dataSyncEndpointMetadataKey(target)]: names.map((name) => ({ + name, + kind: 'table' as const, + })), + }, + fieldsByObject, + capabilities: { + [task.id]: { + level: 'full', + canExecute: true, + supportsAutoCreate: true, + supportsAutoAddColumns: true, + requiresExistingTarget: false, + supportsCdc: false, + }, + }, + }); + const renderer = TestRenderer.create( + , + ); + await flush(); + + act(() => buttonWithText(renderer, '选择同步数据').props.onClick()); + await flush(); + act(() => buttonWithText(renderer, '选择源对象').props.onClick()); + const selectAll = renderer.root.findByProps({ + 'data-object-picker-control': 'select-filtered', + }); + act(() => selectAll.props.onChange({ target: { checked: true } })); + act(() => buttonWithText(renderer, '添加 3 个对象').props.onClick()); + await flush(); + await flush(); + + expect(renderer.root.findAllByProps({ 'data-ready': 'true' })).toHaveLength(3); + expect( + renderer.root + .findAllByProps({ 'data-object-side': 'source' }) + .map((input) => input.props.value), + ).toEqual(names); + }); + + it('waits for target metadata before offering safe automatic matching', async () => { + const draft = createDataSyncTaskDraft({ + id: 'wait-for-target-task', + kind: 'reconcile', + }); + const source = { + connectionId: 'source', + connectionName: 'Source', + type: 'mysql', + database: 'sales', + schema: '', + }; + const target = { + connectionId: 'target', + connectionName: 'Target', + type: 'postgresql', + database: 'warehouse', + schema: '', + }; + const task = reviseDataSyncTask(draft, { source, target }); + const targetObjects = deferred(); + const baseGateway = createStaticDataSyncWorkbenchGateway({ + tasks: [task], + objectsByEndpoint: { + [dataSyncEndpointMetadataKey(source)]: [{ name: 'orders', kind: 'table' }], + }, + }); + const gateway: DataSyncWorkbenchGateway = { + ...baseGateway, + listObjects: (endpoint) => + endpoint.connectionId === 'target' + ? targetObjects.promise + : baseGateway.listObjects(endpoint), + }; + const renderer = TestRenderer.create( + , + ); + await flush(); + act(() => buttonWithText(renderer, '选择同步数据').props.onClick()); + await flush(); + + expect(buttonWithText(renderer, '选择源对象').props.disabled).toBe(true); + await act(async () => { + targetObjects.resolve([{ name: 'orders', kind: 'table' }]); + await targetObjects.promise; + }); + expect(buttonWithText(renderer, '选择源对象').props.disabled).toBe(false); + }); + + it('preserves edits made while asynchronous key detection is running', async () => { + const draft = createDataSyncTaskDraft({ + id: 'merge-late-metadata-task', + kind: 'reconcile', + }); + const source = { + connectionId: 'source', + connectionName: 'Source', + type: 'mysql', + database: 'sales', + schema: '', + }; + const target = { + connectionId: 'target', + connectionName: 'Target', + type: 'postgresql', + database: 'warehouse', + schema: '', + }; + const existing = { + ...createDataSyncTableMapping('existing-map', 'customers', 'customers'), + keyColumns: ['id'], + }; + const task = reviseDataSyncTask(draft, { + source, + target, + mappings: [existing], + }); + const orderFields = deferred(); + const names = ['customers', 'orders']; + const baseGateway = createStaticDataSyncWorkbenchGateway({ + tasks: [task], + objectsByEndpoint: { + [dataSyncEndpointMetadataKey(source)]: names.map((name) => ({ + name, + kind: 'table' as const, + })), + [dataSyncEndpointMetadataKey(target)]: names.map((name) => ({ + name, + kind: 'table' as const, + })), + }, + }); + const gateway: DataSyncWorkbenchGateway = { + ...baseGateway, + listFields: (endpoint, objectName) => + endpoint.connectionId === 'source' && objectName === 'orders' + ? orderFields.promise + : baseGateway.listFields(endpoint, objectName), + }; + const renderer = TestRenderer.create( + , + ); + await flush(); + act(() => buttonWithText(renderer, '选择同步数据').props.onClick()); + await flush(); + act(() => buttonWithText(renderer, '选择源对象').props.onClick()); + act(() => + renderer.root + .findByProps({ 'data-object-name': 'orders' }) + .findByType('input') + .props.onChange({ target: { checked: true } }), + ); + act(() => buttonWithText(renderer, '添加 1 个对象').props.onClick()); + + const existingTarget = renderer.root.findAllByProps({ + 'data-object-side': 'target', + })[0]; + act(() => + existingTarget.props.onChange({ target: { value: 'customers_archive' } }), + ); + await act(async () => { + orderFields.resolve([ + { name: 'id', type: 'bigint', nullable: false, ordinal: 1, key: true }, + ]); + await orderFields.promise; + await Promise.resolve(); + }); + + expect( + renderer.root + .findAllByProps({ 'data-object-side': 'target' }) + .map((input) => input.props.value), + ).toEqual(['customers_archive', 'orders']); + expect( + renderer.root.findByProps({ 'data-mapping-id': 'existing-map' }).props[ + 'data-ready' + ], + ).toBe('false'); + }); + + it('does not label an incomplete CDC mapping as configured', async () => { + const source = { + connectionId: 'source', + connectionName: 'Source', + type: 'mongodb', + database: 'sales', + schema: '', + }; + const target = { + connectionId: 'target', + connectionName: 'Target', + type: 'postgresql', + database: 'warehouse', + schema: '', + }; + const draft = createDataSyncTaskDraft({ + id: 'cdc-ready-state-task', + kind: 'cdc', + }); + const mapping = { + ...createDataSyncTableMapping('cdc-map', 'users', 'users'), + keyColumns: ['id'], + fields: [], + }; + const task = reviseDataSyncTask(draft, { + source, + target, + mappings: [mapping], + }); + const gateway = createStaticDataSyncWorkbenchGateway({ + tasks: [task], + objectsByEndpoint: { + [dataSyncEndpointMetadataKey(source)]: [{ name: 'users', kind: 'collection' }], + [dataSyncEndpointMetadataKey(target)]: [{ name: 'users', kind: 'table' }], + }, + capabilities: { + [task.id]: { + level: 'full', + canExecute: true, + supportsAutoCreate: false, + supportsAutoAddColumns: false, + requiresExistingTarget: true, + supportsCdc: true, + }, + }, + }); + const renderer = TestRenderer.create( + , + ); + await flush(); + act(() => buttonWithText(renderer, '选择同步数据').props.onClick()); + await flush(); + + expect(renderer.root.findAllByProps({ 'data-ready': 'true' })).toHaveLength(0); + expect(buttonWithText(renderer, '需要确认同步字段')).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/data-sync/DataSyncObjectPicker.test.tsx b/frontend/src/components/data-sync/DataSyncObjectPicker.test.tsx new file mode 100644 index 00000000..f952edfe --- /dev/null +++ b/frontend/src/components/data-sync/DataSyncObjectPicker.test.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import { DataSyncObjectPicker } from './DataSyncObjectPicker'; +import { createDataSyncWorkbenchTranslate } from './text'; + +const flush = async () => { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +}; + +describe('DataSyncObjectPicker', () => { + it('searches, selects all filtered objects, preserves cross-search choices, and excludes mapped rows', async () => { + const confirm = vi.fn(async () => undefined); + const renderer = TestRenderer.create( + undefined} + onConfirm={confirm} + />, + ); + await flush(); + + const mapped = renderer.root.findByProps({ 'data-object-name': 'messages' }); + expect(mapped.findByType('input').props.disabled).toBe(true); + expect(mapped.findAllByType('small')).toHaveLength(2); + expect( + renderer.root + .findByProps({ 'data-object-name': 'orders' }) + .findAllByType('small'), + ).toHaveLength(1); + + const search = renderer.root.findByProps({ + 'data-object-picker-control': 'search', + }); + act(() => search.props.onChange({ target: { value: 'admin' } })); + const selectFiltered = renderer.root.findByProps({ + 'data-object-picker-control': 'select-filtered', + }); + act(() => selectFiltered.props.onChange({ target: { checked: true } })); + + act(() => search.props.onChange({ target: { value: 'orders' } })); + act(() => + renderer.root + .findByProps({ 'data-object-name': 'orders' }) + .findByType('input').props.onChange({ target: { checked: true } }), + ); + + const addButton = renderer.root + .findAllByType('button') + .find((button) => button.children.includes('添加 2 个对象'))!; + act(() => addButton.props.onClick()); + await flush(); + + expect(confirm).toHaveBeenCalledWith(['admin_users', 'orders']); + }); + + it('excludes views by default and exposes them only after explicit opt-in', async () => { + const renderer = TestRenderer.create( + undefined} + onConfirm={() => undefined} + />, + ); + await flush(); + + expect(renderer.root.findAllByProps({ 'data-object-name': 'orders_view' })).toHaveLength(0); + const includeViews = renderer.root.findByProps({ + 'data-object-picker-control': 'include-views', + }); + act(() => includeViews.props.onChange({ target: { checked: true } })); + expect(renderer.root.findByProps({ 'data-object-name': 'orders_view' })).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/data-sync/DataSyncObjectPicker.tsx b/frontend/src/components/data-sync/DataSyncObjectPicker.tsx new file mode 100644 index 00000000..7fab6ca3 --- /dev/null +++ b/frontend/src/components/data-sync/DataSyncObjectPicker.tsx @@ -0,0 +1,228 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +import type { DataSyncObjectMetadata } from './model'; +import type { DataSyncWorkbenchTranslate } from './text'; + +const normalizeName = (value: string): string => value.trim().toLowerCase(); + +const formatBytes = (value?: number): string => { + if (!Number.isFinite(value) || Number(value) < 0) return ''; + const bytes = Number(value); + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; + return `${(bytes / 1024 ** 3).toFixed(1)} GB`; +}; + +const ObjectFacts: React.FC<{ + object: DataSyncObjectMetadata; + t: DataSyncWorkbenchTranslate; +}> = ({ object, t }) => { + const hasSize = object.dataBytes !== undefined || object.indexBytes !== undefined; + const totalBytes = hasSize + ? (object.dataBytes ?? 0) + (object.indexBytes ?? 0) + : undefined; + const facts = [ + Number.isFinite(object.rowCount) + ? t('mapping.rows_count', { + count: Number(object.rowCount).toLocaleString(), + }) + : '', + formatBytes(totalBytes), + ].filter(Boolean); + return facts.length > 0 ? {facts.join(' · ')} : null; +}; + +export const DataSyncObjectPicker: React.FC<{ + open: boolean; + objects: DataSyncObjectMetadata[]; + mappedSourceNames: string[]; + disabled?: boolean; + t: DataSyncWorkbenchTranslate; + onClose: () => void; + onConfirm: (sourceNames: string[]) => void | Promise; +}> = ({ + open, + objects, + mappedSourceNames, + disabled = false, + t, + onClose, + onConfirm, +}) => { + const [search, setSearch] = useState(''); + const [includeViews, setIncludeViews] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!open) return; + setSearch(''); + setIncludeViews(false); + setSelected(new Set()); + setSubmitting(false); + }, [open]); + + const mapped = useMemo( + () => new Set(mappedSourceNames.map(normalizeName).filter(Boolean)), + [mappedSourceNames], + ); + const filtered = useMemo(() => { + const needle = normalizeName(search); + return objects.filter( + (object) => + (includeViews || object.kind !== 'view') && + (!needle || normalizeName(object.name).includes(needle)), + ); + }, [includeViews, objects, search]); + const eligibleFiltered = filtered.filter( + (object) => !mapped.has(normalizeName(object.name)), + ); + const allFilteredSelected = + eligibleFiltered.length > 0 && + eligibleFiltered.every((object) => selected.has(object.name)); + + if (!open) return null; + + const toggleSelected = (name: string, checked: boolean) => { + setSelected((previous) => { + const next = new Set(previous); + if (checked) next.add(name); + else next.delete(name); + return next; + }); + }; + + return ( +
+
+ + + + {t('mapping.selected_count', { + selected: selected.size, + total: objects.length, + })} + +
+ +
+ + {t('mapping.selection_fixed_scope')} +
+ +
+ {filtered.length === 0 ? ( +

{t('mapping.no_objects')}

+ ) : ( + filtered.map((object) => { + const alreadyMapped = mapped.has(normalizeName(object.name)); + const checked = selected.has(object.name); + return ( + + ); + }) + )} +
+ +
+ + +
+
+ ); +}; diff --git a/frontend/src/components/data-sync/DataSyncOperationalViews.tsx b/frontend/src/components/data-sync/DataSyncOperationalViews.tsx new file mode 100644 index 00000000..d6fd6623 --- /dev/null +++ b/frontend/src/components/data-sync/DataSyncOperationalViews.tsx @@ -0,0 +1,331 @@ +import React from 'react'; + +import type { + DataSyncCdcSourceStatus, + DataSyncCheckpointSummary, + DataSyncErrorRow, + DataSyncRunRecord, + DataSyncScheduleSummary, +} from './model'; +import type { DataSyncWorkbenchTranslate } from './text'; + +const EmptyState: React.FC<{ + title: string; + description: string; +}> = ({ title, description }) => ( +
+ + {title} +

{description}

+
+); + +const formatCdcLag = ( + lagMs: number | null, + t: DataSyncWorkbenchTranslate, +): string => + lagMs === null + ? t('cdc.lag_unknown') + : `${Math.max(0, lagMs).toLocaleString()} ms`; + +export const DataSyncRunHistory: React.FC<{ + runs: DataSyncRunRecord[]; + selectedRunId: string; + errorRows: DataSyncErrorRow[]; + t: DataSyncWorkbenchTranslate; + onSelectRun: (runId: string) => void; + checkpoint: DataSyncCheckpointSummary | null; + busyAction: string; + onRefresh: () => void; + onCancel: (runId: string) => void; + onResume: (runId: string) => void; + onRetry: (runId: string) => void; + onDiscardErrorRow: (errorRowId: string) => void; + errorRowRetryAvailable: boolean; + onRetryErrorRow: (errorRowId: string) => void; + checkpointResetEnabled: boolean; + onResetCheckpoint: () => void; +}> = ({ + runs, + selectedRunId, + errorRows, + checkpoint, + busyAction, + t, + onSelectRun, + onRefresh, + onCancel, + onResume, + onRetry, + onDiscardErrorRow, + errorRowRetryAvailable, + onRetryErrorRow, + checkpointResetEnabled, + onResetCheckpoint, +}) => ( +
+
+

{t('runs.title')}

+
+

{t('runs.subtitle')}

+ +
+
+ {runs.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + + {runs.map((run) => ( + + + + + + + + + + + ))} + +
{t('runs.id')}{t('runs.task')}{t('runs.status')}{t('runs.started_at')}{t('runs.rows_written')}{t('runs.rows_failed')}{t('runs.checkpoint')}{t('runs.actions')}
{run.id}{run.taskName} + + {t(`status.${run.status}`)} + + {run.startedAt || '—'}{run.rowsWritten.toLocaleString()}{run.rowsFailed.toLocaleString()}{run.checkpoint || '—'} + + {run.status === 'queued' || run.status === 'running' ? ( + + ) : null} + {run.resumable ? ( + + ) : null} + {['failed', 'partial', 'canceled', 'cancelled', 'interrupted'].includes( + run.status, + ) ? ( + + ) : null} +
+
+ )} + +
+

{t('errors.title')}

+ {!selectedRunId || errorRows.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {errorRows.map((row) => ( + + + + + + + + ))} + +
{t('errors.source')}{t('errors.reason')}{t('errors.payload')}{t('errors.retryable')}{t('runs.actions')}
{row.sourceObject}{row.reason}{row.payloadPreview}{t(row.retryable ? 'common.yes' : 'common.no')} + + +
+
+ )} +
+
+

{t('checkpoint.title')}

+ {!selectedRunId || !checkpoint ? ( +

{t('checkpoint.empty')}

+ ) : ( +
+
{t('checkpoint.kind')}
{checkpoint.kind || '—'}
+
{t('checkpoint.phase')}
{checkpoint.phase || '—'}
+
{t('checkpoint.updated_at')}
{checkpoint.updatedAt || '—'}
+
{t('checkpoint.cursor')}
{checkpoint.cursorPreview || '—'}
+
+ )} + +

{t('checkpoint.reset_warning')}

+
+
+); + +export const DataSyncScheduleView: React.FC<{ + schedules: DataSyncScheduleSummary[]; + t: DataSyncWorkbenchTranslate; + refreshing: boolean; + onRefresh: () => void; +}> = ({ schedules, t, refreshing, onRefresh }) => ( +
+
+

{t('schedules.title')}

+
+

{t('schedules.subtitle')}

+ +
+
+ {schedules.length === 0 ? ( + + ) : ( +
    + {schedules.map((schedule) => ( +
  • + + {schedule.taskName} + {schedule.expression} + {schedule.timezone} + +
  • + ))} +
+ )} +
+); + +export const DataSyncCdcView: React.FC<{ + sources: DataSyncCdcSourceStatus[]; + t: DataSyncWorkbenchTranslate; + refreshing: boolean; + onRefresh: () => void; +}> = ({ sources, t, refreshing, onRefresh }) => ( +
+
+

{t('cdc.title')}

+
+

{t('cdc.subtitle')}

+ +
+
+ {sources.length === 0 ? ( + + ) : ( +
    + {sources.map((source) => ( +
  • + + {source.connectionName} + {source.adapter || source.type} + {formatCdcLag(source.lagMs, t)} + {source.checkpoint || '—'} + {source.reason || t(`cdc.status.${source.status}`)} +
  • + ))} +
+ )} +
+); diff --git a/frontend/src/components/data-sync/DataSyncPreflightPanel.test.tsx b/frontend/src/components/data-sync/DataSyncPreflightPanel.test.tsx new file mode 100644 index 00000000..e3718416 --- /dev/null +++ b/frontend/src/components/data-sync/DataSyncPreflightPanel.test.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { DataSyncPreflightPanel } from './DataSyncPreflightPanel'; +import { + createDataSyncWorkbenchTranslate, + dataSyncValidationIssueText, +} from './text'; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('DataSyncPreflightPanel production approval', () => { + it('localizes known backend issue codes and preserves unknown diagnostics', () => { + const t = createDataSyncWorkbenchTranslate('zh-CN'); + + expect( + dataSyncValidationIssueText( + { + code: 'route_unsupported', + message: 'migration route mysql -> qdrant is unsupported', + }, + t, + ), + ).toBe('当前源端与目标端组合不支持执行此同步任务。'); + expect( + dataSyncValidationIssueText( + { code: 'driver_specific_failure', message: 'driver unavailable' }, + t, + ), + ).toBe('driver unavailable'); + }); + + it('prefers the localized validation text over a backend English message', () => { + const renderer = TestRenderer.create( + undefined} + />, + ); + + const rendered = JSON.stringify(renderer.toJSON()); + expect(rendered).toContain( + '任务定义无效,请检查必填项、对象映射和执行策略。', + ); + expect(renderer.root.findByType('p').props.title).toContain( + 'requires a targetTable', + ); + }); + + it('uses the backend notBefore window and never creates a frontend-only approval', () => { + vi.useFakeTimers(); + const now = Date.parse('2030-08-08T00:00:00.000Z'); + vi.setSystemTime(now); + const onBegin = vi.fn(); + const onApprove = vi.fn(); + const snapshot = { + taskId: 'task-1', + taskRevision: 4, + status: 'passed' as const, + issues: [], + definitionHash: 'hash-1', + approvalRequired: true, + approvalSatisfied: false, + checkedAt: new Date(now).toISOString(), + }; + const t = createDataSyncWorkbenchTranslate('en-US'); + const renderer = TestRenderer.create( + undefined} + onBeginApproval={onBegin} + onApprove={onApprove} + />, + ); + + act(() => { + renderer.root + .findAllByType('button') + .find((button) => + button.children.includes('Begin server 10-second confirmation'), + )! + .props.onClick(); + }); + expect(onBegin).toHaveBeenCalledTimes(1); + expect(onApprove).not.toHaveBeenCalled(); + + act(() => { + renderer.update( + undefined} + approvalChallenge={{ + definitionHash: 'hash-1', + notBefore: new Date(now + 10_000).toISOString(), + expiresAt: new Date(now + 120_000).toISOString(), + }} + onBeginApproval={onBegin} + onApprove={onApprove} + />, + ); + }); + expect( + renderer.root + .findAllByType('button') + .find((button) => button.children.includes('Wait 10 seconds'))!.props.disabled, + ).toBe(true); + + act(() => { + vi.advanceTimersByTime(10_000); + }); + const confirm = renderer.root + .findAllByType('button') + .find((button) => + button.children.includes('Confirm production write and grant token'), + )!; + expect(confirm.props.disabled).toBe(false); + act(() => confirm.props.onClick()); + expect(onApprove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/data-sync/DataSyncPreflightPanel.tsx b/frontend/src/components/data-sync/DataSyncPreflightPanel.tsx new file mode 100644 index 00000000..5d164220 --- /dev/null +++ b/frontend/src/components/data-sync/DataSyncPreflightPanel.tsx @@ -0,0 +1,208 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +import type { + DataSyncPreflightSnapshot, + DataSyncApprovalChallenge, + DataSyncApprovalGrant, + DataSyncTaskStage, + DataSyncValidationIssue, +} from './model'; +import { + dataSyncValidationIssueText, + type DataSyncWorkbenchTranslate, +} from './text'; + +const statusText = ( + snapshot: DataSyncPreflightSnapshot | null, + stale: boolean, + running: boolean, + t: DataSyncWorkbenchTranslate, +): string => { + if (running) return t('preflight.running'); + if (stale) return t('preflight.stale'); + if (!snapshot) return t('preflight.not_run'); + if (snapshot.status === 'blocked') { + return t('preflight.blocked', { + count: snapshot.issues.filter((issue) => issue.severity === 'blocker').length, + }); + } + if (snapshot.status === 'warning') { + return t('preflight.warning', { + count: snapshot.issues.filter((issue) => issue.severity === 'warning').length, + }); + } + return t('preflight.passed'); +}; + +const issueText = ( + issue: DataSyncValidationIssue, + t: DataSyncWorkbenchTranslate, +) => dataSyncValidationIssueText(issue, t); + +export const DataSyncPreflightPanel: React.FC<{ + snapshot: DataSyncPreflightSnapshot | null; + currentRevision: number; + stale: boolean; + running: boolean; + t: DataSyncWorkbenchTranslate; + onLocateIssue: (stage: DataSyncTaskStage, mappingId?: string) => void; + approval?: DataSyncApprovalGrant | null; + approvalChallenge?: DataSyncApprovalChallenge | null; + beginningApproval?: boolean; + approving?: boolean; + approvalError?: string; + onBeginApproval?: () => void; + onApprove?: () => void; +}> = ({ + snapshot, + currentRevision, + stale, + running, + t, + onLocateIssue, + approval = null, + approvalChallenge = null, + beginningApproval = false, + approving = false, + approvalError = '', + onBeginApproval, + onApprove, +}) => { + const [clock, setClock] = useState(Date.now()); + const effectiveIssues = stale ? [] : snapshot?.issues || []; + const approvalRequired = Boolean(snapshot && snapshot.approvalRequired !== false); + const approvalCurrent = Boolean( + snapshot && + approval && + approval.definitionHash === snapshot.definitionHash && + Date.parse(approval.expiresAt) > clock, + ); + const challengeCurrent = Boolean( + snapshot && + approvalChallenge && + approvalChallenge.definitionHash === snapshot.definitionHash && + Date.parse(approvalChallenge.expiresAt) > clock, + ); + const remainingSeconds = useMemo(() => { + if (!approvalChallenge || !challengeCurrent) return 0; + const remaining = Date.parse(approvalChallenge.notBefore) - clock; + return Math.max(0, Math.ceil(remaining / 1_000)); + }, [approvalChallenge, challengeCurrent, clock]); + const status = running + ? 'running' + : stale + ? 'stale' + : snapshot?.status || 'stale'; + + useEffect(() => { + setClock(Date.now()); + }, [snapshot?.definitionHash, stale]); + + useEffect(() => { + if (!challengeCurrent || remainingSeconds <= 0) return undefined; + const timer = globalThis.setInterval(() => setClock(Date.now()), 250); + return () => globalThis.clearInterval(timer); + }, [challengeCurrent, remainingSeconds]); + + return ( + + ); +}; diff --git a/frontend/src/components/data-sync/DataSyncRouteBar.tsx b/frontend/src/components/data-sync/DataSyncRouteBar.tsx new file mode 100644 index 00000000..6bdbeebd --- /dev/null +++ b/frontend/src/components/data-sync/DataSyncRouteBar.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import type { + DataSyncEndpointRef, + DataSyncRouteCapability, +} from './model'; +import type { DataSyncWorkbenchTranslate } from './text'; + +const endpointTitle = ( + endpoint: DataSyncEndpointRef, + fallback: string, +): string => endpoint.connectionName || endpoint.connectionId || fallback; + +const endpointScope = ( + endpoint: DataSyncEndpointRef, + fallback: string, +): string => + [endpoint.database, endpoint.schema].filter(Boolean).join(' / ') || fallback; + +export const DataSyncRouteBar: React.FC<{ + source: DataSyncEndpointRef; + target: DataSyncEndpointRef; + capability: DataSyncRouteCapability; + active: boolean; + t: DataSyncWorkbenchTranslate; +}> = ({ source, target, capability, active, t }) => ( +
+
+ {t('route.source')} + {endpointTitle(source, t('route.pending_source'))} + {endpointScope(source, t('route.database_fallback'))} +
+
+
+
+ {t('route.target')} + {endpointTitle(target, t('route.pending_target'))} + {endpointScope(target, t('route.database_fallback'))} +
+
+); diff --git a/frontend/src/components/data-sync/DataSyncTaskEditor.tsx b/frontend/src/components/data-sync/DataSyncTaskEditor.tsx new file mode 100644 index 00000000..2f75ce61 --- /dev/null +++ b/frontend/src/components/data-sync/DataSyncTaskEditor.tsx @@ -0,0 +1,1329 @@ +import React, { useEffect, useRef, useState } from 'react'; + +import { DataSyncEndpointSelector } from './DataSyncEndpointSelector'; +import { DataSyncFieldMappingEditor } from './DataSyncFieldMappingEditor'; +import { DataSyncMappingTable } from './DataSyncMappingTable'; +import type { DataSyncWorkbenchGateway } from './gateway'; +import { + autoMatchDataSyncFields, + buildDataSyncMappingsFromSelection, + createDataSyncTableMapping, + canUseDataSyncRowErrorIsolation, + validateDataSyncTask, + type DataSyncDeliveryPolicy, + type DataSyncFieldMetadata, + type DataSyncIncrementalPolicy, + type DataSyncPreflightSnapshot, + type DataSyncRouteCapability, + type DataSyncTableMapping, + type DataSyncTaskDefinition, + type DataSyncTaskStage, + type DataSyncTriggerPolicy, + type DataSyncSavedConnectionView, +} from './model'; +import { + dataSyncValidationIssueText, + type DataSyncWorkbenchTranslate, +} from './text'; +import { + useDataSyncDatabases, + useDataSyncObjects, + useDataSyncSavedConnections, +} from './useDataSyncMetadata'; + +const STAGES: DataSyncTaskStage[] = [ + 'endpoints', + 'mappings', + 'delivery', + 'trigger', + 'preflight', +]; + +type TaskPatch = Partial< + Omit +>; + +const Field: React.FC<{ + label: string; + children: React.ReactNode; + wide?: boolean; +}> = ({ label, children, wide = false }) => ( + +); + +const updateMapping = ( + task: DataSyncTaskDefinition, + mapping: DataSyncTableMapping, +): DataSyncTableMapping[] => + task.mappings.map((item) => (item.id === mapping.id ? mapping : item)); + +const createTrigger = ( + mode: DataSyncTriggerPolicy['mode'], +): DataSyncTriggerPolicy => { + if (mode === 'once') { + return { mode, runAt: '', timezone: 'Local' }; + } + if (mode === 'cron') { + return { + mode, + expression: '', + timezone: 'Asia/Shanghai', + overlap: 'skip', + }; + } + if (mode === 'interval') { + return { mode, intervalSeconds: 300, timezone: 'Asia/Shanghai' }; + } + if (mode === 'manual') return { mode: 'manual' }; + return { mode: 'continuous' }; +}; + +const toLocalDateTimeInput = (value: string): string => { + const date = new Date(value); + if (!value || !Number.isFinite(date.getTime())) return ''; + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); + return local.toISOString().slice(0, 16); +}; + +const fromLocalDateTimeInput = (value: string): string => { + if (!value) return ''; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date.toISOString() : ''; +}; + +const createIncremental = ( + mode: DataSyncIncrementalPolicy['mode'], +): DataSyncIncrementalPolicy => { + if (mode === 'watermark') { + return { mode, column: '', tieBreaker: '', overlapWindowMs: 0 }; + } + if (mode === 'cdc') { + return { + mode, + initialSnapshot: false, + startPosition: 'latest', + adapter: '', + slotName: '', + publicationName: '', + }; + } + return { mode }; +}; + +const clearEndpointMappings = ( + mappings: DataSyncTableMapping[], + side: 'source' | 'target', +): DataSyncTableMapping[] => + mappings.map((mapping) => + side === 'source' + ? { ...mapping, sourceObject: '', keyColumns: [], fields: [] } + : { ...mapping, targetObject: '', fields: [] }, + ); + +const EndpointStage: React.FC<{ + task: DataSyncTaskDefinition; + gateway: DataSyncWorkbenchGateway; + t: DataSyncWorkbenchTranslate; + onPatch: (patch: TaskPatch) => void; +}> = ({ task, gateway, t, onPatch }) => { + const connections = useDataSyncSavedConnections(gateway); + const sourceDatabases = useDataSyncDatabases(gateway, task.source.connectionId); + const targetDatabases = useDataSyncDatabases(gateway, task.target.connectionId); + + const selectConnection = ( + side: 'source' | 'target', + connection: DataSyncSavedConnectionView | null, + ) => { + const endpoint = connection + ? { + connectionId: connection.id, + connectionName: connection.name, + type: connection.type, + database: '', + schema: '', + } + : { + connectionId: '', + connectionName: '', + type: '', + database: '', + schema: '', + }; + onPatch({ + [side]: endpoint, + mappings: clearEndpointMappings(task.mappings, side), + }); + }; + + const selectDatabase = (side: 'source' | 'target', database: string) => { + onPatch({ + [side]: { ...task[side], database, schema: '' }, + mappings: clearEndpointMappings(task.mappings, side), + }); + }; + + const changeSchema = (side: 'source' | 'target', schema: string) => { + onPatch({ + [side]: { ...task[side], schema }, + mappings: clearEndpointMappings(task.mappings, side), + }); + }; + + return ( +
+
+
+

{t('stage.endpoints')}

+

{t('editor.endpoint_help')}

+
+
+
+ + onPatch({ name: event.target.value })} + /> + +
+
+ selectConnection('source', connection)} + onDatabaseChange={(database) => selectDatabase('source', database)} + onSchemaChange={(schema) => changeSchema('source', schema)} + /> + selectConnection('target', connection)} + onDatabaseChange={(database) => selectDatabase('target', database)} + onSchemaChange={(schema) => changeSchema('target', schema)} + /> +
+ {task.sourceMode === 'query' ? ( +
+ +