From b0c6afad091836e553753d780044ed966fb7695d Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Tue, 19 May 2026 11:53:32 +0200 Subject: [PATCH] (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. --- README.md | 3 + .../templates/pobsync_backend/base.html | 103 ++++++++++++++++++ .../templates/pobsync_backend/dashboard.html | 76 +++++++++++++ .../pobsync_backend/host_detail.html | 101 +++++++++++++++++ src/pobsync_backend/tests/test_views.py | 83 ++++++++++++++ src/pobsync_backend/views.py | 55 ++++++++++ src/pobsync_server/urls.py | 4 +- 7 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 src/pobsync_backend/templates/pobsync_backend/base.html create mode 100644 src/pobsync_backend/templates/pobsync_backend/dashboard.html create mode 100644 src/pobsync_backend/templates/pobsync_backend/host_detail.html create mode 100644 src/pobsync_backend/tests/test_views.py create mode 100644 src/pobsync_backend/views.py diff --git a/README.md b/README.md index 9b77ac3..755a176 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ python3 manage.py runserver The admin is available at: +- http://127.0.0.1:8000/ - http://127.0.0.1:8000/admin/ Staff-only JSON endpoints are available at: @@ -116,6 +117,7 @@ docker compose up --build web This starts Django on: +- http://127.0.0.1:8010/ - http://127.0.0.1:8010/admin/ - http://127.0.0.1:8010/api/ - 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. 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 dashboard views expose the same operational state through Django templates. The remaining internal engine code still contains reusable backup primitives: diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html new file mode 100644 index 0000000..8aaa3b5 --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -0,0 +1,103 @@ + + + + + + {% block title %}pobsync{% endblock %} + + + +
+ +
+
+ {% block content %}{% endblock %} +
+ + diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html new file mode 100644 index 0000000..79bd17b --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -0,0 +1,76 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}pobsync dashboard{% endblock %} + +{% block content %} +

Dashboard

+ +
+
Hosts
{{ counts.enabled_hosts }}/{{ counts.hosts }}
+
Schedules
{{ counts.enabled_schedules }}/{{ counts.schedules }}
+
Snapshots
{{ counts.snapshots }}
+
Runs
{{ counts.runs }}
+
Running
{{ counts.running_runs }}
+
Failed
{{ counts.failed_runs }}
+
+ +
+

Hosts

+ + + + + + + + + + + + + {% for host in hosts %} + + + + + + + + + {% empty %} + + {% endfor %} + +
HostAddressEnabledSnapshotsRunsRetention
{{ host.host }}{{ host.address }}{{ host.enabled|yesno:"yes,no" }}{{ host.snapshot_count }}{{ host.run_count }}d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}
No hosts configured yet.
+
+ +
+

Latest Runs

+ + + + + + + + + + + + + {% for run in latest_runs %} + + + + + + + + + {% empty %} + + {% endfor %} + +
HostStatusStartedEndedSnapshotRsync
{{ run.host.host }}{{ run.status }}{{ run.started_at|default:"" }}{{ run.ended_at|default:"" }}{% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}{{ run.snapshot_path }}{% endif %}{{ run.rsync_exit_code|default:"" }}
No backup runs recorded yet.
+
+{% endblock %} diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html new file mode 100644 index 0000000..1d10b85 --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -0,0 +1,101 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}{{ host.host }} | pobsync{% endblock %} + +{% block content %} +

{{ host.host }}

+ +
+
Snapshots
{{ counts.snapshots }}
+
Runs
{{ counts.runs }}
+
Failed Runs
{{ counts.failed_runs }}
+
Incomplete
{{ counts.incomplete_snapshots }}
+
+ +
+
+

Config

+
+
Address: {{ host.address }}
+
Enabled: {{ host.enabled|yesno:"yes,no" }}
+
SSH: {{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}
+
Source: {{ host.source_root|default:"global default" }}
+
Retention: daily {{ host.retention_daily }}, weekly {{ host.retention_weekly }}, monthly {{ host.retention_monthly }}, yearly {{ host.retention_yearly }}
+
+
+ +
+

Schedule

+ {% if schedule %} +
+
Cron: {{ schedule.cron_expr }}
+
Enabled: {{ schedule.enabled|yesno:"yes,no" }}
+
Prune: {{ schedule.prune|yesno:"yes,no" }}
+
Last status: {{ schedule.last_status|default:"" }}
+
Last started: {{ schedule.last_started_at|default:"" }}
+
Last finished: {{ schedule.last_finished_at|default:"" }}
+
+ {% else %} +

No schedule configured.

+ {% endif %} +
+
+ +
+

Latest Runs

+ + + + + + + + + + + + + {% for run in latest_runs %} + + + + + + + + + {% empty %} + + {% endfor %} + +
StatusStartedEndedSnapshotBaseRsync
{{ run.status }}{{ run.started_at|default:"" }}{{ run.ended_at|default:"" }}{% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}{{ run.snapshot_path }}{% endif %}{{ run.base_path|default:"" }}{{ run.rsync_exit_code|default:"" }}
No backup runs recorded for this host.
+
+ +
+

Snapshots

+ + + + + + + + + + + + {% for snapshot in snapshots %} + + + + + + + + {% empty %} + + {% endfor %} + +
KindStatusStartedDirnameBase
{{ snapshot.kind }}{{ snapshot.status }}{{ snapshot.started_at|default:"" }}{{ snapshot.dirname }}{% if snapshot.base %}{{ snapshot.base.dirname }}{% else %}{{ snapshot.base_dirname }}{% endif %}
No snapshots discovered for this host.
+
+{% endblock %} diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py new file mode 100644 index 0000000..e11d3aa --- /dev/null +++ b/src/pobsync_backend/tests/test_views.py @@ -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, + ) diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py new file mode 100644 index 0000000..1c612fa --- /dev/null +++ b/src/pobsync_backend/views.py @@ -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 diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index 508dde0..940dd63 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -3,10 +3,12 @@ from __future__ import annotations from django.contrib import admin from django.urls import path -from pobsync_backend import api +from pobsync_backend import api, views urlpatterns = [ + path("", views.dashboard, name="dashboard"), + path("hosts//", views.host_detail, name="host_detail"), path("api/", api.api_index), path("api/status/", api.status), path("api/hosts/", api.hosts),