feat(tasks): Add task overview page with active, completed, and failed task summaries
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user