feat: add Stop Process button to cancel in-progress jobs

Adds POST /jobs/{id}/cancel endpoint that revokes the Celery task and
sets status to 'cancelled'. Shows a confirmation widget in the job
detail sidebar for admin/production roles when the job is in an active
processing state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-30 19:50:39 +01:00
parent 08a8a0d636
commit f681bd4f53
5 changed files with 188 additions and 2 deletions

View file

@ -2339,6 +2339,107 @@ async def retry_job(
)
CANCELLABLE_STATUSES = {
JobStatus.INGESTING.value,
JobStatus.AI_PROCESSING.value,
JobStatus.TRANSLATING.value,
JobStatus.TTS_GENERATING.value,
JobStatus.RENDERING_VIDEO.value,
JobStatus.RENDERING_QC.value,
}
@router.post("/{job_id}/cancel", response_model=JobResponse)
async def cancel_job(
job_id: str,
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Cancel a job that is currently being processed.
Revokes the Celery task and sets the job status to cancelled.
Only allowed for jobs in active processing states.
"""
job_doc = await get_job_or_403(job_id, ctx, db)
current_user = ctx.user
if job_doc["status"] not in CANCELLABLE_STATUSES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Job cannot be cancelled in its current state ({job_doc['status']})",
)
now = datetime.utcnow()
# Revoke Celery task if one is tracked
task_id = job_doc.get("task_id")
if task_id:
try:
celery_app.control.revoke(task_id, terminate=True)
except Exception as e:
logger.warning(f"Could not revoke task {task_id} for job {job_id}: {e}")
result = await db.jobs.find_one_and_update(
{"_id": job_id},
{
"$set": {
"status": JobStatus.CANCELLED.value,
"updated_at": now,
},
"$push": {
"history": {
"at": now,
"status": JobStatus.CANCELLED.value,
"by": current_user.email,
"notes": "Cancelled by user",
}
},
},
return_document=True,
)
if not result:
raise HTTPException(status_code=404, detail="Job not found")
try:
await log_job_action(
db=db,
user=current_user,
action=AuditAction.JOB_UPDATED,
resource_type="job",
resource_id=job_id,
resource_name=result.get("title"),
description="Job cancelled by user",
details={"previous_status": job_doc["status"]},
)
except Exception as e:
logger.warning(f"Failed to write audit log for job cancel {job_id}: {e}")
try:
await connection_manager.broadcast_job_status_update(
job_id=job_id,
status=JobStatus.CANCELLED.value,
job_title=result.get("title"),
message=f"{result.get('title', 'Job')} was cancelled",
)
except Exception as e:
logger.warning(f"Failed to broadcast cancel status for {job_id}: {e}")
logger.info(f"Job {job_id} cancelled by {current_user.email}")
return JobResponse(
id=str(result["_id"]),
title=result["title"],
status=result["status"],
source=result["source"],
requested_outputs=RequestedOutputs(**result["requested_outputs"]),
review=result.get("review", {"notes": "", "history": []}),
outputs=result.get("outputs"),
created_at=result["created_at"].isoformat(),
updated_at=result["updated_at"].isoformat(),
)
@router.get("/{job_id}/validate", response_model=AssetValidationResponse)
async def validate_job_assets(
job_id: str,

View file

@ -25,6 +25,7 @@ class JobStatus(str, Enum):
RENDERING_QC = "rendering_qc" # Re-rendering accessible video during QC review
PENDING_FINAL_REVIEW = "pending_final_review"
COMPLETED = "completed"
CANCELLED = "cancelled"
@classmethod
def is_approved(cls, status: str) -> bool:

View file

@ -318,6 +318,18 @@ export function useRetryJob() {
});
}
export function useCancelJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id }: { id: string }) => apiClient.cancelJob(id),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['jobs', id] });
queryClient.invalidateQueries({ queryKey: ['jobs'] });
},
});
}
export function useReturnToQC() {
const queryClient = useQueryClient();

View file

@ -349,6 +349,11 @@ class ApiClient {
return response.data;
}
async cancelJob(id: string): Promise<Job> {
const response = await this.client.post(`/jobs/${id}/cancel`);
return response.data;
}
async retryJob(id: string, fromStep?: string): Promise<Job> {
const params = fromStep ? { from_step: fromStep } : {};
const response = await this.client.post(`/jobs/${id}/retry`, null, { params });

View file

@ -1,7 +1,7 @@
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import { useJob, useJobDownloads, useJobVttContent, useRetryJob, useReturnToQC, useUpdateJob } from '../../hooks/useJob';
import { useCancelJob, useJob, useJobDownloads, useJobVttContent, useRetryJob, useReturnToQC, useUpdateJob } from '../../hooks/useJob';
import { useAuthStore } from '../../lib/auth';
import type { JobFailure } from '../../types/api';
import { StatusBadge } from '../../components/StatusBadge';
@ -15,6 +15,10 @@ const RETURN_TO_QC_ELIGIBLE_STATUSES = [
'tts_failed', 'render_failed', 'approved_english', 'approved_source',
];
const CANCELLABLE_STATUSES = new Set([
'ingesting', 'ai_processing', 'translating', 'tts_generating', 'rendering_video', 'rendering_qc',
]);
const FAILURE_STEP_TO_PIPELINE: Record<string, string> = {
ingestion: 'ingesting',
@ -44,6 +48,7 @@ const ProgressIndicator = ({ status, failure }: { status: string; failure?: JobF
: status;
const currentIndex = steps.indexOf(normalizedStatus);
const isRejected = status === 'rejected';
const isCancelled = status === 'cancelled';
const isFailed = status === 'tts_failed' || status === 'render_failed' || status === 'processing_failed';
// For failed states, find which step they failed at
@ -65,6 +70,17 @@ const ProgressIndicator = ({ status, failure }: { status: string; failure?: JobF
);
}
if (isCancelled) {
return (
<div className="bg-gray-50 border border-gray-300 rounded-md p-4">
<p className="text-gray-700 font-medium">Job Cancelled</p>
<p className="text-gray-500 text-sm mt-1">
This job was stopped before completion. You can retry it from the failed state.
</p>
</div>
);
}
if (isFailed) {
const failedStepLabel = failedAtStep?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
return (
@ -134,10 +150,12 @@ export function JobDetail() {
// Get connection status from global WebSocket
const { connectionStatus } = useGlobalWebSocket();
// Retry mutations
// Retry / cancel mutations
const retryJobMutation = useRetryJob();
const cancelJobMutation = useCancelJob();
const updateJobMutation = useUpdateJob();
const toast = useToastContext();
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState('');
@ -189,6 +207,18 @@ export function JobDetail() {
};
const isFailedStatus = job?.status === 'tts_failed' || job?.status === 'render_failed' || job?.status === 'processing_failed';
const canCancel = user && ['admin', 'production'].includes(user.role || '') && job?.status && CANCELLABLE_STATUSES.has(job.status);
const handleCancelJob = async () => {
if (!id) return;
try {
await cancelJobMutation.mutateAsync({ id });
toast.toastOnly.success('Job cancelled');
setShowCancelConfirm(false);
} catch {
toast.toastOnly.error('Failed to cancel job');
}
};
// Get video URL from downloads
@ -631,6 +661,43 @@ export function JobDetail() {
<div className="space-y-6">
<ProgressIndicator status={job.status} failure={job.failure} />
{/* Stop Process */}
{canCancel && (
<div>
{!showCancelConfirm ? (
<button
onClick={() => setShowCancelConfirm(true)}
className="w-full inline-flex items-center justify-center px-4 py-2 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Stop Process
</button>
) : (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-800 font-medium mb-1">Stop this job?</p>
<p className="text-xs text-red-600 mb-3">The pipeline will be interrupted. You can retry it later.</p>
<div className="flex gap-2">
<button
onClick={handleCancelJob}
disabled={cancelJobMutation.isPending}
className="flex-1 px-3 py-1.5 text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 disabled:opacity-50"
>
{cancelJobMutation.isPending ? 'Stopping…' : 'Yes, stop it'}
</button>
<button
onClick={() => setShowCancelConfirm(false)}
className="flex-1 px-3 py-1.5 text-sm font-medium rounded-md text-gray-700 bg-white border border-gray-300 hover:bg-gray-50"
>
Keep running
</button>
</div>
</div>
)}
</div>
)}
{/* Error Display */}
{job.error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">