diff --git a/backend/app/pypi_cache_worker.py b/backend/app/pypi_cache_worker.py index 180c0e2..9f8c202 100644 --- a/backend/app/pypi_cache_worker.py +++ b/backend/app/pypi_cache_worker.py @@ -699,3 +699,37 @@ def retry_all_failed_tasks(db: Session) -> int: logger.info(f"Reset {count} failed tasks for retry") return count + + +def cancel_cache_task(db: Session, package_name: str) -> Optional[PyPICacheTask]: + """ + Cancel an in-progress or pending cache task. + + Args: + db: Database session. + package_name: The package name to cancel. + + Returns: + The cancelled task, or None if not found. + """ + normalized = re.sub(r"[-_.]+", "-", package_name).lower() + + task = ( + db.query(PyPICacheTask) + .filter( + PyPICacheTask.package_name == normalized, + PyPICacheTask.status.in_(["pending", "in_progress"]), + ) + .first() + ) + + if not task: + return None + + task.status = "failed" + task.completed_at = datetime.utcnow() + task.error_message = "Cancelled by admin" + db.commit() + + logger.info(f"Cancelled cache task: {normalized}") + return task diff --git a/backend/app/pypi_proxy.py b/backend/app/pypi_proxy.py index a673436..6832c28 100644 --- a/backend/app/pypi_proxy.py +++ b/backend/app/pypi_proxy.py @@ -34,6 +34,7 @@ from .pypi_cache_worker import ( get_recent_activity, retry_failed_task, retry_all_failed_tasks, + cancel_cache_task, ) logger = logging.getLogger(__name__) @@ -924,3 +925,26 @@ async def pypi_cache_retry_all( """ count = retry_all_failed_tasks(db) return {"message": f"Queued {count} tasks for retry", "count": count} + + +@router.post("/cache/cancel/{package_name}") +async def pypi_cache_cancel( + package_name: str, + db: Session = Depends(get_db), + _current_user: User = Depends(require_admin), +): + """ + Cancel an in-progress or pending cache task. + + Args: + package_name: The package name to cancel. + + Requires admin privileges. + """ + task = cancel_cache_task(db, package_name) + if not task: + raise HTTPException( + status_code=404, + detail=f"No active cache task found for package '{package_name}'" + ) + return {"message": f"Cancelled task for {task.package_name}", "task_id": str(task.id)} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b079b48..e021f49 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -794,3 +794,11 @@ export async function retryAllPyPICacheTasks(): Promise }); return handleResponse(response); } + +export async function cancelPyPICacheTask(packageName: string): Promise { + const response = await fetch(`/pypi/cache/cancel/${encodeURIComponent(packageName)}`, { + method: 'POST', + credentials: 'include', + }); + return handleResponse(response); +} diff --git a/frontend/src/pages/AdminJobsPage.css b/frontend/src/pages/AdminJobsPage.css index d2706e2..1aca635 100644 --- a/frontend/src/pages/AdminJobsPage.css +++ b/frontend/src/pages/AdminJobsPage.css @@ -429,6 +429,17 @@ border-color: var(--text-secondary); } +.btn-danger { + background-color: #ef4444; + border-color: #ef4444; + color: white; +} + +.btn-danger:hover { + background-color: #dc2626; + border-color: #dc2626; +} + .btn-sm { padding: 0.25rem 0.75rem; font-size: 0.8rem; diff --git a/frontend/src/pages/AdminJobsPage.tsx b/frontend/src/pages/AdminJobsPage.tsx index 85372f8..6b1fc82 100644 --- a/frontend/src/pages/AdminJobsPage.tsx +++ b/frontend/src/pages/AdminJobsPage.tsx @@ -7,6 +7,7 @@ import { getPyPICacheActiveTasks, retryPyPICacheTask, retryAllPyPICacheTasks, + cancelPyPICacheTask, } from '../api'; import { PyPICacheStatus, PyPICacheTask, PyPICacheActiveTask } from '../types'; import './AdminJobsPage.css'; @@ -25,6 +26,7 @@ function AdminJobsPage() { // Action states const [retryingPackage, setRetryingPackage] = useState(null); const [retryingAll, setRetryingAll] = useState(false); + const [cancelingPackage, setCancelingPackage] = useState(null); const [successMessage, setSuccessMessage] = useState(null); // Auto-refresh @@ -110,6 +112,19 @@ function AdminJobsPage() { } } + async function handleCancelPackage(packageName: string) { + setCancelingPackage(packageName); + try { + const result = await cancelPyPICacheTask(packageName); + setSuccessMessage(result.message); + await loadData(); + } catch (err) { + setStatusError(err instanceof Error ? err.message : 'Failed to cancel'); + } finally { + setCancelingPackage(null); + } + } + if (authLoading) { return
Loading...
; } @@ -217,18 +232,10 @@ function AdminJobsPage() { )} - {/* Unified Jobs Section */} + {/* Jobs Section */}
-
-

- - - - - - All Jobs -

- {failedTasks.length > 0 && ( + {failedTasks.length > 0 && ( +
- )} -
+
+ )} {loadingStatus && !cacheStatus ? (

Loading job status...

@@ -249,9 +256,9 @@ function AdminJobsPage() { + - @@ -262,13 +269,6 @@ function AdminJobsPage() { {allJobs.map((job) => { return ( - - + + );
Status Type PackageStatus Depth Attempts Details
- PyPI - - {job.status === 'in_progress' && } - {job.package} - {job.status === 'in_progress' ? ( Working @@ -278,6 +278,13 @@ function AdminJobsPage() { Pending )} + PyPI + + {job.status === 'in_progress' && } + {job.package} + {job.depth} {job.attempts} @@ -305,6 +312,15 @@ function AdminJobsPage() { {retryingPackage === job.package ? '...' : 'Retry'} )} + {job.status === 'in_progress' && ( + + )}