(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:
@@ -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"],
|
||||
|
||||
@@ -37,6 +37,16 @@
|
||||
<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>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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -27,6 +27,7 @@ urlpatterns = [
|
||||
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("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("snapshots/<int:snapshot_id>/", views.snapshot_detail, name="snapshot_detail"),
|
||||
path("api/", api.api_index),
|
||||
|
||||
Reference in New Issue
Block a user