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