(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.
This commit is contained in:
@@ -27,12 +27,51 @@
|
|||||||
<h2>Failure</h2>
|
<h2>Failure</h2>
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<div><strong>Category:</strong> {{ failure.category|default:"unknown" }}</div>
|
<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><strong>Hint:</strong> {{ failure.hint|default:"" }}</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% 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">
|
<div class="two-col">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Timing</h2>
|
<h2>Timing</h2>
|
||||||
|
|||||||
@@ -1066,12 +1066,69 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "--archive")
|
self.assertContains(response, "--archive")
|
||||||
self.assertContains(response, "Rsync Log")
|
self.assertContains(response, "Rsync Log")
|
||||||
self.assertContains(response, "sending incremental file list")
|
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, "Stats")
|
||||||
self.assertContains(response, "Files seen:</strong> 10")
|
self.assertContains(response, "Files seen:</strong> 10")
|
||||||
self.assertContains(response, "Estimated link-dest saving")
|
self.assertContains(response, "Estimated link-dest saving")
|
||||||
self.assertContains(response, ""ok": true")
|
self.assertContains(response, ""ok": true")
|
||||||
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
|
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:
|
def test_run_detail_links_existing_rsync_log(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
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 {}
|
prune_result = result.get("prune") if isinstance(result.get("prune"), dict) else {}
|
||||||
rsync_log_path = _run_rsync_log_path(run)
|
rsync_log_path = _run_rsync_log_path(run)
|
||||||
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
|
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
|
||||||
|
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
|
||||||
context = {
|
context = {
|
||||||
"run": run,
|
"run": run,
|
||||||
"can_cancel": run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING},
|
"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 {},
|
"stats": run_stats if isinstance(run_stats, dict) else {},
|
||||||
"rsync": rsync_result,
|
"rsync": rsync_result,
|
||||||
"rsync_command": _run_rsync_command(rsync_result),
|
"rsync_command": _run_rsync_command(rsync_result),
|
||||||
"failure": failure,
|
"failure": failure,
|
||||||
|
"failure_summary": failure.get("message") or failure.get("summary") or "",
|
||||||
"prune_result": prune_result,
|
"prune_result": prune_result,
|
||||||
"has_prune_result": bool(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_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_exists": bool(rsync_log_path and rsync_log_path.exists()),
|
||||||
"rsync_log_tail": rsync_log_tail,
|
"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),
|
"result_json": _pretty_json(run.result),
|
||||||
}
|
}
|
||||||
return render(request, "pobsync_backend/run_detail.html", context)
|
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 []
|
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]:
|
def _log_context(request) -> dict[str, object]:
|
||||||
units = ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service")
|
units = ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service")
|
||||||
priorities = {
|
priorities = {
|
||||||
|
|||||||
Reference in New Issue
Block a user