From 5faef1492d2ec83132416fbdc77d996b91f7caae Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 00:55:19 +0200 Subject: [PATCH] (ui) Add readable dry-run summaries Surface dry-run status, transfer estimates, file counts, warnings, and the full rsync log link directly on the run detail page. Keep raw rsync output and JSON available, but make the common review path easier to scan before starting a real backup. --- .../templates/pobsync_backend/run_detail.html | 41 ++++++++++++- src/pobsync_backend/tests/test_views.py | 57 +++++++++++++++++++ src/pobsync_backend/views.py | 54 +++++++++++++++++- 3 files changed, 150 insertions(+), 2 deletions(-) diff --git a/src/pobsync_backend/templates/pobsync_backend/run_detail.html b/src/pobsync_backend/templates/pobsync_backend/run_detail.html index 7f5a7b3..e66cc28 100644 --- a/src/pobsync_backend/templates/pobsync_backend/run_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/run_detail.html @@ -27,12 +27,51 @@

Failure

Category: {{ failure.category|default:"unknown" }}
-
Summary: {{ failure.summary|default:"" }}
+
Summary: {{ failure_summary }}
Hint: {{ failure.hint|default:"" }}
{% endif %} + {% if dry_run_summary %} +
+

Dry Run Summary

+
+
Status
{{ dry_run_summary.status }}
+
Files Seen
{{ dry_run_summary.files_seen|default:"unknown" }}
+
Would Transfer
{{ dry_run_summary.files_would_transfer|default:"unknown" }}
+
Transfer Estimate
{{ dry_run_summary.transfer_estimate_bytes|filesizeformat }}
+
Total Size
{{ dry_run_summary.total_file_size_bytes|filesizeformat }}
+
Link-Dest Saving
{{ dry_run_summary.link_dest_estimated_savings_bytes|filesizeformat }}
+
+
+ {% if dry_run_summary.duration_seconds is not None %} +
Duration: {{ dry_run_summary.duration_seconds }}s
+ {% endif %} +
+ Log: + {% if dry_run_summary.log_available %} + Open full rsync log + {% elif rsync_log_path %} + {{ rsync_log_path }} (missing) + {% else %} + not recorded yet + {% endif %} +
+ {% if dry_run_summary.warnings %} +
Warnings:
+
    + {% for warning in dry_run_summary.warnings %} +
  • {{ warning }}
  • + {% endfor %} +
+ {% else %} +
Warnings: none recorded
+ {% endif %} +
+
+ {% endif %} +

Timing

diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index aec3105..9c66eee 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -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: none recorded") self.assertContains(response, "Stats") self.assertContains(response, "Files seen: 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") diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 56363f1..524ea23 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -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 = {