mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
feat(data): 数据页同步任务支持手动停止 (二次确认)
- 同步运行中顶栏出现「停止」按钮, 点击先弹二次确认: 注明协作式停止
(当前分块完成后中断, 不损坏已写入数据) + 停止后再次拉取需重新走
完整管道 (拉取→指标计算→监控规则), 无法从停止处续跑
- api.ts 新增 pipelineJobCancel, 调用既有 POST /api/pipeline/jobs/{id}/cancel
(取消标志 + 分块回调检查 + JobCancelledError 协作式退出, 后端零改动)
- 补 cancel 端点契约测试: running/pending 可停、终态 400、未知 404、
停止后可立即新建任务
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -2525,6 +2525,9 @@ export const api = {
|
||||
'/api/pipeline/run', { method: 'POST' },
|
||||
),
|
||||
pipelineJob: (id: string) => request<PipelineJob>(`/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}`,
|
||||
|
||||
@@ -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) ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isStarting ? '启动中…' : isRunning ? '同步中…' : '立即同步'}
|
||||
</button>
|
||||
{isRunning && !!activeJobId && (
|
||||
<button
|
||||
onClick={() => setShowStopConfirm(true)}
|
||||
title="停止当前同步任务"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-danger/12 border border-danger/30 text-danger text-xs font-medium hover:bg-danger/20 transition-all duration-150"
|
||||
>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
停止
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setOpenSettings('pipeline-scope')}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn text-secondary hover:text-accent hover:bg-accent/8 text-xs transition-colors duration-150"
|
||||
@@ -1155,6 +1176,64 @@ export function Data() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 停止同步二次确认弹窗 */}
|
||||
<AnimatePresence>
|
||||
{showStopConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => !stopSync.isPending && setShowStopConfirm(false)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative w-[90vw] max-w-[420px] rounded-card border border-border bg-base shadow-2xl p-6"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 h-10 w-10 rounded-full bg-danger/12 flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-1.5">确认停止数据同步?</h3>
|
||||
<p className="text-xs text-secondary leading-relaxed">
|
||||
当前任务将在<span className="text-foreground font-medium">正在拉取的分块完成后</span>中断(协作式停止,不会损坏已写入的数据)。
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] text-danger/90 leading-relaxed">
|
||||
停止后再次拉取需要重新走完整管道(拉取 → 指标计算 → 监控规则),无法从停止处续跑。
|
||||
</p>
|
||||
<div className="mt-2 flex items-start gap-1.5 text-[11px] text-muted">
|
||||
<Info className="h-3.5 w-3.5 shrink-0 mt-px text-muted" />
|
||||
<span>已写入的部分数据会保留,下次同步时覆盖或补齐。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-5">
|
||||
<button
|
||||
onClick={() => setShowStopConfirm(false)}
|
||||
disabled={stopSync.isPending}
|
||||
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => stopSync.mutate()}
|
||||
disabled={stopSync.isPending}
|
||||
className="px-3 py-1.5 rounded-btn bg-danger/90 text-base text-sm font-medium hover:bg-danger disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{stopSync.isPending ? '停止中…' : '确认停止'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 清除数据二次确认弹窗 */}
|
||||
<AnimatePresence>
|
||||
{adjGate.dialog}
|
||||
|
||||
Reference in New Issue
Block a user