(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.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -84,10 +84,10 @@
|
||||
{% for run in latest_runs %}
|
||||
<tr>
|
||||
<td><a href="{% url 'host_detail' run.host.host %}">{{ run.host.host }}</a></td>
|
||||
<td><span class="status {{ run.status }}">{{ run.status }}</span></td>
|
||||
<td><a href="{% url 'run_detail' run.id %}"><span class="status {{ run.status }}">{{ run.status }}</span></a></td>
|
||||
<td>{{ run.started_at|default:"" }}</td>
|
||||
<td>{{ run.ended_at|default:"" }}</td>
|
||||
<td>{% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
|
||||
<td>{% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
|
||||
<td>{{ run.rsync_exit_code|default:"" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
|
||||
@@ -67,10 +67,10 @@
|
||||
<tbody>
|
||||
{% for run in latest_runs %}
|
||||
<tr>
|
||||
<td><span class="status {{ run.status }}">{{ run.status }}</span></td>
|
||||
<td><a href="{% url 'run_detail' run.id %}"><span class="status {{ run.status }}">{{ run.status }}</span></a></td>
|
||||
<td>{{ run.started_at|default:"" }}</td>
|
||||
<td>{{ run.ended_at|default:"" }}</td>
|
||||
<td>{% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
|
||||
<td>{% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
|
||||
<td>{{ run.base_path|default:"" }}</td>
|
||||
<td>{{ run.rsync_exit_code|default:"" }}</td>
|
||||
</tr>
|
||||
@@ -99,8 +99,8 @@
|
||||
<td>{{ snapshot.kind }}</td>
|
||||
<td>{{ snapshot.status }}</td>
|
||||
<td>{{ snapshot.started_at|default:"" }}</td>
|
||||
<td>{{ snapshot.dirname }}</td>
|
||||
<td>{% if snapshot.base %}{{ snapshot.base.dirname }}{% else %}{{ snapshot.base_dirname }}{% endif %}</td>
|
||||
<td><a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a></td>
|
||||
<td>{% if snapshot.base %}<a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a>{% else %}{{ snapshot.base_dirname }}{% endif %}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="muted">No snapshots discovered for this host.</td></tr>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{% extends "pobsync_backend/base.html" %}
|
||||
|
||||
{% block title %}Run {{ run.id }} | {{ run.host.host }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Run {{ run.id }}</h1>
|
||||
|
||||
<section class="actions" aria-label="Run actions">
|
||||
<a class="button-link" href="{% url 'host_detail' run.host.host %}">Back to host</a>
|
||||
</section>
|
||||
|
||||
<section class="grid" aria-label="Run summary">
|
||||
<div class="metric"><div class="label">Host</div><div class="value">{{ run.host.host }}</div></div>
|
||||
<div class="metric"><div class="label">Status</div><div class="value">{{ run.status }}</div></div>
|
||||
<div class="metric"><div class="label">Type</div><div class="value">{{ run.run_type }}</div></div>
|
||||
<div class="metric"><div class="label">Rsync</div><div class="value">{{ run.rsync_exit_code|default:"" }}</div></div>
|
||||
</section>
|
||||
|
||||
<div class="two-col">
|
||||
<section class="panel">
|
||||
<h2>Timing</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Created:</strong> {{ run.created_at }}</div>
|
||||
<div><strong>Started:</strong> {{ run.started_at|default:"" }}</div>
|
||||
<div><strong>Ended:</strong> {{ run.ended_at|default:"" }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Snapshot</h2>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Result</h2>
|
||||
<pre>{{ result_json }}</pre>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,99 @@
|
||||
{% extends "pobsync_backend/base.html" %}
|
||||
|
||||
{% block title %}Snapshot {{ snapshot.dirname }} | {{ snapshot.host.host }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ snapshot.dirname }}</h1>
|
||||
|
||||
<section class="actions" aria-label="Snapshot actions">
|
||||
<a class="button-link" href="{% url 'host_detail' snapshot.host.host %}">Back to host</a>
|
||||
</section>
|
||||
|
||||
<section class="grid" aria-label="Snapshot summary">
|
||||
<div class="metric"><div class="label">Host</div><div class="value">{{ snapshot.host.host }}</div></div>
|
||||
<div class="metric"><div class="label">Kind</div><div class="value">{{ snapshot.kind }}</div></div>
|
||||
<div class="metric"><div class="label">Status</div><div class="value">{{ snapshot.status|default:"" }}</div></div>
|
||||
<div class="metric"><div class="label">Runs</div><div class="value">{{ backup_runs|length }}</div></div>
|
||||
</section>
|
||||
|
||||
<div class="two-col">
|
||||
<section class="panel">
|
||||
<h2>Snapshot</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Path:</strong> {{ snapshot.path }}</div>
|
||||
<div><strong>Started:</strong> {{ snapshot.started_at|default:"" }}</div>
|
||||
<div><strong>Ended:</strong> {{ snapshot.ended_at|default:"" }}</div>
|
||||
<div><strong>Discovered:</strong> {{ snapshot.discovered_at }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Base</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Record:</strong> {% if snapshot.base %}<a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a>{% endif %}</div>
|
||||
<div><strong>Kind:</strong> {{ snapshot.base_kind }}</div>
|
||||
<div><strong>Dirname:</strong> {{ snapshot.base_dirname }}</div>
|
||||
<div><strong>Path:</strong> {{ snapshot.base_path }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Backup Runs</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Ended</th>
|
||||
<th>Rsync</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in backup_runs %}
|
||||
<tr>
|
||||
<td><a href="{% url 'run_detail' run.id %}">Run {{ run.id }}</a></td>
|
||||
<td><span class="status {{ run.status }}">{{ run.status }}</span></td>
|
||||
<td>{{ run.started_at|default:"" }}</td>
|
||||
<td>{{ run.ended_at|default:"" }}</td>
|
||||
<td>{{ run.rsync_exit_code|default:"" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="muted">No backup runs linked to this snapshot.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Derived Snapshots</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kind</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Dirname</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for child in derived_snapshots %}
|
||||
<tr>
|
||||
<td>{{ child.kind }}</td>
|
||||
<td>{{ child.status }}</td>
|
||||
<td>{{ child.started_at|default:"" }}</td>
|
||||
<td><a href="{% url 'snapshot_detail' child.id %}">{{ child.dirname }}</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="muted">No derived snapshots linked to this snapshot.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Metadata</h2>
|
||||
<pre>{{ metadata_json }}</pre>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,6 +15,8 @@ urlpatterns = [
|
||||
path("hosts/<str:host>/discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"),
|
||||
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("snapshots/<int:snapshot_id>/", views.snapshot_detail, name="snapshot_detail"),
|
||||
path("api/", api.api_index),
|
||||
path("api/status/", api.status),
|
||||
path("api/hosts/", api.hosts),
|
||||
|
||||
Reference in New Issue
Block a user