Files
MyGoNavi/frontend/src/components/data-sync/DataSyncWorkbenchShell.tsx
Syngnat 4c04609df2 feat(data-sync): 新增多对象迁移与同步任务工作台
- 以五步流程组织端点、对象、写入、运行方式和预检
- 支持批量选表、同名目标匹配、字段映射与主键识别
- 完善生命周期、运行记录、CDC、错误行和响应式交互
2026-08-08 19:29:29 +08:00

896 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};