diff --git a/README.md b/README.md index 755a176..65ba319 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ The Django retention command plans from `SnapshotRecord` instead of rediscoverin 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. +Host pages include a safe snapshot discovery action that records existing snapshots into SQL. 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 index 8aaa3b5..6f9fd09 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -79,6 +79,28 @@ .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); } + .actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; } + button { + appearance: none; + background: #17202a; + border: 1px solid #17202a; + border-radius: 6px; + color: #fff; + cursor: pointer; + font: inherit; + font-weight: 650; + padding: 8px 12px; + } + button:hover { background: #2a394a; } + .messages { display: grid; gap: 8px; margin-bottom: 18px; } + .message { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + } + .message.success { border-color: #a7d8b9; background: #edf8f1; color: var(--success); } + .message.error { border-color: #e8b4b4; background: #fff0f0; color: var(--failed); } @media (max-width: 800px) { main { padding: 16px; } nav { padding: 0; } @@ -97,6 +119,13 @@
+ {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} {% block content %}{% endblock %}
diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html index 1d10b85..1c3e7dc 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -5,6 +5,13 @@ {% block content %}

{{ host.host }}

+
+
+ {% csrf_token %} + +
+
+
Snapshots
{{ counts.snapshots }}
Runs
{{ counts.runs }}
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index e11d3aa..cfbc9fc 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -1,12 +1,15 @@ from __future__ import annotations from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory 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 +from pobsync.util import write_yaml_atomic +from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord class ViewTests(TestCase): @@ -63,6 +66,7 @@ class ViewTests(TestCase): self.assertContains(response, "web-01.example.test") self.assertContains(response, "15 2 * * *") self.assertContains(response, "20260519-021500Z__ABCDEFGH") + self.assertContains(response, "Discover snapshots") def test_host_detail_returns_404_for_unknown_host(self) -> None: self.client.force_login(self.staff_user) @@ -71,6 +75,31 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 404) + def test_discover_host_snapshots_action_discovers_and_redirects(self) -> None: + self.client.force_login(self.staff_user) + with TemporaryDirectory() as tmp: + backup_root = Path(tmp) / "backups" + GlobalConfig.objects.create(name="default", backup_root=str(backup_root)) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + snapshot_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__ABCDEFGH" + meta_dir = snapshot_dir / "meta" + meta_dir.mkdir(parents=True) + write_yaml_atomic(meta_dir / "meta.yaml", {"status": "success", "started_at": "2026-05-19T02:15:00Z"}) + + response = self.client.post(reverse("discover_host_snapshots", args=[host.host]), follow=True) + + self.assertRedirects(response, reverse("host_detail", args=[host.host])) + self.assertContains(response, "Snapshot discovery scanned 1 items") + self.assertTrue(SnapshotRecord.objects.filter(host=host, dirname=snapshot_dir.name).exists()) + + def test_discover_host_snapshots_requires_post(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + + response = self.client.get(reverse("discover_host_snapshots", args=[host.host])) + + self.assertEqual(response.status_code, 405) + 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( diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 1c612fa..58bc8d0 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -1,10 +1,13 @@ from __future__ import annotations +from django.contrib import messages 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 django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.http import require_POST from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord +from .snapshot_discovery import discover_snapshots @staff_member_required @@ -48,6 +51,25 @@ def host_detail(request, host: str): return render(request, "pobsync_backend/host_detail.html", context) +@staff_member_required +@require_POST +def discover_host_snapshots(request, host: str): + host_config = get_object_or_404(HostConfig, host=host) + try: + result = discover_snapshots(host=host_config) + except Exception as exc: + messages.error(request, f"Snapshot discovery failed for {host_config.host}: {exc}") + else: + messages.success( + request, + ( + f"Snapshot discovery scanned {result['scanned']} items for {host_config.host}: " + f"{result['created']} created, {result['updated']} updated." + ), + ) + return redirect("host_detail", host=host_config.host) + + def _schedule_for_host(host_config: HostConfig) -> ScheduleConfig | None: try: return host_config.schedule diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index 940dd63..e09470f 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -9,6 +9,7 @@ from pobsync_backend import api, views urlpatterns = [ path("", views.dashboard, name="dashboard"), path("hosts//", views.host_detail, name="host_detail"), + path("hosts//discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"), path("api/", api.api_index), path("api/status/", api.status), path("api/hosts/", api.hosts),