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:
+ + {% 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 = {