From 9412feaa5813f089b6de434d3558f7efd856d4b4 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 13:21:09 +0200 Subject: [PATCH] (ui) Rework dashboard around operator priorities Move required actions, upcoming scheduled work, and recent run activity to the top of the dashboard so the first screen answers what needs attention next. Keep summary metrics, trends, and host cards as supporting drill-down content. Refs #27 --- .../templates/pobsync_backend/base.html | 65 +++++++- .../templates/pobsync_backend/dashboard.html | 155 ++++++++++-------- src/pobsync_backend/tests/test_views.py | 26 +-- src/pobsync_backend/views.py | 78 ++++++++- 4 files changed, 235 insertions(+), 89 deletions(-) diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index ed8e326..c9b4ea1 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -285,9 +285,63 @@ cursor: not-allowed; } .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; 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 { align-items: center; @@ -312,12 +366,6 @@ box-shadow: var(--shadow-sm); transform: translateY(-1px); } - .status-summary .summary-action { - color: var(--muted-strong); - font-size: 12px; - font-weight: 650; - margin-left: auto; - } .operator-state { align-items: center; display: flex; @@ -564,6 +612,9 @@ } .page-header .actions { justify-content: flex-start; } .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-status { justify-content: flex-start; max-width: none; } .host-card-layout { grid-template-columns: 1fr; } diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index 32cfc37..2c44ff3 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -32,58 +32,100 @@ {% endif %} +
+
+

Required Action

+ {% if action_items %} +
+ {% for item in action_items %} + + {{ item.label }} + + {{ item.host.host }} + {{ item.message }} + + + {% endfor %} +
+ {% elif counts.hosts %} +

ok No queued, running, unreviewed warning/failed runs, or retention warnings.

+ {% else %} +

Add a host to start tracking backup status here.

+ {% endif %} + {% if counts.running_runs or counts.queued_runs %} + + {% endif %} +
+ + + + +
+
Hosts
{{ counts.enabled_hosts }}/{{ counts.hosts }}
Schedules
{{ counts.enabled_schedules }}/{{ counts.schedules }}
Snapshots
{{ counts.snapshots }}
Runs
{{ counts.runs }}
-
Queued
{{ counts.queued_runs }}
-
Running
{{ counts.running_runs }}
Warnings
{{ counts.warning_runs }}
Failed
{{ counts.failed_runs }}
-

Operational Status

- {% if counts.failed_runs or counts.warning_runs or counts.running_runs or counts.queued_runs %} - - {% elif counts.hosts %} -

ok No queued, running, or unreviewed warning/failed runs.

- {% else %} -

Add a host to start tracking backup status here.

- {% endif %} -
- -

Backup Trends

{% if stats_summary.runs_sampled %}
@@ -145,7 +187,7 @@ {% endif %}
-
+

Hosts

{% for host in hosts %} @@ -269,31 +311,4 @@
-
-

Latest Runs View all

- - - - - - - - - - - - {% for run in latest_runs %} - - - - - - - - {% empty %} - - {% endfor %} - -
HostStatusStartedEndedSnapshot
{{ run.host.host }}{{ run.status }}{{ run.started_at|default:"" }}{{ run.ended_at|default:"" }}{% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}{{ run.snapshot_path }}{% endif %}
No backup runs recorded yet.
-
{% endblock %} diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index aa929b8..118fcf7 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -120,20 +120,24 @@ class ViewTests(TestCase): self.assertContains(response, "running 1") self.assertContains(response, "warning 1") self.assertContains(response, "failed 1") - self.assertContains(response, "Operational Status") - self.assertContains(response, "1 failed run needs review.") - self.assertContains(response, "1 run completed with warnings.") + self.assertContains(response, "Required Action") + self.assertContains(response, "Failed runs") + 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 waiting for the worker.") - self.assertContains(response, "Review failed runs") - self.assertContains(response, "Review warnings") - self.assertContains(response, "View running runs") - self.assertContains(response, "View queued runs") + self.assertContains(response, "1 backup run waiting.") + self.assertContains(response, "Next Scheduled Work") + self.assertContains(response, "Recent Activity") 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=running"', html=False) self.assertContains(response, f'href="{reverse("runs_list")}?status=warning&review=needed"', html=False) self.assertContains(response, f'href="{reverse("runs_list")}?status=failed&review=needed"', html=False) + self.assertContains( + response, + f'href="{reverse("runs_list")}?host=web-01&status=failed&review=needed"', + html=False, + ) self.assertContains(response, f'href="{reverse("snapshots_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")) self.assertEqual(response.status_code, 200) - self.assertContains(response, "Operational Status") - self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.") + self.assertContains(response, "Required Action") + self.assertContains(response, "No queued, running, unreviewed warning/failed runs, or retention warnings.") def test_runs_list_filters_by_status_and_review(self) -> None: self.client.force_login(self.staff_user) @@ -342,7 +346,7 @@ class ViewTests(TestCase): response = self.client.get(reverse("dashboard")) 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, "warning 1") diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 5e2b85d..ae0d91c 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -4,7 +4,9 @@ import json import shlex import shutil import subprocess +from datetime import datetime, timezone as datetime_timezone from pathlib import Path +from urllib.parse import urlencode from django.contrib import messages 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.db.models import Count, Q from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.utils import timezone 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.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) context = { "hosts": hosts, "global_config": global_config, "stats_summary": stats_summary, "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": { "global_configs": GlobalConfig.objects.count(), "hosts": HostConfig.objects.count(), @@ -104,6 +112,74 @@ def dashboard(request): 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 def changelog(request): changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md"