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 }}
+
+
+
+
+
+
+
+ Rsync
{{ run.rsync_exit_code|default:"" }}
+
+
+
+
+ Timing
+
+
Created: {{ run.created_at }}
+
Started: {{ run.started_at|default:"" }}
+
Ended: {{ run.ended_at|default:"" }}
+
+
+
+
+ Snapshot
+
+
+
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 }}
+
+
+
+
+ Host
{{ snapshot.host.host }}
+
+ 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
+
+
+
Kind: {{ snapshot.base_kind }}
+
Dirname: {{ snapshot.base_dirname }}
+
Path: {{ snapshot.base_path }}
+
+
+
+
+
+ Backup Runs
+
+
+
+ | Run |
+ Status |
+ Started |
+ Ended |
+ Rsync |
+
+
+
+ {% for run in backup_runs %}
+
+ | Run {{ run.id }} |
+ {{ run.status }} |
+ {{ run.started_at|default:"" }} |
+ {{ run.ended_at|default:"" }} |
+ {{ run.rsync_exit_code|default:"" }} |
+
+ {% empty %}
+ | No backup runs linked to this snapshot. |
+ {% endfor %}
+
+
+
+
+
+ Derived Snapshots
+
+
+
+ | Kind |
+ Status |
+ Started |
+ Dirname |
+
+
+
+ {% for child in derived_snapshots %}
+
+ | {{ child.kind }} |
+ {{ child.status }} |
+ {{ child.started_at|default:"" }} |
+ {{ child.dirname }} |
+
+ {% empty %}
+ | No derived snapshots linked to this snapshot. |
+ {% endfor %}
+
+
+
+
+
+ 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),