diff --git a/netx_api/config_sync_router.py b/netx_api/config_sync_router.py index a33dd58..f397a80 100644 --- a/netx_api/config_sync_router.py +++ b/netx_api/config_sync_router.py @@ -24,6 +24,7 @@ from .config_sync_service import ( list_snapshots, pause_cycle, resume_cycle, + stop_cycle, update_policy, ) from .db import get_db @@ -137,6 +138,23 @@ def api_resume_cycle( 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") def api_list_snapshots( page: int = Query(default=1, ge=1), diff --git a/netx_api/config_sync_service.py b/netx_api/config_sync_service.py index 1f5cd9f..80a40e7 100644 --- a/netx_api/config_sync_service.py +++ b/netx_api/config_sync_service.py @@ -377,7 +377,7 @@ def pause_cycle(db: Session, cycle_id: str) -> ConfigSyncCycleOut: row = db.get(ConfigSyncCycle, cycle_id) if not row: 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") row.status = "paused" db.commit() @@ -407,6 +407,45 @@ def resume_cycle(db: Session, cycle_id: str) -> ConfigSyncCycleOut: 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: policy = ensure_policy(db) 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) if not cycle: return - if str(cycle.status) == "paused": + if str(cycle.status) in ("paused", "cancelled"): return pending = ( db.query(func.count()) @@ -642,6 +681,8 @@ def finalize_cycle(db: Session, cycle_id: str) -> None: return sync_cycle_progress(db, cycle_id) db.refresh(cycle) + if str(cycle.status) in ("paused", "cancelled"): + return # Cycle outcome is about finishing the run, not per-NE results. # Individual task failures stay in fail_count for retry/dashboard. cycle.status = "success" diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index ccaff76..a86dcbf 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -139,9 +139,12 @@ const en = { retryFailed: "Retry failed", pause: "Pause", resume: "Resume", + stop: "Stop", + confirmStop: "Stop this cycle and cancel remaining NE tasks?", started: "Sync cycle started", paused: "Paused", resumed: "Resumed", + stopped: "Sync cycle stopped", policyTitle: "Sync policy", policySaved: "Policy saved", enabled: "Enable scheduled sync", @@ -178,6 +181,7 @@ const en = { started: "Started", ended: "Ended", message: "Message", + actions: "Actions", }, }, networkDevices: { diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index f989db0..03ed73c 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -139,9 +139,12 @@ const zh = { retryFailed: "一键重试失败", pause: "暂停", resume: "继续", + stop: "停止", + confirmStop: "停止后将取消未执行的网元任务,确定停止本轮同步?", started: "已启动同步周期", paused: "已暂停", resumed: "已继续", + stopped: "已停止同步周期", policyTitle: "同步策略", policySaved: "策略已保存", enabled: "启用周期调度", @@ -178,6 +181,7 @@ const zh = { started: "开始", ended: "结束", message: "消息", + actions: "操作", }, }, networkDevices: { diff --git a/web/src/pages/ConfigSyncPage.tsx b/web/src/pages/ConfigSyncPage.tsx index 509a386..abbdeb2 100644 --- a/web/src/pages/ConfigSyncPage.tsx +++ b/web/src/pages/ConfigSyncPage.tsx @@ -8,6 +8,7 @@ import { fetchConfigSyncDashboard, pauseConfigSyncCycle, resumeConfigSyncCycle, + stopConfigSyncCycle, updateConfigSyncPolicy, } from "../services/api"; import { queryKeys } from "../constants/queryKeys"; @@ -48,7 +49,9 @@ export function ConfigSyncPage() { staleTime: 1000, refetchInterval: (q) => { 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)), }); + 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 running = dash?.running_cycle; const last = dash?.last_cycle; @@ -193,7 +205,7 @@ export function ConfigSyncPage() { > {t("configSync.retryFailed")} - {running?.status === "running" ? ( + {running?.status === "running" || running?.status === "pending" ? ( @@ -203,6 +215,17 @@ export function ConfigSyncPage() { {t("configSync.resume")} ) : null} + {running && (running.status === "running" || running.status === "paused" || running.status === "pending") ? ( + + ) : null} @@ -369,6 +392,7 @@ export function ConfigSyncPage() { {t("configSync.col.progress")} {t("configSync.col.started")} {t("configSync.col.ended")} + {t("configSync.col.actions")} @@ -393,11 +417,36 @@ export function ConfigSyncPage() { {c.started_at ? formatSystemTime(c.started_at) : "-"} {c.ended_at ? formatSystemTime(c.ended_at) : "-"} + +
+ {c.status === "running" || c.status === "pending" ? ( + + ) : null} + {c.status === "paused" ? ( + + ) : null} + {c.status === "running" || c.status === "paused" || c.status === "pending" ? ( + + ) : null} +
+ ))} {!cycles.length ? ( - + {t("configSync.cyclesEmpty")} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 803539a..ba2bc5e 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -708,6 +708,9 @@ export const pauseConfigSyncCycle = (cycleId: string) => export const resumeConfigSyncCycle = (cycleId: string) => apiPost(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/resume`, {}); +export const stopConfigSyncCycle = (cycleId: string) => + apiPost(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/stop`, {}); + export const fetchConfigSyncCycleTasks = (params: { cycleId: string; page?: number;