From eb6c7237137f65fe7a6ce1233f93970a74866e61 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sat, 23 May 2026 16:54:05 -0400 Subject: [PATCH] fix(jobs): cancel running + delete failed jobs to unstick the queue The Jobs page only exposed a delete button for queued + done jobs, so a stalled-active job (worker died holding a BullMQ concurrency slot) had no way out from the UI. Operators were watching the queue back up behind a single stuck thumbnail job with no kill switch. - Running jobs now show a "Cancel" button (red text). Confirm copy spells out that the worker may run a few seconds longer in the background but the queue slot frees up immediately. - Failed jobs now show the X icon for delete in addition to the existing Retry button. - Both routes hit the same DELETE /jobs/:id endpoint; BullMQ's job.remove() works on any state including stalled-active. - handleDelete takes an optional mode ('cancel' | 'delete') only to customise the confirm prompt and error toast wording. Right-aligned the action cell so the Retry/Cancel/Delete buttons sit flush right like the rest of the table's actions. Co-Authored-By: Claude Opus 4.7 (1M context) --- services/web-ui/public/screens-jobs.jsx | 30 ++++++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/services/web-ui/public/screens-jobs.jsx b/services/web-ui/public/screens-jobs.jsx index ebf208f..ba4503c 100644 --- a/services/web-ui/public/screens-jobs.jsx +++ b/services/web-ui/public/screens-jobs.jsx @@ -82,11 +82,19 @@ function Jobs({ navigate }) { .catch(e => alert('Retry failed: ' + e.message)); }, [refresh]); - const handleDelete = React.useCallback((job) => { - if (!window.confirm('Remove this job from the queue?')) return; + // One handler covers cancel (running) AND delete (queued / done / failed). + // BullMQ's job.remove() — what the API calls — works on any state, so a + // stalled-active job (worker died mid-process, holding a concurrency slot) + // gets yanked and the next queued job runs. mode just changes the prompt + // copy so the operator knows what they're doing. + const handleDelete = React.useCallback((job, mode) => { + const msg = mode === 'cancel' + ? 'Cancel this running ' + job.kind + ' job?\n\nThe worker may run a few seconds longer in the background, but its result will be discarded and the queue slot frees up immediately.' + : 'Remove this ' + job.status + ' ' + job.kind + ' job from the queue?'; + if (!window.confirm(msg)) return; window.ZAMPP_API.fetch('/jobs/' + job.id, { method: 'DELETE' }) .then(() => setJobs(prev => prev.filter(j => j.id !== job.id))) - .catch(e => alert('Delete failed: ' + e.message)); + .catch(e => alert((mode === 'cancel' ? 'Cancel' : 'Delete') + ' failed: ' + e.message)); }, []); // Retry every failed job at once. Useful after a transient infra issue @@ -221,12 +229,22 @@ function JobRow({ job, onRetry, onDelete }) { })()}
{job.priority}
-
+
{job.status === 'failed' && ( )} - {(job.status === 'queued' || job.status === 'done') && ( - + {job.status === 'running' && ( + /* Cancel a stalled-active job — frees the BullMQ concurrency slot + so anything queued behind it can run. The worker may finish in + the background but its result is discarded. */ + + )} + {(job.status === 'queued' || job.status === 'done' || job.status === 'failed') && ( + )}