From ba708332a56e871914aa385462471f509aa4fb9b Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Mon, 2 Feb 2026 11:26:55 -0600 Subject: [PATCH] Add Background Jobs dashboard for admin users New admin page at /admin/jobs showing: - PyPI cache job status (pending, in-progress, completed, failed) - Failed task list with error details - Retry individual packages or retry all failed - Auto-refresh every 5 seconds (toggleable) - Placeholder for future NPM cache jobs Accessible from admin dropdown menu as "Background Jobs". --- frontend/src/App.tsx | 2 + frontend/src/api.ts | 40 ++++ frontend/src/components/Layout.tsx | 11 + frontend/src/pages/AdminJobsPage.css | 293 +++++++++++++++++++++++++++ frontend/src/pages/AdminJobsPage.tsx | 263 ++++++++++++++++++++++++ frontend/src/types.ts | 23 +++ 6 files changed, 632 insertions(+) create mode 100644 frontend/src/pages/AdminJobsPage.css create mode 100644 frontend/src/pages/AdminJobsPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7a76ba3..1ecf8bf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import APIKeysPage from './pages/APIKeysPage'; import AdminUsersPage from './pages/AdminUsersPage'; import AdminOIDCPage from './pages/AdminOIDCPage'; import AdminCachePage from './pages/AdminCachePage'; +import AdminJobsPage from './pages/AdminJobsPage'; import ProjectSettingsPage from './pages/ProjectSettingsPage'; import TeamsPage from './pages/TeamsPage'; import TeamDashboardPage from './pages/TeamDashboardPage'; @@ -52,6 +53,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4c556c1..c6a3667 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -746,3 +746,43 @@ export async function testUpstreamSource(id: string): Promise(response); } + +// ============================================================================= +// PyPI Cache Jobs API +// ============================================================================= + +import { + PyPICacheStatus, + PyPICacheTask, + PyPICacheRetryResponse, +} from './types'; + +export async function getPyPICacheStatus(): Promise { + const response = await fetch('/pypi/cache/status', { + credentials: 'include', + }); + return handleResponse(response); +} + +export async function getPyPICacheFailedTasks(limit: number = 50): Promise { + const response = await fetch(`/pypi/cache/failed?limit=${limit}`, { + credentials: 'include', + }); + return handleResponse(response); +} + +export async function retryPyPICacheTask(packageName: string): Promise { + const response = await fetch(`/pypi/cache/retry/${encodeURIComponent(packageName)}`, { + method: 'POST', + credentials: 'include', + }); + return handleResponse(response); +} + +export async function retryAllPyPICacheTasks(): Promise { + const response = await fetch('/pypi/cache/retry-all', { + method: 'POST', + credentials: 'include', + }); + return handleResponse(response); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index f7c55a3..dfaf150 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -195,6 +195,17 @@ function Layout({ children }: LayoutProps) { Cache Management + setShowUserMenu(false)} + > + + + + + Background Jobs + )}
diff --git a/frontend/src/pages/AdminJobsPage.css b/frontend/src/pages/AdminJobsPage.css new file mode 100644 index 0000000..2aaf08d --- /dev/null +++ b/frontend/src/pages/AdminJobsPage.css @@ -0,0 +1,293 @@ +.admin-jobs-page { + padding: 2rem; + max-width: 1400px; + margin: 0 auto; +} + +.admin-jobs-page h1 { + margin: 0; + color: var(--text-primary); +} + +.admin-jobs-page h2 { + margin: 0; + color: var(--text-primary); + font-size: 1.25rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.admin-jobs-page h3 { + margin: 1.5rem 0 1rem; + color: var(--text-primary); + font-size: 1rem; +} + +/* Page Header */ +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.header-actions { + display: flex; + align-items: center; + gap: 1rem; +} + +.auto-refresh-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-secondary); + cursor: pointer; + font-size: 0.9rem; +} + +.auto-refresh-toggle input { + cursor: pointer; +} + +/* Messages */ +.success-message { + padding: 0.75rem 1rem; + background-color: #d4edda; + border: 1px solid #c3e6cb; + border-radius: 4px; + color: #155724; + margin-bottom: 1rem; +} + +.error-message { + padding: 0.75rem 1rem; + background-color: #f8d7da; + border: 1px solid #f5c6cb; + border-radius: 4px; + color: #721c24; + margin-bottom: 1rem; +} + +.loading-text { + color: var(--text-secondary); + padding: 2rem; + text-align: center; +} + +.success-text { + color: #2e7d32; + padding: 1rem; + text-align: center; + background: #e8f5e9; + border-radius: 4px; + margin-top: 1rem; +} + +.empty-message { + color: var(--text-secondary); + font-style: italic; + padding: 2rem; + text-align: center; +} + +/* Jobs Section */ +.jobs-section { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.jobs-section.coming-soon { + opacity: 0.7; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +/* Status Cards */ +.status-cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin-bottom: 1rem; +} + +.status-card { + background: var(--bg-primary); + border-radius: 8px; + padding: 1rem; + text-align: center; + border: 1px solid var(--border-color); +} + +.status-card .status-value { + font-size: 2rem; + font-weight: 600; + line-height: 1.2; +} + +.status-card .status-label { + font-size: 0.85rem; + color: var(--text-secondary); + text-transform: uppercase; + margin-top: 0.25rem; +} + +.status-card.pending .status-value { + color: #f59e0b; +} + +.status-card.in-progress .status-value { + color: #3b82f6; +} + +.status-card.completed .status-value { + color: #10b981; +} + +.status-card.failed .status-value { + color: #ef4444; +} + +/* Jobs Table */ +.jobs-table { + width: 100%; + border-collapse: collapse; + background: var(--bg-primary); + border-radius: 4px; + overflow: hidden; +} + +.jobs-table th, +.jobs-table td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.jobs-table th { + background: var(--bg-tertiary); + font-weight: 600; + color: var(--text-secondary); + font-size: 0.85rem; + text-transform: uppercase; +} + +.jobs-table tr:last-child td { + border-bottom: none; +} + +.jobs-table .package-name { + font-family: monospace; + font-weight: 500; + color: var(--text-primary); +} + +.jobs-table .error-cell { + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #c62828; + font-size: 0.9rem; +} + +.jobs-table .timestamp { + font-size: 0.85rem; + color: var(--text-secondary); + white-space: nowrap; +} + +.jobs-table .actions-cell { + white-space: nowrap; + text-align: right; +} + +/* Badges */ +.coming-soon-badge { + display: inline-block; + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 500; + background-color: #e0e0e0; + color: #616161; + margin-left: 0.75rem; + text-transform: uppercase; +} + +/* Buttons */ +.btn { + padding: 0.5rem 1rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; + font-size: 0.875rem; +} + +.btn:hover { + background: var(--bg-tertiary); +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-primary { + background-color: var(--color-primary); + border-color: var(--color-primary); + color: white; +} + +.btn-primary:hover { + background-color: var(--color-primary-hover); +} + +.btn-secondary { + background-color: var(--bg-tertiary); + border-color: var(--border-color); + color: var(--text-primary); + font-weight: 500; +} + +.btn-secondary:hover { + background-color: var(--bg-secondary); + border-color: var(--text-secondary); +} + +.btn-sm { + padding: 0.25rem 0.75rem; + font-size: 0.8rem; +} + +/* Responsive */ +@media (max-width: 768px) { + .status-cards { + grid-template-columns: repeat(2, 1fr); + } + + .page-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .jobs-table { + font-size: 0.85rem; + } + + .jobs-table .error-cell { + max-width: 150px; + } +} diff --git a/frontend/src/pages/AdminJobsPage.tsx b/frontend/src/pages/AdminJobsPage.tsx new file mode 100644 index 0000000..c8ac839 --- /dev/null +++ b/frontend/src/pages/AdminJobsPage.tsx @@ -0,0 +1,263 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import { + getPyPICacheStatus, + getPyPICacheFailedTasks, + retryPyPICacheTask, + retryAllPyPICacheTasks, +} from '../api'; +import { PyPICacheStatus, PyPICacheTask } from '../types'; +import './AdminJobsPage.css'; + +function AdminJobsPage() { + const { user, loading: authLoading } = useAuth(); + const navigate = useNavigate(); + + // PyPI cache status + const [cacheStatus, setCacheStatus] = useState(null); + const [failedTasks, setFailedTasks] = useState([]); + const [loadingStatus, setLoadingStatus] = useState(true); + const [statusError, setStatusError] = useState(null); + + // Action states + const [retryingPackage, setRetryingPackage] = useState(null); + const [retryingAll, setRetryingAll] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + + // Auto-refresh + const [autoRefresh, setAutoRefresh] = useState(true); + + useEffect(() => { + if (!authLoading && !user) { + navigate('/login', { state: { from: '/admin/jobs' } }); + } + }, [user, authLoading, navigate]); + + const loadData = useCallback(async () => { + if (!user?.is_admin) return; + + setStatusError(null); + try { + const [status, failed] = await Promise.all([ + getPyPICacheStatus(), + getPyPICacheFailedTasks(100), + ]); + setCacheStatus(status); + setFailedTasks(failed); + } catch (err) { + setStatusError(err instanceof Error ? err.message : 'Failed to load status'); + } finally { + setLoadingStatus(false); + } + }, [user]); + + useEffect(() => { + if (user?.is_admin) { + loadData(); + } + }, [user, loadData]); + + // Auto-refresh every 5 seconds when enabled + useEffect(() => { + if (!autoRefresh || !user?.is_admin) return; + + const interval = setInterval(() => { + loadData(); + }, 5000); + + return () => clearInterval(interval); + }, [autoRefresh, user, loadData]); + + useEffect(() => { + if (successMessage) { + const timer = setTimeout(() => setSuccessMessage(null), 3000); + return () => clearTimeout(timer); + } + }, [successMessage]); + + async function handleRetryPackage(packageName: string) { + setRetryingPackage(packageName); + try { + const result = await retryPyPICacheTask(packageName); + setSuccessMessage(result.message); + await loadData(); + } catch (err) { + setStatusError(err instanceof Error ? err.message : 'Failed to retry'); + } finally { + setRetryingPackage(null); + } + } + + async function handleRetryAll() { + if (!window.confirm('Retry all failed tasks? This will re-queue all failed packages.')) { + return; + } + + setRetryingAll(true); + try { + const result = await retryAllPyPICacheTasks(); + setSuccessMessage(result.message); + await loadData(); + } catch (err) { + setStatusError(err instanceof Error ? err.message : 'Failed to retry all'); + } finally { + setRetryingAll(false); + } + } + + if (authLoading) { + return
Loading...
; + } + + if (!user?.is_admin) { + return ( +
+
Access denied. Admin privileges required.
+
+ ); + } + + const totalJobs = cacheStatus + ? cacheStatus.pending + cacheStatus.in_progress + cacheStatus.completed + cacheStatus.failed + : 0; + + return ( +
+
+

Background Jobs

+
+ + +
+
+ + {successMessage &&
{successMessage}
} + {statusError &&
{statusError}
} + + {/* PyPI Cache Jobs Section */} +
+
+

+ + + + + + PyPI Cache Jobs +

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

Loading job status...

+ ) : ( + <> + {/* Status Cards */} +
+
+
{cacheStatus?.pending ?? 0}
+
Pending
+
+
+
{cacheStatus?.in_progress ?? 0}
+
In Progress
+
+
+
{cacheStatus?.completed ?? 0}
+
Completed
+
+
+
{cacheStatus?.failed ?? 0}
+
Failed
+
+
+ + {totalJobs === 0 ? ( +

No cache jobs yet. Jobs are created when packages are downloaded through the PyPI proxy.

+ ) : failedTasks.length === 0 ? ( +

All jobs completed successfully.

+ ) : ( + <> +

Failed Tasks

+ + + + + + + + + + + + + {failedTasks.map((task) => ( + + + + + + + + + ))} + +
PackageErrorAttemptsDepthFailed AtActions
{task.package} + {task.error || 'Unknown error'} + {task.attempts}{task.depth} + {task.failed_at + ? new Date(task.failed_at).toLocaleString() + : '-'} + + +
+ + )} + + )} +
+ + {/* Placeholder for future job types */} +
+
+

+ + + + + + NPM Cache Jobs + Coming Soon +

+
+

NPM proxy support is planned for a future release.

+
+
+ ); +} + +export default AdminJobsPage; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8469560..f06f8d0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -557,3 +557,26 @@ export interface UpstreamSourceTestResult { source_id: string; source_name: string; } + +// PyPI Cache Job types +export interface PyPICacheStatus { + pending: number; + in_progress: number; + completed: number; + failed: number; +} + +export interface PyPICacheTask { + id: string; + package: string; + error: string | null; + attempts: number; + depth: number; + failed_at: string | null; +} + +export interface PyPICacheRetryResponse { + message: string; + task_id?: string; + count?: number; +}