Add compare batch cancel and restart recovery so stuck runs unblock.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-08 14:48:10 +08:00
parent 9adf9f2764
commit 958942c5b6
8 changed files with 532 additions and 50 deletions

View file

@ -159,6 +159,17 @@ def run_api_startup() -> None:
)
except Exception:
_log.exception("startup: biz_state collect recovery failed")
try:
from .biz_state.compare_service import recover_interrupted_compares_on_startup
cmp_rec = recover_interrupted_compares_on_startup(db)
if cmp_rec.get("runs"):
_log.info(
"startup: cancelled %s interrupted biz compare run(s)",
cmp_rec.get("runs"),
)
except Exception:
_log.exception("startup: biz compare recovery failed")
try:
from .port_traffic_migrate import backfill_port_traffic_series

View file

@ -1868,13 +1868,17 @@ def _set_run_progress(
progress.update(extra)
prev["progress"] = progress
run.summary_json = prev
run.status = "running"
title = progress["sheet_title"] or progress["sheet_id"] or ""
run.message = (
f"{phase} {sheet_index}/{sheet_total}"
+ (f" · {title}" if title else "")
+ f" · {elapsed_ms // 1000}s"
)[:1024]
# Re-read status from DB — cancel may have been committed by another session
# (UI cancel / startup recovery) while this worker still holds a stale "running".
db.expire(run, ["status", "message"])
if str(run.status or "") != "cancelled":
run.status = "running"
title = progress["sheet_title"] or progress["sheet_id"] or ""
run.message = (
f"{phase} {sheet_index}/{sheet_total}"
+ (f" · {title}" if title else "")
+ f" · {elapsed_ms // 1000}s"
)[:1024]
db.commit()
@ -1937,10 +1941,18 @@ def _execute_compare_into_run(db: Session, run_id: str) -> dict[str, Any]:
prev["sheets"] = list(sheet_metas)
prev.update({k: agg[k] for k in agg})
run.summary_json = prev
db.expire(run, ["status"])
# Never resurrect cancelled while publishing incremental sheet metas
if str(run.status or "") == "cancelled":
db.commit()
return
db.commit()
try:
for idx, sheet in enumerate(sheets_cfg, start=1):
if _run_is_cancelled(db, run_id):
run = db.get(BizCompareRun, run_id) or run
return get_run(db, run.id)
sid = sheet_key(sheet)
# Mark current sheet running in the sidebar list
for meta in sheet_metas:
@ -2091,6 +2103,13 @@ def _execute_compare_into_run(db: Session, run_id: str) -> dict[str, Any]:
fail_diffs.clear()
ok_diffs.clear()
db.commit()
if _run_is_cancelled(db, run_id):
run = db.get(BizCompareRun, run_id) or run
return get_run(db, run.id)
if _run_is_cancelled(db, run_id):
run = db.get(BizCompareRun, run_id) or run
return get_run(db, run.id)
duration_ms = int((time.monotonic() - started_mono) * 1000)
top_fields = sorted(
@ -2117,6 +2136,9 @@ def _execute_compare_into_run(db: Session, run_id: str) -> dict[str, Any]:
},
"sheets": sheet_metas,
}
run = db.get(BizCompareRun, run_id) or run
if str(run.status or "") == "cancelled":
return get_run(db, run.id)
run.status = "success"
run.summary_json = summary_payload
run.mapping_stats_json = mapping_by_metric
@ -2129,34 +2151,123 @@ def _execute_compare_into_run(db: Session, run_id: str) -> dict[str, Any]:
db.commit()
return get_run(db, run.id)
except HTTPException as exc:
run.status = "failed"
run.message = str(getattr(exc, "detail", "") or exc)[:1024]
prev = dict(run.summary_json or {})
prog = dict(prev.get("progress") or {})
prog["phase"] = "failed"
prog["elapsed_ms"] = int((time.monotonic() - started_mono) * 1000)
prev["progress"] = prog
prev["duration_ms"] = prog["elapsed_ms"]
run.summary_json = prev
db.commit()
run = db.get(BizCompareRun, run_id) or run
if str(run.status or "") != "cancelled":
run.status = "failed"
run.message = str(getattr(exc, "detail", "") or exc)[:1024]
prev = dict(run.summary_json or {})
prog = dict(prev.get("progress") or {})
prog["phase"] = "failed"
prog["elapsed_ms"] = int((time.monotonic() - started_mono) * 1000)
prev["progress"] = prog
prev["duration_ms"] = prog["elapsed_ms"]
run.summary_json = prev
db.commit()
raise
except Exception as exc:
_log.exception("compare run failed run=%s job=%s", run_id, j.id)
run.status = "failed"
run.message = str(exc)[:1024]
prev = dict(run.summary_json or {})
prog = dict(prev.get("progress") or {})
prog["phase"] = "failed"
prog["elapsed_ms"] = int((time.monotonic() - started_mono) * 1000)
prev["progress"] = prog
prev["duration_ms"] = prog["elapsed_ms"]
run.summary_json = prev
db.commit()
run = db.get(BizCompareRun, run_id) or run
if str(run.status or "") != "cancelled":
run.status = "failed"
run.message = str(exc)[:1024]
prev = dict(run.summary_json or {})
prog = dict(prev.get("progress") or {})
prog["phase"] = "failed"
prog["elapsed_ms"] = int((time.monotonic() - started_mono) * 1000)
prev["progress"] = prog
prev["duration_ms"] = prog["elapsed_ms"]
run.summary_json = prev
db.commit()
raise
_INTERRUPT_MARK = "interrupted_by_restart"
def _job_has_active_run(db: Session, job_id: str) -> BizCompareRun | None:
return (
db.query(BizCompareRun)
.filter(
BizCompareRun.job_id == str(job_id or ""),
BizCompareRun.status.in_(("running", "queued")),
)
.order_by(BizCompareRun.created_at.desc())
.first()
)
def _run_is_cancelled(db: Session, run_id: str) -> bool:
"""Re-read status so user/startup cancel is visible to the worker thread."""
db.expire_all()
r = db.get(BizCompareRun, run_id)
return bool(r and str(r.status or "") == "cancelled")
def recover_interrupted_compares_on_startup(db: Session) -> dict[str, Any]:
"""Mark orphaned running/queued compare runs as cancelled after process restart.
In-memory job locks die with the process; without this, the UI stays on
「比对中」and blocks a new run.
"""
now = _utcnow()
rows = (
db.query(BizCompareRun)
.filter(BizCompareRun.status.in_(("running", "queued")))
.all()
)
n = 0
for r in rows:
r.status = "cancelled"
msg = str(r.message or "").strip()
if _INTERRUPT_MARK not in msg:
r.message = f"{msg} | {_INTERRUPT_MARK}".strip(" |")[:1024]
prev = dict(r.summary_json or {})
prog = dict(prev.get("progress") or {})
prog["phase"] = "cancelled"
prog["elapsed_ms"] = int(prog.get("elapsed_ms") or 0)
prev["progress"] = prog
# Mark in-flight sheet placeholders so UI does not show fake pass
sheets = list(prev.get("sheets") or [])
for sh in sheets:
st = str(sh.get("status") or "")
if st in ("pending", "running", "queued"):
sh["status"] = "cancelled"
prev["sheets"] = sheets
r.summary_json = prev
n += 1
if n:
db.commit()
_log.info("startup: cancelled %s interrupted compare run(s)", n)
return {"runs": n, "at": now.isoformat() + "Z"}
def cancel_compare_run(db: Session, run_id: str) -> dict[str, Any]:
"""Cancel a running/queued compare so a new run can start."""
r = db.get(BizCompareRun, run_id)
if not r:
raise HTTPException(status_code=404, detail="run_not_found")
st = str(r.status or "")
if st not in ("running", "queued"):
return get_run(db, run_id)
r.status = "cancelled"
msg = str(r.message or "").strip()
r.message = f"{msg} | cancelled_by_user".strip(" |")[:1024]
prev = dict(r.summary_json or {})
prog = dict(prev.get("progress") or {})
prog["phase"] = "cancelled"
prev["progress"] = prog
for sh in list(prev.get("sheets") or []):
if str(sh.get("status") or "") in ("pending", "running", "queued"):
sh["status"] = "cancelled"
r.summary_json = prev
db.commit()
return get_run(db, run_id)
def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> dict[str, Any]:
"""Synchronous compare (auto-compare / tests). Blocks the caller until done."""
if _job_has_active_run(db, job_id):
raise HTTPException(status_code=409, detail="compare_already_running")
lock = _job_compare_lock(job_id)
if not lock.acquire(blocking=False):
raise HTTPException(status_code=409, detail="compare_already_running")
@ -2183,6 +2294,8 @@ def enqueue_compare(
Returns immediately so the HTTP worker / UI stay responsive. Poll
``GET /compare/runs/{id}`` for progress (``summary.progress``).
"""
if _job_has_active_run(db, job_id):
raise HTTPException(status_code=409, detail="compare_already_running")
lock = _job_compare_lock(job_id)
if not lock.acquire(blocking=False):
raise HTTPException(status_code=409, detail="compare_already_running")

View file

@ -746,6 +746,12 @@ def api_get_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.get_run(db, run_id)
@router.post("/compare/runs/{run_id}/cancel")
def api_cancel_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
"""Cancel a stuck/running compare so a new run can start."""
return cmp_svc.cancel_compare_run(db, run_id)
@router.delete("/compare/runs/{run_id}")
def api_delete_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.delete_run(db, run_id)

View file

@ -0,0 +1,98 @@
"""Startup recovery for interrupted biz-state compare runs."""
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from netx_api.biz_state.compare_service import (
cancel_compare_run,
recover_interrupted_compares_on_startup,
)
from netx_api.db import Base
from netx_api.models import BizCompareJob, BizCompareRun
class BizStateCompareRecoveryTests(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
TestingSession = sessionmaker(
bind=engine, autoflush=False, autocommit=False, expire_on_commit=False
)
Base.metadata.create_all(bind=engine)
self.db = TestingSession()
self.job = BizCompareJob(
id="j1",
name="cutover",
template_id="tpl1",
status="active",
)
self.db.add(self.job)
self.running = BizCompareRun(
id="r_run",
job_id="j1",
status="running",
message="loading 1/2 · BGP",
summary_json={
"progress": {"phase": "loading", "sheet_index": 1, "sheet_total": 2},
"sheets": [
{"sheet_id": "bgp", "status": "running"},
{"sheet_id": "isis", "status": "pending"},
],
},
)
self.queued = BizCompareRun(
id="r_q",
job_id="j1",
status="queued",
message="queued",
summary_json={"sheets": [{"sheet_id": "bgp", "status": "pending"}]},
)
self.ok = BizCompareRun(
id="r_ok",
job_id="j1",
status="success",
message="done",
summary_json={"added": 0, "removed": 0, "changed": 0},
)
self.db.add_all([self.running, self.queued, self.ok])
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def test_startup_cancels_running_and_queued(self) -> None:
out = recover_interrupted_compares_on_startup(self.db)
self.assertEqual(out["runs"], 2)
self.db.refresh(self.running)
self.db.refresh(self.queued)
self.db.refresh(self.ok)
self.assertEqual(self.running.status, "cancelled")
self.assertIn("interrupted_by_restart", self.running.message or "")
self.assertEqual(
(self.running.summary_json or {}).get("progress", {}).get("phase"),
"cancelled",
)
sheets = list((self.running.summary_json or {}).get("sheets") or [])
self.assertEqual(sheets[0].get("status"), "cancelled")
self.assertEqual(sheets[1].get("status"), "cancelled")
self.assertEqual(self.queued.status, "cancelled")
self.assertEqual(self.ok.status, "success")
def test_cancel_compare_run_user(self) -> None:
out = cancel_compare_run(self.db, "r_run")
self.assertEqual(out["status"], "cancelled")
self.db.refresh(self.running)
self.assertEqual(self.running.status, "cancelled")
self.assertIn("cancelled_by_user", self.running.message or "")
def test_cancel_idempotent_on_success(self) -> None:
out = cancel_compare_run(self.db, "r_ok")
self.assertEqual(out["status"], "success")
if __name__ == "__main__":
unittest.main()

View file

@ -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",

View file

@ -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}} 次",

View file

@ -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>
</>
)}

View file

@ -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)}`,