From 98695f988843455298f354346f47fd4223d15a59 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 00:17:39 +0200 Subject: [PATCH 1/2] (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 = { -- 2.43.0 From a9e40df44b128497fb7d9457ebc457c87c5639ba Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 00:24:07 +0200 Subject: [PATCH 2/2] (feature) Add focused filtering to the service logs view Extend the Django logs view with filters for service unit, severity, time window, host, run id, and message text. Pass severity and time window directly to journalctl, then apply host/run/message filtering to the returned pobsync journal lines. This makes failed or slow backups easier to investigate from the control panel without needing shell access. --- .../templates/pobsync_backend/logs.html | 16 +++++++ src/pobsync_backend/tests/test_views.py | 30 ++++++++++-- src/pobsync_backend/views.py | 48 +++++++++++++++++-- 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/pobsync_backend/templates/pobsync_backend/logs.html b/src/pobsync_backend/templates/pobsync_backend/logs.html index ba5adae..537fb3d 100644 --- a/src/pobsync_backend/templates/pobsync_backend/logs.html +++ b/src/pobsync_backend/templates/pobsync_backend/logs.html @@ -29,6 +29,22 @@ {% endfor %}
+
+ + +
+
+ + +
+
+ + +
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 7670133..6b00d13 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -150,21 +150,41 @@ class ViewTests(TestCase): completed = subprocess.CompletedProcess( args=["journalctl"], returncode=0, - stdout="2026-05-19 pobsync-worker.service failed backup\n2026-05-19 pobsync-web.service started\n", + stdout=( + "2026-05-19 pobsync-worker.service web-01 failed backup run 12\n" + "2026-05-19 pobsync-worker.service web-02 failed backup run 12\n" + "2026-05-19 pobsync-web.service web-01 started run 12\n" + ), stderr="", ) with patch("pobsync_backend.views.shutil.which", return_value="/usr/bin/journalctl"), patch( "pobsync_backend.views.subprocess.run", return_value=completed ) as run: - response = self.client.get(reverse("logs"), {"unit": "pobsync-worker.service", "priority": "0..3", "q": "failed"}) + response = self.client.get( + reverse("logs"), + { + "unit": "pobsync-worker.service", + "priority": "0..3", + "window": "6h", + "host": "web-01", + "run": "12", + "q": "failed", + }, + ) self.assertEqual(response.status_code, 200) self.assertContains(response, "Logs") - self.assertContains(response, "failed backup") + self.assertContains(response, "web-01 failed backup run 12") + self.assertNotContains(response, "web-02 failed backup run 12") self.assertNotContains(response, "started") - self.assertIn("-u", run.call_args.args[0]) - self.assertIn("pobsync-worker.service", run.call_args.args[0]) + command = run.call_args.args[0] + self.assertIn("-u", command) + self.assertIn("pobsync-worker.service", command) + self.assertIn("-p", command) + self.assertIn("0..3", command) + self.assertIn("--since", command) + self.assertIn("6 hours ago", command) def test_ssh_credentials_view_creates_key(self) -> None: self.client.force_login(self.staff_user) diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 5d16cd4..98b4969 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -703,8 +703,26 @@ def _log_context(request) -> dict[str, object]: "6": "Info", "7": "Debug", } + time_windows = { + "1h": "Last hour", + "6h": "Last 6 hours", + "24h": "Last 24 hours", + "7d": "Last 7 days", + "": "All available", + } + since_values = { + "1h": "1 hour ago", + "6h": "6 hours ago", + "24h": "24 hours ago", + "7d": "7 days ago", + } selected_unit = request.GET.get("unit", "") priority = request.GET.get("priority", "0..4") + time_window = request.GET.get("window", "24h") + if time_window not in time_windows: + time_window = "24h" + host_filter = request.GET.get("host", "").strip() + run_filter = request.GET.get("run", "").strip() query = request.GET.get("q", "").strip() lines = [] error = "" @@ -713,6 +731,8 @@ def _log_context(request) -> dict[str, object]: error = "journalctl is not available in this runtime." else: command = ["journalctl", "--no-pager", "-n", "300", "-o", "short-iso"] + if time_window: + command.extend(["--since", since_values[time_window]]) if selected_unit in units: command.extend(["-u", selected_unit]) else: @@ -725,16 +745,38 @@ def _log_context(request) -> dict[str, object]: error = result.stderr.strip() or "Could not read journal logs." else: lines = result.stdout.splitlines() - if query: - lowered_query = query.lower() - lines = [line for line in lines if lowered_query in line.lower()] + lines = _filter_log_lines(lines, query=query, host=host_filter, run_id=run_filter) return { "units": units, "priorities": priorities, + "time_windows": time_windows, "selected_unit": selected_unit, "selected_priority": priority, + "selected_window": time_window, + "host_filter": host_filter, + "run_filter": run_filter, "query": query, "lines": lines, "error": error, } + + +def _filter_log_lines(lines: list[str], *, query: str, host: str, run_id: str) -> list[str]: + filters = [] + if query: + filters.append(lambda line: query.lower() in line.lower()) + if host: + filters.append(lambda line: host.lower() in line.lower()) + if run_id: + run_tokens = ( + f"run {run_id}", + f"run={run_id}", + f"run_id={run_id}", + f"run-{run_id}", + f"#{run_id}", + ) + filters.append(lambda line: any(token in line.lower() for token in run_tokens)) + if not filters: + return lines + return [line for line in lines if all(matches(line) for matches in filters)] -- 2.43.0