No hosts configured yet.
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 893a7ca..7de778c 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -48,6 +48,7 @@ class ViewTests(TestCase): self.assertContains(response, 'aria-label="Primary navigation"', html=False) self.assertContains(response, 'aria-label="System navigation"', html=False) self.assertContains(response, reverse("dashboard")) + self.assertContains(response, reverse("hosts_list")) self.assertContains(response, reverse("ssh_credentials")) self.assertContains(response, reverse("logs")) self.assertContains(response, reverse("purged_snapshots")) @@ -202,6 +203,72 @@ class ViewTests(TestCase): self.assertContains(response, "Snapshot health") self.assertNotContains(response, " 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", enabled=False) + ScheduleConfig.objects.create(host=web, cron_expr="15 2 * * *", enabled=True, prune=True) + BackupRun.objects.create(host=web, status=BackupRun.Status.RUNNING) + + response = self.client.get(reverse("hosts_list")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Inventory") + self.assertContains(response, "Configured backup targets") + self.assertContains(response, "web-01") + self.assertContains(response, "db-01") + self.assertContains(response, "running 1") + self.assertContains(response, "schedule on") + self.assertContains(response, "retention on") + self.assertContains(response, "Disable host") + self.assertContains(response, "Enable host") + self.assertContains(response, "Pause schedule") + self.assertContains(response, "Pause retention") + self.assertContains(response, reverse("update_host_state", args=[web.host])) + self.assertContains(response, reverse("edit_host_config", args=[web.host])) + self.assertContains(response, reverse("edit_host_schedule", args=[web.host])) + + def test_hosts_list_filters_by_enabled_state(self) -> None: + self.client.force_login(self.staff_user) + HostConfig.objects.create(host="web-01", address="web-01.example.test") + HostConfig.objects.create(host="db-01", address="db-01.example.test", enabled=False) + + response = self.client.get(reverse("hosts_list"), {"enabled": "no"}) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "db-01") + self.assertNotContains(response, "web-01") + + def test_update_host_state_toggles_host_schedule_and_retention(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + schedule = ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", enabled=True, prune=True) + + response = self.client.post( + reverse("update_host_state", args=[host.host]), + {"action": "disable_host", "next": reverse("hosts_list")}, + follow=True, + ) + self.assertRedirects(response, reverse("hosts_list")) + host.refresh_from_db() + self.assertFalse(host.enabled) + self.assertContains(response, "Disabled host web-01.") + + self.client.post(reverse("update_host_state", args=[host.host]), {"action": "disable_schedule"}) + self.client.post(reverse("update_host_state", args=[host.host]), {"action": "disable_prune"}) + schedule.refresh_from_db() + self.assertFalse(schedule.enabled) + self.assertFalse(schedule.prune) + + self.client.post(reverse("update_host_state", args=[host.host]), {"action": "enable_host"}) + self.client.post(reverse("update_host_state", args=[host.host]), {"action": "enable_schedule"}) + self.client.post(reverse("update_host_state", args=[host.host]), {"action": "enable_prune"}) + host.refresh_from_db() + schedule.refresh_from_db() + self.assertTrue(host.enabled) + self.assertTrue(schedule.enabled) + self.assertTrue(schedule.prune) + def test_dashboard_renders_backup_trend_summary(self) -> None: self.client.force_login(self.staff_user) GlobalConfig.objects.create(name="default", backup_root="/missing-backup-root") diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 02aa302..9870c9e 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -17,6 +17,7 @@ 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.utils.http import url_has_allowed_host_and_scheme from django.views.decorators.http import require_POST from pobsync import __version__ @@ -62,8 +63,7 @@ def dashboard_hosts_live(request): return render(request, "pobsync_backend/partials/dashboard_hosts.html", _dashboard_context()) -def _dashboard_context() -> dict[str, object]: - global_config = GlobalConfig.objects.filter(name="default").first() +def _host_cards_context(*, enabled: str = "") -> dict[str, object]: hosts = list( HostConfig.objects.select_related("schedule") .annotate( @@ -84,6 +84,11 @@ def _dashboard_context() -> dict[str, object]: ) .order_by("host") ) + if enabled == "yes": + hosts = [host for host in hosts if host.enabled] + elif enabled == "no": + hosts = [host for host in hosts if not host.enabled] + for host_config in hosts: host_config.latest_snapshot = ( host_config.snapshots.select_related("base") @@ -92,6 +97,22 @@ def _dashboard_context() -> dict[str, object]: ) 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)) + return { + "hosts": hosts, + "scheduler_timezone": timezone.get_current_timezone_name(), + "selected_enabled": enabled, + "counts": { + "hosts": len(hosts), + "enabled_hosts": sum(1 for host in hosts if host.enabled), + "disabled_hosts": sum(1 for host in hosts if not host.enabled), + }, + } + + +def _dashboard_context() -> dict[str, object]: + global_config = GlobalConfig.objects.filter(name="default").first() + host_context = _host_cards_context() + hosts = host_context["hosts"] 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] @@ -100,7 +121,7 @@ def _dashboard_context() -> dict[str, object]: "hosts": hosts, "global_config": global_config, "stats_summary": stats_summary, - "scheduler_timezone": timezone.get_current_timezone_name(), + "scheduler_timezone": host_context["scheduler_timezone"], "action_items": action_items, "next_schedule_rows": next_schedule_rows, "recent_runs": recent_runs, @@ -127,6 +148,69 @@ def _dashboard_context() -> dict[str, object]: return context +@staff_member_required +def hosts_list(request): + enabled = request.GET.get("enabled", "").strip() + if enabled not in {"", "yes", "no"}: + enabled = "" + global_config = GlobalConfig.objects.filter(name="default").first() + context = _host_cards_context(enabled=enabled) + collect_dashboard_stats(hosts=context["hosts"], global_config=global_config) + return render( + request, + "pobsync_backend/hosts_list.html", + { + **context, + "global_config": global_config, + "show_host_controls": True, + "total_count": HostConfig.objects.count(), + }, + ) + + +@staff_member_required +@require_POST +def update_host_state(request, host: str): + host_config = get_object_or_404(HostConfig, host=host) + action = request.POST.get("action", "").strip() + next_url = request.POST.get("next") or reverse("hosts_list") + if not url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}): + next_url = reverse("hosts_list") + + if action == "enable_host": + host_config.enabled = True + host_config.save(update_fields=["enabled", "updated_at"]) + messages.success(request, f"Enabled host {host_config.host}.") + elif action == "disable_host": + host_config.enabled = False + host_config.save(update_fields=["enabled", "updated_at"]) + messages.success(request, f"Disabled host {host_config.host}.") + elif action in {"enable_schedule", "disable_schedule", "enable_prune", "disable_prune"}: + try: + schedule = host_config.schedule + except ScheduleConfig.DoesNotExist: + messages.warning(request, f"{host_config.host} does not have a schedule yet.") + else: + if action == "enable_schedule": + schedule.enabled = True + message = f"Enabled backup schedule for {host_config.host}." + elif action == "disable_schedule": + schedule.enabled = False + message = f"Paused backup schedule for {host_config.host}." + elif action == "enable_prune": + schedule.prune = True + message = f"Enabled scheduled retention for {host_config.host}." + else: + schedule.prune = False + message = f"Paused scheduled retention for {host_config.host}." + schedule.save(update_fields=["enabled", "prune", "updated_at"]) + messages.success(request, message) + else: + messages.error(request, "Unknown host state action.") + + return redirect(next_url) + + def _dashboard_action_items(hosts: list[HostConfig]) -> list[dict[str, object]]: action_items: list[dict[str, object]] = [] for host_config in hosts: diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index 9e71b59..5e4b04b 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -21,8 +21,10 @@ urlpatterns = [ path("ssh-credentials/generate/", views.generate_ssh_credential, name="generate_ssh_credential"), path("ssh-credentials/