(feature) Add host backup control actions
Turn the host detail page into a more useful operator surface for starting greenfield backups from Django. Add quick actions for dry-run and real backup runs, keep the advanced manual options available, and show whether a host is ready, disabled, or blocked by missing global config. Surface queued and running counts plus a direct link to the active run. Expose requested backup options on the run detail page and cover the new control flow with view tests.
This commit is contained in:
@@ -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.",
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<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">Runs</div><div class="value">{{ counts.runs }}</div></div>
|
||||
<div class="metric"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
|
||||
<div class="metric"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
|
||||
<div class="metric"><div class="label">Failed Runs</div><div class="value">{{ counts.failed_runs }}</div></div>
|
||||
<div class="metric"><div class="label">Incomplete</div><div class="value">{{ counts.incomplete_snapshots }}</div></div>
|
||||
</section>
|
||||
@@ -68,7 +70,43 @@
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Queue Manual Backup</h2>
|
||||
<h2>Backup Control</h2>
|
||||
<div class="operator-state">
|
||||
{% if active_run %}
|
||||
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
|
||||
<a href="{% url 'run_detail' active_run.id %}">Run {{ active_run.id }}</a>
|
||||
{% elif can_queue_backup %}
|
||||
<span class="status success">ready</span>
|
||||
{% elif not host.enabled %}
|
||||
<span class="status failed">disabled</span>
|
||||
{% elif not has_global_config %}
|
||||
<span class="status failed">missing global config</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<section class="actions inline" aria-label="Quick backup actions">
|
||||
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="dry_run" value="on">
|
||||
<input type="hidden" name="prune_max_delete" value="10">
|
||||
<button type="submit" class="secondary" {% if not can_queue_backup %}disabled{% endif %}>Queue dry-run</button>
|
||||
</form>
|
||||
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="prune_max_delete" value="10">
|
||||
<button type="submit" {% if not can_queue_backup %}disabled{% endif %}>Queue backup</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if not can_queue_backup %}
|
||||
{% if not has_global_config %}
|
||||
<p class="muted">Create the default global config before queueing backups.</p>
|
||||
{% elif not host.enabled %}
|
||||
<p class="muted">Enable this host before queueing backups.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<h3>Advanced Options</h3>
|
||||
<form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid">
|
||||
{% csrf_token %}
|
||||
{{ manual_backup_form.non_field_errors }}
|
||||
@@ -83,7 +121,7 @@
|
||||
{% endfor %}
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">Queue backup</button>
|
||||
<button type="submit" {% if not can_queue_backup %}disabled{% endif %}>Queue with options</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -35,6 +35,18 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if requested %}
|
||||
<section class="panel">
|
||||
<h2>Requested Options</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Dry run:</strong> {{ requested.dry_run|yesno:"yes,no" }}</div>
|
||||
<div><strong>Apply retention:</strong> {{ requested.prune|yesno:"yes,no" }}</div>
|
||||
<div><strong>Retention max delete:</strong> {{ requested.prune_max_delete }}</div>
|
||||
<div><strong>Protect bases:</strong> {{ requested.prune_protect_bases|yesno:"yes,no" }}</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Result</h2>
|
||||
<pre>{{ result_json }}</pre>
|
||||
|
||||
@@ -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:</strong> yes")
|
||||
self.assertContains(response, ""ok": true")
|
||||
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user