mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-11 17:23:48 +08:00
896 lines
30 KiB
TypeScript
896 lines
30 KiB
TypeScript
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>
|
||
);
|
||
};
|