(feature) Add read-only retention plan view

Add a staff-only retention plan page for each host using the SQL-backed
retention service. Link it from the host detail page and show policy settings,
keep reasons, and snapshots that would be deleted for scheduled, manual, or all
snapshot kinds.

Keep the flow non-destructive for now, validate query parameters, and cover the
view with tests for rendering, base protection, and invalid kind handling.
This commit is contained in:
2026-05-19 12:00:19 +02:00
parent 3f3bdf2d45
commit 123583a502
7 changed files with 165 additions and 2 deletions

View File

@@ -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 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. 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 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: The remaining internal engine code still contains reusable backup primitives:

View File

@@ -80,7 +80,7 @@
.stack { display: grid; gap: 4px; } .stack { display: grid; gap: 4px; }
.two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } .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; } .actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; }
button { button, .button-link {
appearance: none; appearance: none;
background: #17202a; background: #17202a;
border: 1px solid #17202a; border: 1px solid #17202a;
@@ -91,7 +91,7 @@
font-weight: 650; font-weight: 650;
padding: 8px 12px; 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; } .messages { display: grid; gap: 8px; margin-bottom: 18px; }
.message { .message {
background: var(--panel); background: var(--panel);

View File

@@ -10,6 +10,7 @@
{% csrf_token %} {% csrf_token %}
<button type="submit">Discover snapshots</button> <button type="submit">Discover snapshots</button>
</form> </form>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
</section> </section>
<section class="grid" aria-label="Host summary"> <section class="grid" aria-label="Host summary">

View File

@@ -0,0 +1,83 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Retention plan | {{ host.host }}{% endblock %}
{% block content %}
<h1>Retention Plan: {{ host.host }}</h1>
<section class="actions" aria-label="Retention filters">
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=scheduled">Scheduled</a>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=manual">Manual</a>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=all">All</a>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}&protect_bases=1">Protect bases</a>
</section>
<section class="grid" aria-label="Retention plan summary">
<div class="metric"><div class="label">Source</div><div class="value">{{ plan.source }}</div></div>
<div class="metric"><div class="label">Kind</div><div class="value">{{ plan.kind }}</div></div>
<div class="metric"><div class="label">Keep</div><div class="value">{{ plan.keep|length }}</div></div>
<div class="metric"><div class="label">Would Delete</div><div class="value">{{ plan.delete|length }}</div></div>
</section>
<section class="panel">
<h2>Policy</h2>
<div class="stack">
<div><strong>Daily:</strong> {{ plan.retention.daily }}</div>
<div><strong>Weekly:</strong> {{ plan.retention.weekly }}</div>
<div><strong>Monthly:</strong> {{ plan.retention.monthly }}</div>
<div><strong>Yearly:</strong> {{ plan.retention.yearly }}</div>
<div><strong>Protect bases:</strong> {{ protect_bases|yesno:"yes,no" }}</div>
</div>
</section>
<section class="panel">
<h2>Would Delete</h2>
<table>
<thead>
<tr>
<th>Kind</th>
<th>Dirname</th>
<th>Started</th>
<th>Status</th>
<th>Path</th>
</tr>
</thead>
<tbody>
{% for snapshot in plan.delete %}
<tr>
<td>{{ snapshot.kind }}</td>
<td>{{ snapshot.dirname }}</td>
<td>{{ snapshot.dt }}</td>
<td>{{ snapshot.status|default:"" }}</td>
<td class="muted">{{ snapshot.path }}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="muted">Retention would not delete snapshots for this selection.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="panel">
<h2>Keep Reasons</h2>
<table>
<thead>
<tr>
<th>Dirname</th>
<th>Reasons</th>
</tr>
</thead>
<tbody>
{% for dirname, reasons in plan.reasons.items %}
<tr>
<td>{{ dirname }}</td>
<td>{{ reasons|join:", " }}</td>
</tr>
{% empty %}
<tr><td colspan="2" class="muted">No snapshots matched this retention selection.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View File

@@ -100,6 +100,58 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 405) 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:</strong> 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: 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) started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
return SnapshotRecord.objects.create( return SnapshotRecord.objects.create(

View File

@@ -6,7 +6,10 @@ from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from pobsync.errors import ConfigError
from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
from .retention import run_sql_retention_plan
from .snapshot_discovery import discover_snapshots 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) 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: def _schedule_for_host(host_config: HostConfig) -> ScheduleConfig | None:
try: try:
return host_config.schedule return host_config.schedule

View File

@@ -10,6 +10,7 @@ urlpatterns = [
path("", views.dashboard, name="dashboard"), path("", views.dashboard, name="dashboard"),
path("hosts/<str:host>/", views.host_detail, name="host_detail"), path("hosts/<str:host>/", views.host_detail, name="host_detail"),
path("hosts/<str:host>/discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"), path("hosts/<str:host>/discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"),
path("hosts/<str:host>/retention-plan/", views.host_retention_plan, name="host_retention_plan"),
path("api/", api.api_index), path("api/", api.api_index),
path("api/status/", api.status), path("api/status/", api.status),
path("api/hosts/", api.hosts), path("api/hosts/", api.hosts),