mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
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:
parent
559cc14d2c
commit
1c8d512701
6 changed files with 124 additions and 5 deletions
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue