From ab9847e22e8040198c7c12dae861ce46396e681d Mon Sep 17 00:00:00 2001 From: Ross Date: Sun, 17 May 2026 14:30:38 +0100 Subject: [PATCH] feat(tasks): Add task overview page with active, completed, and failed task summaries --- atlas/templates/atlas/series_viewer.html | 123 +++++++++++++++++++++++ atlas/templates/atlas/task_overview.html | 102 +++++++++++++++++++ atlas/urls.py | 1 + atlas/views.py | 70 +++++++++++-- 4 files changed, 288 insertions(+), 8 deletions(-) create mode 100644 atlas/templates/atlas/task_overview.html diff --git a/atlas/templates/atlas/series_viewer.html b/atlas/templates/atlas/series_viewer.html index e76869fe..d2331315 100755 --- a/atlas/templates/atlas/series_viewer.html +++ b/atlas/templates/atlas/series_viewer.html @@ -214,6 +214,21 @@ data-bs-target="#truncate-series-modal"> Truncate series + +
+
+ Background Task Progress +
+ Task Overview + +
+
+
+
+
No tracked reconstruction tasks yet.
+
+
+
{% endif %} @@ -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 = ` +
+
Recon task ${taskId}
+ +
+
+
Loading task status...
+
+ `; + + 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() diff --git a/atlas/templates/atlas/task_overview.html b/atlas/templates/atlas/task_overview.html new file mode 100644 index 00000000..9f76620b --- /dev/null +++ b/atlas/templates/atlas/task_overview.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} +{% load static %} +{% load django_htmx %} + +{% block title %} +Atlas Task Overview +{% endblock title %} + +{% block content %} +
+
+

Atlas Task Overview

+ +
+ + {% if backend_missing %} +
+ django_tasks_db backend is not available in this environment. Install and migrate it to see task records. +
+ {% endif %} + +
+
+
+
+
Active
+
{{ counts.active }}
+
+
+
+
+
+
+
Completed
+
{{ counts.completed }}
+
+
+
+
+
+
+
Failed
+
{{ counts.failed }}
+
+
+
+
+ +
+ + + + + + + + + + + + + + + {% for task in tasks %} + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
TaskStatusQueueEnqueuedStartedFinishedWorkerError
+
{{ task.task_path }}
+
{{ task.id }}
+
+ {% if task.status == "FAILED" %} + {{ task.status }} + {% elif task.status == "SUCCESSFUL" %} + {{ task.status }} + {% elif task.status == "RUNNING" or task.status == "READY" %} + {{ task.status }} + {% else %} + {{ task.status }} + {% endif %} + {{ task.queue_name }}{{ task.enqueued_at|date:"Y-m-d H:i:s" }}{{ task.started_at|date:"Y-m-d H:i:s" }}{{ task.finished_at|date:"Y-m-d H:i:s" }}{{ task.worker_ids }}{{ task.exception_class_path|default:"" }}
No tasks found for this filter.
+
+
+{% endblock content %} diff --git a/atlas/urls.py b/atlas/urls.py index f7b6a979..8dcddfd2 100755 --- a/atlas/urls.py +++ b/atlas/urls.py @@ -552,6 +552,7 @@ urlpatterns = [ path("structure/create", views.StructureCreate.as_view(), name="structure_create"), path("series//thumbnail/fail", views.series_thumbnail_fail, name="series_thumbnail_fail"), path("series//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/", views.series_detail, name="series_detail"), path("series//viewer", views.series_viewer, name="series_viewer"), diff --git a/atlas/views.py b/atlas/views.py index 5e306b8f..c9497fcd 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -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">' '
' 'Starting reconstruction task...
' + f'
' ) ) @@ -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('
Permission denied
') + return HttpResponse(f'
Permission denied
') try: task_result = series_reconstruct_task.get_result(task_id) except NotImplementedError: return HttpResponse( - '
Task backend does not support result polling. Configure a backend with result retrieval support.
' + f'
Task backend does not support result polling. Configure a backend with result retrieval support.
' ) except Exception: - return HttpResponse('
Task result not found.
') + return HttpResponse(f'
Task result not found.
') 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'
' + f'
' f'
{message}
' '
' f'
' + f'
' f'Reconstruction complete. Created {len(created_series)} series: {links}' '
' ) ) - return HttpResponse('
Reconstruction finished but no output series were created.
') + return HttpResponse(f'
Reconstruction finished but no output series were created.
') if state == TaskResultStatus.FAILED: err = "Unknown failure" if task_result.errors: err = escape(task_result.errors[-1].traceback.splitlines()[-1]) return HttpResponse( - f'
Reconstruction failed: {err}
' + f'
Reconstruction failed: {err}
' ) return HttpResponse( - f'
Task state: {escape(str(state))}
' + f'
Task state: {escape(str(state))}
' + ) + + +@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, + }, )