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:
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,52 +196,101 @@ 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 ? (
|
|
||||||
<>
|
|
||||||
<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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user