(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):
|
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.",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, ""ok": true")
|
self.assertContains(response, ""ok": true")
|
||||||
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
|
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user