diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 0d321f5..d2695b3 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -168,6 +168,21 @@ .metric.warning { border-color: #e7cf8a; background: #fffaf0; } .metric.running { border-color: #e7cf8a; background: #fffaf0; } .metric.queued { border-color: #b5cdea; background: #eef6ff; } + .metric-link { + color: inherit; + display: block; + text-decoration: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; + } + .metric-link:hover { + border-color: #9eb2c8; + box-shadow: var(--shadow); + transform: translateY(-1px); + } + .metric-link:focus-visible { + outline: 3px solid #93c5fd; + outline-offset: 2px; + } .panel { margin-bottom: 18px; overflow: auto; diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index 358fd72..f78f584 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -33,17 +33,17 @@ {% 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 }}
+
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 %}
@@ -266,7 +266,7 @@
-

Latest Runs

+

Latest Runs View all

diff --git a/src/pobsync_backend/templates/pobsync_backend/runs_list.html b/src/pobsync_backend/templates/pobsync_backend/runs_list.html new file mode 100644 index 0000000..a8dc10d --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/runs_list.html @@ -0,0 +1,106 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}Runs | pobsync{% endblock %} + +{% block content %} + + +
+

Filters

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Clear +
+ +
+ +
+

Backup Runs

+

Showing up to 200 of {{ total_count }} run{{ total_count|pluralize }}.

+
+ + + + + + + + + + + + + + + {% for run in runs %} + + + + + + + + + + + + {% empty %} + + {% endfor %} + +
RunHostStatusTypeCreatedStartedEndedSnapshotReview
Run {{ run.id }}{{ run.host.host }}{{ run.status }}{{ run.run_type }}{{ run.created_at }}{{ run.started_at|default:"" }}{{ run.ended_at|default:"" }} + {% if run.snapshot %} + {{ run.snapshot.dirname }} + {% elif run.snapshot_path %} + {{ run.snapshot_path }} + {% else %} + none + {% endif %} + {% if run.reviewed_at %}reviewed{% elif run.status == "failed" or run.status == "warning" %}needed{% else %}none{% endif %}
No runs matched the current filter.
+
+{% endblock %} diff --git a/src/pobsync_backend/templates/pobsync_backend/snapshots_list.html b/src/pobsync_backend/templates/pobsync_backend/snapshots_list.html new file mode 100644 index 0000000..a6d8e7c --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/snapshots_list.html @@ -0,0 +1,96 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}Snapshots | pobsync{% endblock %} + +{% block content %} + + +
+

Filters

+
+
+ + +
+
+ + +
+
+ + +
+
+ + Clear +
+
+
+ +
+

Snapshot Records

+

Showing up to 200 of {{ total_count }} snapshot{{ total_count|pluralize }}.

+ + + + + + + + + + + + + + + {% for snapshot in snapshots %} + + + + + + + + + + + {% empty %} + + {% endfor %} + +
SnapshotHostKindStatusStartedEndedBasePath
{{ snapshot.dirname }}{{ snapshot.host.host }}{{ snapshot.kind }}{% if snapshot.status %}{{ snapshot.status }}{% else %}unknown{% endif %}{{ snapshot.started_at|default:"" }}{{ snapshot.ended_at|default:"" }} + {% if snapshot.base %} + {{ snapshot.base.dirname }} + {% elif snapshot.base_dirname %} + {{ snapshot.base_dirname }} + {% else %} + none + {% endif %} + {{ snapshot.path }}
No snapshots matched the current filter.
+
+{% endblock %} diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 510f77f..08c7040 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -125,6 +125,12 @@ class ViewTests(TestCase): self.assertContains(response, "1 run completed with warnings.") self.assertContains(response, "1 backup run in progress.") self.assertContains(response, "1 backup run waiting for the worker.") + 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("snapshots_list")}"', html=False) def test_dashboard_renders_backup_trend_summary(self) -> None: self.client.force_login(self.staff_user) @@ -200,6 +206,45 @@ class ViewTests(TestCase): self.assertContains(response, "Operational Status") self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.") + def test_runs_list_filters_by_status_and_review(self) -> None: + self.client.force_login(self.staff_user) + web = HostConfig.objects.create(host="web-01", address="web-01.example.test") + db = HostConfig.objects.create(host="db-01", address="db-01.example.test") + failed = BackupRun.objects.create(host=web, status=BackupRun.Status.FAILED, run_type=BackupRun.RunType.MANUAL) + success = BackupRun.objects.create(host=db, status=BackupRun.Status.SUCCESS, run_type=BackupRun.RunType.SCHEDULED) + BackupRun.objects.create( + host=web, + status=BackupRun.Status.WARNING, + reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc), + reviewed_by="admin", + ) + + response = self.client.get(reverse("runs_list"), {"status": "failed", "review": "needed"}) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Runs") + self.assertContains(response, "Review queued, running, completed") + self.assertContains(response, f"Run {failed.id}") + self.assertContains(response, "web-01") + self.assertContains(response, "needed") + self.assertNotContains(response, f"Run {success.id}") + + def test_snapshots_list_filters_by_host_and_kind(self) -> None: + self.client.force_login(self.staff_user) + web = HostConfig.objects.create(host="web-01", address="web-01.example.test") + db = HostConfig.objects.create(host="db-01", address="db-01.example.test") + manual = self._snapshot(web, "20260519-021500Z__MANUAL01", kind=SnapshotRecord.Kind.MANUAL) + scheduled = self._snapshot(db, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED) + + response = self.client.get(reverse("snapshots_list"), {"host": web.host, "kind": SnapshotRecord.Kind.MANUAL}) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Snapshots") + self.assertContains(response, "Browse discovered scheduled, manual, and incomplete snapshots") + self.assertContains(response, manual.dirname) + self.assertContains(response, "web-01") + self.assertNotContains(response, scheduled.dirname) + def test_dashboard_surfaces_retention_warnings(self) -> None: self.client.force_login(self.staff_user) host = HostConfig.objects.create( @@ -2223,13 +2268,19 @@ class ViewTests(TestCase): self.assertEqual(host.excludes_add, []) self.assertEqual(host.excludes_replace, ["*.cache", "node_modules/"]) - def _snapshot(self, host: HostConfig, dirname: str) -> SnapshotRecord: + def _snapshot( + self, + host: HostConfig, + dirname: str, + *, + kind: str = SnapshotRecord.Kind.SCHEDULED, + ) -> SnapshotRecord: started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc) return SnapshotRecord.objects.create( host=host, - kind=SnapshotRecord.Kind.SCHEDULED, + kind=kind, dirname=dirname, - path=f"/backups/{host.host}/scheduled/{dirname}", + path=f"/backups/{host.host}/{kind}/{dirname}", status="success", started_at=started_at, ) diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index bca4d2d..70fb456 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -145,6 +145,64 @@ def logs(request): return render(request, "pobsync_backend/logs.html", context) +@staff_member_required +def runs_list(request): + status = request.GET.get("status", "").strip() + run_type = request.GET.get("type", "").strip() + host = request.GET.get("host", "").strip() + review = request.GET.get("review", "").strip() + runs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at", "-id") + if status: + runs = runs.filter(status=status) + if run_type: + runs = runs.filter(run_type=run_type) + if host: + runs = runs.filter(host__host=host) + if review == "needed": + runs = runs.filter(status__in=[BackupRun.Status.FAILED, BackupRun.Status.WARNING], reviewed_at__isnull=True) + elif review == "reviewed": + runs = runs.filter(reviewed_at__isnull=False) + + context = { + "runs": runs[:200], + "total_count": runs.count(), + "hosts": HostConfig.objects.order_by("host"), + "statuses": BackupRun.Status.choices, + "run_types": BackupRun.RunType.choices, + "selected_status": status, + "selected_type": run_type, + "selected_host": host, + "selected_review": review, + } + return render(request, "pobsync_backend/runs_list.html", context) + + +@staff_member_required +def snapshots_list(request): + kind = request.GET.get("kind", "").strip() + status = request.GET.get("status", "").strip() + host = request.GET.get("host", "").strip() + snapshots = SnapshotRecord.objects.select_related("host", "base").order_by("-started_at", "-discovered_at", "-id") + if kind: + snapshots = snapshots.filter(kind=kind) + if status: + snapshots = snapshots.filter(status=status) + if host: + snapshots = snapshots.filter(host__host=host) + + context = { + "snapshots": snapshots[:200], + "total_count": snapshots.count(), + "hosts": HostConfig.objects.order_by("host"), + "kinds": SnapshotRecord.Kind.choices, + "statuses": SnapshotRecord.objects.exclude(status="").order_by("status").values_list("status", flat=True).distinct(), + "selected_kind": kind, + "selected_status": status, + "selected_host": host, + } + return render(request, "pobsync_backend/snapshots_list.html", context) + + @staff_member_required def purged_snapshots(request): host = request.GET.get("host", "").strip() diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index 6f77f1c..934a1b2 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -34,6 +34,7 @@ urlpatterns = [ name="cleanup_host_incomplete_snapshots", ), path("hosts//schedule/", views.edit_host_schedule, name="edit_host_schedule"), + path("runs/", views.runs_list, name="runs_list"), path("runs//", views.run_detail, name="run_detail"), path("runs//rsync-log/", views.run_rsync_log, name="run_rsync_log"), path("runs//cancel/", views.cancel_run, name="cancel_run"), @@ -43,6 +44,7 @@ urlpatterns = [ views.resolve_host_incomplete_reviews, name="resolve_host_incomplete_reviews", ), + path("snapshots/", views.snapshots_list, name="snapshots_list"), path("snapshots//", views.snapshot_detail, name="snapshot_detail"), path("api/", api.api_index), path("api/status/", api.status),