mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Add compare batch cancel and restart recovery so stuck runs unblock.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9adf9f2764
commit
958942c5b6
8 changed files with 532 additions and 50 deletions
|
|
@ -520,10 +520,20 @@ const en = {
|
|||
edit: "Edit",
|
||||
delete: "Delete",
|
||||
tabConfig: "Job config",
|
||||
tabRuns: "Compare batches",
|
||||
tabResult: "Result",
|
||||
runs: "Run history",
|
||||
runsHint: "Each “Run now” creates a batch. Stuck “Running” after restart is auto-cancelled; you can also cancel manually and re-run.",
|
||||
noRuns: "No runs yet — run a compare first",
|
||||
result: "Result",
|
||||
viewResult: "View result",
|
||||
cancelRun: "Cancel",
|
||||
confirmCancelRun: "Cancel this compare batch? You can start a new run afterward.",
|
||||
runCancelled: "Compare cancelled",
|
||||
runStatusCancelled: "Cancelled",
|
||||
compareAlreadyRunning: "A compare is already running — cancel it under Compare batches or wait",
|
||||
runBatchSummary: "Fail {{fail}} · Pass {{ok}} · Added {{added}}",
|
||||
colTime: "Time",
|
||||
pickBatchRun: "Select compare run",
|
||||
pickRun: "Select run…",
|
||||
runCount: "{{n}} runs",
|
||||
|
|
|
|||
|
|
@ -519,10 +519,20 @@ const zh = {
|
|||
edit: "编辑",
|
||||
delete: "删除",
|
||||
tabConfig: "任务配置",
|
||||
tabRuns: "对比批次",
|
||||
tabResult: "比对结果",
|
||||
runs: "历史比对",
|
||||
runsHint: "每次「立即比对」生成一条批次。重启后卡住的「比对中」会自动标为已取消;也可手动终止后重跑。",
|
||||
noRuns: "尚无比对记录,请先执行比对",
|
||||
result: "比对结果",
|
||||
viewResult: "查看结果",
|
||||
cancelRun: "终止",
|
||||
confirmCancelRun: "确定终止该比对批次?终止后可重新发起比对。",
|
||||
runCancelled: "比对已取消",
|
||||
runStatusCancelled: "已取消",
|
||||
compareAlreadyRunning: "已有比对在进行中,请先在「对比批次」中终止或等待完成",
|
||||
runBatchSummary: "失败 {{fail}} · 成功 {{ok}} · 新增 {{added}}",
|
||||
colTime: "时间",
|
||||
pickBatchRun: "选择比对记录",
|
||||
pickRun: "选择比对记录…",
|
||||
runCount: "{{n}} 次",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
|||
import { useToast } from "../../hooks/useToast";
|
||||
import { useI18n } from "../../i18n";
|
||||
import {
|
||||
bizCompareCancelRun,
|
||||
bizCompareCreateJob,
|
||||
bizCompareCreateMapping,
|
||||
bizCompareCreateTemplate,
|
||||
|
|
@ -35,7 +36,7 @@ import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from ".
|
|||
import { jobChipColor, NmStatusChip } from "./nmChips";
|
||||
|
||||
type PageTab = "templates" | "jobs";
|
||||
type JobDetailTab = "config" | "result";
|
||||
type JobDetailTab = "config" | "runs" | "result";
|
||||
type KindFilter = "diff" | "all" | "added" | "removed" | "changed" | "unchanged";
|
||||
type CreateJobStep = 0 | 1 | 2 | 3;
|
||||
const CREATE_JOB_STEPS = 4;
|
||||
|
|
@ -1802,12 +1803,14 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
const job = jobs.find((x) => x.id === id);
|
||||
if (job) resetJobForm(job);
|
||||
try {
|
||||
const r = await bizCompareListRuns(id);
|
||||
setRuns(r.items || []);
|
||||
if ((r.items || []).length) {
|
||||
const latest = await bizCompareGetRun(String((r.items as any[])[0].id));
|
||||
const r = await bizCompareListRuns(id, 50);
|
||||
const items = r.items || [];
|
||||
setRuns(items);
|
||||
if (items.length) {
|
||||
const latest = await bizCompareGetRun(String((items as any[])[0].id));
|
||||
setRunDetail(latest);
|
||||
setJobDetailTab("result");
|
||||
// Land on batch list so stuck/cancelled runs are visible and actionable
|
||||
setJobDetailTab("runs");
|
||||
}
|
||||
} catch (e) {
|
||||
showError(formatErr(e));
|
||||
|
|
@ -1843,32 +1846,42 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
}
|
||||
};
|
||||
|
||||
const runStatus = String(runDetail?.status || "");
|
||||
const runIsActive = runStatus === "running" || runStatus === "queued";
|
||||
const jobHasActiveRun = runs.some((r) => {
|
||||
const st = String((r as any).status || "");
|
||||
return st === "running" || st === "queued";
|
||||
});
|
||||
|
||||
const runNow = async () => {
|
||||
if (!jobId) return;
|
||||
if (!enabledJobSheetCount) {
|
||||
showError(t("bizCompare.needSheets"));
|
||||
return;
|
||||
}
|
||||
if (jobHasActiveRun) {
|
||||
showError(t("bizCompare.compareAlreadyRunning"));
|
||||
setJobDetailTab("runs");
|
||||
return;
|
||||
}
|
||||
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");
|
||||
setJobDetailTab("runs");
|
||||
showOk(t("bizCompare.runStarted"));
|
||||
const r = await bizCompareListRuns(jobId);
|
||||
const r = await bizCompareListRuns(jobId, 50);
|
||||
setRuns(r.items || []);
|
||||
await refresh({ force: true });
|
||||
} catch (e) {
|
||||
showError(formatErr(e));
|
||||
setJobDetailTab("runs");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runStatus = String(runDetail?.status || "");
|
||||
const runIsActive = runStatus === "running" || runStatus === "queued";
|
||||
const runProgress = (runDetail?.summary?.progress || {}) as {
|
||||
phase?: string;
|
||||
sheet_index?: number;
|
||||
|
|
@ -1906,6 +1919,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
} else if (!notified && st === "failed") {
|
||||
notified = true;
|
||||
showError(String(d.message || t("bizCompare.runFailed")));
|
||||
} else if (!notified && st === "cancelled") {
|
||||
notified = true;
|
||||
showError(String(d.message || t("bizCompare.runCancelled")));
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) showError(formatErr(e));
|
||||
|
|
@ -1919,6 +1935,31 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
};
|
||||
}, [runDetail?.id, runIsActive, jobId, showOk, showError, t]);
|
||||
|
||||
// Keep the runs list fresh while any batch on this job is active
|
||||
useEffect(() => {
|
||||
if (!jobId || !jobHasActiveRun || jobDetailTab !== "runs") return;
|
||||
let cancelled = false;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const r = await bizCompareListRuns(jobId, 50);
|
||||
if (!cancelled) setRuns(r.items || []);
|
||||
const curId = String(runDetail?.id || "");
|
||||
if (curId) {
|
||||
const d = await bizCompareGetRun(curId);
|
||||
if (!cancelled) setRunDetail(d);
|
||||
}
|
||||
} catch {
|
||||
/* ignore list poll errors */
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const id = window.setInterval(() => void tick(), 2500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [jobId, jobHasActiveRun, jobDetailTab, runDetail?.id]);
|
||||
|
||||
const loadRun = async (runId: string) => {
|
||||
try {
|
||||
const d = await bizCompareGetRun(runId);
|
||||
|
|
@ -1931,6 +1972,25 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
}
|
||||
};
|
||||
|
||||
const cancelRun = async (runId: string) => {
|
||||
if (!runId) return;
|
||||
if (!window.confirm(t("bizCompare.confirmCancelRun"))) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const d = await bizCompareCancelRun(runId);
|
||||
if (String(runDetail?.id || "") === runId) setRunDetail(d);
|
||||
if (jobId) {
|
||||
const r = await bizCompareListRuns(jobId, 50);
|
||||
setRuns(r.items || []);
|
||||
}
|
||||
showOk(t("bizCompare.runCancelled"));
|
||||
} catch (e) {
|
||||
showError(formatErr(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeRun = async (runId: string) => {
|
||||
if (!runId) return;
|
||||
if (!window.confirm(t("bizCompare.confirmDeleteRun"))) return;
|
||||
|
|
@ -1940,19 +2000,16 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
await bizCompareDeleteRun(runId);
|
||||
let nextRuns: typeof runs = [];
|
||||
if (jobId) {
|
||||
const r = await bizCompareListRuns(jobId);
|
||||
const r = await bizCompareListRuns(jobId, 50);
|
||||
nextRuns = r.items || [];
|
||||
} else {
|
||||
nextRuns = (runs || []).filter((r) => String(r.id) !== runId);
|
||||
}
|
||||
setRuns(nextRuns);
|
||||
if (wasCurrent) {
|
||||
if (nextRuns.length) {
|
||||
await loadRun(String(nextRuns[0].id));
|
||||
} else {
|
||||
setRunDetail(null);
|
||||
setResultSheetId("");
|
||||
}
|
||||
setRunDetail(null);
|
||||
setResultSheetId("");
|
||||
setJobDetailTab("runs");
|
||||
}
|
||||
showOk(t("bizCompare.runDeleted"));
|
||||
} catch (e) {
|
||||
|
|
@ -2864,6 +2921,18 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
>
|
||||
{t("bizCompare.tabConfig")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={jobDetailTab === "runs" ? "primary" : "secondary"}
|
||||
className={jobDetailTab === "runs" ? "is-active" : undefined}
|
||||
onPress={() => {
|
||||
setJobDetailTab("runs");
|
||||
if (jobId) void bizCompareListRuns(jobId, 50).then((r) => setRuns(r.items || []));
|
||||
}}
|
||||
>
|
||||
{t("bizCompare.tabRuns")}
|
||||
{runs.length ? ` (${runs.length})` : ""}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={jobDetailTab === "result" ? "primary" : "secondary"}
|
||||
|
|
@ -2876,6 +2945,156 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
|
||||
{jobDetailTab === "config" ? (
|
||||
renderJobForm()
|
||||
) : jobDetailTab === "runs" ? (
|
||||
<div className="bs-cmp-runs">
|
||||
<div className="btn-row" style={{ marginBottom: 8, justifyContent: "space-between" }}>
|
||||
<p className="muted bm-hint" style={{ margin: 0 }}>
|
||||
{t("bizCompare.runsHint")}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={busy || jobHasActiveRun}
|
||||
onPress={() => void runNow()}
|
||||
>
|
||||
{jobHasActiveRun ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
|
||||
</Button>
|
||||
</div>
|
||||
{!runs.length ? (
|
||||
<div className="pt-list-empty">{t("bizCompare.noRuns")}</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("bizCompare.colStatus")}</th>
|
||||
<th>{t("bizCompare.colTime")}</th>
|
||||
<th>{t("bizCompare.sidesTitle")}</th>
|
||||
<th>{t("bizCompare.colResult")}</th>
|
||||
<th>{t("bizCompare.colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r) => {
|
||||
const st = String((r as any).status || "");
|
||||
const active = st === "running" || st === "queued";
|
||||
const selected = String(runDetail?.id || "") === String(r.id);
|
||||
const before = enrichSide(
|
||||
(r as any).before,
|
||||
beforeTaskId,
|
||||
tasks,
|
||||
beforeBatches,
|
||||
);
|
||||
const after = enrichSide(
|
||||
(r as any).after,
|
||||
afterTaskId || beforeTaskId,
|
||||
tasks,
|
||||
afterBatches.length ? afterBatches : beforeBatches,
|
||||
);
|
||||
const when = formatSystemTime((r as any).created_at) || "—";
|
||||
const sum = ((r as any).summary || {}) as Record<string, number>;
|
||||
const fail =
|
||||
Number(sum.removed || 0) + Number(sum.changed || 0);
|
||||
const ok = Number(sum.unchanged || 0);
|
||||
const durMs = Number(sum.duration_ms || 0);
|
||||
const prog = ((r as any).progress || {}) as {
|
||||
phase?: string;
|
||||
sheet_index?: number;
|
||||
sheet_total?: number;
|
||||
sheet_title?: string;
|
||||
rows_loaded?: number;
|
||||
load_side?: string;
|
||||
};
|
||||
const stLabel =
|
||||
st === "running" || st === "queued"
|
||||
? t("bizCompare.runStatusRunning")
|
||||
: st === "failed"
|
||||
? t("bizCompare.runStatusFailed")
|
||||
: st === "cancelled"
|
||||
? t("bizCompare.runStatusCancelled")
|
||||
: t("bizCompare.runStatusDoneSec", {
|
||||
s: String(durMs > 0 ? Math.round(durMs / 1000) : 0),
|
||||
});
|
||||
return (
|
||||
<tr key={String(r.id)} className={selected ? "is-selected" : undefined}>
|
||||
<td>
|
||||
<NmStatusChip color={jobChipColor(st)}>{stLabel}</NmStatusChip>
|
||||
{active && prog.phase ? (
|
||||
<div className="muted" style={{ fontSize: "0.75rem", marginTop: 4 }}>
|
||||
{prog.phase}
|
||||
{prog.sheet_total
|
||||
? ` · ${prog.sheet_index || 0}/${prog.sheet_total}`
|
||||
: ""}
|
||||
{prog.sheet_title ? ` · ${prog.sheet_title}` : ""}
|
||||
{Number(prog.rows_loaded || 0) > 0
|
||||
? ` · ${prog.load_side || ""} ${prog.rows_loaded}`
|
||||
: ""}
|
||||
</div>
|
||||
) : null}
|
||||
{(r as any).message ? (
|
||||
<div
|
||||
className="muted"
|
||||
style={{ fontSize: "0.75rem", marginTop: 2 }}
|
||||
title={String((r as any).message)}
|
||||
>
|
||||
{String((r as any).message).slice(0, 80)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{when}</td>
|
||||
<td>
|
||||
{sideDeviceName(before)} {sideCollectTime(before)}
|
||||
{" → "}
|
||||
{sideDeviceName(after)} {sideCollectTime(after)}
|
||||
</td>
|
||||
<td>
|
||||
{active
|
||||
? "…"
|
||||
: t("bizCompare.runBatchSummary", {
|
||||
fail: String(fail),
|
||||
ok: String(ok),
|
||||
added: String(Number(sum.added || 0)),
|
||||
})}
|
||||
</td>
|
||||
<td>
|
||||
<div className="btn-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={busy || active}
|
||||
onPress={() => void loadRun(String(r.id))}
|
||||
>
|
||||
{t("bizCompare.viewResult")}
|
||||
</Button>
|
||||
{active ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={busy}
|
||||
onPress={() => void cancelRun(String(r.id))}
|
||||
>
|
||||
{t("bizCompare.cancelRun")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={busy}
|
||||
onPress={() => void removeRun(String(r.id))}
|
||||
>
|
||||
{t("bizCompare.deleteRun")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={boardRef}
|
||||
|
|
@ -3536,10 +3755,10 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={busy || runIsActive}
|
||||
isDisabled={busy || jobHasActiveRun}
|
||||
onPress={() => void runNow()}
|
||||
>
|
||||
{runIsActive ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
|
||||
{jobHasActiveRun ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
|
||||
</Button>
|
||||
{jobId ? (
|
||||
<Button size="sm" variant="danger" isDisabled={busy} onPress={() => void removeJob(jobId)}>
|
||||
|
|
@ -3547,12 +3766,21 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : jobDetailTab === "runs" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={busy || jobHasActiveRun}
|
||||
onPress={() => void runNow()}
|
||||
>
|
||||
{jobHasActiveRun ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={busy || !runDetail?.id}
|
||||
isDisabled={busy || runIsActive || !runDetail?.id}
|
||||
onPress={() => void downloadRunTables()}
|
||||
>
|
||||
{t("bizCompare.exportTables")}
|
||||
|
|
@ -3568,10 +3796,10 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={busy || runIsActive}
|
||||
isDisabled={busy || jobHasActiveRun}
|
||||
onPress={() => void runNow()}
|
||||
>
|
||||
{runIsActive ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
|
||||
{jobHasActiveRun ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2154,6 +2154,12 @@ export const bizCompareListRuns = (jobId: string, limit = 20) =>
|
|||
export const bizCompareGetRun = (runId: string) =>
|
||||
apiGet<Record<string, unknown>>(`/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`);
|
||||
|
||||
export const bizCompareCancelRun = (runId: string) =>
|
||||
apiPost<Record<string, unknown>>(
|
||||
`/v1/biz-state/compare/runs/${encodeURIComponent(runId)}/cancel`,
|
||||
{},
|
||||
);
|
||||
|
||||
export const bizCompareDeleteRun = (runId: string) =>
|
||||
apiDelete<{ ok: boolean; job_id?: string; run_id?: string }>(
|
||||
`/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue