(feature) Add snapshot discovery action to host view
Add a staff-only POST action on host detail pages to discover existing snapshots for that host and record them into SQL. Show success or failure feedback through Django messages, and keep the action non-destructive before adding heavier backup or retention controls. Cover the action with view tests for successful discovery, redirect behavior, and method safety.
This commit is contained in:
@@ -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.
|
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.
|
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:
|
The remaining internal engine code still contains reusable backup primitives:
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,28 @@
|
|||||||
.status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
|
.status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
|
||||||
.stack { display: grid; gap: 4px; }
|
.stack { display: grid; gap: 4px; }
|
||||||
.two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
.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) {
|
@media (max-width: 800px) {
|
||||||
main { padding: 16px; }
|
main { padding: 16px; }
|
||||||
nav { padding: 0; }
|
nav { padding: 0; }
|
||||||
@@ -97,6 +119,13 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
{% if messages %}
|
||||||
|
<section class="messages" aria-label="Messages">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="message {{ message.tags }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{{ host.host }}</h1>
|
<h1>{{ host.host }}</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Host actions">
|
||||||
|
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit">Discover snapshots</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="grid" aria-label="Host summary">
|
<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">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">Runs</div><div class="value">{{ counts.runs }}</div></div>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
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):
|
class ViewTests(TestCase):
|
||||||
@@ -63,6 +66,7 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "web-01.example.test")
|
self.assertContains(response, "web-01.example.test")
|
||||||
self.assertContains(response, "15 2 * * *")
|
self.assertContains(response, "15 2 * * *")
|
||||||
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
||||||
|
self.assertContains(response, "Discover snapshots")
|
||||||
|
|
||||||
def test_host_detail_returns_404_for_unknown_host(self) -> None:
|
def test_host_detail_returns_404_for_unknown_host(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -71,6 +75,31 @@ class ViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 404)
|
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:
|
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)
|
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||||
return SnapshotRecord.objects.create(
|
return SnapshotRecord.objects.create(
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
from django.db.models import Count
|
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 .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
|
||||||
|
from .snapshot_discovery import discover_snapshots
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
@@ -48,6 +51,25 @@ def host_detail(request, host: str):
|
|||||||
return render(request, "pobsync_backend/host_detail.html", context)
|
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:
|
def _schedule_for_host(host_config: HostConfig) -> ScheduleConfig | None:
|
||||||
try:
|
try:
|
||||||
return host_config.schedule
|
return host_config.schedule
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pobsync_backend import api, views
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.dashboard, name="dashboard"),
|
path("", views.dashboard, name="dashboard"),
|
||||||
path("hosts/<str:host>/", views.host_detail, name="host_detail"),
|
path("hosts/<str:host>/", views.host_detail, name="host_detail"),
|
||||||
|
path("hosts/<str:host>/discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"),
|
||||||
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),
|
||||||
|
|||||||
Reference in New Issue
Block a user