diff --git a/README.md b/README.md index 65ba319..41d01c7 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ Post-backup pruning from Django also uses the SQL retention service after the co Staff-only JSON endpoints expose service status, hosts, snapshots, and backup runs for lightweight inspection. Staff-only dashboard views expose the same operational state through Django templates. Host pages include a safe snapshot discovery action that records existing snapshots into SQL. +Host pages also include a read-only SQL retention plan view before any destructive pruning action. The remaining internal engine code still contains reusable backup primitives: diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 6f9fd09..8a17752 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -80,7 +80,7 @@ .stack { display: grid; gap: 4px; } .two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } .actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; } - button { + button, .button-link { appearance: none; background: #17202a; border: 1px solid #17202a; @@ -91,7 +91,7 @@ font-weight: 650; padding: 8px 12px; } - button:hover { background: #2a394a; } + button:hover, .button-link:hover { background: #2a394a; text-decoration: none; } .messages { display: grid; gap: 8px; margin-bottom: 18px; } .message { background: var(--panel); diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html index 1c3e7dc..d4fd7bc 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -10,6 +10,7 @@ {% csrf_token %} + Plan retention
diff --git a/src/pobsync_backend/templates/pobsync_backend/retention_plan.html b/src/pobsync_backend/templates/pobsync_backend/retention_plan.html new file mode 100644 index 0000000..dac85a7 --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/retention_plan.html @@ -0,0 +1,83 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}Retention plan | {{ host.host }}{% endblock %} + +{% block content %} +

Retention Plan: {{ host.host }}

+ +
+ Back to host + Scheduled + Manual + All + Protect bases +
+ +
+
Source
{{ plan.source }}
+
Kind
{{ plan.kind }}
+
Keep
{{ plan.keep|length }}
+
Would Delete
{{ plan.delete|length }}
+
+ +
+

Policy

+
+
Daily: {{ plan.retention.daily }}
+
Weekly: {{ plan.retention.weekly }}
+
Monthly: {{ plan.retention.monthly }}
+
Yearly: {{ plan.retention.yearly }}
+
Protect bases: {{ protect_bases|yesno:"yes,no" }}
+
+
+ +
+

Would Delete

+ + + + + + + + + + + + {% for snapshot in plan.delete %} + + + + + + + + {% empty %} + + {% endfor %} + +
KindDirnameStartedStatusPath
{{ snapshot.kind }}{{ snapshot.dirname }}{{ snapshot.dt }}{{ snapshot.status|default:"" }}{{ snapshot.path }}
Retention would not delete snapshots for this selection.
+
+ +
+

Keep Reasons

+ + + + + + + + + {% for dirname, reasons in plan.reasons.items %} + + + + + {% empty %} + + {% endfor %} + +
DirnameReasons
{{ dirname }}{{ reasons|join:", " }}
No snapshots matched this retention selection.
+
+{% endblock %} diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index cfbc9fc..e34a0ed 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -100,6 +100,58 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 405) + def test_retention_plan_renders_keep_and_delete_sets(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create( + host="web-01", + address="web-01.example.test", + retention_daily=0, + retention_weekly=0, + retention_monthly=0, + retention_yearly=0, + ) + old_snapshot = self._snapshot(host, "20260518-021500Z__OLDSNAP") + new_snapshot = self._snapshot(host, "20260519-021500Z__NEWSNAP") + + response = self.client.get(reverse("host_retention_plan", args=[host.host])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Retention Plan: web-01") + self.assertContains(response, old_snapshot.dirname) + self.assertContains(response, new_snapshot.dirname) + self.assertContains(response, "newest") + self.assertContains(response, "Would Delete") + + def test_retention_plan_can_enable_base_protection(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create( + host="web-01", + address="web-01.example.test", + retention_daily=0, + retention_weekly=0, + retention_monthly=0, + retention_yearly=0, + ) + base = self._snapshot(host, "20260518-021500Z__BASESNAP") + child = self._snapshot(host, "20260519-021500Z__CHILDSNP") + child.base = base + child.save(update_fields=["base"]) + + response = self.client.get(reverse("host_retention_plan", args=[host.host]), {"protect_bases": "1"}) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Protect bases: yes") + self.assertContains(response, f"base-of:{child.dirname}") + + 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") + + response = self.client.get(reverse("host_retention_plan", args=[host.host]), {"kind": "weird"}, follow=True) + + self.assertRedirects(response, reverse("host_detail", args=[host.host])) + self.assertContains(response, "Retention kind must be scheduled, manual, or all.") + def _snapshot(self, host: HostConfig, dirname: str) -> SnapshotRecord: started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc) return SnapshotRecord.objects.create( diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 58bc8d0..d04fb8c 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -6,7 +6,10 @@ from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.http import require_POST +from pobsync.errors import ConfigError + from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord +from .retention import run_sql_retention_plan from .snapshot_discovery import discover_snapshots @@ -70,6 +73,28 @@ def discover_host_snapshots(request, host: str): return redirect("host_detail", host=host_config.host) +@staff_member_required +def host_retention_plan(request, host: str): + host_config = get_object_or_404(HostConfig, host=host) + kind = request.GET.get("kind", "scheduled") + if kind not in {"scheduled", "manual", "all"}: + messages.error(request, "Retention kind must be scheduled, manual, or all.") + return redirect("host_detail", host=host_config.host) + protect_bases = request.GET.get("protect_bases") in {"1", "true", "on", "yes"} + try: + plan = run_sql_retention_plan(host=host_config.host, kind=kind, protect_bases=protect_bases) + except ConfigError as exc: + messages.error(request, str(exc)) + return redirect("host_detail", host=host_config.host) + context = { + "host": host_config, + "kind": kind, + "protect_bases": protect_bases, + "plan": plan, + } + return render(request, "pobsync_backend/retention_plan.html", context) + + def _schedule_for_host(host_config: HostConfig) -> ScheduleConfig | None: try: return host_config.schedule diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index e09470f..fbb6dbe 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -10,6 +10,7 @@ urlpatterns = [ path("", views.dashboard, name="dashboard"), path("hosts//", views.host_detail, name="host_detail"), path("hosts//discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"), + path("hosts//retention-plan/", views.host_retention_plan, name="host_retention_plan"), path("api/", api.api_index), path("api/status/", api.status), path("api/hosts/", api.hosts),