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),