diff --git a/src/pobsync/commands/run_scheduled.py b/src/pobsync/commands/run_scheduled.py
index d82e898..890035d 100644
--- a/src/pobsync/commands/run_scheduled.py
+++ b/src/pobsync/commands/run_scheduled.py
@@ -403,6 +403,7 @@ def run_scheduled(
"host": host,
"snapshot": str(final_dir),
"base": str(base_dir) if base_dir else None,
+ "log": str(final_log_path),
"rsync": {"exit_code": result.exit_code},
"verbose_output": bool(verbose_output),
"duration_seconds": meta["duration_seconds"],
diff --git a/src/pobsync_backend/templates/pobsync_backend/run_detail.html b/src/pobsync_backend/templates/pobsync_backend/run_detail.html
index 3a26722..0b73264 100644
--- a/src/pobsync_backend/templates/pobsync_backend/run_detail.html
+++ b/src/pobsync_backend/templates/pobsync_backend/run_detail.html
@@ -37,6 +37,16 @@
Base: {{ run.base_path|default:"" }}
+
+
Rsync log:
+ {% if rsync_log_exists %}
+
{{ rsync_log_path }}
+ {% elif rsync_log_path %}
+
{{ rsync_log_path }} (missing)
+ {% else %}
+
none
+ {% endif %}
+
diff --git a/src/pobsync_backend/tests/test_run_scheduled_config_source.py b/src/pobsync_backend/tests/test_run_scheduled_config_source.py
index a1752e6..0d599c5 100644
--- a/src/pobsync_backend/tests/test_run_scheduled_config_source.py
+++ b/src/pobsync_backend/tests/test_run_scheduled_config_source.py
@@ -242,6 +242,7 @@ class RunScheduledConfigSourceTests(SimpleTestCase):
meta_text = meta_path.read_text(encoding="utf-8")
self.assertTrue(result["ok"])
+ self.assertEqual(result["log"], str(Path(result["snapshot"]) / "meta" / "rsync.log"))
self.assertEqual(result["stats"]["rsync"]["files_total"], 10)
self.assertEqual(result["stats"]["rsync"]["files_transferred"], 2)
self.assertEqual(result["stats"]["rsync"]["link_dest_estimated_savings_bytes"], 1500)
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py
index 20b0276..d5db42c 100644
--- a/src/pobsync_backend/tests/test_views.py
+++ b/src/pobsync_backend/tests/test_views.py
@@ -895,6 +895,50 @@ class ViewTests(TestCase):
self.assertContains(response, ""ok": true")
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
+ 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")
+ 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")
+ run = BackupRun.objects.create(
+ host=host,
+ status=BackupRun.Status.SUCCESS,
+ snapshot_path=str(log_path.parent.parent),
+ result={"ok": True, "log": str(log_path)},
+ )
+
+ response = self.client.get(reverse("run_detail", args=[run.id]))
+ log_response = self.client.get(reverse("run_rsync_log", args=[run.id]))
+ log_body = b"".join(log_response.streaming_content)
+
+ self.assertContains(response, reverse("run_rsync_log", args=[run.id]))
+ self.assertContains(response, str(log_path))
+ self.assertEqual(log_response.status_code, 200)
+ self.assertEqual(log_body, b"rsync log line\n")
+
+ def test_run_detail_infers_rsync_log_from_snapshot_path(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:
+ snapshot_path = Path(tmp) / "snapshot"
+ log_path = snapshot_path / "meta" / "rsync.log"
+ log_path.parent.mkdir(parents=True)
+ log_path.write_text("scheduled log\n", encoding="utf-8")
+ run = BackupRun.objects.create(
+ host=host,
+ status=BackupRun.Status.SUCCESS,
+ snapshot_path=str(snapshot_path),
+ result={"ok": True},
+ )
+
+ response = self.client.get(reverse("run_rsync_log", args=[run.id]))
+ body = b"".join(response.streaming_content)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(body, b"scheduled log\n")
+
def test_run_detail_offers_cancel_for_running_run(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 239d7b7..8c90d65 100644
--- a/src/pobsync_backend/views.py
+++ b/src/pobsync_backend/views.py
@@ -8,6 +8,7 @@ from pathlib import Path
from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required
from django.conf import settings
+from django.http import FileResponse, Http404
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
@@ -362,16 +363,28 @@ def queue_manual_backup(request, host: str):
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 {}
+ rsync_log_path = _run_rsync_log_path(run)
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 {},
"stats": run_stats if isinstance(run_stats, dict) 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()),
"result_json": _pretty_json(run.result),
}
return render(request, "pobsync_backend/run_detail.html", context)
+@staff_member_required
+def run_rsync_log(request, run_id: int):
+ run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
+ log_path = _run_rsync_log_path(run)
+ if log_path is None or not log_path.is_file():
+ raise Http404("Rsync log not found")
+ return FileResponse(log_path.open("rb"), content_type="text/plain; charset=utf-8")
+
+
@staff_member_required
@require_POST
def cancel_run(request, run_id: int):
@@ -634,6 +647,21 @@ def _pretty_json(value: object) -> str:
return json.dumps(value or {}, indent=2, sort_keys=True)
+def _run_rsync_log_path(run: BackupRun) -> Path | None:
+ if isinstance(run.result, dict):
+ log = run.result.get("log")
+ if isinstance(log, str) and log:
+ return Path(log)
+ execution = run.result.get("execution")
+ if isinstance(execution, dict):
+ execution_log = execution.get("log")
+ if isinstance(execution_log, str) and execution_log:
+ return Path(execution_log)
+ if run.snapshot_path:
+ return Path(run.snapshot_path) / "meta" / "rsync.log"
+ return None
+
+
def _log_context(request) -> dict[str, object]:
units = ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service")
priorities = {
diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py
index cf13fc1..097db34 100644
--- a/src/pobsync_server/urls.py
+++ b/src/pobsync_server/urls.py
@@ -27,6 +27,7 @@ urlpatterns = [
path("hosts//retention-plan/", views.host_retention_plan, name="host_retention_plan"),
path("hosts//schedule/", views.edit_host_schedule, name="edit_host_schedule"),
path("runs//", views.run_detail, name="run_detail"),
+ path("runs//rsync-log/", views.run_rsync_log, name="run_rsync_log"),
path("runs//cancel/", views.cancel_run, name="cancel_run"),
path("snapshots//", views.snapshot_detail, name="snapshot_detail"),
path("api/", api.api_index),