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

Next Scheduled Work View all

+ {% if next_schedule_rows %} + + {% else %} +

No enabled schedules yet.

+ {% endif %} +
+ +
+

Recent Activity View all

+ {% if recent_runs %} + + {% else %} +

No backup runs recorded yet.

+ {% 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 %} -
- {% if counts.failed_runs %} - - failed - {{ counts.failed_runs }} failed run{{ counts.failed_runs|pluralize }} need{{ counts.failed_runs|pluralize:"s," }} review. - Review failed runs - - {% endif %} - {% if counts.warning_runs %} - - warning - {{ counts.warning_runs }} run{{ counts.warning_runs|pluralize }} completed with warnings. - Review warnings - - {% endif %} - {% if counts.running_runs %} - - running - {{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress. - View running runs - - {% endif %} - {% if counts.queued_runs %} - - queued - {{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting for the worker. - View queued runs - - {% endif %} -
- {% 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"