issue-27-dashboard-information-architecture #33

Merged
parkel merged 2 commits from issue-27-dashboard-information-architecture into master 2026-05-21 13:29:19 +02:00
4 changed files with 235 additions and 89 deletions
Showing only changes of commit 9412feaa58 - Show all commits

View File

@@ -285,9 +285,63 @@
cursor: not-allowed; cursor: not-allowed;
} }
.inline-form { margin: 0; } .inline-form { margin: 0; }
.status-overview { .dashboard-priority-grid {
display: grid;
gap: 14px;
grid-template-columns: minmax(280px, 1.3fr) repeat(2, minmax(240px, 1fr));
margin-bottom: 20px;
}
.priority-panel {
display: grid;
gap: 12px;
margin-bottom: 0;
}
.priority-panel > h2:first-child { margin-bottom: 0; }
.action-list,
.activity-list,
.schedule-list {
display: grid; display: grid;
gap: 8px; gap: 8px;
}
.action-row,
.activity-row,
.schedule-row {
align-items: start;
border: 1px solid var(--border);
border-radius: 7px;
color: inherit;
display: grid;
gap: 9px;
padding: 10px;
text-decoration: none;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.action-row,
.activity-row {
grid-template-columns: max-content minmax(0, 1fr);
}
.schedule-row {
grid-template-columns: minmax(0, 1fr) max-content;
}
.action-row:hover,
.activity-row:hover,
.schedule-row:hover {
background: var(--panel-subtle);
border-color: var(--border-strong);
box-shadow: var(--shadow-sm);
}
.action-row.failed { border-color: #e8b4b4; background: #fff7f7; }
.action-row.warning { border-color: #e7cf8a; background: #fffaf0; }
.action-row span:not(.status),
.activity-row span:not(.status),
.schedule-row span {
display: grid;
gap: 2px;
min-width: 0;
}
.schedule-time {
justify-items: end;
text-align: right;
} }
.status-summary { .status-summary {
align-items: center; align-items: center;
@@ -312,12 +366,6 @@
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
transform: translateY(-1px); transform: translateY(-1px);
} }
.status-summary .summary-action {
color: var(--muted-strong);
font-size: 12px;
font-weight: 650;
margin-left: auto;
}
.operator-state { .operator-state {
align-items: center; align-items: center;
display: flex; display: flex;
@@ -564,6 +612,9 @@
} }
.page-header .actions { justify-content: flex-start; } .page-header .actions { justify-content: flex-start; }
.two-col { grid-template-columns: 1fr; } .two-col { grid-template-columns: 1fr; }
.dashboard-priority-grid { grid-template-columns: 1fr; }
.schedule-row { grid-template-columns: 1fr; }
.schedule-time { justify-items: start; text-align: left; }
.host-card-header { display: grid; } .host-card-header { display: grid; }
.host-card-status { justify-content: flex-start; max-width: none; } .host-card-status { justify-content: flex-start; max-width: none; }
.host-card-layout { grid-template-columns: 1fr; } .host-card-layout { grid-template-columns: 1fr; }

View File

@@ -32,58 +32,100 @@
</section> </section>
{% endif %} {% endif %}
<section class="grid" aria-label="Summary"> <section class="dashboard-priority-grid" aria-label="Operator priorities">
<a class="metric metric-link" href="#hosts"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></a> <article class="panel priority-panel">
<a class="metric metric-link" href="{% url 'schedules_list' %}"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></a> <h2>Required Action</h2>
<a class="metric metric-link" href="{% url 'snapshots_list' %}"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></a> {% if action_items %}
<a class="metric metric-link" href="{% url 'runs_list' %}"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></a> <div class="action-list">
<a class="metric metric-link {% if counts.queued_runs %}queued{% endif %}" href="{% url 'runs_list' %}?status=queued"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></a> {% for item in action_items %}
<a class="metric metric-link {% if counts.running_runs %}running{% endif %}" href="{% url 'runs_list' %}?status=running"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></a> <a class="action-row {{ item.status }}" href="{{ item.url }}">
<a class="metric metric-link {% if counts.warning_runs %}warning{% endif %}" href="{% url 'runs_list' %}?status=warning&amp;review=needed"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></a> <span class="status {{ item.status }}">{{ item.label }}</span>
<a class="metric metric-link {% if counts.failed_runs %}failed{% endif %}" href="{% url 'runs_list' %}?status=failed&amp;review=needed"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></a> <span>
</section> <strong>{{ item.host.host }}</strong>
<span class="muted">{{ item.message }}</span>
<section class="panel"> </span>
<h2>Operational Status</h2>
{% if counts.failed_runs or counts.warning_runs or counts.running_runs or counts.queued_runs %}
<div class="status-overview">
{% if counts.failed_runs %}
<a class="status-summary failed" href="{% url 'runs_list' %}?status=failed&amp;review=needed">
<span class="status failed">failed</span>
<strong>{{ counts.failed_runs }} failed run{{ counts.failed_runs|pluralize }} need{{ counts.failed_runs|pluralize:"s," }} review.</strong>
<span class="summary-action">Review failed runs</span>
</a>
{% endif %}
{% if counts.warning_runs %}
<a class="status-summary warning" href="{% url 'runs_list' %}?status=warning&amp;review=needed">
<span class="status warning">warning</span>
<strong>{{ counts.warning_runs }} run{{ counts.warning_runs|pluralize }} completed with warnings.</strong>
<span class="summary-action">Review warnings</span>
</a> </a>
{% endfor %}
</div>
{% elif counts.hosts %}
<p><span class="status ok">ok</span> No queued, running, unreviewed warning/failed runs, or retention warnings.</p>
{% else %}
<p class="muted">Add a host to start tracking backup status here.</p>
{% endif %} {% endif %}
{% if counts.running_runs or counts.queued_runs %}
<div class="operator-state">
{% if counts.running_runs %} {% if counts.running_runs %}
<a class="status-summary running" href="{% url 'runs_list' %}?status=running"> <a class="status-summary running" href="{% url 'runs_list' %}?status=running">
<span class="status running">running</span> <span class="status running">running</span>
<strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong> <strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong>
<span class="summary-action">View running runs</span>
</a> </a>
{% endif %} {% endif %}
{% if counts.queued_runs %} {% if counts.queued_runs %}
<a class="status-summary queued" href="{% url 'runs_list' %}?status=queued"> <a class="status-summary queued" href="{% url 'runs_list' %}?status=queued">
<span class="status queued">queued</span> <span class="status queued">queued</span>
<strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting for the worker.</strong> <strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting.</strong>
<span class="summary-action">View queued runs</span>
</a> </a>
{% endif %} {% endif %}
</div> </div>
{% elif counts.hosts %}
<p><span class="status ok">ok</span> No queued, running, or unreviewed warning/failed runs.</p>
{% else %}
<p class="muted">Add a host to start tracking backup status here.</p>
{% endif %} {% endif %}
</article>
<article class="panel priority-panel">
<h2>Next Scheduled Work <a class="button-link secondary compact" href="{% url 'schedules_list' %}">View all</a></h2>
{% if next_schedule_rows %}
<div class="schedule-list">
{% for row in next_schedule_rows %}
<a class="schedule-row" href="{% url 'host_detail' row.schedule.host.host %}">
<span>
<strong>{{ row.schedule.host.host }}</strong>
<span class="muted">{{ row.schedule.cron_expr }}</span>
</span>
<span class="schedule-time">
{% if row.next_run_at %}
{{ row.next_run_at|date:"Y-m-d H:i T" }}
<span class="muted">{{ scheduler_timezone }}</span>
{% else %}
<span class="muted">not due</span>
{% endif %}
</span>
</a>
{% endfor %}
</div>
{% else %}
<p class="muted">No enabled schedules yet.</p>
{% endif %}
</article>
<article class="panel priority-panel">
<h2>Recent Activity <a class="button-link secondary compact" href="{% url 'runs_list' %}">View all</a></h2>
{% if recent_runs %}
<div class="activity-list">
{% for run in recent_runs %}
<a class="activity-row" href="{% url 'run_detail' run.id %}">
<span class="status {{ run.status }}">{{ run.status }}</span>
<span>
<strong>Run {{ run.id }}</strong>
<span class="muted">{{ run.host.host }} · {{ run.run_type }} · {{ run.started_at|default:run.created_at }}</span>
</span>
</a>
{% endfor %}
</div>
{% else %}
<p class="muted">No backup runs recorded yet.</p>
{% endif %}
</article>
</section> </section>
<section class="panel" id="hosts"> <section class="grid" aria-label="Summary">
<a class="metric metric-link" href="#hosts"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></a>
<a class="metric metric-link" href="{% url 'schedules_list' %}"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></a>
<a class="metric metric-link" href="{% url 'snapshots_list' %}"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></a>
<a class="metric metric-link" href="{% url 'runs_list' %}"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></a>
<a class="metric metric-link {% if counts.warning_runs %}warning{% endif %}" href="{% url 'runs_list' %}?status=warning&amp;review=needed"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></a>
<a class="metric metric-link {% if counts.failed_runs %}failed{% endif %}" href="{% url 'runs_list' %}?status=failed&amp;review=needed"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></a>
</section>
<section class="panel">
<h2>Backup Trends</h2> <h2>Backup Trends</h2>
{% if stats_summary.runs_sampled %} {% if stats_summary.runs_sampled %}
<div class="insight-grid" aria-label="Backup trends"> <div class="insight-grid" aria-label="Backup trends">
@@ -145,7 +187,7 @@
{% endif %} {% endif %}
</section> </section>
<section class="panel"> <section class="panel" id="hosts">
<h2>Hosts</h2> <h2>Hosts</h2>
<div class="host-list"> <div class="host-list">
{% for host in hosts %} {% for host in hosts %}
@@ -269,31 +311,4 @@
</div> </div>
</section> </section>
<section class="panel">
<h2>Latest Runs <a class="button-link secondary" href="{% url 'runs_list' %}">View all</a></h2>
<table>
<thead>
<tr>
<th>Host</th>
<th>Status</th>
<th>Started</th>
<th>Ended</th>
<th>Snapshot</th>
</tr>
</thead>
<tbody>
{% for run in latest_runs %}
<tr>
<td><a href="{% url 'host_detail' run.host.host %}">{{ run.host.host }}</a></td>
<td><a href="{% url 'run_detail' run.id %}"><span class="status {{ run.status }}">{{ run.status }}</span></a></td>
<td>{{ run.started_at|default:"" }}</td>
<td>{{ run.ended_at|default:"" }}</td>
<td>{% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="muted">No backup runs recorded yet.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %} {% endblock %}

View File

@@ -120,20 +120,24 @@ class ViewTests(TestCase):
self.assertContains(response, "running 1") self.assertContains(response, "running 1")
self.assertContains(response, "warning 1") self.assertContains(response, "warning 1")
self.assertContains(response, "failed 1") self.assertContains(response, "failed 1")
self.assertContains(response, "Operational Status") self.assertContains(response, "Required Action")
self.assertContains(response, "1 failed run needs review.") self.assertContains(response, "Failed runs")
self.assertContains(response, "1 run completed with warnings.") self.assertContains(response, "1 failed run(s) need review.")
self.assertContains(response, "1 run(s) completed with warnings.")
self.assertContains(response, "1 backup run in progress.") self.assertContains(response, "1 backup run in progress.")
self.assertContains(response, "1 backup run waiting for the worker.") self.assertContains(response, "1 backup run waiting.")
self.assertContains(response, "Review failed runs") self.assertContains(response, "Next Scheduled Work")
self.assertContains(response, "Review warnings") self.assertContains(response, "Recent Activity")
self.assertContains(response, "View running runs")
self.assertContains(response, "View queued runs")
self.assertContains(response, f'href="{reverse("runs_list")}"', html=False) self.assertContains(response, f'href="{reverse("runs_list")}"', html=False)
self.assertContains(response, f'href="{reverse("runs_list")}?status=queued"', html=False) self.assertContains(response, f'href="{reverse("runs_list")}?status=queued"', html=False)
self.assertContains(response, f'href="{reverse("runs_list")}?status=running"', html=False) self.assertContains(response, f'href="{reverse("runs_list")}?status=running"', html=False)
self.assertContains(response, f'href="{reverse("runs_list")}?status=warning&amp;review=needed"', html=False) self.assertContains(response, f'href="{reverse("runs_list")}?status=warning&amp;review=needed"', html=False)
self.assertContains(response, f'href="{reverse("runs_list")}?status=failed&amp;review=needed"', html=False) self.assertContains(response, f'href="{reverse("runs_list")}?status=failed&amp;review=needed"', html=False)
self.assertContains(
response,
f'href="{reverse("runs_list")}?host=web-01&amp;status=failed&amp;review=needed"',
html=False,
)
self.assertContains(response, f'href="{reverse("snapshots_list")}"', html=False) self.assertContains(response, f'href="{reverse("snapshots_list")}"', html=False)
self.assertContains(response, f'href="{reverse("schedules_list")}"', html=False) self.assertContains(response, f'href="{reverse("schedules_list")}"', html=False)
@@ -208,8 +212,8 @@ class ViewTests(TestCase):
response = self.client.get(reverse("dashboard")) response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Operational Status") self.assertContains(response, "Required Action")
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.") self.assertContains(response, "No queued, running, unreviewed warning/failed runs, or retention warnings.")
def test_runs_list_filters_by_status_and_review(self) -> None: def test_runs_list_filters_by_status_and_review(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
@@ -342,7 +346,7 @@ class ViewTests(TestCase):
response = self.client.get(reverse("dashboard")) response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.") self.assertContains(response, "No queued, running, unreviewed warning/failed runs, or retention warnings.")
self.assertNotContains(response, "failed 1") self.assertNotContains(response, "failed 1")
self.assertNotContains(response, "warning 1") self.assertNotContains(response, "warning 1")

View File

@@ -4,7 +4,9 @@ import json
import shlex import shlex
import shutil import shutil
import subprocess import subprocess
from datetime import datetime, timezone as datetime_timezone
from pathlib import Path from pathlib import Path
from urllib.parse import urlencode
from django.contrib import messages from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import staff_member_required
@@ -12,6 +14,7 @@ from django.conf import settings
from django.http import FileResponse, Http404 from django.http import FileResponse, Http404
from django.db.models import Count, Q from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
@@ -74,13 +77,18 @@ def dashboard(request):
) )
host_config.next_run_at = _next_run_for_host(host_config) host_config.next_run_at = _next_run_for_host(host_config)
host_config.retention_warning = _retention_warning_for_host(host_config, _schedule_for_host(host_config)) host_config.retention_warning = _retention_warning_for_host(host_config, _schedule_for_host(host_config))
action_items = _dashboard_action_items(hosts)
next_schedule_rows = _dashboard_next_schedule_rows()
recent_runs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at", "-id")[:6]
stats_summary = collect_dashboard_stats(hosts=hosts, global_config=global_config) stats_summary = collect_dashboard_stats(hosts=hosts, global_config=global_config)
context = { context = {
"hosts": hosts, "hosts": hosts,
"global_config": global_config, "global_config": global_config,
"stats_summary": stats_summary, "stats_summary": stats_summary,
"scheduler_timezone": timezone.get_current_timezone_name(), "scheduler_timezone": timezone.get_current_timezone_name(),
"latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10], "action_items": action_items,
"next_schedule_rows": next_schedule_rows,
"recent_runs": recent_runs,
"counts": { "counts": {
"global_configs": GlobalConfig.objects.count(), "global_configs": GlobalConfig.objects.count(),
"hosts": HostConfig.objects.count(), "hosts": HostConfig.objects.count(),
@@ -104,6 +112,74 @@ def dashboard(request):
return render(request, "pobsync_backend/dashboard.html", context) return render(request, "pobsync_backend/dashboard.html", context)
def _dashboard_action_items(hosts: list[HostConfig]) -> list[dict[str, object]]:
action_items: list[dict[str, object]] = []
for host_config in hosts:
if host_config.failed_run_count:
action_items.append(
{
"host": host_config,
"status": BackupRun.Status.FAILED,
"label": "Failed runs",
"message": f"{host_config.failed_run_count} failed run(s) need review.",
"url": _runs_list_url(host=host_config.host, status="failed", review="needed"),
}
)
if host_config.warning_run_count:
action_items.append(
{
"host": host_config,
"status": BackupRun.Status.WARNING,
"label": "Warnings",
"message": f"{host_config.warning_run_count} run(s) completed with warnings.",
"url": _runs_list_url(host=host_config.host, status="warning", review="needed"),
}
)
if host_config.retention_warning.has_warning:
action_items.append(
{
"host": host_config,
"status": BackupRun.Status.WARNING,
"label": "Retention",
"message": _retention_warning_summary(host_config.retention_warning),
"url": reverse("host_detail", args=[host_config.host]),
}
)
return action_items
def _runs_list_url(**params: str) -> str:
return f"{reverse('runs_list')}?{urlencode(params)}"
def _dashboard_next_schedule_rows() -> list[dict[str, object]]:
rows = []
schedules = ScheduleConfig.objects.select_related("host").filter(enabled=True).order_by("host__host")
for schedule in schedules[:200]:
rows.append(
{
"schedule": schedule,
"next_run_at": _next_run_for_schedule(schedule, schedule.host),
}
)
rows.sort(key=lambda row: row["next_run_at"] or datetime.max.replace(tzinfo=datetime_timezone.utc))
return rows[:6]
def _retention_warning_summary(retention_warning) -> str:
parts = []
if retention_warning.prune_exceeded:
parts.append(
f"Scheduled prune would delete {retention_warning.delete_count} snapshot(s), "
f"above max {retention_warning.max_delete}."
)
if retention_warning.incomplete_count:
parts.append(f"{retention_warning.incomplete_count} incomplete snapshot(s) need review.")
if retention_warning.error:
parts.append(str(retention_warning.error))
return " ".join(parts)
@staff_member_required @staff_member_required
def changelog(request): def changelog(request):
changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md" changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md"