mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-11 09:13:36 +08:00
✨ feat(data-sync): 新增多对象迁移与同步任务工作台
- 以五步流程组织端点、对象、写入、运行方式和预检 - 支持批量选表、同名目标匹配、字段映射与主键识别 - 完善生命周期、运行记录、CDC、错误行和响应式交互
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
277
frontend/src/components/data-sync/DataSyncDeliveryStage.test.tsx
Normal file
277
frontend/src/components/data-sync/DataSyncDeliveryStage.test.tsx
Normal 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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
183
frontend/src/components/data-sync/DataSyncEndpointSelector.tsx
Normal file
183
frontend/src/components/data-sync/DataSyncEndpointSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
304
frontend/src/components/data-sync/DataSyncFieldMappingEditor.tsx
Normal file
304
frontend/src/components/data-sync/DataSyncFieldMappingEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
486
frontend/src/components/data-sync/DataSyncMappingTable.tsx
Normal file
486
frontend/src/components/data-sync/DataSyncMappingTable.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
228
frontend/src/components/data-sync/DataSyncObjectPicker.tsx
Normal file
228
frontend/src/components/data-sync/DataSyncObjectPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
331
frontend/src/components/data-sync/DataSyncOperationalViews.tsx
Normal file
331
frontend/src/components/data-sync/DataSyncOperationalViews.tsx
Normal 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>
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
208
frontend/src/components/data-sync/DataSyncPreflightPanel.tsx
Normal file
208
frontend/src/components/data-sync/DataSyncPreflightPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
55
frontend/src/components/data-sync/DataSyncRouteBar.tsx
Normal file
55
frontend/src/components/data-sync/DataSyncRouteBar.tsx
Normal 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>
|
||||
);
|
||||
1329
frontend/src/components/data-sync/DataSyncTaskEditor.tsx
Normal file
1329
frontend/src/components/data-sync/DataSyncTaskEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
118
frontend/src/components/data-sync/DataSyncTaskList.tsx
Normal file
118
frontend/src/components/data-sync/DataSyncTaskList.tsx
Normal 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>
|
||||
);
|
||||
2003
frontend/src/components/data-sync/DataSyncWorkbench.css
Normal file
2003
frontend/src/components/data-sync/DataSyncWorkbench.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
895
frontend/src/components/data-sync/DataSyncWorkbenchShell.tsx
Normal file
895
frontend/src/components/data-sync/DataSyncWorkbenchShell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
21
frontend/src/components/data-sync/index.ts
Normal file
21
frontend/src/components/data-sync/index.ts
Normal 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';
|
||||
856
frontend/src/components/data-sync/text.ts
Normal file
856
frontend/src/components/data-sync/text.ts
Normal 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');
|
||||
};
|
||||
122
frontend/src/components/data-sync/useDataSyncMetadata.ts
Normal file
122
frontend/src/components/data-sync/useDataSyncMetadata.ts
Normal 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),
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user