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:
parent
08a8a0d636
commit
f681bd4f53
5 changed files with 188 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue