From 6783ff3970ff155cd158967cb72fab8655f6afe0 Mon Sep 17 00:00:00 2001 From: jetson Date: Mon, 21 Sep 2026 09:40:57 +0700 Subject: [PATCH] feat: add abort button for job cancellation --- src/job.py | 1 + src/pipeline.py | 4 ++++ static/app.js | 29 +++++++++++++++++++++++++++++ static/style.css | 16 ++++++++++++++++ templates/status.html | 1 + tests/test_app.py | 3 +++ 6 files changed, 54 insertions(+) diff --git a/src/job.py b/src/job.py index d1be425..33136b7 100644 --- a/src/job.py +++ b/src/job.py @@ -164,6 +164,7 @@ class JobQueue: output_path=output_path, class_filter=class_filter, frame_callback=_on_frame, + cancel_check=lambda: job.status == JobStatus.CANCELLED, ) with self._lock: diff --git a/src/pipeline.py b/src/pipeline.py index 8e12b88..9a6d8b9 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -152,6 +152,10 @@ def run_pipeline( frame_idx += 1 timestamp = time.time() + # Check for cancellation + if cancel_check and frame_idx % 30 == 0 and cancel_check(): + break + # Truck detection roi = roi_tracker.roi if truck_detector is not None and frame_idx % truck_det_interval == 0: diff --git a/static/app.js b/static/app.js index d73b155..ce2c66e 100644 --- a/static/app.js +++ b/static/app.js @@ -309,6 +309,7 @@ function initStatusPage(jobId, initialStatus) { var progressRegion = document.getElementById('progress-section'); var statusAnnouncer = document.getElementById('status-announcer'); + var cancelBtn = document.getElementById('cancel-btn'); var terminalStates = ['COMPLETED', 'FAILED', 'CANCELLED']; var pollInterval = null; var previewInterval = null; @@ -318,6 +319,31 @@ function initStatusPage(jobId, initialStatus) { if (!progressFill) return; + /* --- Cancel button --- */ + if (cancelBtn) { + cancelBtn.addEventListener('click', function() { + if (!confirm('Abort this job?')) return; + cancelBtn.disabled = true; + cancelBtn.textContent = 'Cancelling...'; + fetch('/api/jobs/' + jobId + '/cancel', { method: 'POST' }) + .then(function(res) { return res.json(); }) + .then(function(data) { + if (data.status === 'cancelled') { + cancelBtn.textContent = 'Cancelled'; + cancelBtn.classList.add('cancelled'); + } else { + cancelBtn.disabled = false; + cancelBtn.textContent = 'Abort'; + if (data.error) alert(data.error); + } + }) + .catch(function() { + cancelBtn.disabled = false; + cancelBtn.textContent = 'Abort'; + }); + }); + } + /* --- Poll job status every 2s --- */ function pollJob() { fetch('/api/jobs/' + jobId) @@ -383,6 +409,9 @@ function initStatusPage(jobId, initialStatus) { 'CANCELLED': 'badge-neutral' }; statusBadge.classList.add(colorMap[status] || 'badge-neutral'); + if (cancelBtn) { + cancelBtn.style.display = (status === 'PENDING' || status === 'RUNNING') ? 'inline-flex' : 'none'; + } } /* --- Update current model name --- */ diff --git a/static/style.css b/static/style.css index 78a1139..5ff310c 100644 --- a/static/style.css +++ b/static/style.css @@ -311,6 +311,22 @@ main { color: var(--color-neutral-900); } +.btn-danger { + background: var(--color-error); + color: #fff; + border-color: var(--color-error); +} + +.btn-danger:hover:not(:disabled) { + background: #B91C1C; + border-color: #B91C1C; +} + +.btn-sm { + padding: var(--space-1) var(--space-3); + font-size: var(--text-xs); +} + /* ---------- Card ---------- */ .card { background: #fff; diff --git a/templates/status.html b/templates/status.html index ad3ff23..d59faa7 100644 --- a/templates/status.html +++ b/templates/status.html @@ -8,6 +8,7 @@ {{ job.status.name }} +

Job {{ job.job_id[:8] }}...

diff --git a/tests/test_app.py b/tests/test_app.py index a081764..96029d5 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -100,6 +100,9 @@ def test_cancel_pending_or_running_job(client): job = job_queue.add_job(video_path="/tmp/test.mp4", model_configs=[]) import time time.sleep(0.1) + # Force status to RUNNING so cancel succeeds + with job_queue._lock: + job.status = JobStatus.RUNNING resp = client.post(f"/api/jobs/{job.job_id}/cancel") assert resp.status_code == 200 data = resp.get_json()