From 98695f988843455298f354346f47fd4223d15a59 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 00:17:39 +0200 Subject: [PATCH] (ui) Surface run debugging details in Django Restructure the run detail page into clearer sections for summary, failure classification, requested options, rsync command, rsync log output, stats, retention, and raw result data. Show recent rsync log output inline with a link to the full log, and promote failure and retention warning details out of the JSON payload so failed or slow runs are easier to debug from the control panel. --- .../templates/pobsync_backend/base.html | 4 ++ .../templates/pobsync_backend/run_detail.html | 58 ++++++++++++++++++- src/pobsync_backend/tests/test_views.py | 45 +++++++++++++- src/pobsync_backend/views.py | 35 ++++++++++- 4 files changed, 136 insertions(+), 6 deletions(-) diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 9b08116..0a33408 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -62,6 +62,10 @@ .metric .label { color: var(--muted); font-size: 12px; text-transform: uppercase; } .metric .value { font-size: 26px; font-weight: 650; margin-top: 4px; } .panel { padding: 16px; margin-bottom: 18px; overflow: auto; } + .panel.highlight { border-left: 4px solid var(--border); } + .panel.highlight.failed { border-left-color: var(--failed); background: #fff7f7; } + .panel.highlight.warning { border-left-color: var(--running); background: #fffaf0; } + .panel.highlight.success { border-left-color: var(--success); background: #f5fbf7; } table { width: 100%; border-collapse: collapse; min-width: 640px; } th, td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; vertical-align: top; } th { color: var(--muted); font-size: 12px; font-weight: 650; text-transform: uppercase; } diff --git a/src/pobsync_backend/templates/pobsync_backend/run_detail.html b/src/pobsync_backend/templates/pobsync_backend/run_detail.html index 0b73264..7f5a7b3 100644 --- a/src/pobsync_backend/templates/pobsync_backend/run_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/run_detail.html @@ -17,11 +17,22 @@
Host
{{ run.host.host }}
-
Status
{{ run.status }}
+
Status
{{ run.status }}
Type
{{ run.run_type }}
Rsync
{{ run.rsync_exit_code|default:"" }}
+ {% if failure %} +
+

Failure

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

Timing

@@ -64,6 +75,36 @@
{% endif %} +
+

Rsync Command

+ {% if rsync_command %} +
{% for part in rsync_command %}{{ part }}{% if not forloop.last %}
+{% endif %}{% endfor %}
+ {% else %} +

No rsync command recorded yet.

+ {% endif %} +
+ +
+

Rsync Log

+
+ {% if rsync_log_exists %} +
Open full rsync log
+
{{ rsync_log_path }}
+ {% elif rsync_log_path %} +
{{ rsync_log_path }} (missing)
+ {% else %} +
No rsync log path recorded yet.
+ {% endif %} +
+ {% if rsync_log_tail %} +
{% for line in rsync_log_tail %}{{ line }}{% if not forloop.last %}
+{% endif %}{% endfor %}
+ {% else %} +

No recent rsync log output recorded yet.

+ {% endif %} +
+ {% if stats %}

Stats

@@ -86,8 +127,21 @@
{% endif %} + {% if has_prune_result %} +
+

Retention

+
+
Status: {% if prune_result.ok %}ok{% else %}warning{% endif %}
+ {% if prune_result.source %}
Source: {{ prune_result.source }}
{% endif %} + {% if prune_result.deleted %}
Deleted: {{ prune_result.deleted|length }}
{% endif %} + {% if prune_result.error %}
Error: {{ prune_result.error }}
{% endif %} + {% if prune_result.type %}
Type: {{ prune_result.type }}
{% endif %} +
+
+ {% endif %} +
-

Result

+

Raw Result

{{ result_json }}
{% endblock %} diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index d5db42c..7670133 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -857,6 +857,11 @@ class ViewTests(TestCase): result={ "ok": True, "snapshot": snapshot.path, + "rsync": { + "command": ["rsync", "--archive", "root@web-01:/", snapshot.path], + "exit_code": 0, + "log_tail": ["sending incremental file list", "sent 500 bytes"], + }, "requested": { "dry_run": True, "verbose_output": True, @@ -889,6 +894,10 @@ class ViewTests(TestCase): self.assertContains(response, "Requested Options") self.assertContains(response, "Dry run: yes") self.assertContains(response, "Verbose rsync output: yes") + self.assertContains(response, "Rsync Command") + self.assertContains(response, "--archive") + self.assertContains(response, "Rsync Log") + self.assertContains(response, "sending incremental file list") self.assertContains(response, "Stats") self.assertContains(response, "Files seen: 10") self.assertContains(response, "Estimated link-dest saving") @@ -901,7 +910,7 @@ class ViewTests(TestCase): with TemporaryDirectory() as tmp: log_path = Path(tmp) / "snapshot" / "meta" / "rsync.log" log_path.parent.mkdir(parents=True) - log_path.write_text("rsync log line\n", encoding="utf-8") + log_path.write_text("old line\nrsync log line\n", encoding="utf-8") run = BackupRun.objects.create( host=host, status=BackupRun.Status.SUCCESS, @@ -915,8 +924,40 @@ class ViewTests(TestCase): self.assertContains(response, reverse("run_rsync_log", args=[run.id])) self.assertContains(response, str(log_path)) + self.assertContains(response, "rsync log line") self.assertEqual(log_response.status_code, 200) - self.assertEqual(log_body, b"rsync log line\n") + self.assertEqual(log_body, b"old line\nrsync log line\n") + + def test_run_detail_surfaces_failure_and_retention_warning(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + run = BackupRun.objects.create( + host=host, + status=BackupRun.Status.WARNING, + rsync_exit_code=0, + result={ + "ok": True, + "failure": { + "category": "transport", + "summary": "SSH connection dropped.", + "hint": "Check network connectivity.", + }, + "prune": { + "ok": False, + "type": "ConfigError", + "error": "Deletion blocked by --max-delete=0", + }, + }, + ) + + response = self.client.get(reverse("run_detail", args=[run.id])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Failure") + self.assertContains(response, "transport") + self.assertContains(response, "Check network connectivity.") + self.assertContains(response, "Retention") + self.assertContains(response, "Deletion blocked by --max-delete=0") def test_run_detail_infers_rsync_log_from_snapshot_path(self) -> None: self.client.force_login(self.staff_user) diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 8c90d65..5d16cd4 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -362,15 +362,26 @@ def queue_manual_backup(request, host: str): @staff_member_required def run_detail(request, run_id: int): run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id) - run_stats = run.result.get("stats") if isinstance(run.result, dict) else {} + result = run.result if isinstance(run.result, dict) else {} + run_stats = result.get("stats") if isinstance(result.get("stats"), dict) else {} + rsync_result = result.get("rsync") if isinstance(result.get("rsync"), dict) else {} + failure = result.get("failure") if isinstance(result.get("failure"), dict) else {} + 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) context = { "run": run, "can_cancel": run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING}, - "requested": run.result.get("requested") if isinstance(run.result, dict) else {}, + "requested": result.get("requested") if isinstance(result.get("requested"), dict) else {}, "stats": run_stats if isinstance(run_stats, dict) else {}, + "rsync": rsync_result, + "rsync_command": _run_rsync_command(rsync_result), + "failure": failure, + "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, "result_json": _pretty_json(run.result), } return render(request, "pobsync_backend/run_detail.html", context) @@ -662,6 +673,26 @@ def _run_rsync_log_path(run: BackupRun) -> Path | None: return None +def _run_rsync_command(rsync_result: dict) -> list[str]: + command = rsync_result.get("command") + if not isinstance(command, list): + return [] + return [str(part) for part in command] + + +def _run_rsync_log_tail(rsync_result: dict, log_path: Path | None, *, max_lines: int = 30) -> list[str]: + log_tail = rsync_result.get("log_tail") + if isinstance(log_tail, list): + return [str(line) for line in log_tail[-max_lines:]] + if log_path is None or not log_path.is_file(): + return [] + try: + with log_path.open("r", encoding="utf-8", errors="replace") as handle: + return handle.read().splitlines()[-max_lines:] + except OSError: + return [] + + def _log_context(request) -> dict[str, object]: units = ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service") priorities = {