diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 0d321f5..4e8c6a7 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; @@ -281,7 +296,23 @@ .status-summary.failed { border-color: #e8b4b4; background: #fff7f7; color: var(--failed); } .status-summary.warning, .status-summary.running { border-color: #e7cf8a; background: #fffaf0; color: var(--running); } - .status-summary.queued { border-color: #b5cdea; background: #eef6ff; color: var(--link); } + .status-summary.queued { border-color: #b5cdea; background: #eef6ff; color: var(--link); } + a.status-summary { + color: inherit; + text-decoration: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; + } + a.status-summary:hover { + border-color: var(--border-strong); + 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; diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index 358fd72..32cfc37 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -33,14 +33,14 @@ {% 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 }}
@@ -48,28 +48,32 @@ {% if counts.failed_runs or counts.warning_runs or counts.running_runs or counts.queued_runs %}
{% if counts.failed_runs %} - + Review failed runs + {% endif %} {% if counts.warning_runs %} - + Review warnings + {% endif %} {% if counts.running_runs %} - + View running runs + {% endif %} {% if counts.queued_runs %} - + View queued runs + {% endif %}
{% elif counts.hosts %} @@ -79,7 +83,7 @@ {% endif %}
-
+

Backup Trends

{% if stats_summary.runs_sampled %}
@@ -266,7 +270,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/schedules_list.html b/src/pobsync_backend/templates/pobsync_backend/schedules_list.html new file mode 100644 index 0000000..ada648b --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/schedules_list.html @@ -0,0 +1,101 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}Schedules | pobsync{% endblock %} + +{% block content %} + + +
+

Filters

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

Configured Schedules

+

Showing up to 200 of {{ total_count }} schedule{{ total_count|pluralize }}. Times use {{ scheduler_timezone }}.

+ + + + + + + + + + + + + + + + {% for row in schedule_rows %} + {% with schedule=row.schedule %} + + + + + + + + + + + + {% endwith %} + {% empty %} + + {% endfor %} + +
HostExpressionEnabledNext RunPruneLast StatusLast StartedLast FinishedActions
{{ schedule.host.host }}{{ schedule.cron_expr }}{{ schedule.enabled|yesno:"enabled,disabled" }} + {% if row.next_run_at %} + {{ row.next_run_at|date:"Y-m-d H:i T" }} + {% else %} + none + {% endif %} + + {{ schedule.prune|yesno:"enabled,disabled" }} + {% if schedule.prune %} +
max {{ schedule.prune_max_delete }}{% if schedule.prune_protect_bases %}, protects bases{% endif %}
+ {% endif %} +
{% if schedule.last_status %}{{ schedule.last_status }}{% else %}none{% endif %}{{ schedule.last_started_at|default:"" }}{{ schedule.last_finished_at|default:"" }}Edit
No schedules 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..3437007 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -125,6 +125,17 @@ 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, "Review failed runs") + self.assertContains(response, "Review warnings") + 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")}?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) + self.assertContains(response, f'href="{reverse("schedules_list")}"', html=False) def test_dashboard_renders_backup_trend_summary(self) -> None: self.client.force_login(self.staff_user) @@ -200,6 +211,63 @@ 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_schedules_list_filters_by_enabled_and_prune(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") + ScheduleConfig.objects.create(host=web, cron_expr="15 2 * * *", enabled=True, prune=True, last_status="success") + ScheduleConfig.objects.create(host=db, cron_expr="30 3 * * *", enabled=False, prune=False, last_status="failed") + + response = self.client.get(reverse("schedules_list"), {"enabled": "yes", "prune": "yes"}) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Schedules") + self.assertContains(response, "Review configured backup schedules") + self.assertContains(response, "web-01") + self.assertContains(response, "15 2 * * *") + self.assertContains(response, "success") + self.assertContains(response, "UTC") + self.assertNotContains(response, "30 3 * * *") + def test_dashboard_surfaces_retention_warnings(self) -> None: self.client.force_login(self.staff_user) host = HostConfig.objects.create( @@ -2223,13 +2291,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..e319485 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -145,6 +145,102 @@ 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 schedules_list(request): + enabled = request.GET.get("enabled", "").strip() + prune = request.GET.get("prune", "").strip() + host = request.GET.get("host", "").strip() + schedules = ScheduleConfig.objects.select_related("host").order_by("host__host") + if enabled == "yes": + schedules = schedules.filter(enabled=True) + elif enabled == "no": + schedules = schedules.filter(enabled=False) + if prune == "yes": + schedules = schedules.filter(prune=True) + elif prune == "no": + schedules = schedules.filter(prune=False) + if host: + schedules = schedules.filter(host__host=host) + + schedule_rows = [] + for schedule in schedules[:200]: + schedule_rows.append( + { + "schedule": schedule, + "next_run_at": _next_run_for_schedule(schedule, schedule.host), + } + ) + + context = { + "schedule_rows": schedule_rows, + "total_count": schedules.count(), + "hosts": HostConfig.objects.order_by("host"), + "selected_enabled": enabled, + "selected_prune": prune, + "selected_host": host, + "scheduler_timezone": timezone.get_current_timezone_name(), + } + return render(request, "pobsync_backend/schedules_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..da215fe 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -12,6 +12,7 @@ urlpatterns = [ path("self-check/", views.self_check, name="self_check"), path("logs/", views.logs, name="logs"), path("purged-snapshots/", views.purged_snapshots, name="purged_snapshots"), + path("schedules/", views.schedules_list, name="schedules_list"), path("config/global/", views.edit_global_config, name="edit_global_config"), path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"), path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"), @@ -34,6 +35,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 +45,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),