(ui) Make retention planning warnings explicit
Show keep/delete reasons in the retention plan, surface scheduled prune limit warnings, and explain base snapshot protection before retention is applied. Also surface incomplete snapshots from the retention views without deleting them automatically, so interrupted backups are visible on the dashboard, host detail, and retention plan.
This commit is contained in:
@@ -32,7 +32,10 @@ class SqlRetentionTests(TestCase):
|
||||
|
||||
self.assertEqual(plan["source"], "sql")
|
||||
self.assertEqual(plan["keep"], [new.dirname])
|
||||
self.assertEqual([item["dirname"] for item in plan["keep_items"]], [new.dirname])
|
||||
self.assertEqual([item["dirname"] for item in plan["delete"]], [old.dirname])
|
||||
self.assertEqual(plan["delete"][0]["reason"], "outside retention policy")
|
||||
self.assertEqual(plan["incomplete"], [])
|
||||
|
||||
def test_plan_can_protect_base_snapshot_from_sql_relation(self) -> None:
|
||||
host = HostConfig.objects.create(
|
||||
|
||||
@@ -99,6 +99,35 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "manual")
|
||||
self.assertContains(response, "1000")
|
||||
|
||||
def test_dashboard_surfaces_retention_warnings(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,
|
||||
)
|
||||
ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", enabled=True, prune=True, prune_max_delete=1)
|
||||
self._snapshot(host, "20260517-021500Z__OLDSNP1")
|
||||
self._snapshot(host, "20260518-021500Z__OLDSNP2")
|
||||
self._snapshot(host, "20260519-021500Z__NEWSNAP")
|
||||
SnapshotRecord.objects.create(
|
||||
host=host,
|
||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||
dirname="20260519-031500Z__BROKEN01",
|
||||
path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
|
||||
status="failed",
|
||||
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("dashboard"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Scheduled prune would delete 2 snapshot(s), above max 1.")
|
||||
self.assertContains(response, "1 incomplete snapshot(s) need review.")
|
||||
|
||||
def test_dashboard_links_latest_snapshot_for_each_host(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
@@ -1351,6 +1380,30 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, new_snapshot.dirname)
|
||||
self.assertContains(response, "newest")
|
||||
self.assertContains(response, "Would Delete")
|
||||
self.assertContains(response, "outside retention policy")
|
||||
|
||||
def test_retention_plan_warns_when_scheduled_prune_limit_is_exceeded(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,
|
||||
)
|
||||
ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", enabled=True, prune=True, prune_max_delete=1)
|
||||
self._snapshot(host, "20260517-021500Z__OLDSNP1")
|
||||
self._snapshot(host, "20260518-021500Z__OLDSNP2")
|
||||
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, "Scheduled Prune Limit")
|
||||
self.assertContains(response, "would delete 2 snapshot(s)")
|
||||
self.assertContains(response, "scheduled prune limit of")
|
||||
self.assertContains(response, "Schedule max delete:</strong> 1")
|
||||
|
||||
def test_retention_plan_can_enable_base_protection(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -1371,8 +1424,58 @@ class ViewTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Protect bases:</strong> yes")
|
||||
self.assertContains(response, "Base snapshots referenced by kept snapshots")
|
||||
self.assertContains(response, f"base-of:{child.dirname}")
|
||||
|
||||
def test_retention_plan_surfaces_incomplete_snapshots_without_deleting_them(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,
|
||||
)
|
||||
self._snapshot(host, "20260518-021500Z__OLDSNAP")
|
||||
self._snapshot(host, "20260519-021500Z__NEWSNAP")
|
||||
SnapshotRecord.objects.create(
|
||||
host=host,
|
||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||
dirname="20260519-031500Z__BROKEN01",
|
||||
path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
|
||||
status="failed",
|
||||
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("host_retention_plan", args=[host.host]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Incomplete Snapshots")
|
||||
self.assertContains(response, "20260519-031500Z__BROKEN01")
|
||||
self.assertContains(response, "excluded from retention cleanup")
|
||||
|
||||
def test_host_detail_surfaces_retention_warnings(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,
|
||||
)
|
||||
ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", enabled=True, prune=True, prune_max_delete=1)
|
||||
self._snapshot(host, "20260517-021500Z__OLDSNP1")
|
||||
self._snapshot(host, "20260518-021500Z__OLDSNP2")
|
||||
self._snapshot(host, "20260519-021500Z__NEWSNAP")
|
||||
|
||||
response = self.client.get(reverse("host_detail", args=[host.host]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Retention Warnings")
|
||||
self.assertContains(response, "Scheduled pruning would delete 2 snapshot(s), above max delete")
|
||||
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user