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".
This commit is contained in:
Mondo Diaz
2026-02-02 11:26:55 -06:00
parent e0562195df
commit a39b6f098f
6 changed files with 632 additions and 0 deletions

View File

@@ -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() {
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/oidc" element={<AdminOIDCPage />} />
<Route path="/admin/cache" element={<AdminCachePage />} />
<Route path="/admin/jobs" element={<AdminJobsPage />} />
<Route path="/teams" element={<TeamsPage />} />
<Route path="/teams/:slug" element={<TeamDashboardPage />} />
<Route path="/teams/:slug/settings" element={<TeamSettingsPage />} />

View File

@@ -746,3 +746,43 @@ export async function testUpstreamSource(id: string): Promise<UpstreamSourceTest
});
return handleResponse<UpstreamSourceTestResult>(response);
}
// =============================================================================
// PyPI Cache Jobs API
// =============================================================================
import {
PyPICacheStatus,
PyPICacheTask,
PyPICacheRetryResponse,
} from './types';
export async function getPyPICacheStatus(): Promise<PyPICacheStatus> {
const response = await fetch('/pypi/cache/status', {
credentials: 'include',
});
return handleResponse<PyPICacheStatus>(response);
}
export async function getPyPICacheFailedTasks(limit: number = 50): Promise<PyPICacheTask[]> {
const response = await fetch(`/pypi/cache/failed?limit=${limit}`, {
credentials: 'include',
});
return handleResponse<PyPICacheTask[]>(response);
}
export async function retryPyPICacheTask(packageName: string): Promise<PyPICacheRetryResponse> {
const response = await fetch(`/pypi/cache/retry/${encodeURIComponent(packageName)}`, {
method: 'POST',
credentials: 'include',
});
return handleResponse<PyPICacheRetryResponse>(response);
}
export async function retryAllPyPICacheTasks(): Promise<PyPICacheRetryResponse> {
const response = await fetch('/pypi/cache/retry-all', {
method: 'POST',
credentials: 'include',
});
return handleResponse<PyPICacheRetryResponse>(response);
}

View File

@@ -195,6 +195,17 @@ function Layout({ children }: LayoutProps) {
</svg>
Cache Management
</NavLink>
<NavLink
to="/admin/jobs"
className="user-menu-item"
onClick={() => setShowUserMenu(false)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"/>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
</svg>
Background Jobs
</NavLink>
</>
)}
<div className="user-menu-divider"></div>

View File

@@ -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;
}
}

View File

@@ -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<PyPICacheStatus | null>(null);
const [failedTasks, setFailedTasks] = useState<PyPICacheTask[]>([]);
const [loadingStatus, setLoadingStatus] = useState(true);
const [statusError, setStatusError] = useState<string | null>(null);
// Action states
const [retryingPackage, setRetryingPackage] = useState<string | null>(null);
const [retryingAll, setRetryingAll] = useState(false);
const [successMessage, setSuccessMessage] = useState<string | null>(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 <div className="admin-jobs-page">Loading...</div>;
}
if (!user?.is_admin) {
return (
<div className="admin-jobs-page">
<div className="error-message">Access denied. Admin privileges required.</div>
</div>
);
}
const totalJobs = cacheStatus
? cacheStatus.pending + cacheStatus.in_progress + cacheStatus.completed + cacheStatus.failed
: 0;
return (
<div className="admin-jobs-page">
<div className="page-header">
<h1>Background Jobs</h1>
<div className="header-actions">
<label className="auto-refresh-toggle">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
/>
Auto-refresh
</label>
<button className="btn btn-secondary" onClick={loadData} disabled={loadingStatus}>
Refresh
</button>
</div>
</div>
{successMessage && <div className="success-message">{successMessage}</div>}
{statusError && <div className="error-message">{statusError}</div>}
{/* PyPI Cache Jobs Section */}
<section className="jobs-section">
<div className="section-header">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
PyPI Cache Jobs
</h2>
{failedTasks.length > 0 && (
<button
className="btn btn-primary"
onClick={handleRetryAll}
disabled={retryingAll}
>
{retryingAll ? 'Retrying...' : `Retry All Failed (${failedTasks.length})`}
</button>
)}
</div>
{loadingStatus && !cacheStatus ? (
<p className="loading-text">Loading job status...</p>
) : (
<>
{/* Status Cards */}
<div className="status-cards">
<div className="status-card pending">
<div className="status-value">{cacheStatus?.pending ?? 0}</div>
<div className="status-label">Pending</div>
</div>
<div className="status-card in-progress">
<div className="status-value">{cacheStatus?.in_progress ?? 0}</div>
<div className="status-label">In Progress</div>
</div>
<div className="status-card completed">
<div className="status-value">{cacheStatus?.completed ?? 0}</div>
<div className="status-label">Completed</div>
</div>
<div className="status-card failed">
<div className="status-value">{cacheStatus?.failed ?? 0}</div>
<div className="status-label">Failed</div>
</div>
</div>
{totalJobs === 0 ? (
<p className="empty-message">No cache jobs yet. Jobs are created when packages are downloaded through the PyPI proxy.</p>
) : failedTasks.length === 0 ? (
<p className="success-text">All jobs completed successfully.</p>
) : (
<>
<h3>Failed Tasks</h3>
<table className="jobs-table">
<thead>
<tr>
<th>Package</th>
<th>Error</th>
<th>Attempts</th>
<th>Depth</th>
<th>Failed At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{failedTasks.map((task) => (
<tr key={task.id}>
<td className="package-name">{task.package}</td>
<td className="error-cell" title={task.error || ''}>
{task.error || 'Unknown error'}
</td>
<td>{task.attempts}</td>
<td>{task.depth}</td>
<td className="timestamp">
{task.failed_at
? new Date(task.failed_at).toLocaleString()
: '-'}
</td>
<td className="actions-cell">
<button
className="btn btn-sm btn-secondary"
onClick={() => handleRetryPackage(task.package)}
disabled={retryingPackage === task.package}
>
{retryingPackage === task.package ? 'Retrying...' : 'Retry'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</>
)}
</section>
{/* Placeholder for future job types */}
<section className="jobs-section coming-soon">
<div className="section-header">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="3" y1="9" x2="21" y2="9"/>
<line x1="9" y1="21" x2="9" y2="9"/>
</svg>
NPM Cache Jobs
<span className="coming-soon-badge">Coming Soon</span>
</h2>
</div>
<p className="empty-message">NPM proxy support is planned for a future release.</p>
</section>
</div>
);
}
export default AdminJobsPage;

View File

@@ -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;
}