(feature) Add backup safety and preflight validation #13
@@ -27,12 +27,51 @@
|
||||
<h2>Failure</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Category:</strong> {{ failure.category|default:"unknown" }}</div>
|
||||
<div><strong>Summary:</strong> {{ failure.summary|default:"" }}</div>
|
||||
<div><strong>Summary:</strong> {{ failure_summary }}</div>
|
||||
<div><strong>Hint:</strong> {{ failure.hint|default:"" }}</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if dry_run_summary %}
|
||||
<section class="panel highlight {{ dry_run_summary.highlight_class }}">
|
||||
<h2>Dry Run Summary</h2>
|
||||
<section class="grid" aria-label="Dry run summary">
|
||||
<div class="metric"><div class="label">Status</div><div class="value">{{ dry_run_summary.status }}</div></div>
|
||||
<div class="metric"><div class="label">Files Seen</div><div class="value">{{ dry_run_summary.files_seen|default:"unknown" }}</div></div>
|
||||
<div class="metric"><div class="label">Would Transfer</div><div class="value">{{ dry_run_summary.files_would_transfer|default:"unknown" }}</div></div>
|
||||
<div class="metric"><div class="label">Transfer Estimate</div><div class="value">{{ dry_run_summary.transfer_estimate_bytes|filesizeformat }}</div></div>
|
||||
<div class="metric"><div class="label">Total Size</div><div class="value">{{ dry_run_summary.total_file_size_bytes|filesizeformat }}</div></div>
|
||||
<div class="metric"><div class="label">Link-Dest Saving</div><div class="value">{{ dry_run_summary.link_dest_estimated_savings_bytes|filesizeformat }}</div></div>
|
||||
</section>
|
||||
<div class="stack">
|
||||
{% if dry_run_summary.duration_seconds is not None %}
|
||||
<div><strong>Duration:</strong> {{ dry_run_summary.duration_seconds }}s</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<strong>Log:</strong>
|
||||
{% if dry_run_summary.log_available %}
|
||||
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
|
||||
{% elif rsync_log_path %}
|
||||
<span class="muted">{{ rsync_log_path }} (missing)</span>
|
||||
{% else %}
|
||||
<span class="muted">not recorded yet</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if dry_run_summary.warnings %}
|
||||
<div><strong>Warnings:</strong></div>
|
||||
<ul>
|
||||
{% for warning in dry_run_summary.warnings %}
|
||||
<li>{{ warning }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div><strong>Warnings:</strong> none recorded</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="two-col">
|
||||
<section class="panel">
|
||||
<h2>Timing</h2>
|
||||
|
||||
@@ -1066,12 +1066,69 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "--archive")
|
||||
self.assertContains(response, "Rsync Log")
|
||||
self.assertContains(response, "sending incremental file list")
|
||||
self.assertContains(response, "Dry Run Summary")
|
||||
self.assertContains(response, "Files Seen")
|
||||
self.assertContains(response, "Would Transfer")
|
||||
self.assertContains(response, "Transfer Estimate")
|
||||
self.assertContains(response, "Warnings:</strong> none recorded")
|
||||
self.assertContains(response, "Stats")
|
||||
self.assertContains(response, "Files seen:</strong> 10")
|
||||
self.assertContains(response, "Estimated link-dest saving")
|
||||
self.assertContains(response, ""ok": true")
|
||||
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
|
||||
|
||||
def test_run_detail_surfaces_dry_run_warnings_and_log_link(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
with TemporaryDirectory() as tmp:
|
||||
log_path = Path(tmp) / "dry-run" / "rsync.log"
|
||||
log_path.parent.mkdir(parents=True)
|
||||
log_path.write_text("WARNING: noisy shell output\npermission denied\n", encoding="utf-8")
|
||||
run = BackupRun.objects.create(
|
||||
host=host,
|
||||
status=BackupRun.Status.FAILED,
|
||||
rsync_exit_code=255,
|
||||
result={
|
||||
"ok": False,
|
||||
"dry_run": True,
|
||||
"log": str(log_path),
|
||||
"failure": {
|
||||
"category": "transport",
|
||||
"message": "Rsync transport failed.",
|
||||
"hint": "Check SSH access.",
|
||||
},
|
||||
"stats": {
|
||||
"duration_seconds": 4,
|
||||
"rsync": {
|
||||
"files_total": 25,
|
||||
"files_transferred": 3,
|
||||
"total_file_size_bytes": 10_000,
|
||||
"total_transferred_file_size_bytes": 1_500,
|
||||
},
|
||||
},
|
||||
"rsync": {
|
||||
"exit_code": 255,
|
||||
"log_tail": ["WARNING: noisy shell output", "permission denied"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("run_detail", args=[run.id]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Dry Run Summary")
|
||||
self.assertContains(response, "failed")
|
||||
self.assertContains(response, "Files Seen")
|
||||
self.assertContains(response, "25")
|
||||
self.assertContains(response, "Would Transfer")
|
||||
self.assertContains(response, "3")
|
||||
self.assertContains(response, "1.5")
|
||||
self.assertContains(response, "Open full rsync log")
|
||||
self.assertContains(response, reverse("run_rsync_log", args=[run.id]))
|
||||
self.assertContains(response, "Rsync transport failed.")
|
||||
self.assertContains(response, "Check SSH access.")
|
||||
self.assertContains(response, "WARNING: noisy shell output")
|
||||
|
||||
def test_run_detail_links_existing_rsync_log(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
|
||||
@@ -415,19 +415,29 @@ def run_detail(request, run_id: int):
|
||||
prune_result = result.get("prune") if isinstance(result.get("prune"), dict) else {}
|
||||
rsync_log_path = _run_rsync_log_path(run)
|
||||
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
|
||||
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
|
||||
context = {
|
||||
"run": run,
|
||||
"can_cancel": run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING},
|
||||
"requested": result.get("requested") if isinstance(result.get("requested"), dict) else {},
|
||||
"requested": requested,
|
||||
"stats": run_stats if isinstance(run_stats, dict) else {},
|
||||
"rsync": rsync_result,
|
||||
"rsync_command": _run_rsync_command(rsync_result),
|
||||
"failure": failure,
|
||||
"failure_summary": failure.get("message") or failure.get("summary") or "",
|
||||
"prune_result": prune_result,
|
||||
"has_prune_result": bool(prune_result),
|
||||
"rsync_log_path": str(rsync_log_path) if rsync_log_path is not None else "",
|
||||
"rsync_log_exists": bool(rsync_log_path and rsync_log_path.exists()),
|
||||
"rsync_log_tail": rsync_log_tail,
|
||||
"dry_run_summary": _dry_run_summary(
|
||||
result=result,
|
||||
requested=requested,
|
||||
stats=run_stats if isinstance(run_stats, dict) else {},
|
||||
failure=failure,
|
||||
rsync_log_tail=rsync_log_tail,
|
||||
rsync_log_exists=bool(rsync_log_path and rsync_log_path.exists()),
|
||||
),
|
||||
"result_json": _pretty_json(run.result),
|
||||
}
|
||||
return render(request, "pobsync_backend/run_detail.html", context)
|
||||
@@ -739,6 +749,48 @@ def _run_rsync_log_tail(rsync_result: dict, log_path: Path | None, *, max_lines:
|
||||
return []
|
||||
|
||||
|
||||
def _dry_run_summary(
|
||||
*,
|
||||
result: dict,
|
||||
requested: dict,
|
||||
stats: dict,
|
||||
failure: dict,
|
||||
rsync_log_tail: list[str],
|
||||
rsync_log_exists: bool,
|
||||
) -> dict[str, object]:
|
||||
if not (result.get("dry_run") or requested.get("dry_run")):
|
||||
return {}
|
||||
|
||||
rsync_stats = stats.get("rsync") if isinstance(stats.get("rsync"), dict) else {}
|
||||
warnings = []
|
||||
if failure:
|
||||
message = failure.get("message") or failure.get("summary")
|
||||
hint = failure.get("hint")
|
||||
if message:
|
||||
warnings.append(str(message))
|
||||
if hint:
|
||||
warnings.append(str(hint))
|
||||
for line in rsync_log_tail:
|
||||
lowered = line.lower()
|
||||
if "warning" in lowered or "permission denied" in lowered or "failed" in lowered:
|
||||
warnings.append(line)
|
||||
|
||||
return {
|
||||
"ok": result.get("ok"),
|
||||
"status": "passed" if result.get("ok") else ("failed" if result.get("ok") is False else "running"),
|
||||
"highlight_class": "success" if result.get("ok") else ("failed" if result.get("ok") is False else "warning"),
|
||||
"files_seen": rsync_stats.get("files_total"),
|
||||
"files_would_transfer": rsync_stats.get("files_transferred"),
|
||||
"total_file_size_bytes": rsync_stats.get("total_file_size_bytes"),
|
||||
"transfer_estimate_bytes": rsync_stats.get("total_transferred_file_size_bytes")
|
||||
or rsync_stats.get("literal_data_bytes"),
|
||||
"link_dest_estimated_savings_bytes": rsync_stats.get("link_dest_estimated_savings_bytes"),
|
||||
"duration_seconds": stats.get("duration_seconds"),
|
||||
"log_available": rsync_log_exists,
|
||||
"warnings": list(dict.fromkeys(warnings)),
|
||||
}
|
||||
|
||||
|
||||
def _log_context(request) -> dict[str, object]:
|
||||
units = ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service")
|
||||
priorities = {
|
||||
|
||||
Reference in New Issue
Block a user