(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

@@ -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);

View File

@@ -10,6 +10,7 @@
{% csrf_token %}
<button type="submit">Discover snapshots</button>
</form>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
</section>
<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)
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:
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
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.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