From 66e1f549b9efcb7a07e33aa50e8ce7524daf05da Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Tue, 19 May 2026 12:31:47 +0200 Subject: [PATCH] (feature) add Django detail views for backup runs and snapshots Add staff-only run and snapshot detail pages so scheduler and command output can be inspected from the Django UI. Link dashboard and host detail tables to the new detail views, including snapshot/base relationships and linked backup runs. Render stored result and metadata JSON in readable form and cover the new inspection views with tests. --- .../templates/pobsync_backend/base.html | 11 +++ .../templates/pobsync_backend/dashboard.html | 4 +- .../pobsync_backend/host_detail.html | 8 +- .../templates/pobsync_backend/run_detail.html | 42 ++++++++ .../pobsync_backend/snapshot_detail.html | 99 +++++++++++++++++++ src/pobsync_backend/tests/test_views.py | 48 +++++++++ src/pobsync_backend/views.py | 31 ++++++ src/pobsync_server/urls.py | 2 + 8 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 src/pobsync_backend/templates/pobsync_backend/run_detail.html create mode 100644 src/pobsync_backend/templates/pobsync_backend/snapshot_detail.html diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 5db2f87..1881132 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -121,6 +121,17 @@ .field textarea { min-height: 92px; resize: vertical; } .field .helptext { color: var(--muted); font-size: 12px; } .field input[type="checkbox"] { justify-self: start; } + pre { + background: #101820; + border-radius: 6px; + color: #edf4fb; + line-height: 1.5; + margin: 0; + overflow: auto; + padding: 12px; + white-space: pre-wrap; + word-break: break-word; + } .errorlist { color: var(--failed); list-style: none; diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index ce0cbb9..ab4c56f 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -84,10 +84,10 @@ {% for run in latest_runs %} {{ run.host.host }} - {{ run.status }} + {{ run.status }} {{ run.started_at|default:"" }} {{ run.ended_at|default:"" }} - {% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}{{ run.snapshot_path }}{% endif %} + {% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}{{ run.snapshot_path }}{% endif %} {{ run.rsync_exit_code|default:"" }} {% empty %} diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html index 14a9c1c..c21b7d0 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -67,10 +67,10 @@ {% for run in latest_runs %} - {{ run.status }} + {{ run.status }} {{ run.started_at|default:"" }} {{ run.ended_at|default:"" }} - {% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}{{ run.snapshot_path }}{% endif %} + {% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}{{ run.snapshot_path }}{% endif %} {{ run.base_path|default:"" }} {{ run.rsync_exit_code|default:"" }} @@ -99,8 +99,8 @@ {{ snapshot.kind }} {{ snapshot.status }} {{ snapshot.started_at|default:"" }} - {{ snapshot.dirname }} - {% if snapshot.base %}{{ snapshot.base.dirname }}{% else %}{{ snapshot.base_dirname }}{% endif %} + {{ snapshot.dirname }} + {% if snapshot.base %}{{ snapshot.base.dirname }}{% else %}{{ snapshot.base_dirname }}{% endif %} {% empty %} No snapshots discovered for this host. diff --git a/src/pobsync_backend/templates/pobsync_backend/run_detail.html b/src/pobsync_backend/templates/pobsync_backend/run_detail.html new file mode 100644 index 0000000..76d59db --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/run_detail.html @@ -0,0 +1,42 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}Run {{ run.id }} | {{ run.host.host }}{% endblock %} + +{% block content %} +

Run {{ run.id }}

+ +
+ Back to host +
+ +
+
Host
{{ run.host.host }}
+
Status
{{ run.status }}
+
Type
{{ run.run_type }}
+
Rsync
{{ run.rsync_exit_code|default:"" }}
+
+ +
+
+

Timing

+
+
Created: {{ run.created_at }}
+
Started: {{ run.started_at|default:"" }}
+
Ended: {{ run.ended_at|default:"" }}
+
+
+ +
+

Snapshot

+
+
Snapshot: {% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}{{ run.snapshot_path|default:"" }}{% endif %}
+
Base: {{ run.base_path|default:"" }}
+
+
+
+ +
+

Result

+
{{ result_json }}
+
+{% endblock %} diff --git a/src/pobsync_backend/templates/pobsync_backend/snapshot_detail.html b/src/pobsync_backend/templates/pobsync_backend/snapshot_detail.html new file mode 100644 index 0000000..514d7b6 --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/snapshot_detail.html @@ -0,0 +1,99 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}Snapshot {{ snapshot.dirname }} | {{ snapshot.host.host }}{% endblock %} + +{% block content %} +

{{ snapshot.dirname }}

+ +
+ Back to host +
+ +
+
Host
{{ snapshot.host.host }}
+
Kind
{{ snapshot.kind }}
+
Status
{{ snapshot.status|default:"" }}
+
Runs
{{ backup_runs|length }}
+
+ +
+
+

Snapshot

+
+
Path: {{ snapshot.path }}
+
Started: {{ snapshot.started_at|default:"" }}
+
Ended: {{ snapshot.ended_at|default:"" }}
+
Discovered: {{ snapshot.discovered_at }}
+
+
+ +
+

Base

+
+
Record: {% if snapshot.base %}{{ snapshot.base.dirname }}{% endif %}
+
Kind: {{ snapshot.base_kind }}
+
Dirname: {{ snapshot.base_dirname }}
+
Path: {{ snapshot.base_path }}
+
+
+
+ +
+

Backup Runs

+ + + + + + + + + + + + {% for run in backup_runs %} + + + + + + + + {% empty %} + + {% endfor %} + +
RunStatusStartedEndedRsync
Run {{ run.id }}{{ run.status }}{{ run.started_at|default:"" }}{{ run.ended_at|default:"" }}{{ run.rsync_exit_code|default:"" }}
No backup runs linked to this snapshot.
+
+ +
+

Derived Snapshots

+ + + + + + + + + + + {% for child in derived_snapshots %} + + + + + + + {% empty %} + + {% endfor %} + +
KindStatusStartedDirname
{{ child.kind }}{{ child.status }}{{ child.started_at|default:"" }}{{ child.dirname }}
No derived snapshots linked to this snapshot.
+
+ +
+

Metadata

+
{{ metadata_json }}
+
+{% endblock %} diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 0346d73..ee2ed6c 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -165,6 +165,8 @@ class ViewTests(TestCase): self.assertContains(response, "Discover snapshots") self.assertContains(response, "Edit schedule") self.assertContains(response, "Edit config") + self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id])) + self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id])) def test_host_detail_returns_404_for_unknown_host(self) -> None: self.client.force_login(self.staff_user) @@ -173,6 +175,52 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 404) + def test_run_detail_renders_result_payload(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + snapshot = self._snapshot(host, "20260519-021500Z__ABCDEFGH") + run = BackupRun.objects.create( + host=host, + status=BackupRun.Status.SUCCESS, + snapshot=snapshot, + snapshot_path=snapshot.path, + base_path="/backups/web-01/scheduled/base", + rsync_exit_code=0, + result={"ok": True, "snapshot": snapshot.path}, + ) + + response = self.client.get(reverse("run_detail", args=[run.id])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Run") + self.assertContains(response, "web-01") + self.assertContains(response, "success") + self.assertContains(response, "ABCDEFGH") + self.assertContains(response, '"ok": true') + self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id])) + + def test_snapshot_detail_renders_metadata_runs_and_children(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + base = self._snapshot(host, "20260518-021500Z__BASESNAP") + base.metadata = {"status": "success", "snapshot_id": "BASESNAP"} + base.save(update_fields=["metadata"]) + child = self._snapshot(host, "20260519-021500Z__CHILDSNP") + child.base = base + child.base_dirname = base.dirname + child.save(update_fields=["base", "base_dirname"]) + run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, snapshot=base) + + response = self.client.get(reverse("snapshot_detail", args=[base.id])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, base.dirname) + self.assertContains(response, "BASESNAP") + self.assertContains(response, child.dirname) + self.assertContains(response, f"Run {run.id}") + self.assertContains(response, reverse("run_detail", args=[run.id])) + self.assertContains(response, reverse("snapshot_detail", args=[child.id])) + def test_discover_host_snapshots_action_discovers_and_redirects(self) -> None: self.client.force_login(self.staff_user) with TemporaryDirectory() as tmp: diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 64925a3..da1a416 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + from django.contrib import messages from django.contrib.admin.views.decorators import staff_member_required from django.db.models import Count @@ -100,6 +102,31 @@ def host_detail(request, host: str): return render(request, "pobsync_backend/host_detail.html", context) +@staff_member_required +def run_detail(request, run_id: int): + run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id) + context = { + "run": run, + "result_json": _pretty_json(run.result), + } + return render(request, "pobsync_backend/run_detail.html", context) + + +@staff_member_required +def snapshot_detail(request, snapshot_id: int): + snapshot = get_object_or_404( + SnapshotRecord.objects.select_related("host", "base").prefetch_related("derived_snapshots", "backup_runs"), + id=snapshot_id, + ) + context = { + "snapshot": snapshot, + "metadata_json": _pretty_json(snapshot.metadata), + "backup_runs": snapshot.backup_runs.select_related("host").order_by("-created_at"), + "derived_snapshots": snapshot.derived_snapshots.select_related("host").order_by("-started_at", "dirname"), + } + return render(request, "pobsync_backend/snapshot_detail.html", context) + + @staff_member_required @require_POST def discover_host_snapshots(request, host: str): @@ -229,3 +256,7 @@ def _default_host_initial() -> dict[str, object]: "retention_monthly": 12, "retention_yearly": 0, } + + +def _pretty_json(value: object) -> str: + return json.dumps(value or {}, indent=2, sort_keys=True) diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index c4fe56f..7b3989e 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -15,6 +15,8 @@ urlpatterns = [ path("hosts//discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"), 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("snapshots//", views.snapshot_detail, name="snapshot_detail"), path("api/", api.api_index), path("api/status/", api.status), path("api/hosts/", api.hosts),