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:
2025-12-12 23:28:16 +01:00
parent a8d1c351fc
commit 280e719a7a
7 changed files with 715 additions and 2 deletions

View File

@@ -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"])

View 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

View File

@@ -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,

View 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()

View 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 &rarr;
</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 &rarr;
</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 %}

View File

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

View 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`;
}
};
}