diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index d77fcb4..dc0f733 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -752,6 +752,15 @@ .host-card-warning > * { min-width: 0; } + .host-card-actions { + align-items: center; + border-top: 1px solid var(--border); + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; + padding-top: 12px; + } .messages { display: grid; gap: 8px; margin-bottom: 18px; } .message { background: var(--panel); @@ -909,6 +918,7 @@ pobsync Dashboard + Hosts SSH Keys Logs Purged diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index 89e893d..8393f9d 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -42,7 +42,7 @@
-
Hosts
{{ counts.enabled_hosts }}/{{ counts.hosts }}
+
Hosts
{{ counts.enabled_hosts }}/{{ counts.hosts }}
Schedules
{{ counts.enabled_schedules }}/{{ counts.schedules }}
Snapshots
{{ counts.snapshots }}
Runs
{{ counts.runs }}
diff --git a/src/pobsync_backend/templates/pobsync_backend/hosts_list.html b/src/pobsync_backend/templates/pobsync_backend/hosts_list.html new file mode 100644 index 0000000..15d7d33 --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/hosts_list.html @@ -0,0 +1,43 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}Hosts | pobsync{% endblock %} + +{% block content %} + + +
+
Showing
{{ counts.hosts }}
+
Enabled
{{ counts.enabled_hosts }}
+
Disabled
{{ counts.disabled_hosts }}
+
Total
{{ total_count }}
+
+ +
+

Filters

+
+
+ + +
+
+ + Reset +
+
+
+ + {% include "pobsync_backend/partials/dashboard_hosts.html" %} +{% endblock %} diff --git a/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_hosts.html b/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_hosts.html index f255a96..a693a56 100644 --- a/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_hosts.html +++ b/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_hosts.html @@ -22,6 +22,14 @@ {% if host.failed_run_count %} failed {{ host.failed_run_count }} {% endif %} + {% if show_host_controls %} + {% if host.schedule %} + schedule {{ host.schedule.enabled|yesno:"on,paused" }} + retention {{ host.schedule.prune|yesno:"on,paused" }} + {% else %} + no schedule + {% endif %} + {% endif %}
@@ -115,6 +123,33 @@ {% endif %}
{% endif %} + {% if show_host_controls %} +
+ Open + Edit config + {% if host.schedule %}Edit schedule{% else %}Create schedule{% endif %} +
+ {% csrf_token %} + + + +
+ {% if host.schedule %} +
+ {% csrf_token %} + + + +
+
+ {% csrf_token %} + + + +
+ {% endif %} +
+ {% endif %} {% empty %}

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//", views.edit_ssh_credential, name="edit_ssh_credential"), path("ssh-credentials//delete/", views.delete_ssh_credential, name="delete_ssh_credential"), + path("hosts/", views.hosts_list, name="hosts_list"), path("hosts/new/", views.create_host_config, name="create_host_config"), path("hosts//", views.host_detail, name="host_detail"), + path("hosts//state/", views.update_host_state, name="update_host_state"), path("hosts//config/", views.edit_host_config, name="edit_host_config"), path("hosts//prepare-directories/", views.prepare_host_directories, name="prepare_host_directories"), path("hosts//scan-known-key/", views.scan_host_known_key, name="scan_host_known_key"),