(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:
2026-05-19 14:25:28 +02:00
parent 4fb33eca6c
commit 91ce7ad4c5
6 changed files with 148 additions and 5 deletions

View File

@@ -118,16 +118,19 @@ class GlobalConfigForm(forms.ModelForm):
class ManualBackupForm(forms.Form): class ManualBackupForm(forms.Form):
dry_run = forms.BooleanField( dry_run = forms.BooleanField(
label="Dry run",
required=False, required=False,
initial=True, initial=True,
help_text="Queue rsync in dry-run mode without writing a snapshot.", help_text="Queue rsync in dry-run mode without writing a snapshot.",
) )
prune = forms.BooleanField( prune = forms.BooleanField(
label="Apply retention after success",
required=False, required=False,
help_text="Apply retention after a successful non-dry-run backup.", 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( prune_protect_bases = forms.BooleanField(
label="Protect base snapshots",
required=False, required=False,
help_text="Keep snapshots that are used as bases by other snapshots.", help_text="Keep snapshots that are used as bases by other snapshots.",
) )

View File

@@ -77,6 +77,7 @@
.status.success { color: var(--success); border-color: #a7d8b9; background: #edf8f1; } .status.success { color: var(--success); border-color: #a7d8b9; background: #edf8f1; }
.status.failed { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; } .status.failed { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; }
.status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; } .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 { display: grid; gap: 4px; }
.stack.spaced { margin-bottom: 14px; } .stack.spaced { margin-bottom: 14px; }
.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); }
@@ -94,12 +95,28 @@
padding: 8px 12px; padding: 8px 12px;
} }
button:hover, .button-link:hover { background: #2a394a; text-decoration: none; } button:hover, .button-link:hover { background: #2a394a; text-decoration: none; }
button.secondary,
.button-link.secondary { .button-link.secondary {
background: #fff; background: #fff;
border-color: var(--border); border-color: var(--border);
color: var(--text); color: var(--text);
} }
button.secondary:hover,
.button-link.secondary:hover { background: #eef3f8; } .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; } .messages { display: grid; gap: 8px; margin-bottom: 18px; }
.message { .message {
background: var(--panel); background: var(--panel);

View File

@@ -18,6 +18,8 @@
<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>
<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">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> <div class="metric"><div class="label">Incomplete</div><div class="value">{{ counts.incomplete_snapshots }}</div></div>
</section> </section>
@@ -68,7 +70,43 @@
</section> </section>
<section class="panel"> <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"> <form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid">
{% csrf_token %} {% csrf_token %}
{{ manual_backup_form.non_field_errors }} {{ manual_backup_form.non_field_errors }}
@@ -83,7 +121,7 @@
{% endfor %} {% endfor %}
<div class="actions"> <div class="actions">
<button type="submit">Queue backup</button> <button type="submit" {% if not can_queue_backup %}disabled{% endif %}>Queue with options</button>
</div> </div>
</form> </form>
</section> </section>

View File

@@ -35,6 +35,18 @@
</section> </section>
</div> </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"> <section class="panel">
<h2>Result</h2> <h2>Result</h2>
<pre>{{ result_json }}</pre> <pre>{{ result_json }}</pre>

View File

@@ -211,6 +211,7 @@ class ViewTests(TestCase):
def test_host_detail_renders_config_schedule_runs_and_snapshots(self) -> None: def test_host_detail_renders_config_schedule_runs_and_snapshots(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
GlobalConfig.objects.create(name="default", backup_root="/backups")
host = HostConfig.objects.create( host = HostConfig.objects.create(
host="web-01", host="web-01",
address="web-01.example.test", address="web-01.example.test",
@@ -231,12 +232,38 @@ class ViewTests(TestCase):
self.assertContains(response, "Discover snapshots") self.assertContains(response, "Discover snapshots")
self.assertContains(response, "Edit schedule") self.assertContains(response, "Edit schedule")
self.assertContains(response, "Edit config") 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, "Snapshot Discovery")
self.assertContains(response, reverse("queue_manual_backup", args=[host.host])) 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("run_detail", args=[BackupRun.objects.get().id]))
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.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: def test_host_detail_renders_discovery_status_for_existing_snapshot_dirs(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
with TemporaryDirectory() as tmp: 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: def test_queue_manual_backup_requires_default_global_config(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test") host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
@@ -332,7 +382,16 @@ class ViewTests(TestCase):
snapshot_path=snapshot.path, snapshot_path=snapshot.path,
base_path="/backups/web-01/scheduled/base", base_path="/backups/web-01/scheduled/base",
rsync_exit_code=0, 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])) 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, "web-01")
self.assertContains(response, "success") self.assertContains(response, "success")
self.assertContains(response, "ABCDEFGH") self.assertContains(response, "ABCDEFGH")
self.assertContains(response, "Requested Options")
self.assertContains(response, "Dry run:</strong> yes")
self.assertContains(response, "&quot;ok&quot;: true") self.assertContains(response, "&quot;ok&quot;: true")
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id])) self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))

View File

@@ -104,16 +104,27 @@ def create_host_config(request):
@staff_member_required @staff_member_required
def host_detail(request, host: str): def host_detail(request, host: str):
host_config = get_object_or_404(HostConfig, host=host) 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 = { context = {
"host": host_config, "host": host_config,
"schedule": _schedule_for_host(host_config), "schedule": _schedule_for_host(host_config),
"discovery": inspect_snapshot_discovery(host=host_config), "discovery": inspect_snapshot_discovery(host=host_config),
"manual_backup_form": ManualBackupForm(initial=_default_manual_backup_initial(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], "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], "snapshots": host_config.snapshots.select_related("base").order_by("-started_at", "dirname")[:20],
"counts": { "counts": {
"snapshots": host_config.snapshots.count(), "snapshots": host_config.snapshots.count(),
"runs": host_config.runs.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(), "failed_runs": host_config.runs.filter(status=BackupRun.Status.FAILED).count(),
"incomplete_snapshots": host_config.snapshots.filter(kind=SnapshotRecord.Kind.INCOMPLETE).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) run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
context = { context = {
"run": run, "run": run,
"requested": run.result.get("requested") if isinstance(run.result, dict) else {},
"result_json": _pretty_json(run.result), "result_json": _pretty_json(run.result),
} }
return render(request, "pobsync_backend/run_detail.html", context) return render(request, "pobsync_backend/run_detail.html", context)