feat: add sync checkpoint restore for last successful sync

Capture rolling online DB snapshots after push/drain/reconcile and expose SyncPage 数据恢复 plus checkpoint/restore APIs; mark Z12h and restore done in coop docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-08-06 11:49:00 +08:00
parent 75622a728e
commit 680d2b8cde
11 changed files with 860 additions and 16 deletions

View File

@@ -34,6 +34,7 @@ import {
createSyncChannel,
deleteSyncChannel,
dropSyncTable,
getSyncCheckpointMeta,
inspectSyncChannel,
listAgents,
listApps,
@@ -44,6 +45,7 @@ import {
revokeBindCode,
previewSyncTable,
reconcileSyncChannel,
restoreSyncChannel,
startSyncChannel,
stopSyncChannel,
testSyncEndpoints,
@@ -308,6 +310,83 @@ export function SyncPage(props: {
}
}
function formatCheckpointTime(iso?: string) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString();
}
async function openRestore(r: SyncChannel) {
setBusy(true);
try {
const meta = await getSyncCheckpointMeta(session, r.id);
if (!meta.latest && !meta.previous) {
message.warning("尚无成功同步快照。请先完成一次同步(含自动推送),约 30 秒后生成。");
return;
}
let which: "latest" | "previous" = meta.latest ? "latest" : "previous";
const options: { value: "latest" | "previous"; label: string }[] = [];
if (meta.latest) {
options.push({
value: "latest",
label: `最近一次 · ${formatCheckpointTime(meta.latest.synced_at)}${meta.latest.row_count ?? 0} 行 / ${meta.latest.table_count ?? 0} 表)`,
});
}
if (meta.previous) {
options.push({
value: "previous",
label: `上一代 · ${formatCheckpointTime(meta.previous.synced_at)}${meta.previous.row_count ?? 0} 行 / ${meta.previous.table_count ?? 0} 表)`,
});
}
Modal.confirm({
title: `数据恢复 · ${r.name || r.id}`,
width: 560,
content: (
<div>
<p style={{ marginTop: 0 }}>
<strong>线</strong>upsert /pull 线
</p>
<p style={{ color: "rgba(0,0,0,0.45)", fontSize: 13 }}>
</p>
<div style={{ marginTop: 12 }}>
<Typography.Text type="secondary"></Typography.Text>
<Select
style={{ width: "100%", marginTop: 6 }}
defaultValue={which}
options={options}
onChange={(v) => {
which = v;
}}
/>
</div>
</div>
),
okText: "确认恢复",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
const res = await restoreSyncChannel(session, r.id, { which, confirm: true });
message.success(
`已恢复:写入 ${res.upserted ?? 0} 行,清理多余 ${res.deleted ?? 0} 行(快照 ${formatCheckpointTime(res.synced_at)}`
);
setInfo("线上库已按同步快照恢复");
await refresh();
} catch (e: any) {
message.error(e.message || String(e));
throw e;
}
},
});
} catch (e: any) {
message.error(e.message || String(e));
} finally {
setBusy(false);
}
}
function confirmDropTable(table: string) {
if (!inspectCh) return;
const sideLabel = inspectSide === "remote" ? "线上库" : "本机端(通道 local";
@@ -583,6 +662,9 @@ export function SyncPage(props: {
>
</Button>
<Button size="small" onClick={() => void openRestore(r)}>
</Button>
<Button
size="small"
danger