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 @@
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 %}
+
+
{{ 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..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)
@@ -857,6 +877,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 +914,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 +930,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 +944,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..98b4969 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 = {
@@ -672,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 = ""
@@ -682,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:
@@ -694,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)]