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 @@
Snapshot: {% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}{{ run.snapshot_path|default:"" }}{% endif %}
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),