diff --git a/backend/tests/test_job_stall_and_cancel.py b/backend/tests/test_job_stall_and_cancel.py index af75c62..64be23b 100644 --- a/backend/tests/test_job_stall_and_cancel.py +++ b/backend/tests/test_job_stall_and_cancel.py @@ -165,3 +165,44 @@ def test_run_slot_reap_release_prevents_zombie_release(): pipeline_jobs.release_run_slot("jobB") assert pipeline_jobs.try_acquire_run_slot("jobC") is True pipeline_jobs.release_run_slot("jobC") + + +# ── 手动取消 API 端点契约 (数据页「停止」按钮) ────────────────────────── + +def test_manual_cancel_endpoint_contract(monkeypatch, tmp_path): + """POST /api/pipeline/jobs/{id}/cancel: running/pending 可停, 终态 400, 未知 404。""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from app.api.pipeline import router + + monkeypatch.setattr(preferences, "load", lambda: {}) + store = JobStore(store_dir=tmp_path / "jobs") + monkeypatch.setattr("app.api.pipeline.job_store", store) + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + # 未知 job → 404 + assert client.post("/api/pipeline/jobs/nope/cancel").status_code == 404 + + # running → 协作式终止: 标 failed + 置取消标志 + 释放执行槽 + jid = _make_running_job(store, timeout_s=60) + pipeline_jobs.try_acquire_run_slot(jid) + resp = client.post(f"/api/pipeline/jobs/{jid}/cancel") + assert resp.status_code == 200 + assert resp.json() == {"cancelled": jid} + j = store.get(jid) + assert j["status"] == "failed" + assert "手动取消" in j["error"] + assert pipeline_jobs.is_cancelled(jid) + assert pipeline_jobs.try_acquire_run_slot("next") is True + + # 已终态 (failed) → 400 拒绝重复取消 + assert client.post(f"/api/pipeline/jobs/{jid}/cancel").status_code == 400 + + # 停止后可再建新任务 (再次拉取走完整管道的单飞基础) + jid2, is_new = store.create(timeout_s=60) + assert is_new is True + assert store.active_id() == jid2 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4b51447..5533b70 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2525,6 +2525,9 @@ export const api = { '/api/pipeline/run', { method: 'POST' }, ), pipelineJob: (id: string) => request(`/api/pipeline/jobs/${id}`), + /** 手动停止一个 running/pending 的同步任务 (协作式: 当前分块完成后线程自行退出) */ + pipelineJobCancel: (id: string) => + request<{ cancelled: string }>(`/api/pipeline/jobs/${id}/cancel`, { method: 'POST' }), pipelineJobs: (limit = 20) => request<{ active_id: string | null; jobs: PipelineJobSummary[] }>( `/api/pipeline/jobs?limit=${limit}`, diff --git a/frontend/src/pages/Data.tsx b/frontend/src/pages/Data.tsx index 99c122b..81695fe 100644 --- a/frontend/src/pages/Data.tsx +++ b/frontend/src/pages/Data.tsx @@ -4,6 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion' import { Database, Play, + Square, Loader2, HardDrive, Clock, @@ -106,6 +107,16 @@ export function Data() { }, }) + // 停止同步: 二次确认后调 cancel 端点 (协作式终止, 当前分块完成后线程自行退出) + const [showStopConfirm, setShowStopConfirm] = useState(false) + const stopSync = useMutation({ + mutationFn: () => api.pipelineJobCancel(activeJobId!), + onSuccess: () => { + setShowStopConfirm(false) + qc.invalidateQueries({ queryKey: QK.pipelineJob(activeJobId!) }) + }, + }) + // 无除权因子能力时同步前置确认 (静默降级告知) const adjGate = useAdjFactorSyncGate() @@ -589,13 +600,23 @@ export function Data() { disabled={isStarting} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium hover:from-accent/35 hover:to-accent/20 disabled:opacity-40 transition-all duration-150" > - {(isRunning || isStarting) ? ( + {(isStarting || isRunning) ? ( ) : ( )} {isStarting ? '启动中…' : isRunning ? '同步中…' : '立即同步'} + {isRunning && !!activeJobId && ( + + )} + + + + + )} + + {/* 清除数据二次确认弹窗 */} {adjGate.dialog}