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 5517048f05
commit a485852a6f
6 changed files with 225 additions and 47 deletions

View File

@@ -526,6 +526,38 @@ def get_failed_tasks(db: Session, limit: int = 50) -> List[dict]:
] ]
def get_active_tasks(db: Session, limit: int = 50) -> List[dict]:
"""
Get list of currently active (in_progress) tasks.
Args:
db: Database session.
limit: Maximum number of tasks to return.
Returns:
List of active task info dicts.
"""
tasks = (
db.query(PyPICacheTask)
.filter(PyPICacheTask.status == "in_progress")
.order_by(PyPICacheTask.started_at.desc())
.limit(limit)
.all()
)
return [
{
"id": str(task.id),
"package": task.package_name,
"version_constraint": task.version_constraint,
"depth": task.depth,
"attempts": task.attempts,
"started_at": task.started_at.isoformat() if task.started_at else None,
}
for task in tasks
]
def retry_failed_task(db: Session, package_name: str) -> Optional[PyPICacheTask]: def retry_failed_task(db: Session, package_name: str) -> Optional[PyPICacheTask]:
""" """
Reset a failed task to retry. Reset a failed task to retry.

View File

@@ -28,6 +28,7 @@ from .pypi_cache_worker import (
enqueue_cache_task, enqueue_cache_task,
get_cache_status, get_cache_status,
get_failed_tasks, get_failed_tasks,
get_active_tasks,
retry_failed_task, retry_failed_task,
retry_all_failed_tasks, retry_all_failed_tasks,
) )
@@ -849,6 +850,25 @@ async def pypi_cache_failed(
return get_failed_tasks(db, limit=limit) return get_failed_tasks(db, limit=limit)
@router.get("/cache/active")
async def pypi_cache_active(
limit: int = Query(default=50, ge=1, le=500),
db: Session = Depends(get_db),
_current_user: User = Depends(require_admin),
):
"""
Get list of currently active (in_progress) cache tasks.
Shows what the cache workers are currently processing.
Args:
limit: Maximum number of tasks to return (default 50, max 500).
Requires admin privileges.
"""
return get_active_tasks(db, limit=limit)
@router.post("/cache/retry/{package_name}") @router.post("/cache/retry/{package_name}")
async def pypi_cache_retry( async def pypi_cache_retry(
package_name: str, package_name: str,

View File

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

View File

@@ -271,6 +271,62 @@
font-size: 0.8rem; 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 */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.status-cards { .status-cards {

View File

@@ -4,10 +4,11 @@ import { useAuth } from '../contexts/AuthContext';
import { import {
getPyPICacheStatus, getPyPICacheStatus,
getPyPICacheFailedTasks, getPyPICacheFailedTasks,
getPyPICacheActiveTasks,
retryPyPICacheTask, retryPyPICacheTask,
retryAllPyPICacheTasks, retryAllPyPICacheTasks,
} from '../api'; } from '../api';
import { PyPICacheStatus, PyPICacheTask } from '../types'; import { PyPICacheStatus, PyPICacheTask, PyPICacheActiveTask } from '../types';
import './AdminJobsPage.css'; import './AdminJobsPage.css';
function AdminJobsPage() { function AdminJobsPage() {
@@ -17,6 +18,7 @@ function AdminJobsPage() {
// PyPI cache status // PyPI cache status
const [cacheStatus, setCacheStatus] = useState<PyPICacheStatus | null>(null); const [cacheStatus, setCacheStatus] = useState<PyPICacheStatus | null>(null);
const [failedTasks, setFailedTasks] = useState<PyPICacheTask[]>([]); const [failedTasks, setFailedTasks] = useState<PyPICacheTask[]>([]);
const [activeTasks, setActiveTasks] = useState<PyPICacheActiveTask[]>([]);
const [loadingStatus, setLoadingStatus] = useState(true); const [loadingStatus, setLoadingStatus] = useState(true);
const [statusError, setStatusError] = useState<string | null>(null); const [statusError, setStatusError] = useState<string | null>(null);
@@ -39,12 +41,14 @@ function AdminJobsPage() {
setStatusError(null); setStatusError(null);
try { try {
const [status, failed] = await Promise.all([ const [status, failed, active] = await Promise.all([
getPyPICacheStatus(), getPyPICacheStatus(),
getPyPICacheFailedTasks(100), getPyPICacheFailedTasks(100),
getPyPICacheActiveTasks(50),
]); ]);
setCacheStatus(status); setCacheStatus(status);
setFailedTasks(failed); setFailedTasks(failed);
setActiveTasks(active);
} catch (err) { } catch (err) {
setStatusError(err instanceof Error ? err.message : 'Failed to load status'); setStatusError(err instanceof Error ? err.message : 'Failed to load status');
} finally { } finally {
@@ -192,9 +196,48 @@ function AdminJobsPage() {
{totalJobs === 0 ? ( {totalJobs === 0 ? (
<p className="empty-message">No cache jobs yet. Jobs are created when packages are downloaded through the PyPI proxy.</p> <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 ? ( {/* 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> <h3>Failed Tasks</h3>
<table className="jobs-table"> <table className="jobs-table">
@@ -236,8 +279,18 @@ function AdminJobsPage() {
</tbody> </tbody>
</table> </table>
</> </>
) : ( )}
<p className="empty-message">Jobs are processing. No failures yet.</p>
{/* 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; 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 { export interface PyPICacheRetryResponse {
message: string; message: string;
task_id?: string; task_id?: string;