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