diff --git a/src/pobsync_backend/forms.py b/src/pobsync_backend/forms.py index 3895084..31987ca 100644 --- a/src/pobsync_backend/forms.py +++ b/src/pobsync_backend/forms.py @@ -118,16 +118,19 @@ class GlobalConfigForm(forms.ModelForm): class ManualBackupForm(forms.Form): dry_run = forms.BooleanField( + label="Dry run", required=False, initial=True, help_text="Queue rsync in dry-run mode without writing a snapshot.", ) prune = forms.BooleanField( + label="Apply retention after success", required=False, help_text="Apply retention after a successful non-dry-run backup.", ) - prune_max_delete = forms.IntegerField(min_value=0, initial=10) + prune_max_delete = forms.IntegerField(label="Retention max delete", min_value=0, initial=10) prune_protect_bases = forms.BooleanField( + label="Protect base snapshots", required=False, help_text="Keep snapshots that are used as bases by other snapshots.", ) diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 7cf6164..02896b7 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -77,6 +77,7 @@ .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; } + .status.queued { color: var(--link); border-color: #b5cdea; background: #eef6ff; } .stack { display: grid; gap: 4px; } .stack.spaced { margin-bottom: 14px; } .two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } @@ -94,12 +95,28 @@ padding: 8px 12px; } button:hover, .button-link:hover { background: #2a394a; text-decoration: none; } + button.secondary, .button-link.secondary { background: #fff; border-color: var(--border); color: var(--text); } + button.secondary:hover, .button-link.secondary:hover { background: #eef3f8; } + button:disabled { + background: #d8dee6; + border-color: #d8dee6; + color: var(--muted); + cursor: not-allowed; + } + .inline-form { margin: 0; } + .operator-state { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; + } .messages { display: grid; gap: 8px; margin-bottom: 18px; } .message { background: var(--panel); diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html index 64cb4a8..fada3de 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -18,6 +18,8 @@
Snapshots
{{ counts.snapshots }}
Runs
{{ counts.runs }}
+
Queued
{{ counts.queued_runs }}
+
Running
{{ counts.running_runs }}
Failed Runs
{{ counts.failed_runs }}
Incomplete
{{ counts.incomplete_snapshots }}
@@ -68,7 +70,43 @@
-

Queue Manual Backup

+

Backup Control

+
+ {% if active_run %} + {{ active_run.status }} + Run {{ active_run.id }} + {% elif can_queue_backup %} + ready + {% elif not host.enabled %} + disabled + {% elif not has_global_config %} + missing global config + {% endif %} +
+ +
+
+ {% csrf_token %} + + + +
+
+ {% csrf_token %} + + +
+
+ + {% if not can_queue_backup %} + {% if not has_global_config %} +

Create the default global config before queueing backups.

+ {% elif not host.enabled %} +

Enable this host before queueing backups.

+ {% endif %} + {% endif %} + +

Advanced Options

{% csrf_token %} {{ manual_backup_form.non_field_errors }} @@ -83,7 +121,7 @@ {% endfor %}
- +
diff --git a/src/pobsync_backend/templates/pobsync_backend/run_detail.html b/src/pobsync_backend/templates/pobsync_backend/run_detail.html index 76d59db..1583fdc 100644 --- a/src/pobsync_backend/templates/pobsync_backend/run_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/run_detail.html @@ -35,6 +35,18 @@ + {% if requested %} +
+

Requested Options

+
+
Dry run: {{ requested.dry_run|yesno:"yes,no" }}
+
Apply retention: {{ requested.prune|yesno:"yes,no" }}
+
Retention max delete: {{ requested.prune_max_delete }}
+
Protect bases: {{ requested.prune_protect_bases|yesno:"yes,no" }}
+
+
+ {% endif %} +

Result

{{ result_json }}
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index d2bd253..e82595c 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -211,6 +211,7 @@ class ViewTests(TestCase): def test_host_detail_renders_config_schedule_runs_and_snapshots(self) -> None: self.client.force_login(self.staff_user) + GlobalConfig.objects.create(name="default", backup_root="/backups") host = HostConfig.objects.create( host="web-01", address="web-01.example.test", @@ -231,12 +232,38 @@ class ViewTests(TestCase): self.assertContains(response, "Discover snapshots") self.assertContains(response, "Edit schedule") self.assertContains(response, "Edit config") - self.assertContains(response, "Queue Manual Backup") + self.assertContains(response, "Backup Control") + self.assertContains(response, "Queue dry-run") + self.assertContains(response, "Queue backup") + self.assertContains(response, "ready") self.assertContains(response, "Snapshot Discovery") self.assertContains(response, reverse("queue_manual_backup", args=[host.host])) self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id])) self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id])) + def test_host_detail_surfaces_active_backup_run(self) -> None: + self.client.force_login(self.staff_user) + GlobalConfig.objects.create(name="default", backup_root="/backups") + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + run = BackupRun.objects.create(host=host, status=BackupRun.Status.QUEUED) + + response = self.client.get(reverse("host_detail", args=[host.host])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "queued") + self.assertContains(response, f"Run {run.id}") + self.assertContains(response, reverse("run_detail", args=[run.id])) + + def test_host_detail_disables_backup_controls_without_global_config(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("host_detail", args=[host.host])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "missing global config") + self.assertContains(response, "Create the default global config before queueing backups.") + def test_host_detail_renders_discovery_status_for_existing_snapshot_dirs(self) -> None: self.client.force_login(self.staff_user) with TemporaryDirectory() as tmp: @@ -292,6 +319,29 @@ class ViewTests(TestCase): }, ) + def test_queue_manual_backup_quick_action_can_queue_real_backup(self) -> None: + self.client.force_login(self.staff_user) + GlobalConfig.objects.create(name="default", backup_root="/backups") + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + + response = self.client.post( + reverse("queue_manual_backup", args=[host.host]), + {"prune_max_delete": "10"}, + follow=True, + ) + + run = BackupRun.objects.get(host=host) + self.assertRedirects(response, reverse("run_detail", args=[run.id])) + self.assertEqual( + run.result["requested"], + { + "dry_run": False, + "prune": False, + "prune_max_delete": 10, + "prune_protect_bases": False, + }, + ) + def test_queue_manual_backup_requires_default_global_config(self) -> None: self.client.force_login(self.staff_user) host = HostConfig.objects.create(host="web-01", address="web-01.example.test") @@ -332,7 +382,16 @@ class ViewTests(TestCase): snapshot_path=snapshot.path, base_path="/backups/web-01/scheduled/base", rsync_exit_code=0, - result={"ok": True, "snapshot": snapshot.path}, + result={ + "ok": True, + "snapshot": snapshot.path, + "requested": { + "dry_run": True, + "prune": False, + "prune_max_delete": 10, + "prune_protect_bases": False, + }, + }, ) response = self.client.get(reverse("run_detail", args=[run.id])) @@ -342,6 +401,8 @@ class ViewTests(TestCase): self.assertContains(response, "web-01") self.assertContains(response, "success") self.assertContains(response, "ABCDEFGH") + self.assertContains(response, "Requested Options") + self.assertContains(response, "Dry run: yes") self.assertContains(response, ""ok": true") self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id])) diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index a838456..a1fffad 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -104,16 +104,27 @@ def create_host_config(request): @staff_member_required def host_detail(request, host: str): host_config = get_object_or_404(HostConfig, host=host) + queued_runs = host_config.runs.filter(status=BackupRun.Status.QUEUED) + running_runs = host_config.runs.filter(status=BackupRun.Status.RUNNING) + active_run = host_config.runs.filter( + status__in=[BackupRun.Status.QUEUED, BackupRun.Status.RUNNING] + ).order_by("created_at", "id").first() + has_global_config = GlobalConfig.objects.filter(name="default").exists() context = { "host": host_config, "schedule": _schedule_for_host(host_config), "discovery": inspect_snapshot_discovery(host=host_config), "manual_backup_form": ManualBackupForm(initial=_default_manual_backup_initial(host_config)), + "can_queue_backup": host_config.enabled and has_global_config, + "has_global_config": has_global_config, + "active_run": active_run, "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(), + "queued_runs": queued_runs.count(), + "running_runs": running_runs.count(), "failed_runs": host_config.runs.filter(status=BackupRun.Status.FAILED).count(), "incomplete_snapshots": host_config.snapshots.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count(), }, @@ -153,6 +164,7 @@ def run_detail(request, run_id: int): run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id) context = { "run": run, + "requested": run.result.get("requested") if isinstance(run.result, dict) else {}, "result_json": _pretty_json(run.result), } return render(request, "pobsync_backend/run_detail.html", context)