(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:
@@ -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:
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ urlpatterns = [
|
||||
path("", views.dashboard, name="dashboard"),
|
||||
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>/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),
|
||||
|
||||
Reference in New Issue
Block a user