From 01b779c8629ca3678ac299dde50a37359621f868 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 12:39:57 +0200 Subject: [PATCH] (ui) Add schedule overview for dashboard drill-down Add a staff-only schedules page with filters for host, enabled state, and prune state, including next run and last scheduler state. Wire the dashboard Schedules metric to the new overview so all primary dashboard count cards have useful destinations. Refs #23 --- .../templates/pobsync_backend/dashboard.html | 2 +- .../pobsync_backend/schedules_list.html | 101 ++++++++++++++++++ src/pobsync_backend/tests/test_views.py | 19 ++++ src/pobsync_backend/views.py | 38 +++++++ src/pobsync_server/urls.py | 1 + 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 src/pobsync_backend/templates/pobsync_backend/schedules_list.html diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index e748d4d..32cfc37 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -34,7 +34,7 @@
Hosts
{{ counts.enabled_hosts }}/{{ counts.hosts }}
-
Schedules
{{ counts.enabled_schedules }}/{{ counts.schedules }}
+
Schedules
{{ counts.enabled_schedules }}/{{ counts.schedules }}
Snapshots
{{ counts.snapshots }}
Runs
{{ counts.runs }}
Queued
{{ counts.queued_runs }}
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/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index cef4230..3437007 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -135,6 +135,7 @@ class ViewTests(TestCase): 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) @@ -249,6 +250,24 @@ class ViewTests(TestCase): 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( diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 70fb456..e319485 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -203,6 +203,44 @@ def snapshots_list(request): 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 934a1b2..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"),