(feature) Improve retention UX and prune safety #14
@@ -78,8 +78,11 @@ def run_sql_retention_apply(
|
|||||||
def _do_apply() -> dict[str, Any]:
|
def _do_apply() -> dict[str, Any]:
|
||||||
plan = run_sql_retention_plan(host=host, kind=kind, protect_bases=bool(protect_bases))
|
plan = run_sql_retention_plan(host=host, kind=kind, protect_bases=bool(protect_bases))
|
||||||
delete_list = plan.get("delete") or []
|
delete_list = plan.get("delete") or []
|
||||||
|
incomplete_list = plan.get("incomplete") or []
|
||||||
if not isinstance(delete_list, list):
|
if not isinstance(delete_list, list):
|
||||||
raise ConfigError("Invalid retention plan output: delete is not a 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:
|
if max_delete == 0 and len(delete_list) > 0:
|
||||||
raise ConfigError("Deletion blocked by --max-delete=0")
|
raise ConfigError("Deletion blocked by --max-delete=0")
|
||||||
if len(delete_list) > max_delete:
|
if len(delete_list) > max_delete:
|
||||||
@@ -116,6 +119,8 @@ def run_sql_retention_apply(
|
|||||||
"protect_bases": bool(protect_bases),
|
"protect_bases": bool(protect_bases),
|
||||||
"max_delete": max_delete,
|
"max_delete": max_delete,
|
||||||
"source": "sql",
|
"source": "sql",
|
||||||
|
"planned_delete_count": len(delete_list),
|
||||||
|
"incomplete_ignored_count": len(incomplete_list),
|
||||||
"deleted": deleted,
|
"deleted": deleted,
|
||||||
"actions": actions,
|
"actions": actions,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,7 +172,20 @@
|
|||||||
<div class="stack">
|
<div class="stack">
|
||||||
<div><strong>Status:</strong> {% if prune_result.ok %}ok{% else %}warning{% endif %}</div>
|
<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.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.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.error %}<div><strong>Error:</strong> {{ prune_result.error }}</div>{% endif %}
|
||||||
{% if prune_result.type %}<div><strong>Type:</strong> {{ prune_result.type }}</div>{% endif %}
|
{% if prune_result.type %}<div><strong>Type:</strong> {{ prune_result.type }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ class SqlRetentionTests(TestCase):
|
|||||||
self.assertTrue(SnapshotRecord.objects.filter(pk=new.pk).exists())
|
self.assertTrue(SnapshotRecord.objects.filter(pk=new.pk).exists())
|
||||||
self.assertFalse(SnapshotRecord.objects.filter(pk=old.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["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:
|
def test_apply_deletes_snapshot_with_readonly_data_directory(self) -> None:
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
|
|||||||
@@ -1197,9 +1197,15 @@ class ViewTests(TestCase):
|
|||||||
"hint": "Check network connectivity.",
|
"hint": "Check network connectivity.",
|
||||||
},
|
},
|
||||||
"prune": {
|
"prune": {
|
||||||
"ok": False,
|
"ok": True,
|
||||||
"type": "ConfigError",
|
"source": "sql",
|
||||||
"error": "Deletion blocked by --max-delete=0",
|
"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, "transport")
|
||||||
self.assertContains(response, "Check network connectivity.")
|
self.assertContains(response, "Check network connectivity.")
|
||||||
self.assertContains(response, "Retention")
|
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:
|
def test_run_detail_surfaces_host_retention_warnings(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
|
|||||||
Reference in New Issue
Block a user