mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
Make biz compare async and cutover-friendly for large tables.
Run compares in the background with progress, persist fails first, and store success as sampled key+row_id hydrated from source so million-row sheets no longer block the API. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3e2cb4165c
commit
227959b9a2
10 changed files with 1035 additions and 168 deletions
|
|
@ -494,6 +494,22 @@ const en = {
|
|||
needBeforeBatch: "Select before task and batch",
|
||||
needAfterBatch: "Select after batch",
|
||||
runNow: "Run now",
|
||||
runStarted: "Compare started in background — you can close this page; check run history for progress",
|
||||
runFailed: "Compare failed",
|
||||
runStatusRunning: "Running",
|
||||
runStatusFailed: "Failed",
|
||||
runStatusDoneSec: "Done {{s}}s",
|
||||
runProgress: "{{phase}} · sheet {{i}}/{{n}} · {{sheet}} · {{s}}s elapsed",
|
||||
ranWithDuration: "Compare finished ({{s}}s)",
|
||||
unchangedNotStored: "Success rows were counted but not stored. Set “Store success rows” to sample and re-run for spot-check.",
|
||||
unchangedSampleHint: "{{total}} success rows total; browsing a sample of {{listed}} (hydrated from source tables for cutover spot-check). Pass rate uses all {{total}}.",
|
||||
storeUnchanged: "Store success rows",
|
||||
storeUnchangedAuto: "Auto (full if small / sample 5k + hydrate if large)",
|
||||
storeUnchangedSample: "Sample only (up to 5k keys + hydrate)",
|
||||
storeUnchangedKeys: "All success keys (full browse; large write still slow)",
|
||||
storeUnchangedAlways: "Always full JSON (slow on huge sheets)",
|
||||
storeUnchangedNever: "Never (counts only)",
|
||||
storeUnchangedHint: "Cutover focuses on fails and pass rate; success defaults to a sample. Pass rate always uses the full success count.",
|
||||
saveJob: "Save config",
|
||||
jobSaved: "Job config saved",
|
||||
jobDeleted: "Compare job deleted",
|
||||
|
|
|
|||
|
|
@ -493,6 +493,22 @@ const zh = {
|
|||
needBeforeBatch: "请选择操作前任务与批次",
|
||||
needAfterBatch: "请选择操作后批次",
|
||||
runNow: "立即比对",
|
||||
runStarted: "比对已在后台启动,可关闭本页;进度见历史记录",
|
||||
runFailed: "比对失败",
|
||||
runStatusRunning: "比对中",
|
||||
runStatusFailed: "失败",
|
||||
runStatusDoneSec: "完成 {{s}}s",
|
||||
runProgress: "{{phase}} · 表 {{i}}/{{n}} · {{sheet}} · 已用 {{s}}s",
|
||||
ranWithDuration: "比对完成(耗时 {{s}} 秒)",
|
||||
unchangedNotStored: "成功行仅统计数量未落库。可在任务配置将「成功行保存」改为抽样后重新比对(抽查用)。",
|
||||
unchangedSampleHint: "成功共 {{total}} 条,明细抽样 {{listed}} 条(从原表补全显示,割接抽查用)。通过率按全部 {{total}} 计。",
|
||||
storeUnchanged: "成功行保存",
|
||||
storeUnchangedAuto: "自动(小表全量 / 大表抽样 5000+原表补全)",
|
||||
storeUnchangedSample: "仅抽样(最多 5000,身份键+原表补全)",
|
||||
storeUnchangedKeys: "全量身份键(可翻完全部成功,大表写入仍较久)",
|
||||
storeUnchangedAlways: "始终全量 JSON(大表很慢,慎用)",
|
||||
storeUnchangedNever: "不保存(只计数量)",
|
||||
storeUnchangedHint: "割接优先看失败与通过率;成功默认抽样抽查。通过率始终按全部成功计数。",
|
||||
saveJob: "保存配置",
|
||||
jobSaved: "任务配置已保存",
|
||||
jobDeleted: "比对任务已删除",
|
||||
|
|
|
|||
|
|
@ -11698,6 +11698,29 @@ html.login-page--paused .login-page__flare {
|
|||
rgba(8, 13, 24, 0.4);
|
||||
}
|
||||
|
||||
.bs-cmp-progress {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px 14px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.35);
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
color: var(--bs-cmp-ink, #e2e8f0);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.bs-cmp-progress.is-failed {
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.bs-cmp-progress__msg {
|
||||
flex: 1 1 100%;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Native :fullscreen + CSS immersive fallback (class only when FS API blocked) */
|
||||
.bs-cmp-board.is-fullscreen,
|
||||
.bs-cmp-board:fullscreen {
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ type Template = {
|
|||
};
|
||||
|
||||
type Mapping = { id: string; name: string; rows: { before_if: string; after_if: string }[] };
|
||||
type StoreUnchanged = "auto" | "always" | "never" | "sample" | "keys";
|
||||
|
||||
type Job = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -112,6 +114,7 @@ type Job = {
|
|||
mode: string;
|
||||
status: string;
|
||||
enabled_sheet_ids?: string[];
|
||||
store_unchanged?: StoreUnchanged | string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
|
|
@ -824,6 +827,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
const [beforeBatchId, setBeforeBatchId] = useState("");
|
||||
const [afterBatchId, setAfterBatchId] = useState("");
|
||||
const [mode, setMode] = useState<"manual" | "auto">("manual");
|
||||
const [storeUnchanged, setStoreUnchanged] = useState<StoreUnchanged>("auto");
|
||||
const [mapName, setMapName] = useState("端口映射");
|
||||
const [mapText, setMapText] = useState("");
|
||||
const [validateOut, setValidateOut] = useState<any>(null);
|
||||
|
|
@ -1056,7 +1060,14 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
useEffect(() => {
|
||||
const runId = String(runDetail?.id || "");
|
||||
const mid = resultSheetId || sheetIdentity(activeRunSheet) || "";
|
||||
if (!runId || !mid || jobDetailTab !== "result") {
|
||||
const st = String(runDetail?.status || "");
|
||||
if (
|
||||
!runId ||
|
||||
!mid ||
|
||||
jobDetailTab !== "result" ||
|
||||
st === "running" ||
|
||||
st === "queued"
|
||||
) {
|
||||
setPagedDiffs([]);
|
||||
setResultTotal(0);
|
||||
return;
|
||||
|
|
@ -1095,6 +1106,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
};
|
||||
}, [
|
||||
runDetail?.id,
|
||||
runDetail?.status,
|
||||
resultSheetId,
|
||||
activeRunSheet?.metric_id,
|
||||
kindFilter,
|
||||
|
|
@ -1649,6 +1661,14 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
setBeforeBatchId(preset?.before_batch_id || "");
|
||||
setAfterBatchId(preset?.after_batch_id || "");
|
||||
setMode(preset?.mode === "auto" ? "auto" : "manual");
|
||||
{
|
||||
const su = String(preset?.store_unchanged || "auto");
|
||||
setStoreUnchanged(
|
||||
su === "always" || su === "never" || su === "sample" || su === "keys"
|
||||
? su
|
||||
: "auto",
|
||||
);
|
||||
}
|
||||
setValidateOut(null);
|
||||
if (preset?.mapping_id) loadMappingText(preset.mapping_id);
|
||||
else {
|
||||
|
|
@ -1667,8 +1687,24 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
after_batch_id: mode === "manual" ? afterBatchId : "",
|
||||
mode,
|
||||
enabled_sheet_ids: enabledSheetIds,
|
||||
store_unchanged: storeUnchanged,
|
||||
});
|
||||
|
||||
const renderStoreUnchangedField = () => (
|
||||
<FieldSelect
|
||||
label={t("bizCompare.storeUnchanged")}
|
||||
value={storeUnchanged}
|
||||
onChange={(e) => setStoreUnchanged(e.target.value as StoreUnchanged)}
|
||||
fullWidth
|
||||
>
|
||||
<option value="auto">{t("bizCompare.storeUnchangedAuto")}</option>
|
||||
<option value="sample">{t("bizCompare.storeUnchangedSample")}</option>
|
||||
<option value="keys">{t("bizCompare.storeUnchangedKeys")}</option>
|
||||
<option value="always">{t("bizCompare.storeUnchangedAlways")}</option>
|
||||
<option value="never">{t("bizCompare.storeUnchangedNever")}</option>
|
||||
</FieldSelect>
|
||||
);
|
||||
|
||||
const closeCreateJob = () => {
|
||||
setJobCreateOpen(false);
|
||||
setCreateStep(0);
|
||||
|
|
@ -1805,10 +1841,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
setBusy(true);
|
||||
try {
|
||||
await bizCompareUpdateJob(jobId, jobConfigBody());
|
||||
// Async enqueue — returns immediately with status=running; poll below.
|
||||
const run = await bizCompareRunJob(jobId);
|
||||
setRunDetail(run);
|
||||
setJobDetailTab("result");
|
||||
showOk(t("bizCompare.ran"));
|
||||
showOk(t("bizCompare.runStarted"));
|
||||
const r = await bizCompareListRuns(jobId);
|
||||
setRuns(r.items || []);
|
||||
await refresh({ force: true });
|
||||
|
|
@ -1819,6 +1856,56 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
}
|
||||
};
|
||||
|
||||
const runStatus = String(runDetail?.status || "");
|
||||
const runIsActive = runStatus === "running" || runStatus === "queued";
|
||||
const runProgress = (runDetail?.summary?.progress || {}) as {
|
||||
phase?: string;
|
||||
sheet_index?: number;
|
||||
sheet_total?: number;
|
||||
sheet_title?: string;
|
||||
sheet_id?: string;
|
||||
elapsed_ms?: number;
|
||||
before_count?: number;
|
||||
after_count?: number;
|
||||
diff_rows?: number;
|
||||
};
|
||||
|
||||
// Poll active compare runs so the modal can be closed and reopened safely.
|
||||
useEffect(() => {
|
||||
if (!runIsActive || !runDetail?.id) return;
|
||||
let cancelled = false;
|
||||
let notified = false;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const d = await bizCompareGetRun(String(runDetail.id));
|
||||
if (cancelled) return;
|
||||
setRunDetail(d);
|
||||
if (jobId) {
|
||||
const r = await bizCompareListRuns(jobId);
|
||||
if (!cancelled) setRuns(r.items || []);
|
||||
}
|
||||
const st = String(d.status || "");
|
||||
if (!notified && st === "success") {
|
||||
notified = true;
|
||||
const ms = Number((d.summary as any)?.duration_ms || 0);
|
||||
const sec = ms > 0 ? Math.round(ms / 1000) : 0;
|
||||
showOk(sec > 0 ? t("bizCompare.ranWithDuration", { s: String(sec) }) : t("bizCompare.ran"));
|
||||
} else if (!notified && st === "failed") {
|
||||
notified = true;
|
||||
showError(String(d.message || t("bizCompare.runFailed")));
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) showError(formatErr(e));
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const id = window.setInterval(() => void tick(), 2000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [runDetail?.id, runIsActive, jobId, showOk, showError, t]);
|
||||
|
||||
const loadRun = async (runId: string) => {
|
||||
try {
|
||||
const d = await bizCompareGetRun(runId);
|
||||
|
|
@ -2034,6 +2121,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
<option value="manual">{t("bizCompare.modeManual")}</option>
|
||||
<option value="auto">{t("bizCompare.modeAuto")}</option>
|
||||
</FieldSelect>
|
||||
{renderStoreUnchangedField()}
|
||||
<p className="muted bm-hint">{t("bizCompare.storeUnchangedHint")}</p>
|
||||
{renderSheetChips()}
|
||||
{renderBatchFields()}
|
||||
{renderMappingBlock()}
|
||||
|
|
@ -2701,6 +2790,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
<option value="manual">{t("bizCompare.modeManual")}</option>
|
||||
<option value="auto">{t("bizCompare.modeAuto")}</option>
|
||||
</FieldSelect>
|
||||
{renderStoreUnchangedField()}
|
||||
<p className="muted bm-hint">{t("bizCompare.storeUnchangedHint")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -2854,8 +2945,21 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
const bl = sideDeviceName(before);
|
||||
const al = sideDeviceName(after);
|
||||
const when = formatSystemTime((r as any).created_at) || "";
|
||||
const st = String((r as any).status || "");
|
||||
const durMs = Number((r as any).summary?.duration_ms || 0);
|
||||
const stLabel =
|
||||
st === "running" || st === "queued"
|
||||
? t("bizCompare.runStatusRunning")
|
||||
: st === "failed"
|
||||
? t("bizCompare.runStatusFailed")
|
||||
: durMs > 0
|
||||
? t("bizCompare.runStatusDoneSec", {
|
||||
s: String(Math.round(durMs / 1000)),
|
||||
})
|
||||
: "";
|
||||
return (
|
||||
<option key={r.id} value={r.id}>
|
||||
{stLabel ? `[${stLabel}] ` : ""}
|
||||
{when ? `${when} · ` : ""}
|
||||
{bl} {sideCollectTime(before)} → {al} {sideCollectTime(after)}
|
||||
</option>
|
||||
|
|
@ -2876,7 +2980,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={busy || !runDetail?.id}
|
||||
isDisabled={busy || runIsActive || !runDetail?.id}
|
||||
onPress={() => void downloadRunTables()}
|
||||
>
|
||||
{t("bizCompare.exportTables")}
|
||||
|
|
@ -2884,7 +2988,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={busy || !runDetail?.id}
|
||||
isDisabled={busy || runIsActive || !runDetail?.id}
|
||||
onPress={() => void removeRun(String(runDetail?.id || ""))}
|
||||
>
|
||||
{t("bizCompare.deleteRun")}
|
||||
|
|
@ -2902,6 +3006,31 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{runDetail && runIsActive ? (
|
||||
<div className="bs-cmp-progress" role="status" aria-live="polite">
|
||||
<strong>{t("bizCompare.runStatusRunning")}</strong>
|
||||
<span className="muted">
|
||||
{t("bizCompare.runProgress", {
|
||||
phase: String(runProgress.phase || "…"),
|
||||
i: String(runProgress.sheet_index || 0),
|
||||
n: String(runProgress.sheet_total || 0),
|
||||
sheet: String(
|
||||
runProgress.sheet_title || runProgress.sheet_id || "—",
|
||||
),
|
||||
s: String(Math.round(Number(runProgress.elapsed_ms || 0) / 1000)),
|
||||
})}
|
||||
</span>
|
||||
{runDetail.message ? (
|
||||
<span className="muted bs-cmp-progress__msg">{String(runDetail.message)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{runDetail && runStatus === "failed" ? (
|
||||
<div className="bs-cmp-progress is-failed" role="alert">
|
||||
<strong>{t("bizCompare.runStatusFailed")}</strong>
|
||||
<span>{String(runDetail.message || "")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{runDetail ? (
|
||||
<div
|
||||
className={`bs-cmp-board__body${navCollapsed ? " is-nav-collapsed" : ""}`}
|
||||
|
|
@ -3101,6 +3230,30 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
{diffsLoading ? "…" : `${pagedDiffs.length}/${resultTotal}`}
|
||||
</span>
|
||||
</div>
|
||||
{kindFilter === "unchanged" &&
|
||||
(runDetail?.summary?.unchanged_truncated ||
|
||||
(Number(runDetail?.summary?.unchanged || 0) >
|
||||
Number(runDetail?.summary?.unchanged_listed || 0) &&
|
||||
Number(runDetail?.summary?.unchanged_listed || 0) > 0)) ? (
|
||||
<p className="muted bm-hint" style={{ margin: "4px 0 0" }}>
|
||||
{t("bizCompare.unchangedSampleHint", {
|
||||
listed: String(
|
||||
Number(
|
||||
(activeRunSheet?.summary as any)?.unchanged_listed ??
|
||||
runDetail?.summary?.unchanged_listed ??
|
||||
resultTotal,
|
||||
),
|
||||
),
|
||||
total: String(
|
||||
Number(
|
||||
(activeRunSheet?.summary as any)?.unchanged ??
|
||||
runDetail?.summary?.unchanged ??
|
||||
0,
|
||||
),
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -3287,7 +3440,16 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
{runDetail && !diffsLoading && !pagedDiffs.length ? (
|
||||
<tr>
|
||||
<td colSpan={resultEmptyColSpan}>
|
||||
<div className="pt-list-empty">{t("bizCompare.resultEmpty")}</div>
|
||||
<div className="pt-list-empty">
|
||||
{kindFilter === "unchanged"
|
||||
? Number(runDetail?.summary?.unchanged || 0) > 0 &&
|
||||
!Number(runDetail?.summary?.unchanged_listed || 0)
|
||||
? t("bizCompare.unchangedNotStored")
|
||||
: t("bizCompare.resultEmpty")
|
||||
: runIsActive
|
||||
? t("bizCompare.runStatusRunning")
|
||||
: t("bizCompare.resultEmpty")}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
|
|
@ -3322,8 +3484,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void saveJobConfig()}>
|
||||
{t("bizCompare.saveJob")}
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void runNow()}>
|
||||
{t("bizCompare.runNow")}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={busy || runIsActive}
|
||||
onPress={() => void runNow()}
|
||||
>
|
||||
{runIsActive ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
|
||||
</Button>
|
||||
{jobId ? (
|
||||
<Button size="sm" variant="danger" isDisabled={busy} onPress={() => void removeJob(jobId)}>
|
||||
|
|
@ -3349,8 +3516,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
>
|
||||
{t("bizCompare.deleteRun")}
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void runNow()}>
|
||||
{t("bizCompare.runNow")}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={busy || runIsActive}
|
||||
onPress={() => void runNow()}
|
||||
>
|
||||
{runIsActive ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue