(feature) Add staff-only Django dashboard views

Add a small template-based UI for inspecting pobsync state through Django. The
dashboard shows host, schedule, snapshot, and backup run summaries, while host
detail pages show config, schedule, recent runs, and discovered snapshots.

Keep the views read-only and staff-protected, document the new dashboard URL,
and cover the routes with focused view tests.
This commit is contained in:
2026-05-19 11:53:32 +02:00
parent 2778a589ea
commit b0c6afad09
7 changed files with 424 additions and 1 deletions

View File

@@ -33,6 +33,7 @@ python3 manage.py runserver
The admin is available at: The admin is available at:
- http://127.0.0.1:8000/
- http://127.0.0.1:8000/admin/ - http://127.0.0.1:8000/admin/
Staff-only JSON endpoints are available at: Staff-only JSON endpoints are available at:
@@ -116,6 +117,7 @@ docker compose up --build web
This starts Django on: This starts Django on:
- http://127.0.0.1:8010/
- http://127.0.0.1:8010/admin/ - http://127.0.0.1:8010/admin/
- http://127.0.0.1:8010/api/ - http://127.0.0.1:8010/api/
- http://127.0.0.1:8010/api/status/ - http://127.0.0.1:8010/api/status/
@@ -150,6 +152,7 @@ base record when it is known.
The Django retention command plans from `SnapshotRecord` instead of rediscovering snapshots from the filesystem. The Django retention command plans from `SnapshotRecord` instead of rediscovering snapshots from the filesystem.
Post-backup pruning from Django also uses the SQL retention service after the completed snapshot is recorded. Post-backup pruning from Django also uses the SQL retention service after the completed snapshot is recorded.
Staff-only JSON endpoints expose service status, hosts, snapshots, and backup runs for lightweight inspection. Staff-only JSON endpoints expose service status, hosts, snapshots, and backup runs for lightweight inspection.
Staff-only dashboard views expose the same operational state through Django templates.
The remaining internal engine code still contains reusable backup primitives: The remaining internal engine code still contains reusable backup primitives:

View File

@@ -0,0 +1,103 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}pobsync{% endblock %}</title>
<style>
:root {
color-scheme: light;
--bg: #f5f7fa;
--panel: #ffffff;
--border: #d9e0e8;
--text: #17202a;
--muted: #657386;
--link: #0b5cad;
--success: #176b3a;
--failed: #a12828;
--running: #8a5a00;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
header {
background: var(--panel);
border-bottom: 1px solid var(--border);
padding: 14px 24px;
}
nav {
display: flex;
align-items: center;
gap: 18px;
max-width: 1180px;
margin: 0 auto;
}
nav strong { font-size: 16px; }
nav .spacer { flex: 1; }
main {
max-width: 1180px;
margin: 0 auto;
padding: 24px;
}
h1 { font-size: 26px; margin: 0 0 18px; }
h2 { font-size: 18px; margin: 0 0 12px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 22px;
}
.metric, .panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
}
.metric { padding: 14px; }
.metric .label { color: var(--muted); font-size: 12px; text-transform: uppercase; }
.metric .value { font-size: 26px; font-weight: 650; margin-top: 4px; }
.panel { padding: 16px; margin-bottom: 18px; overflow: auto; }
table { width: 100%; border-collapse: collapse; min-width: 640px; }
th, td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; vertical-align: top; }
th { color: var(--muted); font-size: 12px; font-weight: 650; text-transform: uppercase; }
tr:last-child td { border-bottom: 0; }
.muted { color: var(--muted); }
.status {
display: inline-block;
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 8px;
white-space: nowrap;
}
.status.success { color: var(--success); border-color: #a7d8b9; background: #edf8f1; }
.status.failed { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; }
.status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
.stack { display: grid; gap: 4px; }
.two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
@media (max-width: 800px) {
main { padding: 16px; }
nav { padding: 0; }
.two-col { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header>
<nav>
<strong><a href="{% url 'dashboard' %}">pobsync</a></strong>
<a href="{% url 'admin:index' %}">Admin</a>
<a href="/api/status/">Status API</a>
<span class="spacer"></span>
<span class="muted">{{ request.user.username }}</span>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,76 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}pobsync dashboard{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<section class="grid" aria-label="Summary">
<div class="metric"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></div>
<div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div>
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
<div class="metric"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
<div class="metric"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div>
</section>
<section class="panel">
<h2>Hosts</h2>
<table>
<thead>
<tr>
<th>Host</th>
<th>Address</th>
<th>Enabled</th>
<th>Snapshots</th>
<th>Runs</th>
<th>Retention</th>
</tr>
</thead>
<tbody>
{% for host in hosts %}
<tr>
<td><a href="{% url 'host_detail' host.host %}">{{ host.host }}</a></td>
<td>{{ host.address }}</td>
<td>{{ host.enabled|yesno:"yes,no" }}</td>
<td>{{ host.snapshot_count }}</td>
<td>{{ host.run_count }}</td>
<td>d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</td>
</tr>
{% empty %}
<tr><td colspan="6" class="muted">No hosts configured yet.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="panel">
<h2>Latest Runs</h2>
<table>
<thead>
<tr>
<th>Host</th>
<th>Status</th>
<th>Started</th>
<th>Ended</th>
<th>Snapshot</th>
<th>Rsync</th>
</tr>
</thead>
<tbody>
{% 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>{{ 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>{{ run.rsync_exit_code|default:"" }}</td>
</tr>
{% empty %}
<tr><td colspan="6" class="muted">No backup runs recorded yet.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View File

@@ -0,0 +1,101 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}{{ host.host }} | pobsync{% endblock %}
{% block content %}
<h1>{{ host.host }}</h1>
<section class="grid" aria-label="Host summary">
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
<div class="metric"><div class="label">Failed Runs</div><div class="value">{{ counts.failed_runs }}</div></div>
<div class="metric"><div class="label">Incomplete</div><div class="value">{{ counts.incomplete_snapshots }}</div></div>
</section>
<div class="two-col">
<section class="panel">
<h2>Config</h2>
<div class="stack">
<div><strong>Address:</strong> {{ host.address }}</div>
<div><strong>Enabled:</strong> {{ host.enabled|yesno:"yes,no" }}</div>
<div><strong>SSH:</strong> {{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</div>
<div><strong>Source:</strong> {{ host.source_root|default:"global default" }}</div>
<div><strong>Retention:</strong> daily {{ host.retention_daily }}, weekly {{ host.retention_weekly }}, monthly {{ host.retention_monthly }}, yearly {{ host.retention_yearly }}</div>
</div>
</section>
<section class="panel">
<h2>Schedule</h2>
{% if schedule %}
<div class="stack">
<div><strong>Cron:</strong> {{ schedule.cron_expr }}</div>
<div><strong>Enabled:</strong> {{ schedule.enabled|yesno:"yes,no" }}</div>
<div><strong>Prune:</strong> {{ schedule.prune|yesno:"yes,no" }}</div>
<div><strong>Last status:</strong> {{ schedule.last_status|default:"" }}</div>
<div><strong>Last started:</strong> {{ schedule.last_started_at|default:"" }}</div>
<div><strong>Last finished:</strong> {{ schedule.last_finished_at|default:"" }}</div>
</div>
{% else %}
<p class="muted">No schedule configured.</p>
{% endif %}
</section>
</div>
<section class="panel">
<h2>Latest Runs</h2>
<table>
<thead>
<tr>
<th>Status</th>
<th>Started</th>
<th>Ended</th>
<th>Snapshot</th>
<th>Base</th>
<th>Rsync</th>
</tr>
</thead>
<tbody>
{% for run in latest_runs %}
<tr>
<td><span class="status {{ run.status }}">{{ run.status }}</span></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>{{ run.base_path|default:"" }}</td>
<td>{{ run.rsync_exit_code|default:"" }}</td>
</tr>
{% empty %}
<tr><td colspan="6" class="muted">No backup runs recorded for this host.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="panel">
<h2>Snapshots</h2>
<table>
<thead>
<tr>
<th>Kind</th>
<th>Status</th>
<th>Started</th>
<th>Dirname</th>
<th>Base</th>
</tr>
</thead>
<tbody>
{% for snapshot in snapshots %}
<tr>
<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>
</tr>
{% empty %}
<tr><td colspan="5" class="muted">No snapshots discovered for this host.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
from datetime import datetime, timezone
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from pobsync_backend.models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
class ViewTests(TestCase):
def setUp(self) -> None:
user_model = get_user_model()
self.staff_user = user_model.objects.create_user(
username="admin",
password="secret",
is_staff=True,
is_superuser=True,
)
def test_dashboard_requires_staff_login(self) -> None:
response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login/", response["Location"])
def test_dashboard_renders_hosts_and_latest_runs(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")
BackupRun.objects.create(
host=host,
status=BackupRun.Status.SUCCESS,
snapshot=snapshot,
started_at=datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc),
)
response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Dashboard")
self.assertContains(response, "web-01")
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
self.assertContains(response, "success")
def test_host_detail_renders_config_schedule_runs_and_snapshots(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(
host="web-01",
address="web-01.example.test",
source_root="/srv",
retention_daily=7,
)
ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", prune=True, last_status="success")
snapshot = self._snapshot(host, "20260519-021500Z__ABCDEFGH")
BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, snapshot=snapshot)
response = self.client.get(reverse("host_detail", args=[host.host]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "web-01")
self.assertContains(response, "web-01.example.test")
self.assertContains(response, "15 2 * * *")
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
def test_host_detail_returns_404_for_unknown_host(self) -> None:
self.client.force_login(self.staff_user)
response = self.client.get(reverse("host_detail", args=["missing-host"]))
self.assertEqual(response.status_code, 404)
def _snapshot(self, host: HostConfig, dirname: str) -> SnapshotRecord:
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
return SnapshotRecord.objects.create(
host=host,
kind=SnapshotRecord.Kind.SCHEDULED,
dirname=dirname,
path=f"/backups/{host.host}/scheduled/{dirname}",
status="success",
started_at=started_at,
)

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
from django.contrib.admin.views.decorators import staff_member_required
from django.db.models import Count
from django.shortcuts import get_object_or_404, render
from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
@staff_member_required
def dashboard(request):
host_qs = (
HostConfig.objects.annotate(snapshot_count=Count("snapshots", distinct=True), run_count=Count("runs", distinct=True))
.order_by("host")
)
context = {
"hosts": host_qs,
"latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10],
"counts": {
"hosts": HostConfig.objects.count(),
"enabled_hosts": HostConfig.objects.filter(enabled=True).count(),
"schedules": ScheduleConfig.objects.count(),
"enabled_schedules": ScheduleConfig.objects.filter(enabled=True).count(),
"snapshots": SnapshotRecord.objects.count(),
"runs": BackupRun.objects.count(),
"running_runs": BackupRun.objects.filter(status=BackupRun.Status.RUNNING).count(),
"failed_runs": BackupRun.objects.filter(status=BackupRun.Status.FAILED).count(),
},
}
return render(request, "pobsync_backend/dashboard.html", context)
@staff_member_required
def host_detail(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
context = {
"host": host_config,
"schedule": _schedule_for_host(host_config),
"latest_runs": host_config.runs.select_related("snapshot").order_by("-created_at")[:10],
"snapshots": host_config.snapshots.select_related("base").order_by("-started_at", "dirname")[:20],
"counts": {
"snapshots": host_config.snapshots.count(),
"runs": host_config.runs.count(),
"failed_runs": host_config.runs.filter(status=BackupRun.Status.FAILED).count(),
"incomplete_snapshots": host_config.snapshots.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count(),
},
}
return render(request, "pobsync_backend/host_detail.html", context)
def _schedule_for_host(host_config: HostConfig) -> ScheduleConfig | None:
try:
return host_config.schedule
except ScheduleConfig.DoesNotExist:
return None

View File

@@ -3,10 +3,12 @@ from __future__ import annotations
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import path
from pobsync_backend import api from pobsync_backend import api, views
urlpatterns = [ urlpatterns = [
path("", views.dashboard, name="dashboard"),
path("hosts/<str:host>/", views.host_detail, name="host_detail"),
path("api/", api.api_index), path("api/", api.api_index),
path("api/status/", api.status), path("api/status/", api.status),
path("api/hosts/", api.hosts), path("api/hosts/", api.hosts),