Add Active Workers table to Background Jobs dashboard

Shows currently processing cache tasks in a dynamic table with:
- Package name and version constraint being cached
- Recursion depth and attempt number
- Start timestamp
- Pulsing indicator to show live activity

Backend changes:
- Add get_active_tasks() function to pypi_cache_worker.py
- Add GET /pypi/cache/active endpoint to pypi_proxy.py

Frontend changes:
- Add PyPICacheActiveTask type
- Add getPyPICacheActiveTasks() API function
- Add Active Workers section with animated table
- Auto-refreshes every 5 seconds with existing data
This commit is contained in:
Mondo Diaz
2026-02-02 13:50:45 -06:00
parent 3bdeade7ca
commit 1138309aaa
6 changed files with 225 additions and 47 deletions

View File

@@ -754,6 +754,7 @@ export async function testUpstreamSource(id: string): Promise<UpstreamSourceTest
import {
PyPICacheStatus,
PyPICacheTask,
PyPICacheActiveTask,
PyPICacheRetryResponse,
} from './types';
@@ -771,6 +772,13 @@ export async function getPyPICacheFailedTasks(limit: number = 50): Promise<PyPIC
return handleResponse<PyPICacheTask[]>(response);
}
export async function getPyPICacheActiveTasks(limit: number = 50): Promise<PyPICacheActiveTask[]> {
const response = await fetch(`/pypi/cache/active?limit=${limit}`, {
credentials: 'include',
});
return handleResponse<PyPICacheActiveTask[]>(response);
}
export async function retryPyPICacheTask(packageName: string): Promise<PyPICacheRetryResponse> {
const response = await fetch(`/pypi/cache/retry/${encodeURIComponent(packageName)}`, {
method: 'POST',

View File

@@ -271,6 +271,62 @@
font-size: 0.8rem;
}
/* Active Workers Section */
.active-workers-section {
margin-bottom: 1.5rem;
}
.active-workers-section h3 {
display: flex;
align-items: center;
gap: 0.5rem;
color: #3b82f6;
margin-top: 1rem;
}
.pulse-indicator {
width: 8px;
height: 8px;
background-color: #3b82f6;
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.2);
}
}
.jobs-table.active-table {
border: 1px solid #3b82f6;
border-radius: 4px;
}
.jobs-table.active-table th {
background: #eff6ff;
color: #1d4ed8;
}
.jobs-table .active-row {
background-color: #f0f9ff;
}
.jobs-table .active-row:hover {
background-color: #e0f2fe;
}
.jobs-table .version-constraint {
font-family: monospace;
font-size: 0.85rem;
color: var(--text-secondary);
}
/* Responsive */
@media (max-width: 768px) {
.status-cards {

View File

@@ -4,10 +4,11 @@ import { useAuth } from '../contexts/AuthContext';
import {
getPyPICacheStatus,
getPyPICacheFailedTasks,
getPyPICacheActiveTasks,
retryPyPICacheTask,
retryAllPyPICacheTasks,
} from '../api';
import { PyPICacheStatus, PyPICacheTask } from '../types';
import { PyPICacheStatus, PyPICacheTask, PyPICacheActiveTask } from '../types';
import './AdminJobsPage.css';
function AdminJobsPage() {
@@ -17,6 +18,7 @@ function AdminJobsPage() {
// PyPI cache status
const [cacheStatus, setCacheStatus] = useState<PyPICacheStatus | null>(null);
const [failedTasks, setFailedTasks] = useState<PyPICacheTask[]>([]);
const [activeTasks, setActiveTasks] = useState<PyPICacheActiveTask[]>([]);
const [loadingStatus, setLoadingStatus] = useState(true);
const [statusError, setStatusError] = useState<string | null>(null);
@@ -39,12 +41,14 @@ function AdminJobsPage() {
setStatusError(null);
try {
const [status, failed] = await Promise.all([
const [status, failed, active] = await Promise.all([
getPyPICacheStatus(),
getPyPICacheFailedTasks(100),
getPyPICacheActiveTasks(50),
]);
setCacheStatus(status);
setFailedTasks(failed);
setActiveTasks(active);
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Failed to load status');
} finally {
@@ -192,52 +196,101 @@ function AdminJobsPage() {
{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 && cacheStatus?.pending === 0 && cacheStatus?.in_progress === 0 ? (
<p className="success-text">All jobs completed successfully.</p>
) : failedTasks.length > 0 ? (
<>
<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>
</>
) : (
<p className="empty-message">Jobs are processing. No failures yet.</p>
<>
{/* Active Workers Table */}
{activeTasks.length > 0 && (
<div className="active-workers-section">
<h3>
<span className="pulse-indicator"></span>
Active Workers ({activeTasks.length})
</h3>
<table className="jobs-table active-table">
<thead>
<tr>
<th>Package</th>
<th>Version</th>
<th>Depth</th>
<th>Attempt</th>
<th>Started</th>
</tr>
</thead>
<tbody>
{activeTasks.map((task) => (
<tr key={task.id} className="active-row">
<td className="package-name">{task.package}</td>
<td className="version-constraint">
{task.version_constraint || '*'}
</td>
<td>{task.depth}</td>
<td>{task.attempts + 1}</td>
<td className="timestamp">
{task.started_at
? new Date(task.started_at).toLocaleTimeString()
: '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Failed Tasks Table */}
{failedTasks.length > 0 && (
<>
<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>
</>
)}
{/* Success message when nothing is pending/in-progress/failed */}
{failedTasks.length === 0 && activeTasks.length === 0 && cacheStatus?.pending === 0 && (
<p className="success-text">All jobs completed successfully.</p>
)}
{/* In progress message */}
{activeTasks.length === 0 && failedTasks.length === 0 && (cacheStatus?.pending ?? 0) > 0 && (
<p className="empty-message">Jobs queued for processing...</p>
)}
</>
)}
</>
)}

View File

@@ -575,6 +575,15 @@ export interface PyPICacheTask {
failed_at: string | null;
}
export interface PyPICacheActiveTask {
id: string;
package: string;
version_constraint: string | null;
depth: number;
attempts: number;
started_at: string | null;
}
export interface PyPICacheRetryResponse {
message: string;
task_id?: string;