feat(data-sync): 新增多对象迁移与同步任务工作台

- 以五步流程组织端点、对象、写入、运行方式和预检
- 支持批量选表、同名目标匹配、字段映射与主键识别
- 完善生命周期、运行记录、CDC、错误行和响应式交互
This commit is contained in:
Syngnat
2026-08-08 19:29:29 +08:00
parent 53dde950fd
commit 4c04609df2
22 changed files with 8831 additions and 21 deletions

View File

@@ -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<string, unknown>) => input,
createWailsDataSyncWorkbenchGateway: () => ({ kind: 'test-gateway' }),
DataSyncWorkbenchShell: ({ initialTasks, locale, onClose }: {
initialTasks: Array<Record<string, unknown>>;
locale?: string;
onClose: () => void;
}) => (
<button
type="button"
data-data-sync-modal="true"
data-embedded={embedded ? 'true' : 'false'}
data-entry-mode={entryMode}
data-task-key={taskKey}
data-data-sync-shell="true"
data-kind={String(initialTasks[0]?.kind || '')}
data-compare-mode={String(initialTasks[0]?.compareMode || '')}
data-task-id={String(initialTasks[0]?.id || '')}
data-locale={locale}
onClick={onClose}
/>
),
@@ -40,18 +42,21 @@ const tab: TabData = {
};
describe('DataSyncWorkbench', () => {
it('renders the selected workflow as an embedded workbench and closes its tab', () => {
it('maps the selected workflow into a full-page task shell and closes its tab', () => {
closeTab.mockReset();
const markup = renderToStaticMarkup(<DataSyncWorkbench tab={tab} />);
expect(markup).toContain('data-data-sync-workbench="true"');
expect(markup).toContain('data-embedded="true"');
expect(markup).toContain('data-entry-mode="schemaCompare"');
expect(markup).toContain('data-task-key="data-sync-workbench-schema-compare"');
expect(markup).toContain('data-data-sync-shell="true"');
expect(markup).toContain('data-kind="compare"');
expect(markup).toContain('data-compare-mode="schema"');
expect(markup).toContain(
'data-task-id="data-sync-local-data-sync-workbench-schema-compare"',
);
const renderer = TestRenderer.create(<DataSyncWorkbench tab={tab} />);
act(() => {
renderer.root.findByProps({ 'data-data-sync-modal': 'true' }).props.onClick();
renderer.root.findByProps({ 'data-data-sync-shell': 'true' }).props.onClick();
});
expect(closeTab).toHaveBeenCalledWith(tab.id);

View File

@@ -1,8 +1,13 @@
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useStore } from '../store';
import type { TabData } from '../types';
import DataSyncModal from './DataSyncModal';
import { useOptionalI18n } from '../i18n/provider';
import {
createDataSyncTaskDraft,
createWailsDataSyncWorkbenchGateway,
DataSyncWorkbenchShell,
} from './data-sync';
import type { DataSyncEntryMode } from './dataSyncEntryMode';
const resolveEntryMode = (tab: TabData): DataSyncEntryMode => {
@@ -14,20 +19,38 @@ const resolveEntryMode = (tab: TabData): DataSyncEntryMode => {
const DataSyncWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
const closeTab = useStore((state) => state.closeTab);
const i18n = useOptionalI18n();
const entryMode = resolveEntryMode(tab);
const handleClose = useCallback(() => {
closeTab(tab.id);
}, [closeTab, tab.id]);
const initialTask = useMemo(
() =>
createDataSyncTaskDraft({
id: `data-sync-local-${tab.id}`,
kind: entryMode === 'sync' ? 'reconcile' : 'compare',
compareMode:
entryMode === 'schemaCompare'
? 'schema'
: entryMode === 'dataCompare'
? 'data'
: undefined,
name: tab.title,
sourceConnectionId: tab.connectionId,
}),
[entryMode, tab.connectionId, tab.id, tab.title],
);
const gateway = useMemo(() => createWailsDataSyncWorkbenchGateway(), []);
return (
<div
data-data-sync-workbench="true"
style={{ width: '100%', height: '100%', minWidth: 0, minHeight: 0, overflow: 'hidden' }}
>
<DataSyncModal
embedded
open
taskKey={tab.id}
entryMode={resolveEntryMode(tab)}
<DataSyncWorkbenchShell
initialTasks={[initialTask]}
gateway={gateway}
locale={i18n?.language}
onClose={handleClose}
/>
</div>

View File

@@ -0,0 +1,277 @@
import React from 'react';
import TestRenderer, { act } from 'react-test-renderer';
import { describe, expect, it, vi } from 'vitest';
import { DataSyncTaskEditor } from './DataSyncTaskEditor';
import { createStaticDataSyncWorkbenchGateway } from './gateway';
import {
createDataSyncTableMapping,
createDataSyncTaskDraft,
reviseDataSyncTask,
type DataSyncRouteCapability,
type DataSyncTaskDefinition,
} from './model';
import { createDataSyncWorkbenchTranslate } from './text';
const supportedCapability: DataSyncRouteCapability = {
level: 'full',
canExecute: true,
supportsAutoCreate: true,
supportsAutoAddColumns: true,
requiresExistingTarget: false,
supportsCdc: true,
};
const endpoint = (role: 'source' | 'target', schema = 'public') => ({
connectionId: role,
connectionName: role,
type: 'mysql',
database: role === 'source' ? 'source_db' : 'target_db',
schema,
});
const renderDelivery = async (
task: DataSyncTaskDefinition,
onPatch = vi.fn(),
capability = supportedCapability,
) => {
let renderer!: TestRenderer.ReactTestRenderer;
await act(async () => {
renderer = TestRenderer.create(
<DataSyncTaskEditor
task={task}
gateway={createStaticDataSyncWorkbenchGateway()}
capability={capability}
activeStage="delivery"
preflight={null}
preflightStale
t={createDataSyncWorkbenchTranslate('zh-CN')}
onStageChange={() => undefined}
onPatch={onPatch}
/>,
);
await Promise.resolve();
await Promise.resolve();
});
return { renderer, onPatch };
};
describe('DataSyncTaskEditor delivery stage', () => {
it('makes quarantine one explicit choice that stores complete rows locally for retry', async () => {
const mapping = {
...createDataSyncTableMapping('cdc:mapping:1', 'orders', 'orders'),
keyColumns: ['id'],
};
const task = reviseDataSyncTask(createDataSyncTaskDraft({ id: 'cdc', kind: 'cdc' }), {
source: endpoint('source'),
target: endpoint('target'),
mappings: [mapping],
});
const { renderer, onPatch } = await renderDelivery(task);
const rendered = JSON.stringify(renderer.toJSON());
expect(rendered).toContain('完整失败行会保存在本机任务数据库中');
expect(rendered).toContain('可在错误行列表中重试');
expect(rendered).not.toContain('保存隔离行数据');
const quarantine = renderer.root.findByProps({
'data-error-policy-option': 'quarantine',
});
act(() => quarantine.findByType('input').props.onChange());
expect(onPatch).toHaveBeenLastCalledWith({
delivery: expect.objectContaining({
errorPolicy: 'quarantine',
captureErrorPayload: true,
}),
});
});
it('shows delete propagation only for keyed snapshot reconcile and resets an invalid saved value', async () => {
const mapping = {
...createDataSyncTableMapping('reconcile:mapping:1', 'orders', 'orders'),
keyColumns: ['id'],
};
const validTask = reviseDataSyncTask(
createDataSyncTaskDraft({ id: 'reconcile', kind: 'reconcile' }),
{
source: endpoint('source'),
target: endpoint('target'),
mappings: [mapping],
},
);
const valid = await renderDelivery(validTask);
expect(
valid.renderer.root.findAllByProps({ 'data-delete-propagation': 'true' }),
).toHaveLength(1);
const invalidTask = reviseDataSyncTask(validTask, {
mappings: [{ ...mapping, keyColumns: [] }],
delivery: { ...validTask.delivery, propagateDeletes: true },
});
const invalid = await renderDelivery(invalidTask);
expect(
invalid.renderer.root.findAllByProps({ 'data-delete-propagation': 'true' }),
).toHaveLength(0);
expect(invalid.onPatch).toHaveBeenCalledWith({
delivery: expect.objectContaining({ propagateDeletes: false }),
});
const cdcWithoutKey = reviseDataSyncTask(
createDataSyncTaskDraft({ id: 'cdc-without-key', kind: 'cdc' }),
{
source: endpoint('source'),
target: endpoint('target'),
mappings: [createDataSyncTableMapping('cdc-without-key:mapping', 'orders', 'orders')],
delivery: {
...createDataSyncTaskDraft({ id: 'cdc-options', kind: 'cdc' }).delivery,
propagateDeletes: true,
},
},
);
const invalidCdc = await renderDelivery(cdcWithoutKey);
expect(
invalidCdc.renderer.root.findAllByProps({
'data-delete-propagation': 'true',
}),
).toHaveLength(0);
expect(invalidCdc.onPatch).toHaveBeenCalledWith({
delivery: expect.objectContaining({ propagateDeletes: false }),
});
});
it('reveals migration schema controls only for supported implicit same-name mappings', async () => {
const implicitMapping = {
...createDataSyncTableMapping('migration:mapping:1', 'public.orders', 'public.orders'),
targetMode: 'create_or_reuse' as const,
};
const implicitTask = reviseDataSyncTask(
createDataSyncTaskDraft({ id: 'migration', kind: 'migration' }),
{
source: endpoint('source'),
target: endpoint('target'),
mappings: [implicitMapping],
},
);
const implicit = await renderDelivery(implicitTask);
expect(
implicit.renderer.root.findAllByProps({
'data-structure-option': 'auto-add-columns',
}),
).toHaveLength(1);
expect(
implicit.renderer.root.findAllByProps({
'data-structure-option': 'create-indexes',
}),
).toHaveLength(1);
const explicitTask = reviseDataSyncTask(implicitTask, {
mappings: [{ ...implicitMapping, keyColumns: ['id'] }],
delivery: {
...implicitTask.delivery,
autoAddColumns: true,
createIndexes: true,
},
});
const explicit = await renderDelivery(explicitTask);
expect(
explicit.renderer.root.findAllByProps({ 'data-delivery-structure': 'true' }),
).toHaveLength(0);
expect(explicit.onPatch).toHaveBeenCalledWith({
delivery: expect.objectContaining({
autoAddColumns: false,
createIndexes: false,
}),
});
});
it('explains compare tasks without exposing irrelevant write controls', async () => {
const task = createDataSyncTaskDraft({ id: 'compare', kind: 'compare' });
const { renderer } = await renderDelivery(task);
const rendered = JSON.stringify(renderer.toJSON());
expect(rendered).toContain('这是只读比较任务');
expect(rendered).toContain('不会写入或删除目标端数据');
expect(
renderer.root.findAllByProps({ 'data-delivery-policy': 'error' }),
).toHaveLength(0);
expect(
renderer.root.findAllByProps({ 'data-delivery-advanced': 'true' }),
).toHaveLength(0);
});
it('does not offer non-atomic overwrite for new persistent tasks', async () => {
const mapping = {
...createDataSyncTableMapping('reconcile:mapping:overwrite', 'orders', 'orders'),
keyColumns: ['id'],
};
const task = reviseDataSyncTask(
createDataSyncTaskDraft({ id: 'reconcile-overwrite', kind: 'reconcile' }),
{
source: endpoint('source'),
target: endpoint('target'),
mappings: [mapping],
},
);
const { renderer } = await renderDelivery(task);
const overwrite = renderer.root
.findAllByType('option')
.find((option) => option.props.value === 'overwrite')!;
expect(overwrite.props.disabled).toBe(true);
expect(overwrite.children).toContain('覆盖(当前任务系统暂不支持)');
});
it('hides write recovery and delete controls when the resolved route cannot run CDC', async () => {
const mapping = {
...createDataSyncTableMapping('cdc:mapping:unsupported', 'orders', 'orders'),
keyColumns: ['id'],
fields: [
{
id: 'cdc:id',
sourceField: 'id',
targetField: 'id',
sourceType: 'bigint',
targetType: 'bigint',
transform: '',
nullable: false,
},
],
};
const base = createDataSyncTaskDraft({ id: 'cdc-unsupported', kind: 'cdc' });
const task = reviseDataSyncTask(base, {
source: endpoint('source'),
target: endpoint('target'),
mappings: [mapping],
delivery: {
...base.delivery,
errorPolicy: 'quarantine',
captureErrorPayload: true,
propagateDeletes: true,
},
});
const unsupportedCdc: DataSyncRouteCapability = {
...supportedCapability,
supportsCdc: false,
};
const { renderer, onPatch } = await renderDelivery(
task,
vi.fn(),
unsupportedCdc,
);
expect(
renderer.root.findAllByProps({ 'data-error-policy-option': 'quarantine' }),
).toHaveLength(0);
expect(
renderer.root.findAllByProps({ 'data-delete-propagation': 'true' }),
).toHaveLength(0);
expect(onPatch).toHaveBeenCalledWith({
delivery: expect.objectContaining({
errorPolicy: 'stop',
captureErrorPayload: false,
propagateDeletes: false,
}),
});
});
});

View File

@@ -0,0 +1,183 @@
import React from 'react';
import type {
DataSyncDatabaseMetadata,
DataSyncEndpointRef,
DataSyncSavedConnectionView,
} from './model';
import type { DataSyncWorkbenchTranslate } from './text';
import type { DataSyncMetadataResult } from './useDataSyncMetadata';
const MetadataStatus: React.FC<{
scope: string;
state: Pick<DataSyncMetadataResult<never>, 'status' | 'reload'>;
loadingText: string;
t: DataSyncWorkbenchTranslate;
}> = ({ scope, state, loadingText, t }) => {
if (state.status === 'idle' || state.status === 'ready') return null;
return (
<div
className="gn-data-sync-metadata-status"
data-metadata-scope={scope}
data-status={state.status}
role={state.status === 'error' ? 'alert' : 'status'}
aria-live="polite"
>
<span>
{state.status === 'loading' ? loadingText : t('metadata.load_failed')}
</span>
{state.status === 'error' ? (
<button
type="button"
className="gn-data-sync-link-button"
onClick={state.reload}
>
{t('metadata.retry')}
</button>
) : null}
</div>
);
};
export const DataSyncEndpointSelector: React.FC<{
role: 'source' | 'target';
title: string;
endpoint: DataSyncEndpointRef;
connections: DataSyncMetadataResult<DataSyncSavedConnectionView>;
databases: DataSyncMetadataResult<DataSyncDatabaseMetadata>;
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 (
<fieldset
className="gn-data-sync-endpoint-fields"
data-endpoint-role={role}
>
<legend>{title}</legend>
<div className="gn-data-sync-field-grid">
<label className="gn-data-sync-field" data-wide="true">
<span>{t('editor.connection')}</span>
<select
className="gn-data-sync-control"
data-endpoint-control="connection"
value={endpoint.connectionId}
disabled={connections.status === 'loading'}
onChange={(event) => {
const selected = connections.items.find(
(connection) => connection.id === event.target.value,
);
onConnectionChange(selected || null);
}}
>
{!endpoint.connectionId ? (
<option value="" disabled hidden>
{t('metadata.select_connection')}
</option>
) : null}
{endpoint.connectionId && !currentConnectionSelectable ? (
<option value={endpoint.connectionId}>
{currentConnection?.name || endpoint.connectionName || endpoint.connectionId}
</option>
) : null}
{selectableConnections.map((connection) => (
<option key={connection.id} value={connection.id}>
{connection.name} · {connection.type}
</option>
))}
</select>
</label>
<label className="gn-data-sync-field">
<span>{t('editor.database_type')}</span>
<output
className="gn-data-sync-control gn-data-sync-control--read-only gn-data-sync-mono"
data-endpoint-control="type"
>
{endpoint.type || t('metadata.not_selected')}
</output>
</label>
<label className="gn-data-sync-field">
<span>{t('editor.database')}</span>
<select
className="gn-data-sync-control gn-data-sync-mono"
data-endpoint-control="database"
value={endpoint.database}
disabled={!endpoint.connectionId || databases.status === 'loading'}
onChange={(event) => onDatabaseChange(event.target.value)}
>
{!endpoint.database ? (
<option value="" disabled hidden>
{t('metadata.select_database')}
</option>
) : null}
{endpoint.database && !currentDatabaseKnown ? (
<option value={endpoint.database}>{endpoint.database}</option>
) : null}
{databases.items.map((database) => (
<option key={database.name} value={database.name}>
{database.name}
</option>
))}
</select>
</label>
<label className="gn-data-sync-field">
<span>{t('editor.schema')}</span>
<input
className="gn-data-sync-control gn-data-sync-mono"
data-endpoint-control="schema"
value={endpoint.schema}
placeholder={t('metadata.schema_optional')}
disabled={!endpoint.connectionId}
onChange={(event) => onSchemaChange(event.target.value)}
/>
</label>
</div>
<MetadataStatus
scope={`${role}-connections`}
state={connections}
loadingText={t('metadata.loading_connections')}
t={t}
/>
<MetadataStatus
scope={`${role}-databases`}
state={databases}
loadingText={t('metadata.loading_databases')}
t={t}
/>
{connections.status === 'ready' && selectableConnections.length === 0 ? (
<p className="gn-data-sync-inline-hint" data-tone="warning">
{t('metadata.no_eligible_connections')}
</p>
) : null}
{databases.status === 'ready' && endpoint.connectionId && databases.items.length === 0 ? (
<p className="gn-data-sync-inline-hint">
{t('metadata.no_databases')}
</p>
) : null}
</fieldset>
);
};

View File

@@ -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<DataSyncFieldMetadata>;
t: DataSyncWorkbenchTranslate;
}> = ({ side, state, t }) => (
<span
className="gn-data-sync-field-metadata-state"
data-metadata-scope={`${side}-fields`}
data-status={state.status}
>
{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' ? (
<button
type="button"
className="gn-data-sync-link-button"
onClick={state.reload}
>
{t('metadata.retry')}
</button>
) : null}
</span>
);
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 (
<section
className="gn-data-sync-field-mapper"
data-data-sync-field-mapping={mapping.id}
>
<header className="gn-data-sync-section__header">
<div>
<h3>{t('field_mapping.title')}</h3>
<p className="gn-data-sync-mono">
{mapping.sourceObject} {mapping.targetObject}
</p>
</div>
<button
type="button"
className="gn-data-sync-link-button"
onClick={onClose}
>
{t('field_mapping.close')}
</button>
</header>
<div className="gn-data-sync-field-metadata-line" aria-live="polite">
<FieldMetadataState side="source" state={sourceFields} t={t} />
<FieldMetadataState side="target" state={targetFields} t={t} />
<span className="gn-data-sync-field-metadata-line__spacer" />
<button
type="button"
className="gn-data-sync-button"
disabled={
sourceFields.status !== 'ready' || targetFields.status !== 'ready'
}
onClick={() =>
onChange({
...mapping,
fields: autoMatchDataSyncFields(
mapping.id,
sourceFields.items,
targetFields.items,
mapping.fields,
),
})
}
>
{t('field_mapping.auto_match')}
</button>
<button
type="button"
className="gn-data-sync-button"
disabled={sourceFields.status === 'loading' || targetFields.status === 'loading'}
onClick={addField}
>
{t('field_mapping.add')}
</button>
</div>
{mapping.fields.length === 0 ? (
<div className="gn-data-sync-field-mapper__empty">
<strong>{t('field_mapping.empty_title')}</strong>
<span>{t('field_mapping.empty_desc')}</span>
</div>
) : (
<div className="gn-data-sync-table-scroll">
<table className="gn-data-sync-field-table">
<thead>
<tr>
<th>{t('field_mapping.source')}</th>
<th>{t('field_mapping.target')}</th>
<th>{t('field_mapping.transform')}</th>
<th>{t('field_mapping.type')}</th>
<th>
<span className="gn-data-sync-visually-hidden">
{t('field_mapping.remove')}
</span>
</th>
</tr>
</thead>
<tbody>
{mapping.fields.map((field) => (
<tr key={field.id} data-field-mapping-id={field.id}>
<td>
<select
className="gn-data-sync-table-input gn-data-sync-mono"
data-field-control="source"
value={field.sourceField}
onChange={(event) => {
const metadata = metadataFor(sourceFields.items, event.target.value);
patchField({
...field,
sourceField: event.target.value,
sourceType: metadata?.type || '',
});
}}
>
{field.sourceField && !metadataFor(sourceFields.items, field.sourceField) ? (
<option value={field.sourceField}>{field.sourceField}</option>
) : null}
<option value="">{t('field_mapping.select_field')}</option>
{sourceFields.items.map((item) => (
<option key={item.name} value={item.name}>{item.name}</option>
))}
</select>
</td>
<td>
<select
className="gn-data-sync-table-input gn-data-sync-mono"
data-field-control="target"
value={field.targetField}
onChange={(event) => {
const metadata = metadataFor(targetFields.items, event.target.value);
patchField({
...field,
targetField: event.target.value,
targetType: metadata?.type || '',
nullable: metadata?.nullable ?? true,
});
}}
>
{field.targetField && !metadataFor(targetFields.items, field.targetField) ? (
<option value={field.targetField}>{field.targetField}</option>
) : null}
<option value="">{t('field_mapping.select_field')}</option>
{targetFields.items.map((item) => (
<option key={item.name} value={item.name}>{item.name}</option>
))}
</select>
</td>
<td>
<select
className="gn-data-sync-table-input gn-data-sync-mono"
data-field-control="transform"
value={field.transform}
onChange={(event) =>
patchField({ ...field, transform: event.target.value })
}
>
{field.transform &&
!SUPPORTED_TRANSFORMS.includes(
field.transform as (typeof SUPPORTED_TRANSFORMS)[number],
) ? (
<option value={field.transform} disabled>{field.transform}</option>
) : null}
{SUPPORTED_TRANSFORMS.map((transform) => (
<option key={transform || 'identity'} value={transform}>
{transform || t('field_mapping.transform_identity')}
</option>
))}
</select>
<input
className="gn-data-sync-table-input gn-data-sync-mono"
data-field-control="transform-argument"
value={field.transformArgument || ''}
placeholder={t('field_mapping.transform_argument')}
onChange={(event) =>
patchField({
...field,
transformArgument: event.target.value,
})
}
/>
</td>
<td>
<span className="gn-data-sync-field-type gn-data-sync-mono">
{field.sourceType || '—'} {field.targetType || '—'}
</span>
</td>
<td>
<button
type="button"
className="gn-data-sync-link-button gn-data-sync-link-button--danger"
onClick={() =>
onChange({
...mapping,
fields: mapping.fields.filter((item) => item.id !== field.id),
})
}
>
{t('field_mapping.remove')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
};

View File

@@ -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<DataSyncObjectMetadata>;
t: DataSyncWorkbenchTranslate;
}> = ({ side, state, t }) => (
<div
className="gn-data-sync-object-status"
data-metadata-scope={`${side}-objects`}
data-status={state.status}
>
<span>{t(`mapping.${side}`)}</span>
<strong>
{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 })}
</strong>
{state.status === 'error' ? (
<button
type="button"
className="gn-data-sync-link-button"
onClick={state.reload}
>
{t('metadata.retry')}
</button>
) : null}
</div>
);
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 (
<div
className="gn-data-sync-object-combobox"
data-open={open ? 'true' : 'false'}
>
<input
className="gn-data-sync-table-input gn-data-sync-mono"
data-object-side={side}
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listId}
aria-activedescendant={
open && activeIndex >= 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);
}
}}
/>
<button
type="button"
className="gn-data-sync-object-combobox__toggle"
aria-label={t('mapping.open_object_list')}
disabled={disabled}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setShowAll(true);
setOpen((valueOpen) => !valueOpen);
}}
>
</button>
{open ? (
<div id={listId} className="gn-data-sync-object-combobox__menu" role="listbox">
{filtered.map((object, optionIndex) => (
<button
id={`${listId}-option-${optionIndex}`}
type="button"
role="option"
aria-selected={normalizeName(object.name) === normalizeName(value)}
data-active={activeIndex === optionIndex ? 'true' : 'false'}
key={`${object.kind}:${object.name}`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
onChange(object.name);
setOpen(false);
setShowAll(false);
}}
>
<span>{object.name}</span>
<small>{t(`mapping.object_kind.${object.kind}`)}</small>
</button>
))}
{filtered.length === 0 ? (
allowCustom && value.trim() ? (
<div className="gn-data-sync-object-combobox__custom">
{t('mapping.will_create_named', { name: value.trim() })}
</div>
) : (
<div className="gn-data-sync-object-combobox__empty">
{t('mapping.no_matching_objects')}
</div>
)
) : null}
{allowCustom && value.trim() && !exactMatch && filtered.length > 0 ? (
<div className="gn-data-sync-object-combobox__custom">
{t('mapping.will_create_named', { name: value.trim() })}
</div>
) : null}
</div>
) : null}
</div>
);
};
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<DataSyncObjectMetadata>;
targetObjects: DataSyncMetadataResult<DataSyncObjectMetadata>;
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<Set<string>>(
new Set(),
);
const querySink = taskKind === 'querySink';
const canPickSources =
!querySink &&
sourceObjects.status === 'ready' &&
targetObjects.status === 'ready' &&
sourceObjects.items.length > 0;
return (
<section className="gn-data-sync-section" data-data-sync-mapping-section="true">
<header className="gn-data-sync-section__header">
<div>
<h2>{t('mapping.title')}</h2>
<p>{t(querySink ? 'mapping.query_help' : 'mapping.help')}</p>
</div>
<button
type="button"
className="gn-data-sync-button gn-data-sync-button--primary"
disabled={disabled || (querySink && mappings.length >= 1) || (!querySink && !canPickSources)}
onClick={() => (querySink ? onAdd() : setPickerOpen(true))}
>
{t(querySink ? 'mapping.add_target' : 'mapping.select_objects')}
</button>
</header>
<div className="gn-data-sync-object-status-line" aria-live="polite">
<ObjectMetadataStatus side="source" state={sourceObjects} t={t} />
<ObjectMetadataStatus side="target" state={targetObjects} t={t} />
</div>
<DataSyncObjectPicker
open={pickerOpen}
objects={sourceObjects.items}
mappedSourceNames={mappings.map((mapping) => mapping.sourceObject)}
disabled={disabled}
t={t}
onClose={() => setPickerOpen(false)}
onConfirm={onAddMany}
/>
{mappings.length === 0 ? (
<div className="gn-data-sync-mapping-empty">
<strong>{t('mapping.empty_title')}</strong>
<p>{t('mapping.empty_desc')}</p>
{!querySink ? (
<button
type="button"
className="gn-data-sync-button"
disabled={disabled || !canPickSources}
onClick={() => setPickerOpen(true)}
>
{t('mapping.select_objects')}
</button>
) : null}
</div>
) : (
<div className="gn-data-sync-mapping-list">
{mappings.map((mapping, index) => {
const targetState = targetStatus(mapping, targetObjects.items);
const ready = mappingReady(mapping, taskKind, targetState);
const detailsOpen =
!ready || expandedMappingIds.has(mapping.id);
return (
<article
key={mapping.id}
className="gn-data-sync-mapping-row"
data-mapping-id={mapping.id}
data-ready={ready ? 'true' : 'false'}
>
<div className="gn-data-sync-mapping-row__route">
<label className="gn-data-sync-mapping-row__enabled">
<input
type="checkbox"
aria-label={t('mapping.enabled')}
checked={mapping.enabled}
disabled={disabled}
onChange={(event) =>
onChange({ ...mapping, enabled: event.target.checked })
}
/>
<span>{index + 1}</span>
</label>
<div className="gn-data-sync-mapping-row__endpoint">
<span>{t('mapping.source')}</span>
{querySink ? (
<strong className="gn-data-sync-query-source">
{t('mapping.query_result_source')}
</strong>
) : (
<DataSyncObjectCombobox
id={`${mapping.id}-source`}
side="source"
value={mapping.sourceObject}
options={sourceObjects.items}
disabled={disabled || !mapping.enabled}
allowCustom={false}
t={t}
onChange={(sourceObject) =>
onChange({ ...mapping, sourceObject, keyColumns: [], fields: [] })
}
/>
)}
</div>
<span className="gn-data-sync-mapping-row__arrow" aria-hidden="true"></span>
<div className="gn-data-sync-mapping-row__endpoint">
<span>{t('mapping.target')}</span>
<DataSyncObjectCombobox
id={`${mapping.id}-target`}
side="target"
value={mapping.targetObject}
options={targetObjects.items}
disabled={disabled || !mapping.enabled}
allowCustom={mapping.targetMode === 'create_or_reuse'}
t={t}
onChange={(targetObject) =>
onChange({ ...mapping, targetObject, fields: [] })
}
/>
</div>
<div className="gn-data-sync-mapping-row__status">
<span
className="gn-data-sync-target-state"
data-state={targetState}
>
{t(`mapping.target_state.${targetState}`)}
</span>
<span
className="gn-data-sync-state-label"
data-state={ready ? 'ready' : 'warning'}
>
{t(ready ? 'mapping.ready' : 'mapping.needs_attention')}
</span>
</div>
<div className="gn-data-sync-mapping-row__actions">
<button
type="button"
className="gn-data-sync-link-button"
aria-expanded={detailsOpen}
onClick={() =>
setExpandedMappingIds((current) => {
const next = new Set(current);
if (next.has(mapping.id)) next.delete(mapping.id);
else next.add(mapping.id);
return next;
})
}
>
{t(
detailsOpen
? 'mapping.collapse_exceptions'
: 'mapping.edit_exceptions',
)}
</button>
<button
type="button"
className="gn-data-sync-link-button gn-data-sync-link-button--danger"
disabled={disabled || (querySink && mappings.length === 1)}
onClick={() => onRemove(mapping.id)}
>
{t('mapping.remove')}
</button>
</div>
</div>
{detailsOpen ? (
<div
className="gn-data-sync-mapping-row__details"
data-mapping-details="true"
>
<label>
<span>{t('mapping.target_mode')}</span>
<select
className="gn-data-sync-table-input"
value={mapping.targetMode}
disabled={disabled || !mapping.enabled || querySink}
onChange={(event) =>
onChange({
...mapping,
targetMode: event.target.value as DataSyncTableMapping['targetMode'],
})
}
>
<option value="create_or_reuse">{t('mapping.create_or_reuse')}</option>
<option value="existing_only">{t('mapping.existing_only')}</option>
</select>
</label>
<label>
<span>{t('mapping.key_columns')}</span>
<input
className="gn-data-sync-table-input gn-data-sync-mono"
value={mapping.keyColumns.join(', ')}
placeholder={t('mapping.key_placeholder')}
disabled={disabled || !mapping.enabled}
onChange={(event) =>
onChange({
...mapping,
keyColumns: event.target.value
.split(',')
.map((value) => value.trim())
.filter(Boolean),
})
}
/>
<small>
{mapping.keyColumns.length > 0
? t('mapping.key_detected')
: t('mapping.key_when_needed')}
</small>
</label>
<div className="gn-data-sync-mapping-row__fields">
<span>{t('mapping.fields')}</span>
<button
type="button"
className="gn-data-sync-link-button"
disabled={
!mapping.enabled ||
(!querySink && !mapping.sourceObject.trim()) ||
querySink ||
!mapping.targetObject.trim() ||
!onInspectFields
}
onClick={() => onInspectFields?.(mapping.id)}
>
{mapping.fields.length > 0
? t('mapping.fields_count', { count: mapping.fields.length })
: t(
taskKind === 'cdc'
? 'mapping.fields_required'
: 'mapping.fields_automatic',
)}
</button>
<small>
{t(
taskKind === 'cdc'
? 'mapping.fields_cdc_help'
: 'mapping.fields_automatic_help',
)}
</small>
</div>
</div>
) : null}
</article>
);
})}
</div>
)}
</section>
);
};

View File

@@ -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 = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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(
<DataSyncWorkbenchShell initialTasks={[task]} gateway={gateway} locale="zh-CN" />,
);
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(
<DataSyncWorkbenchShell initialTasks={[task]} gateway={gateway} locale="zh-CN" />,
);
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<DataSyncDatabaseMetadata[]>();
const sourceB = deferred<DataSyncDatabaseMetadata[]>();
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(
<DataSyncWorkbenchShell initialTasks={[task]} gateway={gateway} locale="en-US" />,
);
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(
<DataSyncWorkbenchShell initialTasks={[task]} gateway={gateway} locale="en-US" />,
);
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(
<DataSyncWorkbenchShell initialTasks={[task]} gateway={gateway} locale="en-US" />,
);
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);
});
});

View File

@@ -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 = <T,>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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(
<DataSyncWorkbenchShell
initialTasks={[task]}
gateway={gateway}
locale="zh-CN"
/>,
);
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<DataSyncObjectMetadata[]>();
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(
<DataSyncWorkbenchShell initialTasks={[task]} gateway={gateway} locale="zh-CN" />,
);
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<DataSyncFieldMetadata[]>();
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(
<DataSyncWorkbenchShell initialTasks={[task]} gateway={gateway} locale="zh-CN" />,
);
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(
<DataSyncWorkbenchShell initialTasks={[task]} gateway={gateway} locale="zh-CN" />,
);
await flush();
act(() => buttonWithText(renderer, '选择同步数据').props.onClick());
await flush();
expect(renderer.root.findAllByProps({ 'data-ready': 'true' })).toHaveLength(0);
expect(buttonWithText(renderer, '需要确认同步字段')).toBeTruthy();
});
});

View File

@@ -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(
<DataSyncObjectPicker
open
objects={[
{ name: 'admin_users', kind: 'table', rowCount: 10 },
{ name: 'messages', kind: 'table', rowCount: 2_000, dataBytes: 4096 },
{ name: 'push_records', kind: 'view' },
{ name: 'orders', kind: 'table' },
]}
mappedSourceNames={['messages']}
t={createDataSyncWorkbenchTranslate('zh-CN')}
onClose={() => 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(
<DataSyncObjectPicker
open
objects={[
{ name: 'orders', kind: 'table' },
{ name: 'orders_view', kind: 'view' },
]}
mappedSourceNames={[]}
t={createDataSyncWorkbenchTranslate('en-US')}
onClose={() => 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();
});
});

View File

@@ -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 ? <small>{facts.join(' · ')}</small> : null;
};
export const DataSyncObjectPicker: React.FC<{
open: boolean;
objects: DataSyncObjectMetadata[];
mappedSourceNames: string[];
disabled?: boolean;
t: DataSyncWorkbenchTranslate;
onClose: () => void;
onConfirm: (sourceNames: string[]) => void | Promise<void>;
}> = ({
open,
objects,
mappedSourceNames,
disabled = false,
t,
onClose,
onConfirm,
}) => {
const [search, setSearch] = useState('');
const [includeViews, setIncludeViews] = useState(false);
const [selected, setSelected] = useState<Set<string>>(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 (
<div
className="gn-data-sync-object-picker"
role="dialog"
aria-modal="false"
aria-label={t('mapping.picker_title')}
data-data-sync-object-picker="true"
>
<div className="gn-data-sync-object-picker__toolbar">
<label className="gn-data-sync-object-picker__search">
<span className="gn-data-sync-visually-hidden">{t('mapping.search_objects')}</span>
<input
type="search"
data-object-picker-control="search"
value={search}
placeholder={t('mapping.search_objects')}
autoFocus
onChange={(event) => setSearch(event.target.value)}
/>
</label>
<label className="gn-data-sync-object-picker__view-toggle">
<input
type="checkbox"
data-object-picker-control="include-views"
checked={includeViews}
onChange={(event) => setIncludeViews(event.target.checked)}
/>
<span>{t('mapping.include_views')}</span>
</label>
<span className="gn-data-sync-object-picker__count" aria-live="polite">
{t('mapping.selected_count', {
selected: selected.size,
total: objects.length,
})}
</span>
</div>
<div className="gn-data-sync-object-picker__select-all">
<label>
<input
type="checkbox"
data-object-picker-control="select-filtered"
checked={allFilteredSelected}
disabled={eligibleFiltered.length === 0}
onChange={(event) => {
const checked = event.target.checked;
setSelected((previous) => {
const next = new Set(previous);
eligibleFiltered.forEach((object) => {
if (checked) next.add(object.name);
else next.delete(object.name);
});
return next;
});
}}
/>
<span>{t('mapping.select_filtered', { count: eligibleFiltered.length })}</span>
</label>
<span>{t('mapping.selection_fixed_scope')}</span>
</div>
<div
className="gn-data-sync-object-picker__list"
role="group"
aria-label={t('mapping.picker_title')}
>
{filtered.length === 0 ? (
<p className="gn-data-sync-object-picker__empty">{t('mapping.no_objects')}</p>
) : (
filtered.map((object) => {
const alreadyMapped = mapped.has(normalizeName(object.name));
const checked = selected.has(object.name);
return (
<label
key={`${object.kind}:${object.name}`}
className="gn-data-sync-object-picker__item"
data-disabled={alreadyMapped ? 'true' : 'false'}
data-object-name={object.name}
>
<input
type="checkbox"
checked={checked || alreadyMapped}
disabled={disabled || alreadyMapped}
onChange={(event) => toggleSelected(object.name, event.target.checked)}
/>
<span className="gn-data-sync-object-picker__object">
<strong>{object.name}</strong>
<small>{t(`mapping.object_kind.${object.kind}`)}</small>
</span>
<ObjectFacts object={object} t={t} />
{alreadyMapped ? (
<span className="gn-data-sync-object-picker__mapped">
{t('mapping.already_added')}
</span>
) : null}
</label>
);
})
)}
</div>
<footer className="gn-data-sync-object-picker__actions">
<button
type="button"
className="gn-data-sync-button"
disabled={submitting}
onClick={onClose}
>
{t('common.cancel')}
</button>
<button
type="button"
className="gn-data-sync-button gn-data-sync-button--primary"
disabled={disabled || submitting || selected.size === 0}
onClick={() => {
setSubmitting(true);
void Promise.resolve(onConfirm(Array.from(selected))).then(
() => {
setSubmitting(false);
onClose();
},
() => setSubmitting(false),
);
}}
>
{submitting
? t('mapping.detecting_keys')
: t('mapping.add_selected', { count: selected.size })}
</button>
</footer>
</div>
);
};

View File

@@ -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 }) => (
<div className="gn-data-sync-empty">
<span className="gn-data-sync-empty__mark" aria-hidden="true"></span>
<strong>{title}</strong>
<p>{description}</p>
</div>
);
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,
}) => (
<section className="gn-data-sync-operational-view" data-data-sync-run-history="true">
<header className="gn-data-sync-view-heading">
<h1>{t('runs.title')}</h1>
<div>
<p>{t('runs.subtitle')}</p>
<button
type="button"
className="gn-data-sync-button"
disabled={busyAction === 'refresh-runs'}
onClick={onRefresh}
>
{t('common.refresh')}
</button>
</div>
</header>
{runs.length === 0 ? (
<EmptyState
title={t('runs.empty_title')}
description={t('runs.empty_desc')}
/>
) : (
<div className="gn-data-sync-table-scroll">
<table className="gn-data-sync-history-table">
<thead>
<tr>
<th>{t('runs.id')}</th>
<th>{t('runs.task')}</th>
<th>{t('runs.status')}</th>
<th>{t('runs.started_at')}</th>
<th>{t('runs.rows_written')}</th>
<th>{t('runs.rows_failed')}</th>
<th>{t('runs.checkpoint')}</th>
<th>{t('runs.actions')}</th>
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.id} data-selected={run.id === selectedRunId ? 'true' : 'false'}>
<td className="gn-data-sync-mono">{run.id}</td>
<td>{run.taskName}</td>
<td>
<span className="gn-data-sync-state-label" data-state={run.status}>
{t(`status.${run.status}`)}
</span>
</td>
<td>{run.startedAt || '—'}</td>
<td>{run.rowsWritten.toLocaleString()}</td>
<td>{run.rowsFailed.toLocaleString()}</td>
<td className="gn-data-sync-mono">{run.checkpoint || '—'}</td>
<td>
<button
type="button"
className="gn-data-sync-link-button"
onClick={() => onSelectRun(run.id)}
>
{t('runs.view_errors')}
</button>
{run.status === 'queued' || run.status === 'running' ? (
<button
type="button"
className="gn-data-sync-link-button"
disabled={busyAction === `cancel:${run.id}`}
onClick={() => onCancel(run.id)}
>
{t('runs.cancel')}
</button>
) : null}
{run.resumable ? (
<button
type="button"
className="gn-data-sync-link-button"
disabled={busyAction === `resume:${run.id}`}
onClick={() => onResume(run.id)}
>
{t('runs.resume')}
</button>
) : null}
{['failed', 'partial', 'canceled', 'cancelled', 'interrupted'].includes(
run.status,
) ? (
<button
type="button"
className="gn-data-sync-link-button"
disabled={busyAction === `retry:${run.id}`}
onClick={() => onRetry(run.id)}
>
{t('runs.retry')}
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<section className="gn-data-sync-error-rows" data-data-sync-error-rows="true">
<h2>{t('errors.title')}</h2>
{!selectedRunId || errorRows.length === 0 ? (
<EmptyState
title={t('errors.empty_title')}
description={t('errors.empty_desc')}
/>
) : (
<div className="gn-data-sync-table-scroll">
<table className="gn-data-sync-history-table">
<thead>
<tr>
<th>{t('errors.source')}</th>
<th>{t('errors.reason')}</th>
<th>{t('errors.payload')}</th>
<th>{t('errors.retryable')}</th>
<th>{t('runs.actions')}</th>
</tr>
</thead>
<tbody>
{errorRows.map((row) => (
<tr key={row.id}>
<td className="gn-data-sync-mono">{row.sourceObject}</td>
<td>{row.reason}</td>
<td className="gn-data-sync-mono">{row.payloadPreview}</td>
<td>{t(row.retryable ? 'common.yes' : 'common.no')}</td>
<td>
<button
type="button"
className="gn-data-sync-link-button"
disabled={
!errorRowRetryAvailable ||
!row.retryable ||
row.status !== 'pending' ||
busyAction === `retry-row:${row.id}`
}
title={
errorRowRetryAvailable && row.retryable
? ''
: t('errors.retry_unavailable')
}
onClick={() => onRetryErrorRow(row.id)}
>
{t('errors.retry')}
</button>
<button
type="button"
className="gn-data-sync-link-button gn-data-sync-link-button--danger"
disabled={
row.status === 'discarded' ||
busyAction === `discard:${row.id}`
}
onClick={() => onDiscardErrorRow(row.id)}
>
{row.status === 'discarded'
? t('errors.discarded')
: t('errors.discard')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="gn-data-sync-checkpoint-panel" data-data-sync-checkpoint="true">
<h2>{t('checkpoint.title')}</h2>
{!selectedRunId || !checkpoint ? (
<p>{t('checkpoint.empty')}</p>
) : (
<dl>
<div><dt>{t('checkpoint.kind')}</dt><dd>{checkpoint.kind || '—'}</dd></div>
<div><dt>{t('checkpoint.phase')}</dt><dd>{checkpoint.phase || '—'}</dd></div>
<div><dt>{t('checkpoint.updated_at')}</dt><dd>{checkpoint.updatedAt || '—'}</dd></div>
<div><dt>{t('checkpoint.cursor')}</dt><dd><code>{checkpoint.cursorPreview || '—'}</code></dd></div>
</dl>
)}
<button
type="button"
className="gn-data-sync-button gn-data-sync-button--danger"
disabled={
!checkpoint ||
!checkpointResetEnabled ||
busyAction === 'reset-checkpoint'
}
onClick={onResetCheckpoint}
>
{t('checkpoint.reset')}
</button>
<p>{t('checkpoint.reset_warning')}</p>
</section>
</section>
);
export const DataSyncScheduleView: React.FC<{
schedules: DataSyncScheduleSummary[];
t: DataSyncWorkbenchTranslate;
refreshing: boolean;
onRefresh: () => void;
}> = ({ schedules, t, refreshing, onRefresh }) => (
<section className="gn-data-sync-operational-view" data-data-sync-schedules="true">
<header className="gn-data-sync-view-heading">
<h1>{t('schedules.title')}</h1>
<div>
<p>{t('schedules.subtitle')}</p>
<button type="button" className="gn-data-sync-button" disabled={refreshing} onClick={onRefresh}>
{t('common.refresh')}
</button>
</div>
</header>
{schedules.length === 0 ? (
<EmptyState
title={t('schedules.empty_title')}
description={t('schedules.empty_desc')}
/>
) : (
<ul className="gn-data-sync-summary-list">
{schedules.map((schedule) => (
<li key={schedule.id}>
<span className="gn-data-sync-summary-list__signal" data-active={schedule.enabled} />
<strong>{schedule.taskName}</strong>
<code>{schedule.expression}</code>
<span>{schedule.timezone}</span>
<time>{schedule.nextRunAt}</time>
</li>
))}
</ul>
)}
</section>
);
export const DataSyncCdcView: React.FC<{
sources: DataSyncCdcSourceStatus[];
t: DataSyncWorkbenchTranslate;
refreshing: boolean;
onRefresh: () => void;
}> = ({ sources, t, refreshing, onRefresh }) => (
<section className="gn-data-sync-operational-view" data-data-sync-cdc="true">
<header className="gn-data-sync-view-heading">
<h1>{t('cdc.title')}</h1>
<div>
<p>{t('cdc.subtitle')}</p>
<button type="button" className="gn-data-sync-button" disabled={refreshing} onClick={onRefresh}>
{t('common.refresh')}
</button>
</div>
</header>
{sources.length === 0 ? (
<EmptyState
title={t('cdc.empty_title')}
description={t('cdc.empty_desc')}
/>
) : (
<ul className="gn-data-sync-summary-list">
{sources.map((source) => (
<li key={source.taskId || source.connectionId}>
<span className="gn-data-sync-summary-list__signal" data-state={source.status} />
<strong>{source.connectionName}</strong>
<code>{source.adapter || source.type}</code>
<span>{formatCdcLag(source.lagMs, t)}</span>
<code>{source.checkpoint || '—'}</code>
<span>{source.reason || t(`cdc.status.${source.status}`)}</span>
</li>
))}
</ul>
)}
</section>
);

View File

@@ -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(
<DataSyncPreflightPanel
snapshot={{
taskId: 'task-1',
taskRevision: 5,
status: 'blocked',
issues: [
{
id: 'definition_invalid:map-1',
code: 'definition_invalid',
severity: 'blocker',
stage: 'mappings',
mappingId: 'map-1',
message:
'table mapping 1 requires a targetTable and a sourceTable unless this is a query sink',
},
],
definitionHash: '',
approvalRequired: false,
approvalSatisfied: false,
checkedAt: '2030-08-08T00:00:00.000Z',
}}
currentRevision={5}
stale={false}
running={false}
t={createDataSyncWorkbenchTranslate('zh-CN')}
onLocateIssue={() => 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(
<DataSyncPreflightPanel
snapshot={snapshot}
currentRevision={4}
stale={false}
running={false}
t={t}
onLocateIssue={() => 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(
<DataSyncPreflightPanel
snapshot={snapshot}
currentRevision={4}
stale={false}
running={false}
t={t}
onLocateIssue={() => 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);
});
});

View File

@@ -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 (
<aside
className="gn-data-sync-preflight"
data-data-sync-preflight="true"
data-status={status}
data-preflight-task-id={snapshot?.taskId || ''}
aria-live="polite"
>
<header className="gn-data-sync-preflight__header">
<span className="gn-data-sync-preflight__signal" aria-hidden="true" />
<div>
<h2>{t('preflight.title')}</h2>
<strong>{statusText(snapshot, stale, running, t)}</strong>
</div>
</header>
<div className="gn-data-sync-preflight__revision">
{t('preflight.current_revision', { revision: currentRevision })}
</div>
{snapshot && !stale ? (
<div
className="gn-data-sync-approval-state"
data-approval-required={approvalRequired ? 'true' : 'false'}
role={approvalRequired ? 'alert' : 'status'}
>
<strong>
{approvalCurrent
? t('preflight.approval_granted')
: approvalRequired
? t('preflight.approval_required')
: t('preflight.approval_not_required')}
</strong>
{approvalRequired && !approvalCurrent ? (
<>
<span>{t('preflight.approval_fail_closed')}</span>
{!challengeCurrent ? (
<button
type="button"
className="gn-data-sync-button"
disabled={beginningApproval || approving || !onBeginApproval}
onClick={onBeginApproval}
>
{beginningApproval
? t('preflight.approval_beginning')
: t('preflight.approval_begin_countdown')}
</button>
) : remainingSeconds > 0 ? (
<button type="button" className="gn-data-sync-button" disabled>
{t('preflight.approval_countdown', { seconds: remainingSeconds })}
</button>
) : (
<button
type="button"
className="gn-data-sync-button gn-data-sync-button--danger"
disabled={approving || !onApprove}
onClick={onApprove}
>
{approving
? t('preflight.approving')
: t('preflight.approval_confirm')}
</button>
)}
{approvalError ? (
<span className="gn-data-sync-error-text" role="alert">
{approvalError}
</span>
) : null}
</>
) : approvalCurrent && approval ? (
<span>
{t('preflight.approval_expires', { expiresAt: approval.expiresAt })}
</span>
) : null}
</div>
) : null}
{effectiveIssues.length === 0 ? (
<div className="gn-data-sync-empty gn-data-sync-empty--compact">
<p>{running ? t('preflight.running') : t('preflight.empty')}</p>
</div>
) : (
<ol className="gn-data-sync-issue-list">
{effectiveIssues.map((issue) => (
<li key={issue.id} data-severity={issue.severity}>
<div>
<span className="gn-data-sync-issue-list__severity">
{t(`preflight.severity.${issue.severity}`)}
</span>
<p title={issue.message || undefined}>{issueText(issue, t)}</p>
</div>
<button
type="button"
className="gn-data-sync-link-button"
onClick={() => onLocateIssue(issue.stage, issue.mappingId)}
>
{t('preflight.open_issue')}
</button>
</li>
))}
</ol>
)}
</aside>
);
};

View File

@@ -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 }) => (
<div
className="gn-data-sync-route"
data-data-sync-route="true"
data-capability={capability.level}
data-active={active ? 'true' : 'false'}
>
<div className="gn-data-sync-route__endpoint">
<small>{t('route.source')}</small>
<strong>{endpointTitle(source, t('route.pending_source'))}</strong>
<span>{endpointScope(source, t('route.database_fallback'))}</span>
</div>
<div className="gn-data-sync-route__track" aria-label={t(`route.capability.${capability.level}`)}>
<span className="gn-data-sync-route__line" aria-hidden="true">
<span className="gn-data-sync-route__traveller" />
</span>
<span
className="gn-data-sync-route__capability"
data-level={capability.level}
>
{t(`route.capability.${capability.level}`)}
</span>
</div>
<div className="gn-data-sync-route__endpoint gn-data-sync-route__endpoint--target">
<small>{t('route.target')}</small>
<strong>{endpointTitle(target, t('route.pending_target'))}</strong>
<span>{endpointScope(target, t('route.database_fallback'))}</span>
</div>
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
import React from 'react';
import type { DataSyncTaskDefinition, DataSyncTaskKind } from './model';
import type { DataSyncWorkbenchTranslate } from './text';
const taskKindKey = (kind: DataSyncTaskKind) => `task_kind.${kind}` as const;
export const DataSyncTaskList: React.FC<{
tasks: DataSyncTaskDefinition[];
selectedTaskId: string;
search: string;
t: DataSyncWorkbenchTranslate;
onSearchChange: (value: string) => void;
onSelectTask: (taskId: string) => void;
onNewTask: () => void;
}> = ({
tasks,
selectedTaskId,
search,
t,
onSearchChange,
onSelectTask,
onNewTask,
}) => (
<aside className="gn-data-sync-task-list" aria-label={t('task_list.title')}>
<div className="gn-data-sync-task-list__header">
<strong>{t('task_list.title')}</strong>
<button
type="button"
className="gn-data-sync-icon-button"
aria-label={t('workbench.new_task')}
title={t('workbench.new_task')}
onClick={onNewTask}
>
+
</button>
</div>
<label className="gn-data-sync-search">
<span className="gn-data-sync-visually-hidden">{t('task_list.search')}</span>
<input
value={search}
placeholder={t('task_list.search')}
onChange={(event) => onSearchChange(event.target.value)}
/>
</label>
<div className="gn-data-sync-task-list__items">
{tasks.length === 0 ? (
<div className="gn-data-sync-task-list__empty">{t('task_list.empty')}</div>
) : (
tasks.map((task) => (
<button
key={task.id}
type="button"
className="gn-data-sync-task-row"
data-task-id={task.id}
data-selected={task.id === selectedTaskId ? 'true' : 'false'}
onClick={() => onSelectTask(task.id)}
>
<span className="gn-data-sync-task-row__marker" aria-hidden="true" />
<span className="gn-data-sync-task-row__content">
<span className="gn-data-sync-task-row__name">
{task.name || t(taskKindKey(task.kind))}
</span>
<span className="gn-data-sync-task-row__meta">
{t(taskKindKey(task.kind))} ·{' '}
{t('task_list.revision', { revision: task.revision })}
</span>
</span>
<span
className="gn-data-sync-state-label"
data-state={task.lifecycle}
>
{t(`task_list.lifecycle.${task.lifecycle}`)}
</span>
</button>
))
)}
</div>
</aside>
);
const TASK_KINDS: DataSyncTaskKind[] = [
'migration',
'reconcile',
'querySink',
'compare',
'cdc',
];
export const DataSyncTaskKindSelector: React.FC<{
t: DataSyncWorkbenchTranslate;
onSelect: (kind: DataSyncTaskKind) => void;
}> = ({ t, onSelect }) => (
<section className="gn-data-sync-kind-selector" data-data-sync-kind-selector="true">
<header>
<h2>{t('task_kind.title')}</h2>
<p>{t('task_kind.subtitle')}</p>
</header>
<div className="gn-data-sync-kind-table" role="list">
{TASK_KINDS.map((kind) => (
<button
key={kind}
type="button"
role="listitem"
className="gn-data-sync-kind-row"
data-task-kind={kind}
onClick={() => onSelect(kind)}
>
<span className="gn-data-sync-kind-row__arrow" aria-hidden="true"></span>
<span>
<strong>{t(taskKindKey(kind))}</strong>
<small>{t(`task_kind.${kind}_desc`)}</small>
</span>
</button>
))}
</div>
</section>
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,410 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import TestRenderer, { act } from 'react-test-renderer';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createStaticDataSyncWorkbenchGateway } from './gateway';
import {
createDataSyncTableMapping,
createDataSyncTaskDraft,
reviseDataSyncTask,
type DataSyncErrorRow,
type DataSyncRunRecord,
} from './model';
import { DataSyncWorkbenchShell } from './DataSyncWorkbenchShell';
const buildTask = () => {
const draft = createDataSyncTaskDraft({
id: 'orders-task',
kind: 'reconcile',
name: '订单同步',
now: '2026-08-08T00:00:00.000Z',
});
return reviseDataSyncTask(draft, {
source: {
connectionId: 'mysql-prod',
connectionName: 'MySQL 生产库',
type: 'mysql',
database: 'sales',
schema: '',
},
target: {
connectionId: 'postgres-warehouse',
connectionName: 'PostgreSQL 数仓',
type: 'postgres',
database: 'warehouse',
schema: 'ods',
},
mappings: [
{
...createDataSyncTableMapping('orders-map', 'orders', 'ods.orders'),
keyColumns: ['id'],
},
],
});
};
describe('DataSyncWorkbenchShell', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('renders a compact full-page shell with route, task list, and five editor stages', () => {
const task = buildTask();
const markup = renderToStaticMarkup(
<DataSyncWorkbenchShell initialTasks={[task]} locale="zh-CN" />,
);
expect(markup).toContain('data-data-sync-workbench-shell="true"');
expect(markup).toContain('data-data-sync-route="true"');
expect(markup).toContain('data-data-sync-task-editor="true"');
expect(markup).toContain('data-data-sync-preflight="true"');
expect(markup).toContain('订单同步');
expect(markup).toContain('MySQL 生产库');
expect(markup).toContain('PostgreSQL 数仓');
expect((markup.match(/gn-data-sync-stage-nav/g) || []).length).toBeGreaterThan(0);
expect(markup).not.toContain('ant-card');
expect(markup).not.toContain('linear-gradient');
});
it('edits mappings and marks the task revision as dirty', async () => {
const task = buildTask();
const renderer = TestRenderer.create(
<DataSyncWorkbenchShell initialTasks={[task]} locale="en-US" />,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
act(() => {
renderer.root
.findAllByType('button')
.find((button) => button.children.includes('Choose data'))!
.props.onClick();
});
const mappingSection = renderer.root.findByProps({
'data-data-sync-mapping-section': 'true',
});
const targetInput = mappingSection
.findAllByType('input')
.find((input) => input.props.value === 'ods.orders')!;
act(() => {
targetInput.props.onChange({ target: { value: 'ods.orders_v2' } });
});
expect(renderer.root.findByProps({ 'data-dirty': 'true' })).toBeTruthy();
expect(
renderer.root
.findAllByType('input')
.some((input) => input.props.value === 'ods.orders_v2'),
).toBe(true);
});
it('adapts run history and quarantined rows through the injected gateway', async () => {
const task = buildTask();
const run: DataSyncRunRecord = {
id: 'run-1',
taskId: task.id,
taskName: task.name,
status: 'failed',
trigger: 'manual',
attempt: 1,
resumable: true,
message: 'invalid timestamp',
startedAt: '2026-08-08T01:00:00.000Z',
finishedAt: '2026-08-08T01:01:00.000Z',
rowsRead: 10,
rowsWritten: 9,
rowsFailed: 1,
throughput: 9,
checkpoint: 'orders:9',
};
const errorRow: DataSyncErrorRow = {
id: 'error-1',
runId: run.id,
taskId: task.id,
mappingId: 'orders-map',
sourceObject: 'orders',
reason: 'invalid timestamp',
payloadPreview: '{"id":10}',
retryable: true,
status: 'pending',
operation: 'insert',
};
const gateway = createStaticDataSyncWorkbenchGateway({
tasks: [task],
runs: [run],
errorRowsByRun: { [run.id]: [errorRow] },
});
const renderer = TestRenderer.create(
<DataSyncWorkbenchShell
initialTasks={[task]}
gateway={gateway}
locale="en-US"
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
act(() => {
renderer.root
.findAllByType('button')
.find((button) => button.children.includes('Runs'))!
.props.onClick();
});
await act(async () => {
renderer.root
.findAllByType('button')
.find((button) => button.children.includes('View error rows'))!
.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findByProps({ 'data-data-sync-run-history': 'true' })).toBeTruthy();
expect(renderer.root.findAllByProps({ children: 'invalid timestamp' })).toHaveLength(1);
expect(renderer.root.findAllByProps({ children: '{"id":10}' })).toHaveLength(1);
await act(async () => {
renderer.root
.findAllByType('button')
.find((button) => button.children.includes('Discard'))!
.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(
renderer.root
.findAllByType('button')
.some((button) => button.children.includes('Discarded')),
).toBe(true);
});
it('rekeys a local draft and its preflight when persistence assigns an ID', async () => {
const task = {
...buildTask(),
id: 'data-sync-local-draft-1',
};
const baseGateway = createStaticDataSyncWorkbenchGateway({ tasks: [task] });
const gateway = {
...baseGateway,
async saveTask(submitted: typeof task) {
return { ...submitted, id: 'persisted-task-42' };
},
};
const renderer = TestRenderer.create(
<DataSyncWorkbenchShell
initialTasks={[task]}
gateway={gateway}
locale="en-US"
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const taskName = renderer.root
.findAllByType('input')
.find((input) => input.props.value === task.name)!;
act(() => {
taskName.props.onChange({ target: { value: 'Renamed before save' } });
});
await act(async () => {
renderer.root
.findAllByType('button')
.find((button) => button.children.includes('Run preflight'))!
.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(
renderer.root.findByProps({ 'data-approval-required': 'false' }),
).toBeTruthy();
await act(async () => {
renderer.root
.findAllByType('button')
.find((button) => button.children.includes('Save draft'))!
.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(
renderer.root.findByProps({
'data-task-id': 'persisted-task-42',
'data-selected': 'true',
}),
).toBeTruthy();
expect(
renderer.root.findAllByProps({ 'data-task-id': 'data-sync-local-draft-1' }),
).toHaveLength(0);
expect(
renderer.root.findByProps({
'data-data-sync-preflight': 'true',
'data-preflight-task-id': 'persisted-task-42',
'data-status': 'stale',
}),
).toBeTruthy();
expect(renderer.root.findByProps({ 'data-dirty': 'false' })).toBeTruthy();
});
it('shows approval-required preflight state and keeps execution fail-closed', async () => {
const task = buildTask();
const gateway = createStaticDataSyncWorkbenchGateway({
tasks: [task],
capabilities: {
[task.id]: {
level: 'full',
canExecute: true,
supportsAutoCreate: true,
supportsCdc: false,
},
},
approvalRequiredByTask: { [task.id]: true },
definitionHashByTask: { [task.id]: 'production-definition' },
});
const renderer = TestRenderer.create(
<DataSyncWorkbenchShell
initialTasks={[task]}
gateway={gateway}
locale="en-US"
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
renderer.root
.findAllByType('button')
.find((button) => button.children.includes('Run preflight'))!
.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(
renderer.root.findAllByProps({ 'data-approval-required': 'true' }).length,
).toBeGreaterThan(0);
expect(
renderer.root
.findAllByType('button')
.find((button) => button.children.includes('Run task'))!.props.disabled,
).toBe(true);
});
it('separates draft save, publish, and runnable lifecycle state', async () => {
const task = buildTask();
const renderer = TestRenderer.create(
<DataSyncWorkbenchShell initialTasks={[task]} locale="en-US" />,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(
renderer.root
.findAllByType('button')
.find((button) => button.children.includes('Run task'))!.props.disabled,
).toBe(true);
act(() => {
renderer.root
.findAllByType('button')
.find((button) => button.children.includes('Publish as ready'))!
.props.onClick();
});
expect(renderer.root.findByProps({ 'data-dirty': 'true' })).toBeTruthy();
expect(
renderer.root
.findAllByType('button')
.find((button) => button.children.includes('Save draft'))!.props.disabled,
).toBe(true);
});
it('enables checkpoint reset only for a paused task and requires confirmation', async () => {
const task = { ...buildTask(), lifecycle: 'paused' as const };
const run: DataSyncRunRecord = {
id: 'checkpoint-run',
taskId: task.id,
taskName: task.name,
status: 'failed',
trigger: 'manual',
attempt: 1,
resumable: true,
message: '',
startedAt: '2026-08-08T01:00:00.000Z',
finishedAt: '2026-08-08T01:01:00.000Z',
rowsRead: 10,
rowsWritten: 10,
rowsFailed: 0,
throughput: 10,
checkpoint: 'orders:10',
};
const baseGateway = createStaticDataSyncWorkbenchGateway({
tasks: [task],
runs: [run],
checkpointsByTask: {
[task.id]: {
taskId: task.id,
runId: run.id,
kind: 'watermark',
phase: 'batch_committed',
cursorPreview: '{"id":10}',
updatedAt: '2026-08-08T01:01:00.000Z',
},
},
});
const resetCheckpoint = vi.fn(baseGateway.resetCheckpoint.bind(baseGateway));
const gateway = { ...baseGateway, resetCheckpoint };
const confirm = vi.fn(() => false);
vi.stubGlobal('confirm', confirm);
const renderer = TestRenderer.create(
<DataSyncWorkbenchShell initialTasks={[task]} gateway={gateway} locale="en-US" />,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
act(() => {
renderer.root
.findAllByType('button')
.find((button) => button.children.includes('Runs'))!
.props.onClick();
});
await act(async () => {
renderer.root
.findAllByType('button')
.find((button) => button.children.includes('View error rows'))!
.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
const resetButton = () =>
renderer.root
.findAllByType('button')
.find((button) => button.children.includes('Reset checkpoint'))!;
expect(resetButton().props.disabled).toBe(false);
await act(async () => {
resetButton().props.onClick();
await Promise.resolve();
});
expect(confirm).toHaveBeenCalledTimes(1);
expect(resetCheckpoint).not.toHaveBeenCalled();
confirm.mockReturnValue(true);
await act(async () => {
resetButton().props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(resetCheckpoint).toHaveBeenCalledWith(task.id, task.revision);
expect(resetButton().props.disabled).toBe(true);
});
});

View File

@@ -0,0 +1,895 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
DataSyncCdcView,
DataSyncRunHistory,
DataSyncScheduleView,
} from './DataSyncOperationalViews';
import { DataSyncPreflightPanel } from './DataSyncPreflightPanel';
import { DataSyncRouteBar } from './DataSyncRouteBar';
import { DataSyncTaskEditor } from './DataSyncTaskEditor';
import {
DataSyncTaskKindSelector,
DataSyncTaskList,
} from './DataSyncTaskList';
import {
createStaticDataSyncWorkbenchGateway,
type DataSyncWorkbenchGateway,
} from './gateway';
import {
canStartDataSyncTask,
createDataSyncTaskDraft,
isDataSyncPreflightCurrent,
reviseDataSyncTask,
type DataSyncCdcSourceStatus,
type DataSyncCheckpointSummary,
type DataSyncApprovalChallenge,
type DataSyncApprovalGrant,
type DataSyncErrorRow,
type DataSyncPreflightSnapshot,
type DataSyncRouteCapability,
type DataSyncRunRecord,
type DataSyncScheduleSummary,
type DataSyncTaskDefinition,
type DataSyncTaskKind,
type DataSyncTaskStage,
} from './model';
import {
createDataSyncWorkbenchTranslate,
type DataSyncWorkbenchLocale,
} from './text';
import './DataSyncWorkbench.css';
type WorkbenchView = 'tasks' | 'runs' | 'schedules' | 'cdc';
const EMPTY_CAPABILITY: DataSyncRouteCapability = {
level: 'unknown',
canExecute: false,
supportsAutoCreate: false,
supportsCdc: false,
};
const viewKeys: WorkbenchView[] = ['tasks', 'runs', 'schedules', 'cdc'];
let localTaskSequence = 0;
const nextLocalTaskId = (): string => {
localTaskSequence += 1;
return `data-sync-local-${Date.now()}-${localTaskSequence}`;
};
export type DataSyncWorkbenchShellProps = {
initialTasks?: DataSyncTaskDefinition[];
gateway?: DataSyncWorkbenchGateway;
locale?: DataSyncWorkbenchLocale | string;
onClose?: () => void;
};
export const DataSyncWorkbenchShell: React.FC<DataSyncWorkbenchShellProps> = ({
initialTasks = [],
gateway,
locale,
onClose,
}) => {
const t = useMemo(() => createDataSyncWorkbenchTranslate(locale), [locale]);
const initialTasksRef = useRef<DataSyncTaskDefinition[]>();
if (!initialTasksRef.current) {
initialTasksRef.current =
initialTasks.length > 0
? initialTasks
: [
createDataSyncTaskDraft({
id: 'data-sync-local-draft',
kind: 'reconcile',
name: t('task_kind.reconcile'),
}),
];
}
const gatewayRef = useRef<DataSyncWorkbenchGateway>();
if (!gatewayRef.current) {
gatewayRef.current =
gateway ||
createStaticDataSyncWorkbenchGateway({ tasks: initialTasksRef.current });
}
const [activeView, setActiveView] = useState<WorkbenchView>('tasks');
const [tasks, setTasks] = useState<DataSyncTaskDefinition[]>(initialTasksRef.current);
const [selectedTaskId, setSelectedTaskId] = useState(
initialTasksRef.current[0]?.id || '',
);
const [activeStage, setActiveStage] = useState<DataSyncTaskStage>('endpoints');
const [search, setSearch] = useState('');
const [showKindSelector, setShowKindSelector] = useState(false);
const [dirtyTaskIds, setDirtyTaskIds] = useState<Set<string>>(
() =>
new Set(
initialTasksRef.current!
.filter((task) => task.id.startsWith('data-sync-local-'))
.map((task) => task.id),
),
);
const [saving, setSaving] = useState(false);
const [preflighting, setPreflighting] = useState(false);
const [preflights, setPreflights] = useState<
Record<string, DataSyncPreflightSnapshot | undefined>
>({});
const [approvals, setApprovals] = useState<
Record<string, DataSyncApprovalGrant | undefined>
>({});
const [approvalChallenges, setApprovalChallenges] = useState<
Record<string, DataSyncApprovalChallenge | undefined>
>({});
const [beginningApproval, setBeginningApproval] = useState(false);
const [approving, setApproving] = useState(false);
const [approvalError, setApprovalError] = useState('');
const [operationError, setOperationError] = useState('');
const [operationBusy, setOperationBusy] = useState('');
const [capability, setCapability] = useState<DataSyncRouteCapability>(
EMPTY_CAPABILITY,
);
const [runs, setRuns] = useState<DataSyncRunRecord[]>([]);
const [schedules, setSchedules] = useState<DataSyncScheduleSummary[]>([]);
const [cdcSources, setCdcSources] = useState<DataSyncCdcSourceStatus[]>([]);
const [selectedRunId, setSelectedRunId] = useState('');
const [errorRows, setErrorRows] = useState<DataSyncErrorRow[]>([]);
const [checkpoint, setCheckpoint] = useState<DataSyncCheckpointSummary | null>(null);
const selectedTask = tasks.find((task) => task.id === selectedTaskId) || null;
const checkpointTask = checkpoint
? tasks.find((task) => task.id === checkpoint.taskId) || null
: null;
const selectedPreflight = selectedTask
? preflights[selectedTask.id] || null
: null;
const selectedApproval = selectedTask ? approvals[selectedTask.id] || null : null;
const selectedApprovalChallenge = selectedTask
? approvalChallenges[selectedTask.id] || null
: null;
const preflightStale = Boolean(
selectedTask &&
selectedPreflight &&
!isDataSyncPreflightCurrent(selectedTask, selectedPreflight),
);
const filteredTasks = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return tasks;
return tasks.filter((task) =>
[task.name, task.kind, task.source.connectionName, task.target.connectionName]
.join(' ')
.toLowerCase()
.includes(query),
);
}, [search, tasks]);
useEffect(() => {
let active = true;
void gatewayRef.current!
.listTasks()
.then(async (loadedTasks) => {
const [loadedRuns, loadedSchedules, loadedSources] = await Promise.all([
gatewayRef.current!.listRuns(),
gatewayRef.current!.listSchedules(),
gatewayRef.current!.listCdcSources(),
]);
return [loadedTasks, loadedRuns, loadedSchedules, loadedSources] as const;
})
.then(([loadedTasks, loadedRuns, loadedSchedules, loadedSources]) => {
if (!active) return;
if (loadedTasks.length > 0) {
setTasks(loadedTasks);
setSelectedTaskId((current) =>
loadedTasks.some((task) => task.id === current)
? current
: loadedTasks[0].id,
);
}
setRuns(loadedRuns);
setSchedules(loadedSchedules);
setCdcSources(loadedSources);
})
.catch((error) => {
if (active) {
setOperationError(error instanceof Error ? error.message : String(error));
}
});
return () => {
active = false;
};
}, []);
useEffect(() => {
if (!selectedTask) {
setCapability(EMPTY_CAPABILITY);
return undefined;
}
let active = true;
setCapability(EMPTY_CAPABILITY);
void gatewayRef.current!
.resolveCapability(selectedTask)
.then((resolved) => {
if (active) setCapability(resolved);
})
.catch((error) => {
if (!active) return;
setCapability(EMPTY_CAPABILITY);
setOperationError(error instanceof Error ? error.message : String(error));
});
return () => {
active = false;
};
}, [selectedTask?.id, selectedTask?.source, selectedTask?.target]);
useEffect(() => {
if (activeView !== 'runs') return undefined;
const hasActiveRun = runs.some((run) =>
['queued', 'running', 'cancelling', 'preflighting', 'snapshotting', 'catching_up', 'streaming']
.includes(run.status),
);
if (!hasActiveRun) return undefined;
const timer = globalThis.setInterval(() => {
void gatewayRef.current!
.listRuns()
.then(setRuns)
.catch((error) =>
setOperationError(error instanceof Error ? error.message : String(error)),
);
}, 3_000);
return () => globalThis.clearInterval(timer);
}, [activeView, runs]);
const patchSelectedTask = (
patch: Partial<
Omit<
DataSyncTaskDefinition,
'id' | 'schemaVersion' | 'revision' | 'createdAt'
>
>,
) => {
const taskId = selectedTask?.id;
if (!taskId) return;
setTasks((current) =>
current.map((task) =>
task.id === taskId ? reviseDataSyncTask(task, patch) : task,
),
);
setDirtyTaskIds((current) => new Set(current).add(taskId));
setApprovalError('');
setApprovalChallenges((current) => {
if (!current[taskId]) return current;
const next = { ...current };
delete next[taskId];
return next;
});
};
const createTask = (kind: DataSyncTaskKind) => {
const task = createDataSyncTaskDraft({
id: nextLocalTaskId(),
kind,
name: t(`task_kind.${kind}`),
});
setTasks((current) => [...current, task]);
setSelectedTaskId(task.id);
setDirtyTaskIds((current) => new Set(current).add(task.id));
setActiveStage('endpoints');
setShowKindSelector(false);
setActiveView('tasks');
};
const saveTask = async () => {
if (!selectedTask || saving) return;
const submittedTaskId = selectedTask.id;
setSaving(true);
setOperationError('');
try {
const saved = await gatewayRef.current!.saveTask(selectedTask);
setTasks((current) => {
const next = current.flatMap((task) => {
if (task.id === submittedTaskId) return [saved];
if (task.id === saved.id) return [];
return [task];
});
return next.some((task) => task.id === saved.id) ? next : [...next, saved];
});
if (saved.id !== submittedTaskId) {
setSelectedTaskId((current) =>
current === submittedTaskId ? saved.id : current,
);
setPreflights((current) => {
const previous = current[submittedTaskId];
if (!previous) return current;
const next = { ...current };
delete next[submittedTaskId];
next[saved.id] = {
...previous,
taskId: saved.id,
// A server-assigned identity can change the authoritative definition hash.
// Keep the keyed evidence for display, but force a fresh preflight.
taskRevision:
previous.taskRevision === saved.revision
? previous.taskRevision - 1
: previous.taskRevision,
};
return next;
});
}
setDirtyTaskIds((current) => {
const next = new Set(current);
next.delete(submittedTaskId);
next.delete(saved.id);
return next;
});
setApprovals((current) => {
const next = { ...current };
delete next[submittedTaskId];
delete next[saved.id];
return next;
});
setApprovalChallenges((current) => {
const next = { ...current };
delete next[submittedTaskId];
delete next[saved.id];
return next;
});
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
} finally {
setSaving(false);
}
};
const runPreflight = async () => {
if (!selectedTask || preflighting) return;
setPreflighting(true);
setOperationError('');
try {
const snapshot = await gatewayRef.current!.preflightTask(selectedTask);
setPreflights((current) => ({ ...current, [selectedTask.id]: snapshot }));
setApprovals((current) => {
const approval = current[selectedTask.id];
if (!approval || approval.definitionHash === snapshot.definitionHash) return current;
const next = { ...current };
delete next[selectedTask.id];
return next;
});
setApprovalChallenges((current) => {
const challenge = current[selectedTask.id];
if (!challenge || challenge.definitionHash === snapshot.definitionHash) {
return current;
}
const next = { ...current };
delete next[selectedTask.id];
return next;
});
setActiveStage('preflight');
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
} finally {
setPreflighting(false);
}
};
const startTask = async () => {
if (
!selectedTask ||
!selectedPreflight ||
!capability.canExecute ||
dirtyTaskIds.has(selectedTask.id) ||
!canStartDataSyncTask(selectedTask, selectedPreflight, selectedApproval)
) {
return;
}
setOperationBusy('start');
setOperationError('');
try {
const run = await gatewayRef.current!.startTask(
selectedTask,
selectedPreflight,
);
setRuns((current) => [run, ...current.filter((item) => item.id !== run.id)]);
setActiveView('runs');
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
} finally {
setApprovals((current) => {
const next = { ...current };
delete next[selectedTask.id];
return next;
});
setOperationBusy('');
}
};
const selectRun = async (runId: string) => {
setSelectedRunId(runId);
const run = runs.find((item) => item.id === runId);
setOperationError('');
try {
const [rows, loadedCheckpoint] = await Promise.all([
gatewayRef.current!.listErrorRows(runId),
run ? gatewayRef.current!.getCheckpoint(run.taskId) : Promise.resolve(null),
]);
setErrorRows(rows);
setCheckpoint(loadedCheckpoint);
} catch (error) {
setErrorRows([]);
setCheckpoint(null);
setOperationError(error instanceof Error ? error.message : String(error));
}
};
const beginApproval = async () => {
if (!selectedTask || !selectedPreflight || beginningApproval) return;
setBeginningApproval(true);
setApprovalError('');
try {
const challenge = await gatewayRef.current!.beginApproval(
selectedTask,
selectedPreflight,
);
setApprovalChallenges((current) => ({
...current,
[selectedTask.id]: challenge,
}));
} catch (error) {
setApprovalError(error instanceof Error ? error.message : String(error));
} finally {
setBeginningApproval(false);
}
};
const approveTask = async () => {
if (!selectedTask || !selectedPreflight || approving) return;
setApproving(true);
setApprovalError('');
try {
const grant = await gatewayRef.current!.approveTask(
selectedTask,
selectedPreflight,
);
setApprovals((current) => ({ ...current, [selectedTask.id]: grant }));
} catch (error) {
setApprovalError(error instanceof Error ? error.message : String(error));
} finally {
setApprovalChallenges((current) => {
const next = { ...current };
delete next[selectedTask.id];
return next;
});
setApproving(false);
}
};
const refreshRuns = async () => {
setOperationBusy('refresh-runs');
setOperationError('');
try {
setRuns(await gatewayRef.current!.listRuns());
if (selectedRunId) await selectRun(selectedRunId);
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
} finally {
setOperationBusy('');
}
};
const updateRun = async (
action: 'cancel' | 'resume' | 'retry',
runId: string,
) => {
setOperationBusy(`${action}:${runId}`);
setOperationError('');
try {
if (action === 'cancel') {
await gatewayRef.current!.cancelRun(runId);
setRuns((current) =>
current.map((run) =>
run.id === runId ? { ...run, status: 'cancelling' } : run,
),
);
} else {
const run =
action === 'resume'
? await gatewayRef.current!.resumeRun(runId)
: await gatewayRef.current!.retryRun(runId);
setRuns((current) => [run, ...current.filter((item) => item.id !== run.id)]);
}
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
} finally {
setOperationBusy('');
}
};
const discardErrorRow = async (errorRowId: string) => {
setOperationBusy(`discard:${errorRowId}`);
setOperationError('');
try {
await gatewayRef.current!.discardErrorRow(errorRowId);
setErrorRows((current) =>
current.map((row) =>
row.id === errorRowId ? { ...row, status: 'discarded' } : row,
),
);
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
} finally {
setOperationBusy('');
}
};
const retryErrorRow = async (errorRowId: string) => {
if (!gatewayRef.current!.capabilities.errorRowRetry) return;
const errorRow = errorRows.find((row) => row.id === errorRowId);
if (!errorRow || !errorRow.retryable || errorRow.status !== 'pending') return;
setOperationBusy(`retry-row:${errorRowId}`);
setOperationError('');
try {
const retried = await gatewayRef.current!.retryErrorRow(errorRowId);
setErrorRows((current) =>
current.map((row) => (row.id === retried.id ? retried : row)),
);
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
} finally {
// A retry may consume a one-time production approval token. Clear the
// visible grant even on an uncertain response so it cannot appear reusable.
setApprovals((current) => {
if (!current[errorRow.taskId]) return current;
const next = { ...current };
delete next[errorRow.taskId];
return next;
});
setOperationBusy('');
}
};
const resetCheckpoint = async () => {
if (!checkpoint || !checkpointTask || checkpointTask.lifecycle !== 'paused') {
return;
}
if (!globalThis.confirm(t('checkpoint.reset_warning'))) return;
setOperationBusy('reset-checkpoint');
setOperationError('');
try {
const saved = await gatewayRef.current!.resetCheckpoint(
checkpoint.taskId,
checkpointTask.revision,
);
setTasks((current) =>
current.map((task) => (task.id === saved.id ? saved : task)),
);
setPreflights((current) => {
if (!current[saved.id]) return current;
const next = { ...current };
delete next[saved.id];
return next;
});
setApprovals((current) => {
if (!current[saved.id]) return current;
const next = { ...current };
delete next[saved.id];
return next;
});
setApprovalChallenges((current) => {
if (!current[saved.id]) return current;
const next = { ...current };
delete next[saved.id];
return next;
});
setCheckpoint(null);
setCdcSources(await gatewayRef.current!.listCdcSources());
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
} finally {
setOperationBusy('');
}
};
const refreshSchedules = async () => {
setOperationBusy('refresh-schedules');
try {
setSchedules(await gatewayRef.current!.listSchedules());
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
} finally {
setOperationBusy('');
}
};
const refreshCdc = async () => {
setOperationBusy('refresh-cdc');
try {
setCdcSources(await gatewayRef.current!.listCdcSources());
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
} finally {
setOperationBusy('');
}
};
const locateIssue = (stage: DataSyncTaskStage) => {
setActiveView('tasks');
setShowKindSelector(false);
setActiveStage(stage);
};
const transitionLifecycle = (
lifecycle: DataSyncTaskDefinition['lifecycle'],
) => {
patchSelectedTask({ lifecycle });
setActiveStage('preflight');
};
const actionEnabled = Boolean(
selectedTask &&
selectedPreflight &&
capability.canExecute &&
!dirtyTaskIds.has(selectedTask.id) &&
canStartDataSyncTask(selectedTask, selectedPreflight, selectedApproval),
);
const saveApprovalReady = Boolean(
selectedTask &&
(selectedTask.lifecycle === 'draft' ||
selectedTask.lifecycle === 'paused' ||
selectedTask.lifecycle === 'archived' ||
(selectedPreflight &&
isDataSyncPreflightCurrent(selectedTask, selectedPreflight) &&
selectedPreflight.status !== 'blocked' &&
(selectedPreflight.approvalRequired === false ||
Boolean(
selectedApproval &&
selectedApproval.definitionHash === selectedPreflight.definitionHash &&
Date.parse(selectedApproval.expiresAt) > Date.now(),
)))),
);
return (
<div
className="gn-data-sync-workbench"
data-data-sync-workbench-shell="true"
>
<header className="gn-data-sync-workbench__header">
<div className="gn-data-sync-workbench__identity">
<strong>{t('workbench.title')}</strong>
<span>{t('workbench.subtitle')}</span>
</div>
<nav className="gn-data-sync-global-nav" aria-label={t('workbench.title')}>
{viewKeys.map((view) => (
<button
key={view}
type="button"
data-active={activeView === view ? 'true' : 'false'}
onClick={() => setActiveView(view)}
>
{t(`nav.${view}`)}
</button>
))}
</nav>
<div className="gn-data-sync-workbench__header-actions">
<button
type="button"
className="gn-data-sync-button"
onClick={() => {
setActiveView('tasks');
setShowKindSelector(true);
}}
>
{t('workbench.new_task')}
</button>
{onClose ? (
<button
type="button"
className="gn-data-sync-icon-button"
aria-label={t('workbench.close')}
title={t('workbench.close')}
onClick={onClose}
>
×
</button>
) : null}
</div>
</header>
{operationError ? (
<div className="gn-data-sync-workbench-error" role="alert">
<span>{operationError}</span>
<button
type="button"
className="gn-data-sync-link-button"
onClick={() => setOperationError('')}
>
{t('common.dismiss')}
</button>
</div>
) : null}
{activeView === 'tasks' ? (
<div className="gn-data-sync-workspace-grid">
<DataSyncTaskList
tasks={filteredTasks}
selectedTaskId={selectedTaskId}
search={search}
t={t}
onSearchChange={setSearch}
onSelectTask={(taskId) => {
setSelectedTaskId(taskId);
setShowKindSelector(false);
}}
onNewTask={() => setShowKindSelector(true)}
/>
<main className="gn-data-sync-editor-column">
{showKindSelector || !selectedTask ? (
<DataSyncTaskKindSelector t={t} onSelect={createTask} />
) : (
<>
<DataSyncRouteBar
source={selectedTask.source}
target={selectedTask.target}
capability={capability}
active={selectedTask.kind === 'cdc' && selectedTask.lifecycle === 'enabled'}
t={t}
/>
<DataSyncTaskEditor
task={selectedTask}
gateway={gatewayRef.current!}
capability={capability}
activeStage={activeStage}
preflight={selectedPreflight}
preflightStale={preflightStale}
t={t}
onStageChange={setActiveStage}
onPatch={patchSelectedTask}
/>
<footer className="gn-data-sync-action-bar">
<span
className="gn-data-sync-save-state"
data-dirty={dirtyTaskIds.has(selectedTask.id) ? 'true' : 'false'}
>
{dirtyTaskIds.has(selectedTask.id)
? t('workbench.unsaved')
: t('workbench.saved')}
</span>
<span className="gn-data-sync-action-bar__spacer" />
{selectedTask.lifecycle === 'draft' ? (
<button
type="button"
className="gn-data-sync-button"
onClick={() => transitionLifecycle('ready')}
>
{t('lifecycle.publish_ready')}
</button>
) : null}
{selectedTask.lifecycle === 'ready' &&
selectedTask.trigger.mode !== 'manual' ? (
<button
type="button"
className="gn-data-sync-button"
onClick={() => transitionLifecycle('enabled')}
>
{t('lifecycle.enable_schedule')}
</button>
) : null}
{selectedTask.lifecycle === 'enabled' ? (
<button
type="button"
className="gn-data-sync-button"
onClick={() => transitionLifecycle('paused')}
>
{t('lifecycle.pause')}
</button>
) : null}
{selectedTask.lifecycle === 'paused' ? (
<button
type="button"
className="gn-data-sync-button"
onClick={() => transitionLifecycle('enabled')}
>
{t('lifecycle.resume_schedule')}
</button>
) : null}
{selectedTask.lifecycle !== 'archived' ? (
<button
type="button"
className="gn-data-sync-link-button gn-data-sync-link-button--danger"
onClick={() => transitionLifecycle('archived')}
>
{t('lifecycle.archive')}
</button>
) : null}
<button
type="button"
className="gn-data-sync-button"
disabled={
saving ||
!dirtyTaskIds.has(selectedTask.id) ||
!saveApprovalReady
}
onClick={() => void saveTask()}
>
{saving ? t('workbench.saving') : t('workbench.save')}
</button>
<button
type="button"
className="gn-data-sync-button"
disabled={preflighting}
onClick={() => void runPreflight()}
>
{preflighting
? t('workbench.preflighting')
: t('workbench.run_preflight')}
</button>
<button
type="button"
className="gn-data-sync-button gn-data-sync-button--primary"
disabled={!actionEnabled || operationBusy === 'start'}
title={actionEnabled ? t('workbench.start') : t('workbench.blocked_action')}
onClick={() => void startTask()}
>
{t('workbench.start')}
</button>
</footer>
</>
)}
</main>
{selectedTask && !showKindSelector ? (
<DataSyncPreflightPanel
snapshot={selectedPreflight}
currentRevision={selectedTask.revision}
stale={preflightStale}
running={preflighting}
t={t}
onLocateIssue={locateIssue}
approval={selectedApproval}
approvalChallenge={selectedApprovalChallenge}
beginningApproval={beginningApproval}
approving={approving}
approvalError={approvalError}
onBeginApproval={() => void beginApproval()}
onApprove={() => void approveTask()}
/>
) : (
<aside className="gn-data-sync-preflight gn-data-sync-preflight--blank" />
)}
</div>
) : null}
{activeView === 'runs' ? (
<DataSyncRunHistory
runs={runs}
selectedRunId={selectedRunId}
errorRows={errorRows}
t={t}
onSelectRun={(runId) => void selectRun(runId)}
checkpoint={checkpoint}
busyAction={operationBusy}
onRefresh={() => void refreshRuns()}
onCancel={(runId) => void updateRun('cancel', runId)}
onResume={(runId) => void updateRun('resume', runId)}
onRetry={(runId) => void updateRun('retry', runId)}
onDiscardErrorRow={(errorRowId) => void discardErrorRow(errorRowId)}
errorRowRetryAvailable={gatewayRef.current!.capabilities.errorRowRetry}
onRetryErrorRow={(errorRowId) => void retryErrorRow(errorRowId)}
checkpointResetEnabled={checkpointTask?.lifecycle === 'paused'}
onResetCheckpoint={() => void resetCheckpoint()}
/>
) : null}
{activeView === 'schedules' ? (
<DataSyncScheduleView
schedules={schedules}
t={t}
refreshing={operationBusy === 'refresh-schedules'}
onRefresh={() => void refreshSchedules()}
/>
) : null}
{activeView === 'cdc' ? (
<DataSyncCdcView
sources={cdcSources}
t={t}
refreshing={operationBusy === 'refresh-cdc'}
onRefresh={() => void refreshCdc()}
/>
) : null}
</div>
);
};

View File

@@ -0,0 +1,21 @@
export { DataSyncWorkbenchShell } from './DataSyncWorkbenchShell';
export type { DataSyncWorkbenchShellProps } from './DataSyncWorkbenchShell';
export {
createStaticDataSyncWorkbenchGateway,
dataSyncEndpointMetadataKey,
dataSyncObjectMetadataKey,
type StaticDataSyncGatewayFixtures,
type DataSyncWorkbenchGateway,
} from './gateway';
export {
createWailsDataSyncWorkbenchGateway,
type WailsDataSyncApi,
} from './wailsGateway';
export * from './model';
export {
createDataSyncWorkbenchTranslate,
resolveDataSyncWorkbenchLocale,
type DataSyncWorkbenchLocale,
type DataSyncWorkbenchTextKey,
type DataSyncWorkbenchTranslate,
} from './text';

View File

@@ -0,0 +1,856 @@
const zhCN = {
'workbench.title': '数据迁移与同步',
'workbench.subtitle': '定义、验证并追踪跨数据源任务',
'workbench.close': '关闭工作台',
'workbench.new_task': '新建任务',
'workbench.save': '保存草稿',
'workbench.saving': '正在保存',
'workbench.saved': '已保存',
'workbench.unsaved': '有未保存修改',
'workbench.run_preflight': '运行预检',
'workbench.preflighting': '正在预检',
'workbench.start': '运行任务',
'workbench.enable': '启用任务',
'workbench.blocked_action': '先解决预检阻断项',
'nav.tasks': '任务',
'nav.runs': '运行记录',
'nav.schedules': '调度',
'nav.cdc': 'CDC',
'task_list.title': '任务',
'task_list.search': '搜索任务',
'task_list.empty': '没有匹配的任务',
'task_list.revision': '版本 {revision}',
'task_list.lifecycle.draft': '草稿',
'task_list.lifecycle.ready': '就绪',
'task_list.lifecycle.enabled': '已启用',
'task_list.lifecycle.paused': '已暂停',
'task_list.lifecycle.archived': '已归档',
'lifecycle.publish_ready': '发布为就绪',
'lifecycle.enable_schedule': '启用调度',
'lifecycle.pause': '暂停任务',
'lifecycle.resume_schedule': '重新启用',
'lifecycle.archive': '归档',
'task_kind.title': '选择任务类型',
'task_kind.subtitle': '类型决定可用的增量和触发策略,创建后仍可复制为其他任务。',
'task_kind.migration': '一次性迁移',
'task_kind.migration_desc': '迁移结构和数据,可创建目标对象。',
'task_kind.reconcile': '批次/差异同步',
'task_kind.reconcile_desc': '按主键执行快照比较或水位线增量。',
'task_kind.querySink': 'SQL 结果写入',
'task_kind.querySink_desc': '执行查询并把稳定结果写入目标表。',
'task_kind.compare': '只读比较',
'task_kind.compare_desc': '比较结构或数据,不修改目标端。',
'task_kind.cdc': '持续增量 CDC',
'task_kind.cdc_desc': '消费变更日志并持久化 checkpoint。',
'stage.endpoints': '选择源和目标',
'stage.mappings': '选择同步数据',
'stage.delivery': '目标写入',
'stage.trigger': '运行方式',
'stage.preflight': '检查并运行',
'route.source': '源端',
'route.target': '目标端',
'route.pending_source': '选择源连接',
'route.pending_target': '选择目标连接',
'route.capability.full': '完整支持',
'route.capability.partial': '兼容模式',
'route.capability.unsupported': '不支持',
'route.capability.unknown': '等待能力确认',
'route.database_fallback': '未选择库',
'editor.task_name': '任务名称',
'editor.task_name_placeholder': '例如:订单数据同步到分析库',
'editor.source_endpoint': '源端点',
'editor.target_endpoint': '目标端点',
'editor.connection': '已保存连接',
'editor.connection_id': '连接 ID',
'editor.connection_name': '连接名称',
'editor.database_type': '数据源类型',
'editor.database': 'Database / Catalog',
'editor.schema': 'Schema',
'editor.source_query': '源 SQL 查询',
'editor.source_query_placeholder': 'SELECT ...',
'editor.endpoint_help': '从已保存连接中选择端点;任务只保存连接引用,不保存账号、密码或连接串。',
'metadata.select_connection': '选择连接',
'metadata.select_database': '选择 Database / Catalog',
'metadata.not_selected': '未选择',
'metadata.schema_optional': '可选;留空使用默认命名空间',
'metadata.loading_connections': '正在加载连接',
'metadata.loading_databases': '正在加载数据库',
'metadata.loading_objects': '正在加载对象',
'metadata.loading_fields': '正在加载字段',
'metadata.loading_cdc_adapters': '正在加载 CDC 适配器',
'metadata.load_failed': '元数据加载失败',
'metadata.retry': '重试',
'metadata.no_eligible_connections': '没有符合当前读写方向的连接。',
'metadata.no_databases': '该连接未返回数据库列表,草稿仍可保存。',
'metadata.endpoint_required': '先选择端点',
'metadata.object_required': '先选择对象',
'metadata.objects_count': '{count} 个对象',
'metadata.fields_count': '{count} 个字段',
'mapping.title': '选择要同步的数据',
'mapping.help': '可选择一张、多张或筛选后的全部表。系统会自动匹配同名目标、识别主键并生成映射,你只需处理例外。',
'mapping.query_help': 'SQL 查询结果只写入一个目标对象;请选择已有目标表并配置写入键。',
'mapping.add': '添加映射',
'mapping.select_objects': '选择源对象',
'mapping.add_target': '添加目标对象',
'mapping.picker_title': '批量选择源对象',
'mapping.search_objects': '搜索表、视图或集合',
'mapping.include_views': '包含视图',
'mapping.selected_count': '已选 {selected} / 共 {total}',
'mapping.select_filtered': '全选当前筛选结果({count}',
'mapping.selection_fixed_scope': '保存时固化本次清单,后续新增表不会自动加入',
'mapping.object_kind.table': '表',
'mapping.object_kind.view': '视图',
'mapping.object_kind.collection': '集合',
'mapping.already_added': '已添加',
'mapping.rows_count': '{count} 行',
'mapping.no_objects': '没有匹配的对象',
'mapping.add_selected': '添加 {count} 个对象',
'mapping.detecting_keys': '正在识别主键…',
'mapping.open_object_list': '展开对象列表',
'mapping.will_create_named': '目标不存在,将创建“{name}”',
'mapping.no_matching_objects': '没有匹配的已有对象',
'mapping.remove': '删除',
'mapping.enabled': '启用',
'mapping.source': '源对象',
'mapping.target': '目标对象',
'mapping.target_mode': '目标不存在时',
'mapping.key_columns': '用于识别同一行',
'mapping.fields': '字段',
'mapping.status': '状态',
'mapping.create_or_reuse': '自动创建;已存在则校验后使用',
'mapping.existing_only': '仅写入已有目标',
'mapping.edit_exceptions': '编辑例外',
'mapping.collapse_exceptions': '收起',
'mapping.fields_count': '{count} 个字段',
'mapping.fields_pending': '自动同名写入',
'mapping.fields_automatic': '自动同名写入',
'mapping.fields_automatic_help': '无需逐列配置;仅在字段改名或转换时编辑。',
'mapping.fields_required': '需要确认同步字段',
'mapping.fields_cdc_help': '持续同步必须明确目标字段,避免源端删字段后目标残留旧值。',
'mapping.ready': '已配置',
'mapping.needs_attention': '需处理',
'mapping.key_placeholder': 'id, tenant_id',
'mapping.key_detected': '已从源端元数据自动识别,可按需修改',
'mapping.key_when_needed': '差异同步和 CDC 必须有稳定主键;一次性迁移可留空',
'mapping.source_placeholder': '搜索或选择源对象',
'mapping.target_placeholder': '搜索已有目标或输入新名称',
'mapping.query_result_source': '查询结果(唯一映射)',
'mapping.target_state.exists': '目标已存在',
'mapping.target_state.create': '将创建',
'mapping.target_state.missing': '未匹配目标',
'mapping.empty_title': '尚未选择同步对象',
'mapping.empty_desc': '先选择源对象。单表和多表使用同一个入口,也可以搜索后全选当前结果。',
'field_mapping.title': '字段映射',
'field_mapping.close': '收起',
'field_mapping.auto_match': '同名自动匹配',
'field_mapping.add': '添加字段',
'field_mapping.empty_title': '尚未配置字段映射',
'field_mapping.empty_desc': '加载两端字段后自动匹配,或手动添加并配置转换表达式。',
'field_mapping.source': '源字段',
'field_mapping.target': '目标字段',
'field_mapping.transform': '转换表达式',
'field_mapping.transform_placeholder': '留空表示直接写入',
'field_mapping.transform_identity': '直接写入',
'field_mapping.transform_argument': '参数 JSON可选',
'field_mapping.type': '类型',
'field_mapping.select_field': '选择字段',
'field_mapping.remove': '删除',
'delivery.title': '设置目标写入',
'delivery.help': '选择目标端如何写入及处理失败行。多数任务保留默认值即可;高风险和专业参数只在适用时显示。',
'delivery.write_mode': '写入模式',
'delivery.write.none': '只读',
'delivery.write.append': '追加',
'delivery.write.append_desc': '每次运行都新增记录;重复运行可能产生重复数据,系统会自动关闭重试。',
'delivery.write.upsert': '新增或更新',
'delivery.write.upsert_desc': '按主键或唯一键识别同一行:不存在则新增,已存在则更新。',
'delivery.write.overwrite': '覆盖',
'delivery.write.overwrite_unavailable': '覆盖(当前任务系统暂不支持)',
'delivery.write.overwrite_desc': '当前没有原子替换保障,预检会阻止覆盖任务运行;请改用新增或更新。',
'delivery.read_only_title': '这是只读比较任务',
'delivery.read_only_note': '任务不会写入或删除目标端数据,因此无需配置写入、失败行处理和高级设置。',
'delivery.error_policy': '失败行处理',
'delivery.error.stop': '遇错停止',
'delivery.error.stop_desc': '任一行写入失败时立即停止,适合优先保证数据完整性的任务。',
'delivery.error.skip': '跳过错误行',
'delivery.error.skip_desc': '记录失败原因并继续处理后续行,不保存完整失败行。',
'delivery.error.quarantine': '隔离并允许重试',
'delivery.error.quarantine_desc': '完整失败行会保存在本机任务数据库中,可能包含敏感数据,可在错误行列表中重试。',
'delivery.batch_size': '批大小',
'delivery.commit_every': '提交间隔(行)',
'delivery.commit_every_note': '当前引擎按批大小提交,此值与批大小保持一致。',
'delivery.retry_limit': '重试次数',
'delivery.retry_backoff': '重试退避(毫秒)',
'delivery.row_isolation_note': '当前配置必须遇错停止。逐行跳过只适用于写入已有关系表,且不能同时自动建表、修改结构、同步删除或覆盖写入。',
'delivery.append_retry_note': '追加写入可能已部分落库,为避免重复写入,自动重试固定为 0。',
'delivery.propagate_deletes': '传播源端删除',
'delivery.delete_policy_title': '目标端删除策略',
'delivery.delete_risk_badge': '高风险',
'delivery.delete.snapshot_label': '删除源端已不存在的目标行',
'delivery.delete.snapshot_desc': '全量差异同步会按每张表的主键比对,并删除目标端中源端已不存在的行。',
'delivery.delete.cdc_label': '同步源端删除事件',
'delivery.delete.cdc_desc': 'CDC 收到删除事件后,会按键删除目标端对应行。',
'delivery.delete_risk_note': '目标端删除不可撤销。开启前请确认目标不是共享表,并在运行前核对预检范围。',
'delivery.advanced': '高级设置',
'delivery.advanced_help': '性能、重试与结构迁移',
'delivery.performance_title': '性能与重试',
'delivery.performance_help': '默认值适合多数任务;仅在明确了解目标端负载和幂等性时调整。',
'delivery.structure_title': '目标结构',
'delivery.structure_help': '仅对一次性迁移中的同名自动映射生效;显式字段、主键或改名映射不会显示这些选项。',
'delivery.auto_add_columns': '自动新增缺失字段',
'delivery.auto_add_columns_desc': '仅新增目标端缺少的字段,不修改或删除已有字段;执行 DDL 时可能短暂锁定目标表。',
'delivery.create_indexes': '复制可兼容的二级索引',
'delivery.create_indexes_desc': '仅在自动创建目标对象时复制源端可兼容的非主键索引;索引在数据写入后创建,会增加总耗时。',
'trigger.title': '设置运行方式',
'trigger.help': '选择手动、定时或持续运行,并按需要配置快照、水位线或 CDC 增量方式。',
'trigger.mode': '触发方式',
'trigger.manual': '手动',
'trigger.once': '指定时间',
'trigger.interval': '固定间隔',
'trigger.interval_seconds': '间隔(秒)',
'trigger.cron': 'Cron 调度',
'trigger.continuous': '持续运行',
'trigger.run_at': '执行时间',
'trigger.timezone': '时区',
'trigger.cron_expression': 'Cron 表达式',
'trigger.overlap': '任务重叠时',
'trigger.overlap.skip': '跳过本次',
'trigger.overlap.queue': '排队等待',
'incremental.mode': '增量策略',
'incremental.snapshot': '全量快照',
'incremental.watermark': '水位线',
'incremental.cdc': 'CDC 日志',
'incremental.watermark_column': '水位列',
'incremental.tie_breaker': '并列排序列',
'incremental.overlap_window': '重叠窗口(毫秒)',
'incremental.watermark_delivery_note': '水位线任务暂不支持追加语义InitialValue、重叠窗口和独立提交间隔尚未接入引擎因此不提供可编辑入口。',
'incremental.watermark_mixed_note': '此任务包含按对象配置的不同水位字段。当前显示第一项;编辑任一水位字段会统一全部对象。',
'incremental.initial_snapshot': '首次启动先执行全量快照',
'incremental.cdc_adapter': 'CDC 适配器',
'incremental.select_cdc_adapter': '选择适配器',
'incremental.start_position': '开始位置',
'incremental.position.latest': '最新位置',
'incremental.position.earliest': '最早位置',
'incremental.position.checkpoint': '上次 checkpoint',
'incremental.cdc_safety_title': 'CDC 起点安全边界',
'incremental.cdc_safety_note': '当前只支持从创建 latest barrier 之后,或从本任务已有 checkpoint 继续,以 at-least-once 方式消费变更。',
'incremental.cdc_snapshot_gap_warning': '如先单独执行快照再从 latest 启动,两者之间存在变更窗口,可能漏掉窗口内变更;当前不保证无缝衔接。',
'incremental.cdc_checkpoint_unavailable': '当前任务没有可用 checkpoint因此不能选择 checkpoint 起点。',
'preflight.title': '预检',
'preflight.stale': '配置已变化,需要重新预检',
'preflight.not_run': '尚未预检',
'preflight.running': '正在检查端点、映射和执行策略',
'preflight.blocked': '{count} 个阻断项',
'preflight.warning': '{count} 个警告',
'preflight.passed': '预检通过',
'preflight.empty': '配置完整后运行预检;问题会定位到对应阶段。',
'preflight.open_issue': '定位',
'preflight.current_revision': '基于任务版本 {revision}',
'preflight.approval_required': '生产写入需要审批',
'preflight.approval_not_required': '无需生产写入审批',
'preflight.approval_fail_closed': '尚未完成倒计时确认并取得一次性授权,保存到生产或运行操作保持禁用。',
'preflight.approval_granted': '一次性生产授权已取得',
'preflight.approval_begin_countdown': '开始服务端 10 秒确认',
'preflight.approval_beginning': '正在创建服务端确认',
'preflight.approval_countdown': '请等待 {seconds} 秒',
'preflight.approval_confirm': '确认生产写入并获取授权',
'preflight.approving': '正在获取授权',
'preflight.approval_expires': '授权仅保存在内存中,将于 {expiresAt} 失效,保存或运行后立即作废。',
'preflight.severity.blocker': '阻断',
'preflight.severity.warning': '警告',
'preflight.severity.info': '提示',
'runs.title': '运行记录',
'runs.subtitle': '运行实例由 gateway 提供;后续接入持久化历史与 checkpoint。',
'runs.empty_title': '还没有运行记录',
'runs.empty_desc': '保存并运行任务后,这里会显示吞吐、错误行和 checkpoint。',
'runs.id': '运行 ID',
'runs.task': '任务',
'runs.status': '状态',
'runs.started_at': '开始时间',
'runs.rows_written': '写入行数',
'runs.rows_failed': '错误行',
'runs.checkpoint': 'Checkpoint',
'runs.view_errors': '查看错误行',
'runs.actions': '操作',
'runs.cancel': '取消',
'runs.resume': '继续',
'runs.retry': '重试运行',
'errors.title': '错误行',
'errors.empty_title': '没有隔离的错误行',
'errors.empty_desc': '选择失败运行后,可在这里查看、重试或导出错误行。',
'errors.source': '源对象',
'errors.reason': '原因',
'errors.payload': '数据预览',
'errors.retryable': '可重试',
'errors.retry': '重试行',
'errors.retry_unavailable': '后端尚未提供错误行重试接口,此操作保持禁用。',
'errors.discard': '丢弃',
'errors.discarded': '已丢弃',
'checkpoint.title': 'Checkpoint',
'checkpoint.empty': '所选运行没有持久化 checkpoint。',
'checkpoint.kind': '类型',
'checkpoint.phase': '阶段',
'checkpoint.updated_at': '更新时间',
'checkpoint.cursor': '游标',
'checkpoint.reset': '重置 checkpoint',
'checkpoint.reset_warning': '重置后不能从该位置继续CDC 任务将只能从新的 latest barrier 开始。',
'common.yes': '是',
'common.no': '否',
'common.refresh': '刷新',
'common.dismiss': '关闭',
'common.cancel': '取消',
'schedules.title': '调度',
'schedules.subtitle': '集中查看任务时区、Cron 和下一次执行。',
'schedules.empty_title': '还没有启用调度',
'schedules.empty_desc': '在任务的“触发与增量”阶段选择指定时间或 Cron。',
'cdc.title': 'CDC 源状态',
'cdc.subtitle': '状态来自适配器探测和任务 checkpoint后端未报告延迟时不会推测。',
'cdc.empty_title': '还没有可监控的 CDC 源',
'cdc.empty_desc': '创建 CDC 任务并完成权限探测后,这里会显示健康状态。',
'cdc.lag_unknown': '延迟未上报',
'cdc.status.ready': '适配器已就绪',
'cdc.status.probing': '正在探测',
'cdc.status.lagging': '延迟较高',
'cdc.status.offline': '源端离线',
'cdc.status.unsupported': '不支持',
'cdc.status.unknown': '状态未知',
'status.queued': '排队中',
'status.running': '运行中',
'status.cancelling': '正在取消',
'status.preflighting': '预检中',
'status.snapshotting': '快照中',
'status.catching_up': '追赶中',
'status.streaming': '持续同步',
'status.paused': '已暂停',
'status.succeeded': '成功',
'status.partial': '部分完成',
'status.failed': '失败',
'status.canceled': '已取消',
'status.cancelled': '已取消',
'status.interrupted': '已中断',
'validation.definition_invalid': '任务定义无效,请检查必填项、对象映射和执行策略。',
'validation.definition_hash_failed': '无法生成任务定义指纹,请检查任务配置。',
'validation.task_name_required': '填写任务名称。',
'validation.source_connection_required': '选择源连接。',
'validation.target_connection_required': '选择目标连接。',
'validation.source_connection_failed': '无法解析源连接,请确认已保存连接仍存在且配置完整。',
'validation.target_connection_failed': '无法解析目标连接,请确认已保存连接仍存在且配置完整。',
'validation.source_connect_failed': '无法初始化源数据库连接。',
'validation.source_ping_failed': '源数据库连通性检查失败。',
'validation.target_connect_failed': '无法初始化目标数据库连接。',
'validation.target_ping_failed': '目标数据库连通性检查失败。',
'validation.same_endpoint': '源端和目标端指向同一个位置,请确认不会覆盖源数据。',
'validation.same_object': '源对象和目标对象指向同一个物理对象,不能执行同步。',
'validation.route_unsupported': '当前源端与目标端组合不支持执行此同步任务。',
'validation.source_query_required': '填写源 SQL 查询。',
'validation.source_query_not_read_only': '源 SQL 必须是单条只读查询。',
'validation.mapping_required': '至少启用一条对象映射。',
'validation.source_object_required': '填写源对象名称。',
'validation.target_object_required': '填写目标对象名称。',
'validation.duplicate_source_object': '同一个源对象只能添加一次。',
'validation.duplicate_target_object': '多个映射指向同一个目标对象。',
'validation.mapping_compile_failed': '对象或字段映射无法编译,请检查映射与转换配置。',
'validation.source_columns_failed': '读取源对象字段失败,请检查对象名和元数据权限。',
'validation.target_columns_failed': '读取目标对象字段失败,请检查对象名和元数据权限。',
'validation.source_column_missing': '配置的源字段不存在。',
'validation.target_column_missing': '配置的目标字段不存在。',
'validation.key_column_missing': '配置的源端主键或唯一键字段不存在。',
'validation.watermark_column_missing': '配置的水位列在源对象中不存在。',
'validation.target_table_check_failed': '检查目标表是否存在时失败。',
'validation.target_table_missing': '目标表不存在,且当前任务不能自动创建该表。',
'validation.target_table_will_be_created': '目标表不存在,运行时将由迁移规划器自动创建。',
'validation.key_columns_required': '差异同步或 CDC 必须配置主键或唯一键。',
'validation.batch_size_invalid': '批大小必须是 1 到 10000 的整数。',
'validation.commit_every_invalid': '提交间隔必须不小于批大小。',
'validation.target_protection_blocked': '目标连接的安全保护策略禁止此次写入。',
'validation.row_error_isolation_unsupported': '当前任务不满足逐行跳过/隔离的安全条件。',
'validation.append_retry_unsupported': '追加写入不允许自动重试,以免重复写入。',
'validation.append_retry_unsafe': '追加写入不能自动重试,否则可能重复写入已提交的数据。',
'validation.append_resume_unsafe': '追加写入任务必须禁用断点恢复,否则可能重放已提交批次。',
'validation.full_overwrite_non_atomic': '持久任务暂不支持非原子全量覆盖,以免失败后留下空表或半表。',
'validation.write_mode_required': '可执行同步任务必须选择明确的写入模式。',
'validation.watermark_append_unsupported': '水位线任务暂不支持追加写入语义。',
'validation.watermark_tie_breaker_required': '水位线任务必须配置稳定的并列排序列或主键。',
'validation.watermark_initial_value_unsupported': '当前尚未实现水位初始值,请移除该配置。',
'validation.watermark_runtime_unsupported': '当前源端、目标端或执行策略不满足水位线同步条件。',
'validation.watermark_overwrite_unsupported': '水位线任务不支持全量覆盖。',
'validation.watermark_delete_unsupported': '水位线任务无法推断源端删除;请改用 CDC 或快照差异同步。',
'validation.query_sink_single_mapping_required': 'SQL 结果写入必须且只能配置一条目标映射。',
'validation.query_key_required': 'SQL 结果更新写入必须配置且只能配置一个稳定键。',
'validation.query_target_pk_mismatch': 'SQL 结果的稳定键必须映射到目标表唯一的主键。',
'validation.query_schema_runtime_validation': 'SQL 结果字段名将在实际执行只读查询时再与映射校验。',
'validation.watermark_column_required': '水位线模式必须配置水位列。',
'validation.cron_expression_required': '填写 Cron 表达式。',
'validation.timezone_required': '填写调度时区。',
'validation.interval_invalid': '固定间隔必须不少于 60 秒。',
'validation.cdc_incremental_required': 'CDC 任务必须使用 CDC 增量策略。',
'validation.cdc_trigger_required': 'CDC 任务必须持续运行。',
'validation.cdc_adapter_required': '选择 CDC 适配器。',
'validation.cdc_initial_snapshot_unsupported': '当前 CDC 不支持安全的初始快照衔接。',
'validation.cdc_initial_snapshot_handoff_unsupported': '当前 CDC 无法保证初始快照与变更流无缝衔接,请关闭初始快照。',
'validation.cdc_earliest_unsupported': '当前 CDC 适配器不支持从最早位置启动。',
'validation.cdc_probe_failed': 'CDC 能力与权限探测失败。',
'validation.cdc_adapter_not_ready': '所选 CDC 适配器当前未就绪。',
'validation.cdc_existing_target_required': 'CDC 任务当前只能写入已存在的目标表。',
'validation.cdc_upsert_required': 'CDC 任务必须使用“新增或更新”写入模式,以保证可安全重放。',
'validation.cdc_target_non_atomic': 'CDC 行隔离和 checkpoint 顺序要求目标端支持原子关系型写入。',
'validation.cdc_authoritative_columns_required': 'CDC 任务必须显式配置源字段到目标字段的映射。',
'validation.cdc_checkpoint_unavailable': '无法读取 CDC checkpoint。',
'validation.cdc_checkpoint_required': '选择 checkpoint 起点前必须存在兼容的持久化 checkpoint。',
'validation.cdc_checkpoint_incompatible': '已存在的 CDC checkpoint 与当前任务定义不兼容,请在暂停任务后显式重置。',
'validation.compare_route_unsupported': '当前源端与目标端组合不支持执行数据比对。',
'validation.capability_unverified': '当前使用静态 gateway运行前仍需后端确认数据源组合能力。',
'validation.unknown': '预检发现未识别的配置问题。',
} as const;
export type DataSyncWorkbenchTextKey = keyof typeof zhCN;
/**
* Literal keys owned by the Data Sync workbench's feature-local catalog.
* Exported so the repository-wide i18n scanner can validate these calls
* against the same catalog contract instead of treating them as global keys.
*/
export const DATA_SYNC_WORKBENCH_TEXT_KEYS: readonly DataSyncWorkbenchTextKey[] =
Object.freeze(Object.keys(zhCN) as DataSyncWorkbenchTextKey[]);
const enUS: Record<DataSyncWorkbenchTextKey, string> = {
'workbench.title': 'Data migration and sync',
'workbench.subtitle': 'Define, validate, and track cross-source jobs',
'workbench.close': 'Close workbench',
'workbench.new_task': 'New task',
'workbench.save': 'Save draft',
'workbench.saving': 'Saving',
'workbench.saved': 'Saved',
'workbench.unsaved': 'Unsaved changes',
'workbench.run_preflight': 'Run preflight',
'workbench.preflighting': 'Running preflight',
'workbench.start': 'Run task',
'workbench.enable': 'Enable task',
'workbench.blocked_action': 'Resolve preflight blockers first',
'nav.tasks': 'Tasks',
'nav.runs': 'Runs',
'nav.schedules': 'Schedules',
'nav.cdc': 'CDC',
'task_list.title': 'Tasks',
'task_list.search': 'Search tasks',
'task_list.empty': 'No matching tasks',
'task_list.revision': 'Revision {revision}',
'task_list.lifecycle.draft': 'Draft',
'task_list.lifecycle.ready': 'Ready',
'task_list.lifecycle.enabled': 'Enabled',
'task_list.lifecycle.paused': 'Paused',
'task_list.lifecycle.archived': 'Archived',
'lifecycle.publish_ready': 'Publish as ready',
'lifecycle.enable_schedule': 'Enable schedule',
'lifecycle.pause': 'Pause task',
'lifecycle.resume_schedule': 'Re-enable',
'lifecycle.archive': 'Archive',
'task_kind.title': 'Choose a task type',
'task_kind.subtitle': 'The type determines incremental and trigger options. You can copy it into another task later.',
'task_kind.migration': 'One-time migration',
'task_kind.migration_desc': 'Move schema and data, with target object creation.',
'task_kind.reconcile': 'Batch / difference sync',
'task_kind.reconcile_desc': 'Compare snapshots or use a watermark by key.',
'task_kind.querySink': 'SQL result sink',
'task_kind.querySink_desc': 'Run a query and write its stable result to a target table.',
'task_kind.compare': 'Read-only compare',
'task_kind.compare_desc': 'Compare schema or data without changing the target.',
'task_kind.cdc': 'Continuous CDC',
'task_kind.cdc_desc': 'Consume change logs and persist checkpoints.',
'stage.endpoints': 'Choose source and target',
'stage.mappings': 'Choose data',
'stage.delivery': 'Target writes',
'stage.trigger': 'Run settings',
'stage.preflight': 'Check and run',
'route.source': 'Source',
'route.target': 'Target',
'route.pending_source': 'Choose source connection',
'route.pending_target': 'Choose target connection',
'route.capability.full': 'Full support',
'route.capability.partial': 'Compatibility mode',
'route.capability.unsupported': 'Unsupported',
'route.capability.unknown': 'Awaiting capability check',
'route.database_fallback': 'No database selected',
'editor.task_name': 'Task name',
'editor.task_name_placeholder': 'For example: Sync orders to analytics',
'editor.source_endpoint': 'Source endpoint',
'editor.target_endpoint': 'Target endpoint',
'editor.connection': 'Saved connection',
'editor.connection_id': 'Connection ID',
'editor.connection_name': 'Connection name',
'editor.database_type': 'Source type',
'editor.database': 'Database / Catalog',
'editor.schema': 'Schema',
'editor.source_query': 'Source SQL query',
'editor.source_query_placeholder': 'SELECT ...',
'editor.endpoint_help': 'Choose from saved connections. Tasks store only connection references, never usernames, passwords, or connection strings.',
'metadata.select_connection': 'Choose a connection',
'metadata.select_database': 'Choose a database / catalog',
'metadata.not_selected': 'Not selected',
'metadata.schema_optional': 'Optional; blank uses the default namespace',
'metadata.loading_connections': 'Loading connections',
'metadata.loading_databases': 'Loading databases',
'metadata.loading_objects': 'Loading objects',
'metadata.loading_fields': 'Loading fields',
'metadata.loading_cdc_adapters': 'Loading CDC adapters',
'metadata.load_failed': 'Metadata failed to load',
'metadata.retry': 'Retry',
'metadata.no_eligible_connections': 'No connection supports this read/write direction.',
'metadata.no_databases': 'This connection returned no databases. The draft can still be saved.',
'metadata.endpoint_required': 'Choose an endpoint first',
'metadata.object_required': 'Choose an object first',
'metadata.objects_count': '{count} objects',
'metadata.fields_count': '{count} fields',
'mapping.title': 'Choose data to sync',
'mapping.help': 'Choose one, many, or all filtered tables. The workbench matches same-name targets, detects keys, and generates mappings so you only handle exceptions.',
'mapping.query_help': 'A SQL query writes to one target object. Choose an existing target table and configure the write key.',
'mapping.add': 'Add mapping',
'mapping.select_objects': 'Choose source objects',
'mapping.add_target': 'Add target object',
'mapping.picker_title': 'Choose source objects in bulk',
'mapping.search_objects': 'Search tables, views, or collections',
'mapping.include_views': 'Include views',
'mapping.selected_count': '{selected} selected / {total} total',
'mapping.select_filtered': 'Select all filtered results ({count})',
'mapping.selection_fixed_scope': 'This list is fixed when saved; future tables are not added automatically',
'mapping.object_kind.table': 'Table',
'mapping.object_kind.view': 'View',
'mapping.object_kind.collection': 'Collection',
'mapping.already_added': 'Already added',
'mapping.rows_count': '{count} rows',
'mapping.no_objects': 'No matching objects',
'mapping.add_selected': 'Add {count} objects',
'mapping.detecting_keys': 'Detecting keys…',
'mapping.open_object_list': 'Open object list',
'mapping.will_create_named': 'Target does not exist; “{name}” will be created',
'mapping.no_matching_objects': 'No matching existing objects',
'mapping.remove': 'Remove',
'mapping.enabled': 'Enabled',
'mapping.source': 'Source object',
'mapping.target': 'Target object',
'mapping.target_mode': 'If the target is missing',
'mapping.key_columns': 'Identify the same row by',
'mapping.fields': 'Fields',
'mapping.status': 'Status',
'mapping.create_or_reuse': 'Create automatically; validate and reuse if present',
'mapping.existing_only': 'Write only to an existing target',
'mapping.edit_exceptions': 'Edit exception',
'mapping.collapse_exceptions': 'Collapse',
'mapping.fields_count': '{count} fields',
'mapping.fields_pending': 'Automatic same-name fields',
'mapping.fields_automatic': 'Automatic same-name fields',
'mapping.fields_automatic_help': 'No per-column setup is needed unless a field is renamed or transformed.',
'mapping.fields_required': 'Confirm synchronized fields',
'mapping.fields_cdc_help': 'Continuous sync needs explicit target fields so removed source fields do not leave stale target values.',
'mapping.ready': 'Configured',
'mapping.needs_attention': 'Needs attention',
'mapping.key_placeholder': 'id, tenant_id',
'mapping.key_detected': 'Detected from source metadata; edit if needed',
'mapping.key_when_needed': 'Difference sync and CDC need a stable key; one-time migration may leave this blank',
'mapping.source_placeholder': 'Search or choose a source object',
'mapping.target_placeholder': 'Search an existing target or enter a new name',
'mapping.query_result_source': 'Query result (single mapping)',
'mapping.target_state.exists': 'Target exists',
'mapping.target_state.create': 'Will create',
'mapping.target_state.missing': 'Target not matched',
'mapping.empty_title': 'No sync objects selected',
'mapping.empty_desc': 'Choose source objects first. The same picker handles one table, many tables, or all current search results.',
'field_mapping.title': 'Field mappings',
'field_mapping.close': 'Collapse',
'field_mapping.auto_match': 'Match same names',
'field_mapping.add': 'Add field',
'field_mapping.empty_title': 'No field mappings yet',
'field_mapping.empty_desc': 'Load both sides and match by name, or add a row and enter a transform.',
'field_mapping.source': 'Source field',
'field_mapping.target': 'Target field',
'field_mapping.transform': 'Transform',
'field_mapping.transform_placeholder': 'Blank writes the value directly',
'field_mapping.transform_identity': 'Identity',
'field_mapping.transform_argument': 'Argument JSON (optional)',
'field_mapping.type': 'Type',
'field_mapping.select_field': 'Choose a field',
'field_mapping.remove': 'Remove',
'delivery.title': 'Configure target writes',
'delivery.help': 'Choose how the target is written and how failed rows are handled. Defaults fit most tasks; risky and specialist options appear only when applicable.',
'delivery.write_mode': 'Write mode',
'delivery.write.none': 'Read only',
'delivery.write.append': 'Append',
'delivery.write.append_desc': 'Each run inserts new rows. Re-running may create duplicates, so automatic retries are disabled.',
'delivery.write.upsert': 'Insert or update',
'delivery.write.upsert_desc': 'Use a primary or unique key to identify a row: insert when missing and update when present.',
'delivery.write.overwrite': 'Overwrite',
'delivery.write.overwrite_unavailable': 'Overwrite (not yet supported for jobs)',
'delivery.write.overwrite_desc': 'Atomic replacement is not available, so preflight blocks overwrite jobs. Use insert or update instead.',
'delivery.read_only_title': 'This is a read-only comparison',
'delivery.read_only_note': 'The task never writes to or deletes from the target, so delivery, failed-row handling, and advanced settings do not apply.',
'delivery.error_policy': 'Failed-row handling',
'delivery.error.stop': 'Stop on error',
'delivery.error.stop_desc': 'Stop as soon as any row fails to write. Use this when data completeness is the priority.',
'delivery.error.skip': 'Skip bad rows',
'delivery.error.skip_desc': 'Record the failure and continue with later rows without storing the complete failed row.',
'delivery.error.quarantine': 'Quarantine and retry later',
'delivery.error.quarantine_desc': 'Store the complete failed row in the local task database. It may contain sensitive data and can be retried from the error-row list.',
'delivery.batch_size': 'Batch size',
'delivery.commit_every': 'Commit interval (rows)',
'delivery.commit_every_note': 'The current engine commits by batch size, so this value follows batch size.',
'delivery.retry_limit': 'Retry limit',
'delivery.retry_backoff': 'Retry backoff (ms)',
'delivery.row_isolation_note': 'This configuration must stop on error. Row-level skip is available only for existing relational targets without auto-create, schema changes, delete propagation, or overwrite.',
'delivery.append_retry_note': 'Append writes may be partially committed. Automatic retries stay at zero to prevent duplicates.',
'delivery.propagate_deletes': 'Propagate source deletes',
'delivery.delete_policy_title': 'Target deletion policy',
'delivery.delete_risk_badge': 'High risk',
'delivery.delete.snapshot_label': 'Delete target rows missing from the source',
'delivery.delete.snapshot_desc': 'A full reconcile compares each table by key and deletes target rows that no longer exist in the source.',
'delivery.delete.cdc_label': 'Apply source delete events',
'delivery.delete.cdc_desc': 'When CDC receives a delete event, it deletes the matching target row by key.',
'delivery.delete_risk_note': 'Target deletes cannot be undone. Confirm the target is not shared and review the preflight scope before running.',
'delivery.advanced': 'Advanced settings',
'delivery.advanced_help': 'Performance, retries, and schema migration',
'delivery.performance_title': 'Performance and retries',
'delivery.performance_help': 'The defaults fit most tasks. Adjust only when you understand target load and retry idempotency.',
'delivery.structure_title': 'Target schema',
'delivery.structure_help': 'Available only for automatic same-name mappings in one-time migrations. Explicit fields, keys, or renamed mappings hide these options.',
'delivery.auto_add_columns': 'Add missing target columns',
'delivery.auto_add_columns_desc': 'Only adds columns missing from the target; existing columns are not changed or removed. DDL may briefly lock the target table.',
'delivery.create_indexes': 'Copy compatible secondary indexes',
'delivery.create_indexes_desc': 'Copies compatible non-primary indexes only when the target is auto-created. Indexes are created after data is loaded and increase total run time.',
'trigger.title': 'Configure run settings',
'trigger.help': 'Choose manual, scheduled, or continuous execution, then configure snapshot, watermark, or CDC incrementality when needed.',
'trigger.mode': 'Trigger',
'trigger.manual': 'Manual',
'trigger.once': 'At a specified time',
'trigger.interval': 'Fixed interval',
'trigger.interval_seconds': 'Interval (seconds)',
'trigger.cron': 'Cron schedule',
'trigger.continuous': 'Continuous',
'trigger.run_at': 'Run at',
'trigger.timezone': 'Timezone',
'trigger.cron_expression': 'Cron expression',
'trigger.overlap': 'When runs overlap',
'trigger.overlap.skip': 'Skip this run',
'trigger.overlap.queue': 'Queue this run',
'incremental.mode': 'Incremental policy',
'incremental.snapshot': 'Full snapshot',
'incremental.watermark': 'Watermark',
'incremental.cdc': 'CDC log',
'incremental.watermark_column': 'Watermark column',
'incremental.tie_breaker': 'Tie-breaker column',
'incremental.overlap_window': 'Overlap window (ms)',
'incremental.watermark_delivery_note': 'Watermark jobs do not yet support append semantics. InitialValue, overlap windows, and independent commit intervals are not wired to the engine and therefore have no editable controls.',
'incremental.watermark_mixed_note': 'This task has per-object watermark differences. The first is shown; editing either field unifies all object mappings.',
'incremental.initial_snapshot': 'Run a full snapshot before the first stream',
'incremental.cdc_adapter': 'CDC adapter',
'incremental.select_cdc_adapter': 'Choose an adapter',
'incremental.start_position': 'Start position',
'incremental.position.latest': 'Latest position',
'incremental.position.earliest': 'Earliest position',
'incremental.position.checkpoint': 'Last checkpoint',
'incremental.cdc_safety_title': 'CDC start-position boundary',
'incremental.cdc_safety_note': 'The current guarantee is at-least-once consumption after a newly created latest barrier, or continuation from this task\'s existing checkpoint.',
'incremental.cdc_snapshot_gap_warning': 'A separate snapshot followed by latest leaves a change window and may miss changes in that window. Seamless handoff is not guaranteed.',
'incremental.cdc_checkpoint_unavailable': 'This task has no usable checkpoint, so checkpoint start is unavailable.',
'preflight.title': 'Preflight',
'preflight.stale': 'Configuration changed; run preflight again',
'preflight.not_run': 'Preflight has not run',
'preflight.running': 'Checking endpoints, mappings, and execution policy',
'preflight.blocked': '{count} blockers',
'preflight.warning': '{count} warnings',
'preflight.passed': 'Preflight passed',
'preflight.empty': 'Complete the configuration and run preflight. Issues link back to their stage.',
'preflight.open_issue': 'Locate',
'preflight.current_revision': 'Based on task revision {revision}',
'preflight.approval_required': 'Production write approval required',
'preflight.approval_not_required': 'No production write approval required',
'preflight.approval_fail_closed': 'Save-to-production and run stay disabled until countdown confirmation grants a one-time token.',
'preflight.approval_granted': 'One-time production authorization granted',
'preflight.approval_begin_countdown': 'Begin server 10-second confirmation',
'preflight.approval_beginning': 'Starting server confirmation',
'preflight.approval_countdown': 'Wait {seconds} seconds',
'preflight.approval_confirm': 'Confirm production write and grant token',
'preflight.approving': 'Requesting authorization',
'preflight.approval_expires': 'The token is memory-only, expires at {expiresAt}, and is invalidated after save or run.',
'preflight.severity.blocker': 'Blocker',
'preflight.severity.warning': 'Warning',
'preflight.severity.info': 'Info',
'runs.title': 'Run history',
'runs.subtitle': 'Run instances come from the gateway; persisted history and checkpoints plug in later.',
'runs.empty_title': 'No runs yet',
'runs.empty_desc': 'After a task runs, throughput, error rows, and checkpoints appear here.',
'runs.id': 'Run ID',
'runs.task': 'Task',
'runs.status': 'Status',
'runs.started_at': 'Started',
'runs.rows_written': 'Rows written',
'runs.rows_failed': 'Error rows',
'runs.checkpoint': 'Checkpoint',
'runs.view_errors': 'View error rows',
'runs.actions': 'Actions',
'runs.cancel': 'Cancel',
'runs.resume': 'Resume',
'runs.retry': 'Retry run',
'errors.title': 'Error rows',
'errors.empty_title': 'No quarantined rows',
'errors.empty_desc': 'Choose a failed run to inspect, retry, or export bad rows.',
'errors.source': 'Source object',
'errors.reason': 'Reason',
'errors.payload': 'Payload preview',
'errors.retryable': 'Retryable',
'errors.retry': 'Retry row',
'errors.retry_unavailable': 'The backend does not expose an error-row retry API, so this action stays disabled.',
'errors.discard': 'Discard',
'errors.discarded': 'Discarded',
'checkpoint.title': 'Checkpoint',
'checkpoint.empty': 'The selected run has no durable checkpoint.',
'checkpoint.kind': 'Kind',
'checkpoint.phase': 'Phase',
'checkpoint.updated_at': 'Updated',
'checkpoint.cursor': 'Cursor',
'checkpoint.reset': 'Reset checkpoint',
'checkpoint.reset_warning': 'Reset removes the resume position. CDC can then start only from a new latest barrier.',
'common.yes': 'Yes',
'common.no': 'No',
'common.refresh': 'Refresh',
'common.dismiss': 'Dismiss',
'common.cancel': 'Cancel',
'schedules.title': 'Schedules',
'schedules.subtitle': 'Review timezones, Cron expressions, and next runs in one place.',
'schedules.empty_title': 'No enabled schedules',
'schedules.empty_desc': 'Choose a one-time or Cron trigger in the task editor.',
'cdc.title': 'CDC source status',
'cdc.subtitle': 'Status comes from adapter probes and task checkpoints. Lag is never inferred when the backend does not report it.',
'cdc.empty_title': 'No CDC sources to monitor',
'cdc.empty_desc': 'Create a CDC task and probe its permissions to see source health here.',
'cdc.lag_unknown': 'Lag not reported',
'cdc.status.ready': 'Adapter ready',
'cdc.status.probing': 'Probing',
'cdc.status.lagging': 'Lagging',
'cdc.status.offline': 'Source offline',
'cdc.status.unsupported': 'Unsupported',
'cdc.status.unknown': 'Unknown',
'status.queued': 'Queued',
'status.running': 'Running',
'status.cancelling': 'Cancelling',
'status.preflighting': 'Preflighting',
'status.snapshotting': 'Snapshotting',
'status.catching_up': 'Catching up',
'status.streaming': 'Streaming',
'status.paused': 'Paused',
'status.succeeded': 'Succeeded',
'status.partial': 'Partial',
'status.failed': 'Failed',
'status.canceled': 'Canceled',
'status.cancelled': 'Cancelled',
'status.interrupted': 'Interrupted',
'validation.definition_invalid': 'The task definition is invalid. Check required fields, object mappings, and execution policies.',
'validation.definition_hash_failed': 'The task definition fingerprint could not be generated. Check the task configuration.',
'validation.task_name_required': 'Enter a task name.',
'validation.source_connection_required': 'Choose a source connection.',
'validation.target_connection_required': 'Choose a target connection.',
'validation.source_connection_failed': 'The source saved connection could not be resolved. Verify that it still exists and is complete.',
'validation.target_connection_failed': 'The target saved connection could not be resolved. Verify that it still exists and is complete.',
'validation.source_connect_failed': 'The source database connection could not be initialized.',
'validation.source_ping_failed': 'The source database connectivity check failed.',
'validation.target_connect_failed': 'The target database connection could not be initialized.',
'validation.target_ping_failed': 'The target database connectivity check failed.',
'validation.same_endpoint': 'Source and target point to the same location. Confirm that source data cannot be overwritten.',
'validation.same_object': 'Source and target resolve to the same physical object, so this sync cannot run.',
'validation.route_unsupported': 'The selected source-target route cannot execute this sync task.',
'validation.source_query_required': 'Enter the source SQL query.',
'validation.source_query_not_read_only': 'The source SQL must be one read-only query.',
'validation.mapping_required': 'Enable at least one object mapping.',
'validation.source_object_required': 'Enter a source object name.',
'validation.target_object_required': 'Enter a target object name.',
'validation.duplicate_source_object': 'Each source object can be added only once.',
'validation.duplicate_target_object': 'Multiple mappings target the same object.',
'validation.mapping_compile_failed': 'The object or field mapping could not be compiled. Check mappings and transforms.',
'validation.source_columns_failed': 'Source fields could not be read. Check the object name and metadata permissions.',
'validation.target_columns_failed': 'Target fields could not be read. Check the object name and metadata permissions.',
'validation.source_column_missing': 'A configured source field does not exist.',
'validation.target_column_missing': 'A configured target field does not exist.',
'validation.key_column_missing': 'A configured source key field does not exist.',
'validation.watermark_column_missing': 'The configured watermark field does not exist on the source object.',
'validation.target_table_check_failed': 'The target table existence check failed.',
'validation.target_table_missing': 'The target table does not exist and this task cannot create it automatically.',
'validation.target_table_will_be_created': 'The target table does not exist and will be created by the migration planner.',
'validation.key_columns_required': 'Difference sync and CDC require a primary or unique key.',
'validation.batch_size_invalid': 'Batch size must be an integer from 1 to 10000.',
'validation.commit_every_invalid': 'Commit interval must be at least the batch size.',
'validation.target_protection_blocked': 'The target connection protection policy blocks this write.',
'validation.row_error_isolation_unsupported': 'This task does not meet the safety requirements for row skip or quarantine.',
'validation.append_retry_unsupported': 'Append writes cannot retry automatically because that could duplicate rows.',
'validation.append_retry_unsafe': 'Append writes cannot retry automatically because committed rows could be duplicated.',
'validation.append_resume_unsafe': 'Append jobs must disable resume because committed batches cannot be replayed safely.',
'validation.full_overwrite_non_atomic': 'Persistent jobs cannot use non-atomic full overwrite because failure could leave an empty or partial table.',
'validation.write_mode_required': 'Executable sync tasks require an explicit write mode.',
'validation.watermark_append_unsupported': 'Watermark jobs do not yet support append delivery semantics.',
'validation.watermark_tie_breaker_required': 'Watermark jobs require a stable tie-breaker field or key.',
'validation.watermark_initial_value_unsupported': 'Watermark initial values are not implemented. Remove this setting.',
'validation.watermark_runtime_unsupported': 'The selected source, target, or execution policy cannot run watermark sync.',
'validation.watermark_overwrite_unsupported': 'Watermark jobs cannot use full overwrite.',
'validation.watermark_delete_unsupported': 'Watermark jobs cannot infer source deletes. Use CDC or snapshot reconcile.',
'validation.query_sink_single_mapping_required': 'A SQL result sink must have exactly one target mapping.',
'validation.query_key_required': 'SQL result upsert requires exactly one stable key.',
'validation.query_target_pk_mismatch': 'The SQL result stable key must map to the target table\'s single primary key.',
'validation.query_schema_runtime_validation': 'SQL result field names will be checked against the mapping when the read-only query runs.',
'validation.watermark_column_required': 'Watermark mode requires a watermark column.',
'validation.cron_expression_required': 'Enter a Cron expression.',
'validation.timezone_required': 'Enter a scheduling timezone.',
'validation.interval_invalid': 'Fixed intervals must be at least 60 seconds.',
'validation.cdc_incremental_required': 'CDC tasks must use the CDC incremental policy.',
'validation.cdc_trigger_required': 'CDC tasks must run continuously.',
'validation.cdc_adapter_required': 'Choose a CDC adapter.',
'validation.cdc_initial_snapshot_unsupported': 'The current CDC reader cannot safely hand off from an initial snapshot.',
'validation.cdc_initial_snapshot_handoff_unsupported': 'The current CDC reader cannot guarantee a gap-free initial snapshot handoff. Disable initial snapshot.',
'validation.cdc_earliest_unsupported': 'The current CDC adapter cannot start from the earliest position.',
'validation.cdc_probe_failed': 'CDC capability and permission probing failed.',
'validation.cdc_adapter_not_ready': 'The selected CDC adapter is not ready.',
'validation.cdc_existing_target_required': 'CDC jobs currently require existing target tables.',
'validation.cdc_upsert_required': 'CDC jobs must use upsert delivery so events can be replayed safely.',
'validation.cdc_target_non_atomic': 'CDC row isolation and checkpoint ordering require an atomic relational target.',
'validation.cdc_authoritative_columns_required': 'CDC jobs require explicit source-to-target field mappings.',
'validation.cdc_checkpoint_unavailable': 'The CDC checkpoint could not be read.',
'validation.cdc_checkpoint_required': 'Checkpoint start requires a compatible durable checkpoint for this task.',
'validation.cdc_checkpoint_incompatible': 'The stored CDC checkpoint is incompatible with this task. Pause the task and reset it explicitly.',
'validation.compare_route_unsupported': 'The selected source-target route cannot execute compare tasks.',
'validation.capability_unverified': 'This task uses the static gateway. The backend must still verify the source-target capability before execution.',
'validation.unknown': 'Preflight found an unrecognized configuration issue.',
};
export type DataSyncWorkbenchLocale = 'zh-CN' | 'en-US';
export const resolveDataSyncWorkbenchLocale = (
language?: string,
): DataSyncWorkbenchLocale =>
String(language || '').toLowerCase().startsWith('zh') ? 'zh-CN' : 'en-US';
export type DataSyncWorkbenchTranslate = (
key: DataSyncWorkbenchTextKey,
params?: Record<string, string | number>,
) => string;
export const createDataSyncWorkbenchTranslate = (
language?: string,
): DataSyncWorkbenchTranslate => {
const catalog = resolveDataSyncWorkbenchLocale(language) === 'zh-CN' ? zhCN : enUS;
return (key, params) => {
let value = catalog[key] || key;
Object.entries(params || {}).forEach(([name, replacement]) => {
value = value.split(`{${name}}`).join(String(replacement));
});
return value;
};
};
/**
* Stable validation codes are the localization contract. Backend messages are
* retained only as a diagnostic fallback for unknown driver/runtime errors.
*/
export const dataSyncValidationIssueText = (
issue: { code: string; message?: string },
t: DataSyncWorkbenchTranslate,
): string => {
const key = `validation.${issue.code}` as DataSyncWorkbenchTextKey;
const localized = t(key);
if (localized !== key) return localized;
return String(issue.message || '').trim() || t('validation.unknown');
};

View File

@@ -0,0 +1,122 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
dataSyncEndpointMetadataKey,
dataSyncObjectMetadataKey,
type DataSyncWorkbenchGateway,
} from './gateway';
import type {
DataSyncDatabaseMetadata,
DataSyncEndpointRef,
DataSyncFieldMetadata,
DataSyncObjectMetadata,
DataSyncSavedConnectionView,
} from './model';
export type DataSyncMetadataState<T> = {
status: 'idle' | 'loading' | 'ready' | 'error';
items: T[];
error: string;
};
export type DataSyncMetadataResult<T> = DataSyncMetadataState<T> & {
reload: () => void;
};
const idleState = <T,>(): DataSyncMetadataState<T> => ({
status: 'idle',
items: [],
error: '',
});
const errorMessage = (error: unknown): string =>
error instanceof Error && error.message ? error.message : String(error || 'unknown');
const useMetadataCollection = <T,>(
gateway: DataSyncWorkbenchGateway,
requestKey: string,
enabled: boolean,
load: () => Promise<T[]>,
): DataSyncMetadataResult<T> => {
const [state, setState] = useState<DataSyncMetadataState<T>>(idleState);
const [reloadRevision, setReloadRevision] = useState(0);
const requestSequence = useRef(0);
const loadRef = useRef(load);
loadRef.current = load;
useEffect(() => {
const requestId = ++requestSequence.current;
if (!enabled) {
setState(idleState());
return undefined;
}
let active = true;
setState({ status: 'loading', items: [], error: '' });
void Promise.resolve()
.then(() => loadRef.current())
.then(
(items) => {
if (!active || requestSequence.current !== requestId) return;
setState({ status: 'ready', items, error: '' });
},
(error) => {
if (!active || requestSequence.current !== requestId) return;
setState({ status: 'error', items: [], error: errorMessage(error) });
},
);
return () => {
active = false;
};
}, [enabled, gateway, reloadRevision, requestKey]);
const reload = useCallback(() => {
setReloadRevision((current) => current + 1);
}, []);
return { ...state, reload };
};
export const useDataSyncSavedConnections = (
gateway: DataSyncWorkbenchGateway,
): DataSyncMetadataResult<DataSyncSavedConnectionView> =>
useMetadataCollection(gateway, 'saved-connections', true, () =>
gateway.listSavedConnections(),
);
export const useDataSyncDatabases = (
gateway: DataSyncWorkbenchGateway,
connectionId: string,
): DataSyncMetadataResult<DataSyncDatabaseMetadata> => {
const normalizedConnectionId = connectionId.trim();
return useMetadataCollection(
gateway,
normalizedConnectionId,
Boolean(normalizedConnectionId),
() => gateway.listDatabases(normalizedConnectionId),
);
};
export const useDataSyncObjects = (
gateway: DataSyncWorkbenchGateway,
endpoint: DataSyncEndpointRef,
): DataSyncMetadataResult<DataSyncObjectMetadata> => {
const requestKey = dataSyncEndpointMetadataKey(endpoint);
const enabled = Boolean(endpoint.connectionId.trim());
return useMetadataCollection(gateway, requestKey, enabled, () =>
gateway.listObjects({ ...endpoint }),
);
};
export const useDataSyncFields = (
gateway: DataSyncWorkbenchGateway,
endpoint: DataSyncEndpointRef,
objectName: string,
): DataSyncMetadataResult<DataSyncFieldMetadata> => {
const requestKey = dataSyncObjectMetadataKey(endpoint, objectName);
const enabled = Boolean(endpoint.connectionId.trim() && objectName.trim());
return useMetadataCollection(gateway, requestKey, enabled, () =>
gateway.listFields({ ...endpoint }, objectName),
);
};