diff --git a/src/pobsync_backend/migrations/0012_review_state.py b/src/pobsync_backend/migrations/0012_review_state.py new file mode 100644 index 0000000..3d282a8 --- /dev/null +++ b/src/pobsync_backend/migrations/0012_review_state.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pobsync_backend", "0011_remove_globalconfig_data"), + ] + + operations = [ + migrations.AddField( + model_name="backuprun", + name="reviewed_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="backuprun", + name="reviewed_by", + field=models.CharField(blank=True, max_length=150), + ), + migrations.AddField( + model_name="snapshotrecord", + name="reviewed_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="snapshotrecord", + name="reviewed_by", + field=models.CharField(blank=True, max_length=150), + ), + ] diff --git a/src/pobsync_backend/models.py b/src/pobsync_backend/models.py index 93faa50..bc00671 100644 --- a/src/pobsync_backend/models.py +++ b/src/pobsync_backend/models.py @@ -124,6 +124,8 @@ class BackupRun(models.Model): rsync_exit_code = models.IntegerField(null=True, blank=True) result = models.JSONField(default=dict, blank=True) created_at = models.DateTimeField(auto_now_add=True) + reviewed_at = models.DateTimeField(null=True, blank=True) + reviewed_by = models.CharField(max_length=150, blank=True) class Meta: ordering = ["-created_at"] @@ -158,6 +160,8 @@ class SnapshotRecord(models.Model): ended_at = models.DateTimeField(null=True, blank=True) metadata = models.JSONField(default=dict, blank=True) discovered_at = models.DateTimeField(auto_now_add=True) + reviewed_at = models.DateTimeField(null=True, blank=True) + reviewed_by = models.CharField(max_length=150, blank=True) class Meta: constraints = [ diff --git a/src/pobsync_backend/stats_summary.py b/src/pobsync_backend/stats_summary.py index 03de25d..62d5134 100644 --- a/src/pobsync_backend/stats_summary.py +++ b/src/pobsync_backend/stats_summary.py @@ -94,6 +94,7 @@ def _run_summary(run: BackupRun) -> dict[str, Any]: "snapshot": run.snapshot, "snapshot_path": run.snapshot_path, "status": run.status, + "reviewed_at": run.reviewed_at, "has_stats": bool(stats), "duration_seconds": _int_at(stats, "duration_seconds"), "rsync": stats.get("rsync") if isinstance(stats.get("rsync"), dict) else {}, @@ -130,7 +131,7 @@ def _is_real_run(run: BackupRun) -> bool: def _first_run_with_status(runs: list[dict[str, Any]], statuses: set[str]) -> dict[str, Any]: for run in runs: - if run["status"] in statuses: + if run["status"] in statuses and run.get("reviewed_at") is None: return run return {} diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index b595b34..b6067fb 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -68,7 +68,7 @@ {% endif %} {% elif counts.hosts %} -

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

+

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

{% else %}

Add a host to start tracking backup status here.

{% endif %} @@ -243,6 +243,10 @@ {% endif %} {% if host.retention_warning.incomplete_count %} {{ host.retention_warning.incomplete_count }} incomplete snapshot(s) need review. +
+ {% csrf_token %} + +
{% endif %} {% if host.retention_warning.error %} {{ host.retention_warning.error }} diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html index e10f681..dae7006 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -84,6 +84,10 @@ {{ retention_warning.incomplete_count }} incomplete snapshot(s) exist. Retention does not delete incomplete snapshots automatically; inspect them before cleanup. +
+ {% csrf_token %} + +
{% endif %} {% if retention_warning.error %}
{{ retention_warning.error }}
diff --git a/src/pobsync_backend/templates/pobsync_backend/run_detail.html b/src/pobsync_backend/templates/pobsync_backend/run_detail.html index 7526e33..b9722c7 100644 --- a/src/pobsync_backend/templates/pobsync_backend/run_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/run_detail.html @@ -13,6 +13,14 @@ {% endif %} + {% if run.status == "failed" or run.status == "warning" %} + {% if not run.reviewed_at %} +
+ {% csrf_token %} + +
+ {% endif %} + {% endif %}
@@ -33,6 +41,16 @@
{% endif %} + {% if run.reviewed_at %} +
+

Review

+
+
Reviewed: {{ run.reviewed_at }}
+
Reviewed by: {{ run.reviewed_by|default:"unknown" }}
+
+
+ {% endif %} + {% if dry_run_summary %}

Dry Run Summary

diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 5dd0861..6538c01 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -186,7 +186,7 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, "Operational Status") - self.assertContains(response, "No queued, running, warning, or failed runs.") + self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.") def test_dashboard_surfaces_retention_warnings(self) -> None: self.client.force_login(self.staff_user) @@ -216,6 +216,30 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, "Scheduled prune would delete 2 snapshot(s), above max 1.") self.assertContains(response, "1 incomplete snapshot(s) need review.") + self.assertContains(response, "Mark reviewed") + + def test_dashboard_ignores_reviewed_problem_runs(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + BackupRun.objects.create( + host=host, + status=BackupRun.Status.FAILED, + reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc), + reviewed_by="admin", + ) + BackupRun.objects.create( + host=host, + status=BackupRun.Status.WARNING, + reviewed_at=datetime(2026, 5, 19, 4, 20, tzinfo=timezone.utc), + reviewed_by="admin", + ) + + response = self.client.get(reverse("dashboard")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.") + self.assertNotContains(response, "failed 1") + self.assertNotContains(response, "warning 1") def test_dashboard_links_latest_snapshot_for_each_host(self) -> None: self.client.force_login(self.staff_user) @@ -1310,6 +1334,34 @@ class ViewTests(TestCase): self.assertContains(response, "Incomplete ignored") self.assertContains(response, "deleted scheduled 20260518-021500Z__OLD") + def test_run_review_action_marks_problem_run_reviewed(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + run = BackupRun.objects.create(host=host, status=BackupRun.Status.FAILED, result={"ok": False}) + + response = self.client.post(reverse("resolve_run_review", args=[run.id]), follow=True) + + run.refresh_from_db() + self.assertIsNotNone(run.reviewed_at) + self.assertEqual(run.reviewed_by, self.staff_user.username) + self.assertRedirects(response, reverse("run_detail", args=[run.id])) + self.assertContains(response, f"Run {run.id} marked reviewed.") + self.assertContains(response, "Review") + self.assertContains(response, self.staff_user.username) + self.assertNotContains(response, "Mark reviewed") + + def test_run_review_action_ignores_successful_run(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, result={"ok": True}) + + response = self.client.post(reverse("resolve_run_review", args=[run.id]), follow=True) + + run.refresh_from_db() + self.assertIsNone(run.reviewed_at) + self.assertRedirects(response, reverse("run_detail", args=[run.id])) + self.assertContains(response, f"Run {run.id} does not need review.") + def test_run_detail_surfaces_host_retention_warnings(self) -> None: self.client.force_login(self.staff_user) host = HostConfig.objects.create( @@ -1704,6 +1756,46 @@ class ViewTests(TestCase): self.assertContains(response, "Retention Warnings") self.assertContains(response, "Scheduled pruning would delete 2 snapshot(s), above max delete") + def test_host_detail_can_mark_incomplete_snapshots_reviewed(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + incomplete = SnapshotRecord.objects.create( + host=host, + kind=SnapshotRecord.Kind.INCOMPLETE, + dirname="20260519-031500Z__BROKEN01", + path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01", + status="failed", + started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc), + ) + + response = self.client.post(reverse("resolve_host_incomplete_reviews", args=[host.host]), follow=True) + + incomplete.refresh_from_db() + self.assertIsNotNone(incomplete.reviewed_at) + self.assertEqual(incomplete.reviewed_by, self.staff_user.username) + self.assertRedirects(response, reverse("host_detail", args=[host.host])) + self.assertContains(response, "Marked 1 incomplete snapshot(s) reviewed for web-01.") + self.assertNotContains(response, "Retention Warnings") + + def test_host_detail_does_not_warn_for_reviewed_incomplete_snapshots(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + SnapshotRecord.objects.create( + host=host, + kind=SnapshotRecord.Kind.INCOMPLETE, + dirname="20260519-031500Z__BROKEN01", + path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01", + status="failed", + started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc), + reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc), + reviewed_by="admin", + ) + + response = self.client.get(reverse("host_detail", args=[host.host])) + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, "Retention Warnings") + def test_retention_plan_rejects_invalid_kind(self) -> None: self.client.force_login(self.staff_user) host = HostConfig.objects.create(host="web-01", address="web-01.example.test") diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 2e54e95..a6d7414 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -53,8 +53,16 @@ def dashboard(request): run_count=Count("runs", distinct=True), queued_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.QUEUED), distinct=True), running_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.RUNNING), distinct=True), - warning_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.WARNING), distinct=True), - failed_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.FAILED), distinct=True), + warning_run_count=Count( + "runs", + filter=Q(runs__status=BackupRun.Status.WARNING, runs__reviewed_at__isnull=True), + distinct=True, + ), + failed_run_count=Count( + "runs", + filter=Q(runs__status=BackupRun.Status.FAILED, runs__reviewed_at__isnull=True), + distinct=True, + ), ) .order_by("host") ) @@ -83,8 +91,14 @@ def dashboard(request): "runs": BackupRun.objects.count(), "queued_runs": BackupRun.objects.filter(status=BackupRun.Status.QUEUED).count(), "running_runs": BackupRun.objects.filter(status=BackupRun.Status.RUNNING).count(), - "warning_runs": BackupRun.objects.filter(status=BackupRun.Status.WARNING).count(), - "failed_runs": BackupRun.objects.filter(status=BackupRun.Status.FAILED).count(), + "warning_runs": BackupRun.objects.filter( + status=BackupRun.Status.WARNING, + reviewed_at__isnull=True, + ).count(), + "failed_runs": BackupRun.objects.filter( + status=BackupRun.Status.FAILED, + reviewed_at__isnull=True, + ).count(), }, } return render(request, "pobsync_backend/dashboard.html", context) @@ -331,7 +345,10 @@ def host_detail(request, host: str): "runs": host_config.runs.count(), "queued_runs": queued_runs.count(), "running_runs": running_runs.count(), - "failed_runs": host_config.runs.filter(status=BackupRun.Status.FAILED).count(), + "failed_runs": host_config.runs.filter( + status=BackupRun.Status.FAILED, + reviewed_at__isnull=True, + ).count(), "incomplete_snapshots": host_config.snapshots.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count(), }, } @@ -515,6 +532,40 @@ def cancel_run(request, run_id: int): return redirect("run_detail", run_id=run.id) +@staff_member_required +@require_POST +def resolve_run_review(request, run_id: int): + run = get_object_or_404(BackupRun.objects.select_related("host"), id=run_id) + if run.status not in {BackupRun.Status.FAILED, BackupRun.Status.WARNING}: + messages.warning(request, f"Run {run.id} does not need review.") + return redirect("run_detail", run_id=run.id) + if run.reviewed_at: + messages.info(request, f"Run {run.id} was already marked reviewed.") + return redirect("run_detail", run_id=run.id) + + run.reviewed_at = timezone.now() + run.reviewed_by = request.user.get_username() + run.save(update_fields=["reviewed_at", "reviewed_by"]) + messages.success(request, f"Run {run.id} marked reviewed.") + return redirect("run_detail", run_id=run.id) + + +@staff_member_required +@require_POST +def resolve_host_incomplete_reviews(request, host: str): + host_config = get_object_or_404(HostConfig, host=host) + reviewed_count = host_config.snapshots.filter( + kind=SnapshotRecord.Kind.INCOMPLETE, + reviewed_at__isnull=True, + ).update(reviewed_at=timezone.now(), reviewed_by=request.user.get_username()) + + if reviewed_count: + messages.success(request, f"Marked {reviewed_count} incomplete snapshot(s) reviewed for {host_config.host}.") + else: + messages.info(request, f"No incomplete snapshots needed review for {host_config.host}.") + return redirect("host_detail", host=host_config.host) + + @staff_member_required def snapshot_detail(request, snapshot_id: int): snapshot = get_object_or_404( @@ -764,7 +815,10 @@ def _next_run_for_schedule(schedule: ScheduleConfig | None, host_config: HostCon def _retention_warning_for_host(host_config: HostConfig, schedule: ScheduleConfig | None) -> dict[str, object]: - incomplete_count = host_config.snapshots.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count() + incomplete_count = host_config.snapshots.filter( + kind=SnapshotRecord.Kind.INCOMPLETE, + reviewed_at__isnull=True, + ).count() warning: dict[str, object] = { "has_warning": incomplete_count > 0, "incomplete_count": incomplete_count, diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index 0fabd24..240b3ee 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -36,6 +36,12 @@ urlpatterns = [ 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"), + path("runs//resolve-review/", views.resolve_run_review, name="resolve_run_review"), + path( + "hosts//resolve-incomplete-reviews/", + views.resolve_host_incomplete_reviews, + name="resolve_host_incomplete_reviews", + ), path("snapshots//", views.snapshot_detail, name="snapshot_detail"), path("api/", api.api_index), path("api/status/", api.status),