feat: add background tasks monitoring dashboard
Adds a unified view of all background tasks (imports and test runs) under Platform Monitoring. Includes real-time status polling, statistics overview, and task history. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ from fastapi import APIRouter
|
||||
from . import (
|
||||
audit,
|
||||
auth,
|
||||
background_tasks,
|
||||
code_quality,
|
||||
companies,
|
||||
content_pages,
|
||||
@@ -118,8 +119,10 @@ router.include_router(marketplace.router, tags=["admin-marketplace"])
|
||||
# Platform Administration
|
||||
# ============================================================================
|
||||
|
||||
# Include monitoring endpoints (placeholder for future implementation)
|
||||
# router.include_router(monitoring.router, tags=["admin-monitoring"])
|
||||
# Include background tasks monitoring endpoints
|
||||
router.include_router(
|
||||
background_tasks.router, prefix="/background-tasks", tags=["admin-background-tasks"]
|
||||
)
|
||||
|
||||
# Include audit logging endpoints
|
||||
router.include_router(audit.router, tags=["admin-audit"])
|
||||
|
||||
189
app/api/v1/admin/background_tasks.py
Normal file
189
app/api/v1/admin/background_tasks.py
Normal file
@@ -0,0 +1,189 @@
|
||||
# app/api/v1/admin/background_tasks.py
|
||||
"""
|
||||
Background Tasks Monitoring API
|
||||
Provides unified view of all background tasks across the system
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.services.background_tasks_service import background_tasks_service
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class BackgroundTaskResponse(BaseModel):
|
||||
"""Unified background task response"""
|
||||
|
||||
id: int
|
||||
task_type: str # 'import' or 'test_run'
|
||||
status: str
|
||||
started_at: str | None
|
||||
completed_at: str | None
|
||||
duration_seconds: float | None
|
||||
description: str
|
||||
triggered_by: str | None
|
||||
error_message: str | None
|
||||
details: dict | None
|
||||
|
||||
|
||||
class BackgroundTasksStatsResponse(BaseModel):
|
||||
"""Statistics for background tasks"""
|
||||
|
||||
total_tasks: int
|
||||
running: int
|
||||
completed: int
|
||||
failed: int
|
||||
tasks_today: int
|
||||
avg_duration_seconds: float | None
|
||||
|
||||
# By type
|
||||
import_jobs: dict
|
||||
test_runs: dict
|
||||
|
||||
|
||||
def _convert_import_to_response(job) -> BackgroundTaskResponse:
|
||||
"""Convert MarketplaceImportJob to BackgroundTaskResponse"""
|
||||
duration = None
|
||||
if job.started_at and job.completed_at:
|
||||
duration = (job.completed_at - job.started_at).total_seconds()
|
||||
elif job.started_at and job.status == "processing":
|
||||
duration = (datetime.now(UTC) - job.started_at).total_seconds()
|
||||
|
||||
return BackgroundTaskResponse(
|
||||
id=job.id,
|
||||
task_type="import",
|
||||
status=job.status,
|
||||
started_at=job.started_at.isoformat() if job.started_at else None,
|
||||
completed_at=job.completed_at.isoformat() if job.completed_at else None,
|
||||
duration_seconds=duration,
|
||||
description=f"Import from {job.marketplace}: {job.source_url[:50]}..." if len(job.source_url) > 50 else f"Import from {job.marketplace}: {job.source_url}",
|
||||
triggered_by=job.user.username if job.user else None,
|
||||
error_message=job.error_message,
|
||||
details={
|
||||
"marketplace": job.marketplace,
|
||||
"vendor_id": job.vendor_id,
|
||||
"imported": job.imported_count,
|
||||
"updated": job.updated_count,
|
||||
"errors": job.error_count,
|
||||
"total_processed": job.total_processed,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _convert_test_run_to_response(run) -> BackgroundTaskResponse:
|
||||
"""Convert TestRun to BackgroundTaskResponse"""
|
||||
duration = run.duration_seconds
|
||||
if run.status == "running" and run.timestamp:
|
||||
duration = (datetime.now(UTC) - run.timestamp).total_seconds()
|
||||
|
||||
return BackgroundTaskResponse(
|
||||
id=run.id,
|
||||
task_type="test_run",
|
||||
status=run.status,
|
||||
started_at=run.timestamp.isoformat() if run.timestamp else None,
|
||||
completed_at=None,
|
||||
duration_seconds=duration,
|
||||
description=f"Test run: {run.test_path}",
|
||||
triggered_by=run.triggered_by,
|
||||
error_message=None,
|
||||
details={
|
||||
"test_path": run.test_path,
|
||||
"total_tests": run.total_tests,
|
||||
"passed": run.passed,
|
||||
"failed": run.failed,
|
||||
"errors": run.errors,
|
||||
"pass_rate": run.pass_rate,
|
||||
"git_branch": run.git_branch,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=list[BackgroundTaskResponse])
|
||||
async def list_background_tasks(
|
||||
status: str | None = Query(None, description="Filter by status"),
|
||||
task_type: str | None = Query(None, description="Filter by type (import, test_run)"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
List all background tasks across the system
|
||||
|
||||
Returns a unified view of import jobs and test runs.
|
||||
"""
|
||||
tasks = []
|
||||
|
||||
# Get import jobs
|
||||
if task_type is None or task_type == "import":
|
||||
import_jobs = background_tasks_service.get_import_jobs(db, status=status, limit=limit)
|
||||
tasks.extend([_convert_import_to_response(job) for job in import_jobs])
|
||||
|
||||
# Get test runs
|
||||
if task_type is None or task_type == "test_run":
|
||||
test_runs = background_tasks_service.get_test_runs(db, status=status, limit=limit)
|
||||
tasks.extend([_convert_test_run_to_response(run) for run in test_runs])
|
||||
|
||||
# Sort by start time (most recent first)
|
||||
tasks.sort(
|
||||
key=lambda t: t.started_at or "1970-01-01T00:00:00",
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
return tasks[:limit]
|
||||
|
||||
|
||||
@router.get("/tasks/stats", response_model=BackgroundTasksStatsResponse)
|
||||
async def get_background_tasks_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get statistics for background tasks
|
||||
"""
|
||||
import_stats = background_tasks_service.get_import_stats(db)
|
||||
test_stats = background_tasks_service.get_test_run_stats(db)
|
||||
|
||||
# Combined stats
|
||||
total_running = import_stats["running"] + test_stats["running"]
|
||||
total_completed = import_stats["completed"] + test_stats["completed"]
|
||||
total_failed = import_stats["failed"] + test_stats["failed"]
|
||||
total_tasks = import_stats["total"] + test_stats["total"]
|
||||
|
||||
return BackgroundTasksStatsResponse(
|
||||
total_tasks=total_tasks,
|
||||
running=total_running,
|
||||
completed=total_completed,
|
||||
failed=total_failed,
|
||||
tasks_today=import_stats["today"] + test_stats["today"],
|
||||
avg_duration_seconds=test_stats.get("avg_duration"),
|
||||
import_jobs=import_stats,
|
||||
test_runs=test_stats,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/running", response_model=list[BackgroundTaskResponse])
|
||||
async def list_running_tasks(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
List currently running background tasks
|
||||
"""
|
||||
tasks = []
|
||||
|
||||
# Running imports
|
||||
running_imports = background_tasks_service.get_running_imports(db)
|
||||
tasks.extend([_convert_import_to_response(job) for job in running_imports])
|
||||
|
||||
# Running test runs
|
||||
running_tests = background_tasks_service.get_running_test_runs(db)
|
||||
tasks.extend([_convert_test_run_to_response(run) for run in running_tests])
|
||||
|
||||
return tasks
|
||||
@@ -500,6 +500,25 @@ async def admin_imports_page(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/background-tasks", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_background_tasks_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_admin_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render background tasks monitoring page.
|
||||
Shows running and completed background tasks across the system.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"admin/background-tasks.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/marketplace", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_marketplace_page(
|
||||
request: Request,
|
||||
|
||||
116
app/services/background_tasks_service.py
Normal file
116
app/services/background_tasks_service.py
Normal file
@@ -0,0 +1,116 @@
|
||||
# app/services/background_tasks_service.py
|
||||
"""
|
||||
Background Tasks Service
|
||||
Service for monitoring background tasks across the system
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import desc, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.database.marketplace_import_job import MarketplaceImportJob
|
||||
from models.database.test_run import TestRun
|
||||
|
||||
|
||||
class BackgroundTasksService:
|
||||
"""Service for monitoring background tasks"""
|
||||
|
||||
def get_import_jobs(
|
||||
self, db: Session, status: str | None = None, limit: int = 50
|
||||
) -> list[MarketplaceImportJob]:
|
||||
"""Get import jobs with optional status filter"""
|
||||
query = db.query(MarketplaceImportJob)
|
||||
if status:
|
||||
query = query.filter(MarketplaceImportJob.status == status)
|
||||
return query.order_by(desc(MarketplaceImportJob.created_at)).limit(limit).all()
|
||||
|
||||
def get_test_runs(
|
||||
self, db: Session, status: str | None = None, limit: int = 50
|
||||
) -> list[TestRun]:
|
||||
"""Get test runs with optional status filter"""
|
||||
query = db.query(TestRun)
|
||||
if status:
|
||||
query = query.filter(TestRun.status == status)
|
||||
return query.order_by(desc(TestRun.timestamp)).limit(limit).all()
|
||||
|
||||
def get_running_imports(self, db: Session) -> list[MarketplaceImportJob]:
|
||||
"""Get currently running import jobs"""
|
||||
return (
|
||||
db.query(MarketplaceImportJob)
|
||||
.filter(MarketplaceImportJob.status == "processing")
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_running_test_runs(self, db: Session) -> list[TestRun]:
|
||||
"""Get currently running test runs"""
|
||||
return db.query(TestRun).filter(TestRun.status == "running").all()
|
||||
|
||||
def get_import_stats(self, db: Session) -> dict:
|
||||
"""Get import job statistics"""
|
||||
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
stats = db.query(
|
||||
func.count(MarketplaceImportJob.id).label("total"),
|
||||
func.sum(
|
||||
func.case((MarketplaceImportJob.status == "processing", 1), else_=0)
|
||||
).label("running"),
|
||||
func.sum(
|
||||
func.case(
|
||||
(MarketplaceImportJob.status.in_(["completed", "completed_with_errors"]), 1),
|
||||
else_=0,
|
||||
)
|
||||
).label("completed"),
|
||||
func.sum(
|
||||
func.case((MarketplaceImportJob.status == "failed", 1), else_=0)
|
||||
).label("failed"),
|
||||
).first()
|
||||
|
||||
today_count = (
|
||||
db.query(func.count(MarketplaceImportJob.id))
|
||||
.filter(MarketplaceImportJob.created_at >= today_start)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return {
|
||||
"total": stats.total or 0,
|
||||
"running": stats.running or 0,
|
||||
"completed": stats.completed or 0,
|
||||
"failed": stats.failed or 0,
|
||||
"today": today_count,
|
||||
}
|
||||
|
||||
def get_test_run_stats(self, db: Session) -> dict:
|
||||
"""Get test run statistics"""
|
||||
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
stats = db.query(
|
||||
func.count(TestRun.id).label("total"),
|
||||
func.sum(func.case((TestRun.status == "running", 1), else_=0)).label("running"),
|
||||
func.sum(func.case((TestRun.status == "passed", 1), else_=0)).label("completed"),
|
||||
func.sum(
|
||||
func.case((TestRun.status.in_(["failed", "error"]), 1), else_=0)
|
||||
).label("failed"),
|
||||
func.avg(TestRun.duration_seconds).label("avg_duration"),
|
||||
).first()
|
||||
|
||||
today_count = (
|
||||
db.query(func.count(TestRun.id))
|
||||
.filter(TestRun.timestamp >= today_start)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return {
|
||||
"total": stats.total or 0,
|
||||
"running": stats.running or 0,
|
||||
"completed": stats.completed or 0,
|
||||
"failed": stats.failed or 0,
|
||||
"today": today_count,
|
||||
"avg_duration": round(stats.avg_duration or 0, 1),
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
background_tasks_service = BackgroundTasksService()
|
||||
257
app/templates/admin/background-tasks.html
Normal file
257
app/templates/admin/background-tasks.html
Normal file
@@ -0,0 +1,257 @@
|
||||
{# app/templates/admin/background-tasks.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
|
||||
{% block title %}Background Tasks{% endblock %}
|
||||
|
||||
{% block alpine_data %}backgroundTasks(){% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/admin/js/background-tasks.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% call page_header_flex(title='Background Tasks', subtitle='Monitor running and completed background tasks') %}
|
||||
{{ refresh_button(variant='secondary') }}
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state('Loading tasks...') }}
|
||||
|
||||
{{ error_state('Error loading tasks') }}
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<div x-show="!loading && !error">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Running Tasks -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
|
||||
<span x-html="$icon('refresh', 'w-5 h-5 animate-spin')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Running</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.running">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed Today -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Today</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.tasks_today">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Failed -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Failed</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.failed">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('collection', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_tasks">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Running Tasks Section -->
|
||||
<div class="mb-8" x-show="runningTasks.length > 0">
|
||||
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200 flex items-center">
|
||||
<span x-html="$icon('refresh', 'w-5 h-5 mr-2 animate-spin text-yellow-500')"></span>
|
||||
Currently Running
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
<template x-for="task in runningTasks" :key="task.task_type + '-' + task.id">
|
||||
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full mr-3"
|
||||
:class="{
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-700 dark:text-blue-100': task.task_type === 'import',
|
||||
'bg-purple-100 text-purple-700 dark:bg-purple-700 dark:text-purple-100': task.task_type === 'test_run'
|
||||
}"
|
||||
x-text="task.task_type === 'import' ? 'Import' : 'Test Run'">
|
||||
</span>
|
||||
<div>
|
||||
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="task.description"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Started by <span x-text="task.triggered_by || 'system'"></span>
|
||||
at <span x-text="task.started_at ? new Date(task.started_at).toLocaleTimeString() : 'N/A'"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-bold text-yellow-600 dark:text-yellow-400" x-text="formatDuration(task.duration_seconds)"></p>
|
||||
<p class="text-xs text-gray-500">elapsed</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Type Stats -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2">
|
||||
<!-- Import Jobs Stats -->
|
||||
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200 flex items-center">
|
||||
<span x-html="$icon('cube', 'w-5 h-5 mr-2 text-blue-500')"></span>
|
||||
Import Jobs
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="stats.import_jobs?.total || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-2xl font-bold text-yellow-600" x-text="stats.import_jobs?.running || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Running</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-2xl font-bold text-green-600" x-text="stats.import_jobs?.completed || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Completed</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-2xl font-bold text-red-600" x-text="stats.import_jobs?.failed || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Failed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 text-center">
|
||||
<a href="/admin/imports" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400">
|
||||
View Import Jobs →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Runs Stats -->
|
||||
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200 flex items-center">
|
||||
<span x-html="$icon('beaker', 'w-5 h-5 mr-2 text-purple-500')"></span>
|
||||
Test Runs
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="stats.test_runs?.total || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-2xl font-bold text-yellow-600" x-text="stats.test_runs?.running || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Running</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-2xl font-bold text-green-600" x-text="stats.test_runs?.completed || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Passed</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-2xl font-bold text-red-600" x-text="stats.test_runs?.failed || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Failed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 text-center">
|
||||
<a href="/admin/testing" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400">
|
||||
View Test Dashboard →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<div class="mb-4">
|
||||
<div class="flex space-x-2">
|
||||
<button @click="filterType = null; loadTasks()"
|
||||
:class="filterType === null ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
All Tasks
|
||||
</button>
|
||||
<button @click="filterType = 'import'; loadTasks()"
|
||||
:class="filterType === 'import' ? 'bg-blue-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Imports
|
||||
</button>
|
||||
<button @click="filterType = 'test_run'; loadTasks()"
|
||||
:class="filterType === 'test_run' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Test Runs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tasks Table -->
|
||||
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Recent Tasks
|
||||
</h4>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full whitespace-nowrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Type</th>
|
||||
<th class="px-4 py-3">Description</th>
|
||||
<th class="px-4 py-3">Started</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
<th class="px-4 py-3">Triggered By</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="task in tasks" :key="task.task_type + '-' + task.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-700 dark:text-blue-100': task.task_type === 'import',
|
||||
'bg-purple-100 text-purple-700 dark:bg-purple-700 dark:text-purple-100': task.task_type === 'test_run'
|
||||
}"
|
||||
x-text="task.task_type === 'import' ? 'Import' : 'Test Run'">
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-medium truncate max-w-xs" x-text="task.description"></p>
|
||||
<p x-show="task.error_message" class="text-xs text-red-500 truncate max-w-xs" x-text="task.error_message"></p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm" x-text="task.started_at ? new Date(task.started_at).toLocaleString() : 'N/A'"></td>
|
||||
<td class="px-4 py-3 text-sm" x-text="formatDuration(task.duration_seconds)"></td>
|
||||
<td class="px-4 py-3 text-sm" x-text="task.triggered_by || 'system'"></td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-700 dark:bg-green-700 dark:text-green-100': task.status === 'completed' || task.status === 'passed',
|
||||
'bg-yellow-100 text-yellow-700 dark:bg-yellow-700 dark:text-yellow-100': task.status === 'running' || task.status === 'processing' || task.status === 'pending',
|
||||
'bg-red-100 text-red-700 dark:bg-red-700 dark:text-red-100': task.status === 'failed' || task.status === 'error',
|
||||
'bg-orange-100 text-orange-700 dark:bg-orange-700 dark:text-orange-100': task.status === 'completed_with_errors',
|
||||
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-100': !['completed', 'passed', 'running', 'processing', 'pending', 'failed', 'error', 'completed_with_errors'].includes(task.status)
|
||||
}"
|
||||
x-text="task.status">
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<template x-if="tasks.length === 0">
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('collection', 'w-12 h-12 mx-auto mb-2 text-gray-400')"></span>
|
||||
<p>No tasks found</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -103,6 +103,7 @@
|
||||
<!-- Platform Monitoring Section -->
|
||||
{{ section_header('Platform Monitoring', 'monitoring') }}
|
||||
{% call section_content('monitoring') %}
|
||||
{{ menu_item('background-tasks', '/admin/background-tasks', 'collection', 'Background Tasks') }}
|
||||
{{ menu_item('imports', '/admin/imports', 'cube', 'Import Jobs') }}
|
||||
{{ menu_item('logs', '/admin/logs', 'document-text', 'Application Logs') }}
|
||||
{% endcall %}
|
||||
|
||||
128
static/admin/js/background-tasks.js
Normal file
128
static/admin/js/background-tasks.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Background Tasks Monitoring Component
|
||||
* Manages the background tasks monitoring page
|
||||
*/
|
||||
|
||||
// Use centralized logger
|
||||
const backgroundTasksLog = window.LogConfig.createLogger('BACKGROUND-TASKS');
|
||||
|
||||
function backgroundTasks() {
|
||||
return {
|
||||
// Extend base data
|
||||
...data(),
|
||||
|
||||
// Set current page for navigation
|
||||
currentPage: 'background-tasks',
|
||||
|
||||
// Page-specific data
|
||||
loading: false,
|
||||
error: null,
|
||||
filterType: null,
|
||||
pollInterval: null,
|
||||
|
||||
// Statistics
|
||||
stats: {
|
||||
total_tasks: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
tasks_today: 0,
|
||||
avg_duration_seconds: null,
|
||||
import_jobs: {},
|
||||
test_runs: {}
|
||||
},
|
||||
|
||||
// Tasks
|
||||
tasks: [],
|
||||
runningTasks: [],
|
||||
|
||||
async init() {
|
||||
backgroundTasksLog.info('Initializing background tasks monitor');
|
||||
await this.loadStats();
|
||||
await this.loadTasks();
|
||||
await this.loadRunningTasks();
|
||||
|
||||
// Poll for updates every 5 seconds
|
||||
this.pollInterval = setInterval(() => {
|
||||
this.loadRunningTasks();
|
||||
if (this.runningTasks.length > 0) {
|
||||
this.loadStats();
|
||||
}
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
}
|
||||
},
|
||||
|
||||
async loadStats() {
|
||||
try {
|
||||
const stats = await apiClient.get('/admin/background-tasks/tasks/stats');
|
||||
this.stats = stats;
|
||||
backgroundTasksLog.info('Stats loaded:', stats);
|
||||
} catch (err) {
|
||||
backgroundTasksLog.error('Failed to load stats:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async loadTasks() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
let url = '/admin/background-tasks/tasks?limit=50';
|
||||
if (this.filterType) {
|
||||
url += `&task_type=${this.filterType}`;
|
||||
}
|
||||
|
||||
const tasks = await apiClient.get(url);
|
||||
this.tasks = tasks;
|
||||
backgroundTasksLog.info('Tasks loaded:', tasks.length);
|
||||
} catch (err) {
|
||||
backgroundTasksLog.error('Failed to load tasks:', err);
|
||||
this.error = err.message;
|
||||
|
||||
if (err.message.includes('Unauthorized')) {
|
||||
window.location.href = '/admin/login';
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadRunningTasks() {
|
||||
try {
|
||||
const running = await apiClient.get('/admin/background-tasks/tasks/running');
|
||||
this.runningTasks = running;
|
||||
|
||||
// Update elapsed time for running tasks
|
||||
const now = new Date();
|
||||
this.runningTasks.forEach(task => {
|
||||
if (task.started_at) {
|
||||
const started = new Date(task.started_at);
|
||||
task.duration_seconds = (now - started) / 1000;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
backgroundTasksLog.error('Failed to load running tasks:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
await this.loadStats();
|
||||
await this.loadTasks();
|
||||
await this.loadRunningTasks();
|
||||
},
|
||||
|
||||
formatDuration(seconds) {
|
||||
if (seconds === null || seconds === undefined) return 'N/A';
|
||||
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`;
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = Math.round(seconds % 60);
|
||||
return `${minutes}m ${secs}s`;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user