Add config sync cycle stop and surface pause/stop controls in the UI.

Cancel remaining tasks on stop and keep finalize from overwriting a cancelled cycle.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-31 17:04:56 +08:00
parent 559cc14d2c
commit 1c8d512701
6 changed files with 124 additions and 5 deletions

View file

@ -24,6 +24,7 @@ from .config_sync_service import (
list_snapshots, list_snapshots,
pause_cycle, pause_cycle,
resume_cycle, resume_cycle,
stop_cycle,
update_policy, update_policy,
) )
from .db import get_db from .db import get_db
@ -137,6 +138,23 @@ def api_resume_cycle(
return out.model_dump() return out.model_dump()
@router.post("/cycles/{cycle_id}/stop")
def api_stop_cycle(cycle_id: str, request: Request, db: Session = Depends(get_db)):
out = stop_cycle(db, cycle_id)
uid, uname = _actor(request)
write_audit(
db,
action="config_sync.stop",
actor_user_id=uid,
actor_username=uname,
method="POST",
path=f"/v1/config-sync/cycles/{cycle_id}/stop",
status_code=200,
detail={"cycle_id": cycle_id},
)
return out.model_dump()
@router.get("/snapshots") @router.get("/snapshots")
def api_list_snapshots( def api_list_snapshots(
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),

View file

@ -377,7 +377,7 @@ def pause_cycle(db: Session, cycle_id: str) -> ConfigSyncCycleOut:
row = db.get(ConfigSyncCycle, cycle_id) row = db.get(ConfigSyncCycle, cycle_id)
if not row: if not row:
raise HTTPException(status_code=404, detail="cycle_not_found") raise HTTPException(status_code=404, detail="cycle_not_found")
if str(row.status) != "running": if str(row.status) not in ("running", "pending"):
raise HTTPException(status_code=400, detail="cycle_not_running") raise HTTPException(status_code=400, detail="cycle_not_running")
row.status = "paused" row.status = "paused"
db.commit() db.commit()
@ -407,6 +407,45 @@ def resume_cycle(db: Session, cycle_id: str) -> ConfigSyncCycleOut:
return cycle_to_out(row) return cycle_to_out(row)
def stop_cycle(db: Session, cycle_id: str) -> ConfigSyncCycleOut:
"""Cancel remaining work and close the cycle (running/paused/pending)."""
row = db.get(ConfigSyncCycle, cycle_id)
if not row:
raise HTTPException(status_code=404, detail="cycle_not_found")
if str(row.status) not in ("running", "paused", "pending"):
raise HTTPException(status_code=400, detail="cycle_not_active")
now = _utcnow()
pending = (
db.query(ConfigSyncTask)
.filter(
ConfigSyncTask.cycle_id == cycle_id,
ConfigSyncTask.status.in_(("pending", "running")),
)
.all()
)
for task in pending:
# In-flight workers may still finish and overwrite; pending must not start.
if str(task.status) == "pending":
task.status = "cancelled"
task.message = "stopped_by_user"
task.ended_at = now
else:
task.message = (str(task.message or "") + " · stop_requested")[:1020]
row.status = "cancelled"
row.error_message = "stopped_by_user"
row.ended_at = now
db.commit()
sync_cycle_progress(db, cycle_id)
db.refresh(row)
try:
from .config_sync_runner import _release_pool
_release_pool(cycle_id)
except Exception:
pass
return cycle_to_out(row)
def dashboard(db: Session) -> ConfigSyncDashboardOut: def dashboard(db: Session) -> ConfigSyncDashboardOut:
policy = ensure_policy(db) policy = ensure_policy(db)
snap_count = int(db.query(func.count()).select_from(NeConfigSnapshot).scalar() or 0) snap_count = int(db.query(func.count()).select_from(NeConfigSnapshot).scalar() or 0)
@ -629,7 +668,7 @@ def finalize_cycle(db: Session, cycle_id: str) -> None:
cycle = db.get(ConfigSyncCycle, cycle_id) cycle = db.get(ConfigSyncCycle, cycle_id)
if not cycle: if not cycle:
return return
if str(cycle.status) == "paused": if str(cycle.status) in ("paused", "cancelled"):
return return
pending = ( pending = (
db.query(func.count()) db.query(func.count())
@ -642,6 +681,8 @@ def finalize_cycle(db: Session, cycle_id: str) -> None:
return return
sync_cycle_progress(db, cycle_id) sync_cycle_progress(db, cycle_id)
db.refresh(cycle) db.refresh(cycle)
if str(cycle.status) in ("paused", "cancelled"):
return
# Cycle outcome is about finishing the run, not per-NE results. # Cycle outcome is about finishing the run, not per-NE results.
# Individual task failures stay in fail_count for retry/dashboard. # Individual task failures stay in fail_count for retry/dashboard.
cycle.status = "success" cycle.status = "success"

View file

@ -139,9 +139,12 @@ const en = {
retryFailed: "Retry failed", retryFailed: "Retry failed",
pause: "Pause", pause: "Pause",
resume: "Resume", resume: "Resume",
stop: "Stop",
confirmStop: "Stop this cycle and cancel remaining NE tasks?",
started: "Sync cycle started", started: "Sync cycle started",
paused: "Paused", paused: "Paused",
resumed: "Resumed", resumed: "Resumed",
stopped: "Sync cycle stopped",
policyTitle: "Sync policy", policyTitle: "Sync policy",
policySaved: "Policy saved", policySaved: "Policy saved",
enabled: "Enable scheduled sync", enabled: "Enable scheduled sync",
@ -178,6 +181,7 @@ const en = {
started: "Started", started: "Started",
ended: "Ended", ended: "Ended",
message: "Message", message: "Message",
actions: "Actions",
}, },
}, },
networkDevices: { networkDevices: {

View file

@ -139,9 +139,12 @@ const zh = {
retryFailed: "一键重试失败", retryFailed: "一键重试失败",
pause: "暂停", pause: "暂停",
resume: "继续", resume: "继续",
stop: "停止",
confirmStop: "停止后将取消未执行的网元任务,确定停止本轮同步?",
started: "已启动同步周期", started: "已启动同步周期",
paused: "已暂停", paused: "已暂停",
resumed: "已继续", resumed: "已继续",
stopped: "已停止同步周期",
policyTitle: "同步策略", policyTitle: "同步策略",
policySaved: "策略已保存", policySaved: "策略已保存",
enabled: "启用周期调度", enabled: "启用周期调度",
@ -178,6 +181,7 @@ const zh = {
started: "开始", started: "开始",
ended: "结束", ended: "结束",
message: "消息", message: "消息",
actions: "操作",
}, },
}, },
networkDevices: { networkDevices: {

View file

@ -8,6 +8,7 @@ import {
fetchConfigSyncDashboard, fetchConfigSyncDashboard,
pauseConfigSyncCycle, pauseConfigSyncCycle,
resumeConfigSyncCycle, resumeConfigSyncCycle,
stopConfigSyncCycle,
updateConfigSyncPolicy, updateConfigSyncPolicy,
} from "../services/api"; } from "../services/api";
import { queryKeys } from "../constants/queryKeys"; import { queryKeys } from "../constants/queryKeys";
@ -48,7 +49,9 @@ export function ConfigSyncPage() {
staleTime: 1000, staleTime: 1000,
refetchInterval: (q) => { refetchInterval: (q) => {
const running = q.state.data?.running_cycle; const running = q.state.data?.running_cycle;
return running && (running.status === "running" || running.status === "paused") ? POLL_MS : false; return running && (running.status === "running" || running.status === "paused" || running.status === "pending")
? POLL_MS
: false;
}, },
}); });
@ -151,6 +154,15 @@ export function ConfigSyncPage() {
onError: (err) => showError(String(err)), onError: (err) => showError(String(err)),
}); });
const stopMut = useMutation({
mutationFn: (id: string) => stopConfigSyncCycle(id),
onSuccess: async () => {
showOk(t("configSync.stopped"));
await refresh();
},
onError: (err) => showError(String(err)),
});
const dash = dashQuery.data; const dash = dashQuery.data;
const running = dash?.running_cycle; const running = dash?.running_cycle;
const last = dash?.last_cycle; const last = dash?.last_cycle;
@ -193,7 +205,7 @@ export function ConfigSyncPage() {
> >
{t("configSync.retryFailed")} {t("configSync.retryFailed")}
</button> </button>
{running?.status === "running" ? ( {running?.status === "running" || running?.status === "pending" ? (
<button type="button" onClick={() => pauseMut.mutate(running.id)} disabled={pauseMut.isPending}> <button type="button" onClick={() => pauseMut.mutate(running.id)} disabled={pauseMut.isPending}>
{t("configSync.pause")} {t("configSync.pause")}
</button> </button>
@ -203,6 +215,17 @@ export function ConfigSyncPage() {
{t("configSync.resume")} {t("configSync.resume")}
</button> </button>
) : null} ) : null}
{running && (running.status === "running" || running.status === "paused" || running.status === "pending") ? (
<button
type="button"
onClick={() => {
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(running.id);
}}
disabled={stopMut.isPending}
>
{t("configSync.stop")}
</button>
) : null}
</div> </div>
</div> </div>
@ -369,6 +392,7 @@ export function ConfigSyncPage() {
<th>{t("configSync.col.progress")}</th> <th>{t("configSync.col.progress")}</th>
<th>{t("configSync.col.started")}</th> <th>{t("configSync.col.started")}</th>
<th>{t("configSync.col.ended")}</th> <th>{t("configSync.col.ended")}</th>
<th>{t("configSync.col.actions")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -393,11 +417,36 @@ export function ConfigSyncPage() {
</td> </td>
<td>{c.started_at ? formatSystemTime(c.started_at) : "-"}</td> <td>{c.started_at ? formatSystemTime(c.started_at) : "-"}</td>
<td>{c.ended_at ? formatSystemTime(c.ended_at) : "-"}</td> <td>{c.ended_at ? formatSystemTime(c.ended_at) : "-"}</td>
<td>
<div className="btn-row">
{c.status === "running" || c.status === "pending" ? (
<button type="button" onClick={() => pauseMut.mutate(c.id)} disabled={pauseMut.isPending}>
{t("configSync.pause")}
</button>
) : null}
{c.status === "paused" ? (
<button type="button" onClick={() => resumeMut.mutate(c.id)} disabled={resumeMut.isPending}>
{t("configSync.resume")}
</button>
) : null}
{c.status === "running" || c.status === "paused" || c.status === "pending" ? (
<button
type="button"
onClick={() => {
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id);
}}
disabled={stopMut.isPending}
>
{t("configSync.stop")}
</button>
) : null}
</div>
</td>
</tr> </tr>
))} ))}
{!cycles.length ? ( {!cycles.length ? (
<tr> <tr>
<td colSpan={7} className="muted"> <td colSpan={8} className="muted">
{t("configSync.cyclesEmpty")} {t("configSync.cyclesEmpty")}
</td> </td>
</tr> </tr>

View file

@ -708,6 +708,9 @@ export const pauseConfigSyncCycle = (cycleId: string) =>
export const resumeConfigSyncCycle = (cycleId: string) => export const resumeConfigSyncCycle = (cycleId: string) =>
apiPost<ConfigSyncCycle>(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/resume`, {}); apiPost<ConfigSyncCycle>(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/resume`, {});
export const stopConfigSyncCycle = (cycleId: string) =>
apiPost<ConfigSyncCycle>(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/stop`, {});
export const fetchConfigSyncCycleTasks = (params: { export const fetchConfigSyncCycleTasks = (params: {
cycleId: string; cycleId: string;
page?: number; page?: number;