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 = {