(feature) Link rsync logs from backup run detail

Record the final rsync log path for successful real backup runs, matching
the existing dry-run and failure result payloads.

Add a staff-only run log endpoint and surface the link on run detail pages,
including fallback log discovery for older runs based on snapshot_path.

Cover direct log links and inferred scheduled backup logs with view tests.
This commit is contained in:
2026-05-20 00:09:59 +02:00
parent f41e59e695
commit 0babc57f57
6 changed files with 85 additions and 0 deletions

View File

@@ -403,6 +403,7 @@ def run_scheduled(
"host": host, "host": host,
"snapshot": str(final_dir), "snapshot": str(final_dir),
"base": str(base_dir) if base_dir else None, "base": str(base_dir) if base_dir else None,
"log": str(final_log_path),
"rsync": {"exit_code": result.exit_code}, "rsync": {"exit_code": result.exit_code},
"verbose_output": bool(verbose_output), "verbose_output": bool(verbose_output),
"duration_seconds": meta["duration_seconds"], "duration_seconds": meta["duration_seconds"],

View File

@@ -37,6 +37,16 @@
<div class="stack"> <div class="stack">
<div><strong>Snapshot:</strong> {% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}{{ run.snapshot_path|default:"" }}{% endif %}</div> <div><strong>Snapshot:</strong> {% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}{{ run.snapshot_path|default:"" }}{% endif %}</div>
<div><strong>Base:</strong> {{ run.base_path|default:"" }}</div> <div><strong>Base:</strong> {{ run.base_path|default:"" }}</div>
<div>
<strong>Rsync log:</strong>
{% if rsync_log_exists %}
<a href="{% url 'run_rsync_log' run.id %}">{{ rsync_log_path }}</a>
{% elif rsync_log_path %}
<span class="muted">{{ rsync_log_path }} (missing)</span>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
</div> </div>
</section> </section>
</div> </div>

View File

@@ -242,6 +242,7 @@ class RunScheduledConfigSourceTests(SimpleTestCase):
meta_text = meta_path.read_text(encoding="utf-8") meta_text = meta_path.read_text(encoding="utf-8")
self.assertTrue(result["ok"]) 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_total"], 10)
self.assertEqual(result["stats"]["rsync"]["files_transferred"], 2) self.assertEqual(result["stats"]["rsync"]["files_transferred"], 2)
self.assertEqual(result["stats"]["rsync"]["link_dest_estimated_savings_bytes"], 1500) self.assertEqual(result["stats"]["rsync"]["link_dest_estimated_savings_bytes"], 1500)

View File

@@ -895,6 +895,50 @@ class ViewTests(TestCase):
self.assertContains(response, "&quot;ok&quot;: true") self.assertContains(response, "&quot;ok&quot;: true")
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id])) 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: def test_run_detail_offers_cancel_for_running_run(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test") host = HostConfig.objects.create(host="web-01", address="web-01.example.test")

View File

@@ -8,6 +8,7 @@ from pathlib import Path
from django.contrib import messages from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import staff_member_required
from django.conf import settings from django.conf import settings
from django.http import FileResponse, Http404
from django.db.models import Count from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone from django.utils import timezone
@@ -362,16 +363,28 @@ def queue_manual_backup(request, host: str):
def run_detail(request, run_id: int): def run_detail(request, run_id: int):
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id) 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 {} run_stats = run.result.get("stats") if isinstance(run.result, dict) else {}
rsync_log_path = _run_rsync_log_path(run)
context = { context = {
"run": run, "run": run,
"can_cancel": run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING}, "can_cancel": run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING},
"requested": run.result.get("requested") if isinstance(run.result, dict) else {}, "requested": run.result.get("requested") if isinstance(run.result, dict) else {},
"stats": run_stats if isinstance(run_stats, 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), "result_json": _pretty_json(run.result),
} }
return render(request, "pobsync_backend/run_detail.html", context) 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 @staff_member_required
@require_POST @require_POST
def cancel_run(request, run_id: int): 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) 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]: def _log_context(request) -> dict[str, object]:
units = ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service") units = ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service")
priorities = { priorities = {

View File

@@ -27,6 +27,7 @@ urlpatterns = [
path("hosts/<str:host>/retention-plan/", views.host_retention_plan, name="host_retention_plan"), path("hosts/<str:host>/retention-plan/", views.host_retention_plan, name="host_retention_plan"),
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"), path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
path("runs/<int:run_id>/", views.run_detail, name="run_detail"), path("runs/<int:run_id>/", views.run_detail, name="run_detail"),
path("runs/<int:run_id>/rsync-log/", views.run_rsync_log, name="run_rsync_log"),
path("runs/<int:run_id>/cancel/", views.cancel_run, name="cancel_run"), path("runs/<int:run_id>/cancel/", views.cancel_run, name="cancel_run"),
path("snapshots/<int:snapshot_id>/", views.snapshot_detail, name="snapshot_detail"), path("snapshots/<int:snapshot_id>/", views.snapshot_detail, name="snapshot_detail"),
path("api/", api.api_index), path("api/", api.api_index),