feat(tasks): Add task overview page with active, completed, and failed task summaries

This commit is contained in:
Ross
2026-05-17 14:30:38 +01:00
parent bbf7542451
commit ab9847e22e
4 changed files with 288 additions and 8 deletions
+123
View File
@@ -214,6 +214,21 @@
data-bs-target="#truncate-series-modal">
<i class="bi bi-scissors"></i> Truncate series
</button>
<div class="card mt-2">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="small fw-semibold">Background Task Progress</span>
<div class="d-flex gap-2">
<a class="btn btn-outline-info btn-sm" href="{% url 'atlas:task_overview' %}">Task Overview</a>
<button id="clear-recon-monitors-btn" type="button" class="btn btn-outline-secondary btn-sm">Clear</button>
</div>
</div>
<div class="card-body p-2">
<div id="persistent-recon-tasks" class="d-flex flex-column gap-2">
<div id="persistent-recon-empty" class="small text-muted">No tracked reconstruction tasks yet.</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
@@ -399,6 +414,7 @@
const totalImages = {{ image_url_array_and_count.1 }};
const viewerHeightKey = `series_viewer_height_${seriesPk}`;
const truncateViewerHeightKey = `truncate_viewer_height_${seriesPk}`;
const reconTaskStorageKey = `series_recon_tasks_${seriesPk}`;
// Restore viewer height from localStorage
function restoreViewerHeight() {
@@ -465,6 +481,94 @@
syncOptimizeBounds();
}
function getStoredReconTasks() {
try {
const raw = localStorage.getItem(reconTaskStorageKey);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
}
function setStoredReconTasks(items) {
localStorage.setItem(reconTaskStorageKey, JSON.stringify(items));
}
function upsertStoredReconTask(taskId, statusUrl) {
const current = getStoredReconTasks();
const existing = current.find((t) => t.taskId === taskId);
if (existing) {
existing.statusUrl = statusUrl;
existing.addedAt = Date.now();
} else {
current.unshift({ taskId, statusUrl, addedAt: Date.now() });
}
setStoredReconTasks(current.slice(0, 25));
}
function removeStoredReconTask(taskId) {
const filtered = getStoredReconTasks().filter((t) => t.taskId !== taskId);
setStoredReconTasks(filtered);
}
function createPersistentTaskMonitor(taskId, statusUrl) {
const wrap = document.getElementById('persistent-recon-tasks');
const empty = document.getElementById('persistent-recon-empty');
if (!wrap) {
return;
}
const cardId = `persistent-recon-card-${taskId}`;
if (document.getElementById(cardId)) {
return;
}
empty?.classList.add('d-none');
const card = document.createElement('div');
card.id = cardId;
card.className = 'border rounded p-2 bg-body-tertiary';
const statusId = `persistent-recon-status-${taskId}`;
const statusUrlWithTarget = `${statusUrl}?target_id=${statusId}`;
card.innerHTML = `
<div class="d-flex align-items-center justify-content-between mb-1">
<div class="small fw-semibold">Recon task ${taskId}</div>
<button type="button" class="btn btn-outline-secondary btn-sm" data-remove-task-id="${taskId}">Hide</button>
</div>
<div id="${statusId}" hx-get="${statusUrlWithTarget}" hx-trigger="load, every 4s" hx-swap="outerHTML">
<div class="small text-muted">Loading task status...</div>
</div>
`;
wrap.prepend(card);
card.querySelector('[data-remove-task-id]')?.addEventListener('click', () => {
removeStoredReconTask(taskId);
card.remove();
if (!wrap.querySelector('[id^="persistent-recon-card-"]')) {
empty?.classList.remove('d-none');
}
});
if (window.htmx) {
window.htmx.process(card);
}
}
function renderStoredReconTaskMonitors() {
const tasks = getStoredReconTasks();
tasks.forEach((task) => {
if (task?.taskId && task?.statusUrl) {
createPersistentTaskMonitor(task.taskId, task.statusUrl);
}
});
}
function getTruncateViewerElement() {
return document.getElementById(truncateApiKey);
}
@@ -612,6 +716,16 @@
document.body.addEventListener('htmx:afterSwap', function (event) {
if (event.target && event.target.id === 'series-optimize-feedback') {
const reconPayload = document.getElementById('recon-task-payload');
if (reconPayload) {
const taskId = reconPayload.dataset.taskId;
const statusUrl = reconPayload.dataset.statusUrl;
if (taskId && statusUrl) {
upsertStoredReconTask(taskId, statusUrl);
createPersistentTaskMonitor(taskId, statusUrl);
}
}
const truncatePayload = document.getElementById('truncate-range-payload');
if (truncatePayload) {
const start = parseInt(truncatePayload.dataset.start || '1', 10);
@@ -667,6 +781,15 @@
});
syncOptimizeBounds();
renderStoredReconTaskMonitors();
document.getElementById('clear-recon-monitors-btn')?.addEventListener('click', () => {
setStoredReconTasks([]);
const wrap = document.getElementById('persistent-recon-tasks');
const empty = document.getElementById('persistent-recon-empty');
wrap?.querySelectorAll('[id^="persistent-recon-card-"]')?.forEach((node) => node.remove());
empty?.classList.remove('d-none');
});
$("#add-finding-button").click(() => {
$("#hidden-form").show()
+102
View File
@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% load static %}
{% load django_htmx %}
{% block title %}
Atlas Task Overview
{% endblock title %}
{% block content %}
<div class="container-xl py-3">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<h2 class="h4 mb-0">Atlas Task Overview</h2>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="?status=all">All</a>
<a class="btn btn-outline-warning btn-sm" href="?status=active">Active</a>
<a class="btn btn-outline-success btn-sm" href="?status=completed">Completed</a>
<a class="btn btn-outline-danger btn-sm" href="?status=failed">Failed</a>
</div>
</div>
{% if backend_missing %}
<div class="alert alert-warning">
django_tasks_db backend is not available in this environment. Install and migrate it to see task records.
</div>
{% endif %}
<div class="row g-2 mb-3">
<div class="col-md-4">
<div class="card border-warning">
<div class="card-body py-2">
<div class="small text-muted">Active</div>
<div class="h4 mb-0 text-warning">{{ counts.active }}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-success">
<div class="card-body py-2">
<div class="small text-muted">Completed</div>
<div class="h4 mb-0 text-success">{{ counts.completed }}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-danger">
<div class="card-body py-2">
<div class="small text-muted">Failed</div>
<div class="h4 mb-0 text-danger">{{ counts.failed }}</div>
</div>
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Task</th>
<th>Status</th>
<th>Queue</th>
<th>Enqueued</th>
<th>Started</th>
<th>Finished</th>
<th>Worker</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td>
<div class="small fw-semibold">{{ task.task_path }}</div>
<div class="small text-muted">{{ task.id }}</div>
</td>
<td>
{% if task.status == "FAILED" %}
<span class="badge bg-danger">{{ task.status }}</span>
{% elif task.status == "SUCCESSFUL" %}
<span class="badge bg-success">{{ task.status }}</span>
{% elif task.status == "RUNNING" or task.status == "READY" %}
<span class="badge bg-warning text-dark">{{ task.status }}</span>
{% else %}
<span class="badge bg-secondary">{{ task.status }}</span>
{% endif %}
</td>
<td>{{ task.queue_name }}</td>
<td class="small">{{ task.enqueued_at|date:"Y-m-d H:i:s" }}</td>
<td class="small">{{ task.started_at|date:"Y-m-d H:i:s" }}</td>
<td class="small">{{ task.finished_at|date:"Y-m-d H:i:s" }}</td>
<td class="small">{{ task.worker_ids }}</td>
<td class="small text-danger">{{ task.exception_class_path|default:"" }}</td>
</tr>
{% empty %}
<tr>
<td colspan="8" class="text-muted">No tasks found for this filter.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock content %}
+1
View File
@@ -552,6 +552,7 @@ urlpatterns = [
path("structure/create", views.StructureCreate.as_view(), name="structure_create"),
path("series/<int:pk>/thumbnail/fail", views.series_thumbnail_fail, name="series_thumbnail_fail"),
path("series/<int:pk>/thumbnail", views.series_thumbnail, name="series_thumbnail"),
path("tasks/overview/", views.task_overview, name="task_overview"),
# TODO: case context series viewing (so that we can view series in the context of a case)
path("series/<int:pk>", views.series_detail, name="series_detail"),
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
+62 -8
View File
@@ -175,6 +175,11 @@ except ImportError:
FAILED = "FAILED"
SUCCESSFUL = "SUCCESSFUL"
try:
from django_tasks_db.models import DBTaskResult
except ImportError:
DBTaskResult = None
from django_tables2 import SingleTableView, SingleTableMixin
from django_filters.views import FilterView
@@ -712,6 +717,7 @@ def series_optimize_htmx(request, series_id):
'hx-trigger="load, every 2s" hx-swap="outerHTML">'
'<div class="d-flex align-items-center gap-2"><span class="spinner-border spinner-border-sm text-primary" role="status"></span>'
'<span class="small text-muted">Starting reconstruction task...</span></div></div>'
f'<div id="recon-task-payload" data-task-id="{escape(str(task_result.id))}" data-status-url="{escape(reverse("atlas:series_reconstruct_status", kwargs={"series_id": series.pk, "task_id": task_result.id}))}"></div>'
)
)
@@ -722,18 +728,20 @@ def series_optimize_htmx(request, series_id):
def series_reconstruct_status_htmx(request, series_id, task_id):
"""HTMX endpoint returning progress for a queued series reconstruction task."""
series = get_object_or_404(Series, pk=series_id)
target_id = (request.GET.get("target_id") or "recon-task-status").strip() or "recon-task-status"
target_id = escape(target_id)
if not series.check_user_can_edit(request.user):
return HttpResponse('<div class="alert alert-danger mb-0">Permission denied</div>')
return HttpResponse(f'<div id="{target_id}" class="alert alert-danger mb-0">Permission denied</div>')
try:
task_result = series_reconstruct_task.get_result(task_id)
except NotImplementedError:
return HttpResponse(
'<div id="recon-task-status" class="alert alert-warning mb-0">Task backend does not support result polling. Configure a backend with result retrieval support.</div>'
f'<div id="{target_id}" class="alert alert-warning mb-0">Task backend does not support result polling. Configure a backend with result retrieval support.</div>'
)
except Exception:
return HttpResponse('<div id="recon-task-status" class="alert alert-danger mb-0">Task result not found.</div>')
return HttpResponse(f'<div id="{target_id}" class="alert alert-danger mb-0">Task result not found.</div>')
task_result.refresh()
state = task_result.status
@@ -748,7 +756,7 @@ def series_reconstruct_status_htmx(request, series_id, task_id):
if state in (TaskResultStatus.READY, TaskResultStatus.RUNNING):
return HttpResponse(
(
f'<div id="recon-task-status" hx-get="{poll_url}" hx-trigger="every 2s" hx-swap="outerHTML">'
f'<div id="{target_id}" hx-get="{poll_url}?target_id={target_id}" hx-trigger="every 2s" hx-swap="outerHTML">'
f'<div class="small text-muted mb-1">{message}</div>'
'<div class="progress" style="height: 10px;">'
f'<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: {pct}%" '
@@ -775,24 +783,70 @@ def series_reconstruct_status_htmx(request, series_id, task_id):
)
return HttpResponse(
(
'<div id="recon-task-status" class="alert alert-success mb-0">'
f'<div id="{target_id}" class="alert alert-success mb-0">'
f'Reconstruction complete. Created <strong>{len(created_series)}</strong> series: {links}'
'</div>'
)
)
return HttpResponse('<div id="recon-task-status" class="alert alert-warning mb-0">Reconstruction finished but no output series were created.</div>')
return HttpResponse(f'<div id="{target_id}" class="alert alert-warning mb-0">Reconstruction finished but no output series were created.</div>')
if state == TaskResultStatus.FAILED:
err = "Unknown failure"
if task_result.errors:
err = escape(task_result.errors[-1].traceback.splitlines()[-1])
return HttpResponse(
f'<div id="recon-task-status" class="alert alert-danger mb-0">Reconstruction failed: {err}</div>'
f'<div id="{target_id}" class="alert alert-danger mb-0">Reconstruction failed: {err}</div>'
)
return HttpResponse(
f'<div id="recon-task-status" class="alert alert-secondary mb-0">Task state: {escape(str(state))}</div>'
f'<div id="{target_id}" class="alert alert-secondary mb-0">Task state: {escape(str(state))}</div>'
)
@login_required
@user_is_atlas_editor
def task_overview(request):
"""Atlas admin-style task overview page with active/completed/failed task summaries."""
if DBTaskResult is None:
return render(
request,
"atlas/task_overview.html",
{
"tasks": [],
"counts": {"active": 0, "completed": 0, "failed": 0},
"backend_missing": True,
},
)
selected_status = (request.GET.get("status") or "all").strip().lower()
status_map = {
"active": ["READY", "RUNNING"],
"completed": ["SUCCESSFUL"],
"failed": ["FAILED"],
}
base_qs = DBTaskResult.objects.all().order_by("-enqueued_at")
if selected_status in status_map:
tasks = base_qs.filter(status__in=status_map[selected_status])[:300]
else:
tasks = base_qs[:300]
counts = {
"active": DBTaskResult.objects.filter(status__in=["READY", "RUNNING"]).count(),
"completed": DBTaskResult.objects.filter(status="SUCCESSFUL").count(),
"failed": DBTaskResult.objects.filter(status="FAILED").count(),
}
return render(
request,
"atlas/task_overview.html",
{
"tasks": tasks,
"counts": counts,
"selected_status": selected_status,
"backend_missing": False,
},
)