(ui) Show retention apply details on run detail

Record planned delete counts, max-delete settings, base protection, and
ignored incomplete snapshots in retention apply results.

Surface those details on run detail pages so scheduled and manual prune
outcomes are understandable without reading the raw JSON payload.
This commit is contained in:
2026-05-21 01:25:40 +02:00
parent 994f7f66c4
commit 97753c3d3c
4 changed files with 35 additions and 4 deletions

View File

@@ -78,8 +78,11 @@ def run_sql_retention_apply(
def _do_apply() -> dict[str, Any]:
plan = run_sql_retention_plan(host=host, kind=kind, protect_bases=bool(protect_bases))
delete_list = plan.get("delete") or []
incomplete_list = plan.get("incomplete") or []
if not isinstance(delete_list, list):
raise ConfigError("Invalid retention plan output: delete is not a list")
if not isinstance(incomplete_list, list):
raise ConfigError("Invalid retention plan output: incomplete is not a list")
if max_delete == 0 and len(delete_list) > 0:
raise ConfigError("Deletion blocked by --max-delete=0")
if len(delete_list) > max_delete:
@@ -116,6 +119,8 @@ def run_sql_retention_apply(
"protect_bases": bool(protect_bases),
"max_delete": max_delete,
"source": "sql",
"planned_delete_count": len(delete_list),
"incomplete_ignored_count": len(incomplete_list),
"deleted": deleted,
"actions": actions,
}

View File

@@ -172,7 +172,20 @@
<div class="stack">
<div><strong>Status:</strong> {% if prune_result.ok %}ok{% else %}warning{% endif %}</div>
{% if prune_result.source %}<div><strong>Source:</strong> {{ prune_result.source }}</div>{% endif %}
{% if prune_result.kind %}<div><strong>Kind:</strong> {{ prune_result.kind }}</div>{% endif %}
{% if prune_result.planned_delete_count is not None %}<div><strong>Planned deletions:</strong> {{ prune_result.planned_delete_count }}</div>{% endif %}
{% if prune_result.deleted %}<div><strong>Deleted:</strong> {{ prune_result.deleted|length }}</div>{% endif %}
{% if prune_result.max_delete is not None %}<div><strong>Max delete:</strong> {{ prune_result.max_delete }}</div>{% endif %}
{% if prune_result.protect_bases is not None %}<div><strong>Protect bases:</strong> {{ prune_result.protect_bases|yesno:"yes,no" }}</div>{% endif %}
{% if prune_result.incomplete_ignored_count %}<div><strong>Incomplete ignored:</strong> {{ prune_result.incomplete_ignored_count }}</div>{% endif %}
{% if prune_result.actions %}
<div><strong>Actions:</strong></div>
<ul>
{% for action in prune_result.actions %}
<li>{{ action }}</li>
{% endfor %}
</ul>
{% endif %}
{% if prune_result.error %}<div><strong>Error:</strong> {{ prune_result.error }}</div>{% endif %}
{% if prune_result.type %}<div><strong>Type:</strong> {{ prune_result.type }}</div>{% endif %}
</div>

View File

@@ -88,6 +88,9 @@ class SqlRetentionTests(TestCase):
self.assertTrue(SnapshotRecord.objects.filter(pk=new.pk).exists())
self.assertFalse(SnapshotRecord.objects.filter(pk=old.pk).exists())
self.assertEqual(result["deleted"], [{"dirname": old.dirname, "kind": "scheduled", "path": str(old_dir)}])
self.assertEqual(result["planned_delete_count"], 1)
self.assertEqual(result["max_delete"], 1)
self.assertEqual(result["incomplete_ignored_count"], 0)
def test_apply_deletes_snapshot_with_readonly_data_directory(self) -> None:
with TemporaryDirectory() as tmp:

View File

@@ -1197,9 +1197,15 @@ class ViewTests(TestCase):
"hint": "Check network connectivity.",
},
"prune": {
"ok": False,
"type": "ConfigError",
"error": "Deletion blocked by --max-delete=0",
"ok": True,
"source": "sql",
"kind": "scheduled",
"planned_delete_count": 1,
"max_delete": 1,
"protect_bases": True,
"incomplete_ignored_count": 1,
"deleted": [{"dirname": "20260518-021500Z__OLD"}],
"actions": ["deleted scheduled 20260518-021500Z__OLD"],
},
},
)
@@ -1211,7 +1217,11 @@ class ViewTests(TestCase):
self.assertContains(response, "transport")
self.assertContains(response, "Check network connectivity.")
self.assertContains(response, "Retention")
self.assertContains(response, "Deletion blocked by --max-delete=0")
self.assertContains(response, "Planned deletions")
self.assertContains(response, "Max delete")
self.assertContains(response, "Protect bases")
self.assertContains(response, "Incomplete ignored")
self.assertContains(response, "deleted scheduled 20260518-021500Z__OLD")
def test_run_detail_surfaces_host_retention_warnings(self) -> None:
self.client.force_login(self.staff_user)