From af548f11c4a4c961c7219ad798f238f31e068dee Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 14:13:05 +0200 Subject: [PATCH 1/4] (ui) Standardize primary form actions Add shared form action styling and consistent Cancel links across config, schedule, and SSH key forms so create/edit flows behave predictably. Refs #25 --- .../templates/pobsync_backend/base.html | 22 +++++++++++++++++++ .../pobsync_backend/global_form.html | 3 ++- .../templates/pobsync_backend/host_form.html | 7 +++++- .../pobsync_backend/schedule_form.html | 3 ++- .../pobsync_backend/ssh_credential_form.html | 7 ++++-- .../ssh_credential_generate.html | 3 ++- src/pobsync_backend/tests/test_views.py | 19 ++++++++++++++++ 7 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index f2453d6..3d3cd22 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -280,6 +280,17 @@ } button.secondary:hover, .button-link.secondary:hover { background: #eef3f8; } + button.danger, + .button-link.danger { + background: var(--failed); + border-color: var(--failed); + color: #fff; + } + button.danger:hover, + .button-link.danger:hover { + background: #842828; + border-color: #842828; + } button.compact, .button-link.compact { font-size: 12px; @@ -679,6 +690,16 @@ .message.error { border-color: #e8b4b4; background: #fff0f0; color: var(--failed); } .message.warning { border-color: #e7cf8a; background: #fff8df; color: var(--running); } .form-grid { display: grid; gap: 15px; max-width: 720px; } + .form-actions { + align-items: center; + border-top: 1px solid var(--border); + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 4px; + padding-top: 15px; + } + .form-actions .button-link.secondary { margin-left: auto; } .field { display: grid; gap: 6px; } .field label { font-weight: 700; } .field input[type="text"], .field input[type="number"], .field select, .field textarea { @@ -736,6 +757,7 @@ .host-control-grid { grid-template-columns: 1fr; } .schedule-row { grid-template-columns: 1fr; } .schedule-time { justify-items: start; text-align: left; } + .form-actions .button-link.secondary { margin-left: 0; } .host-card-header { display: grid; } .host-card-status { justify-content: flex-start; max-width: none; } .host-card-layout { grid-template-columns: 1fr; } diff --git a/src/pobsync_backend/templates/pobsync_backend/global_form.html b/src/pobsync_backend/templates/pobsync_backend/global_form.html index 98fe535..41c05bc 100644 --- a/src/pobsync_backend/templates/pobsync_backend/global_form.html +++ b/src/pobsync_backend/templates/pobsync_backend/global_form.html @@ -33,8 +33,9 @@ {% endfor %} -
+
+ Cancel
diff --git a/src/pobsync_backend/templates/pobsync_backend/host_form.html b/src/pobsync_backend/templates/pobsync_backend/host_form.html index 6847561..06a8abd 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_form.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_form.html @@ -33,8 +33,13 @@
{% endfor %} -
+
+ {% if host %} + Cancel + {% else %} + Cancel + {% endif %}
diff --git a/src/pobsync_backend/templates/pobsync_backend/schedule_form.html b/src/pobsync_backend/templates/pobsync_backend/schedule_form.html index 9aa7581..d9cee47 100644 --- a/src/pobsync_backend/templates/pobsync_backend/schedule_form.html +++ b/src/pobsync_backend/templates/pobsync_backend/schedule_form.html @@ -30,8 +30,9 @@
{% endfor %} -
+
+ Cancel
diff --git a/src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html b/src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html index 092480f..0cdf441 100644 --- a/src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html +++ b/src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html @@ -41,8 +41,9 @@
{% endfor %} -
+
+ Cancel
@@ -64,7 +65,9 @@
- +
+ +
{% endif %} diff --git a/src/pobsync_backend/templates/pobsync_backend/ssh_credential_generate.html b/src/pobsync_backend/templates/pobsync_backend/ssh_credential_generate.html index 1b5933c..acb10ba 100644 --- a/src/pobsync_backend/templates/pobsync_backend/ssh_credential_generate.html +++ b/src/pobsync_backend/templates/pobsync_backend/ssh_credential_generate.html @@ -29,8 +29,9 @@ {% endfor %} -
+
+ Cancel
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 1f63df6..76ebdc6 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -542,6 +542,19 @@ class ViewTests(TestCase): self.assertEqual(credential.private_key, "UPLOADED PRIVATE KEY\n") self.assertEqual(credential.public_key, "DERIVED PUBLIC KEY") + def test_ssh_credential_forms_render_cancel_actions(self) -> None: + self.client.force_login(self.staff_user) + credential = SshCredential.objects.create(name="backup-key") + + create_response = self.client.get(reverse("create_ssh_credential")) + edit_response = self.client.get(reverse("edit_ssh_credential", args=[credential.id])) + generate_response = self.client.get(reverse("generate_ssh_credential")) + + for response in (create_response, edit_response, generate_response): + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Cancel") + self.assertContains(response, reverse("ssh_credentials")) + def test_ssh_credentials_view_generates_filesystem_key(self) -> None: self.client.force_login(self.staff_user) with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")): @@ -746,6 +759,8 @@ class ViewTests(TestCase): self.assertContains(response, f'value="{credential.id}" selected') self.assertContains(response, "--archive") self.assertContains(response, "/proc/***") + self.assertContains(response, "Cancel") + self.assertContains(response, reverse("dashboard")) def test_global_config_form_renders_static_container_backup_root_on_edit(self) -> None: self.client.force_login(self.staff_user) @@ -2142,6 +2157,8 @@ class ViewTests(TestCase): self.assertContains(response, "evaluated by the pobsync scheduler service") self.assertContains(response, "15 2 * * *") self.assertContains(response, "Save schedule") + self.assertContains(response, "Cancel") + self.assertContains(response, reverse("host_detail", args=[host.host])) def test_schedule_form_creates_schedule(self) -> None: self.client.force_login(self.staff_user) @@ -2243,6 +2260,8 @@ class ViewTests(TestCase): self.assertContains(response, "/srv") self.assertContains(response, "*.tmp") self.assertContains(response, "--numeric-ids") + self.assertContains(response, "Cancel") + self.assertContains(response, reverse("host_detail", args=[host.host])) def test_host_config_form_renders_effective_config_check(self) -> None: self.client.force_login(self.staff_user) -- 2.43.0 From 1604f0f6f4289d1473d3d69427de03b8ff645bb4 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 14:17:07 +0200 Subject: [PATCH 2/4] (ui) Clarify destructive action flows Make retention apply, incomplete cleanup, and SSH key deletion visibly destructive with warning copy, danger styling, and consistent cancel actions while keeping the existing confirmation requirements intact. Refs #25 --- .../pobsync_backend/retention_plan.html | 20 ++++++++++++++----- .../pobsync_backend/ssh_credential_form.html | 1 + src/pobsync_backend/tests/test_views.py | 7 +++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/pobsync_backend/templates/pobsync_backend/retention_plan.html b/src/pobsync_backend/templates/pobsync_backend/retention_plan.html index c1547b8..02fd800 100644 --- a/src/pobsync_backend/templates/pobsync_backend/retention_plan.html +++ b/src/pobsync_backend/templates/pobsync_backend/retention_plan.html @@ -104,8 +104,12 @@ {% if plan.delete %} -
+

Apply Retention

+

+ This permanently deletes the snapshot directories listed in Would Delete. Confirm the host and delete count + before applying the plan. +

{% csrf_token %} {{ apply_form.non_field_errors }} @@ -138,8 +142,9 @@
{{ apply_form.confirm_delete_count.help_text }}
-
- +
+ + Cancel
@@ -200,6 +205,10 @@

Cleanup Incomplete Snapshots

+

+ This deletes only incomplete snapshot directories and their tracking records. Successful manual and scheduled + snapshots are not touched. +

{% csrf_token %} {{ incomplete_cleanup_form.non_field_errors }} @@ -225,8 +234,9 @@
{{ incomplete_cleanup_form.confirm_delete_count.help_text }}
-
- +
+ + Cancel
diff --git a/src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html b/src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html index 0cdf441..f0bbd6f 100644 --- a/src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html +++ b/src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html @@ -67,6 +67,7 @@
+ Cancel
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 76ebdc6..5099734 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -554,6 +554,8 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, "Cancel") self.assertContains(response, reverse("ssh_credentials")) + self.assertContains(edit_response, "Delete SSH key") + self.assertContains(edit_response, 'class="danger"', html=False) def test_ssh_credentials_view_generates_filesystem_key(self) -> None: self.client.force_login(self.staff_user) @@ -1835,6 +1837,9 @@ class ViewTests(TestCase): self.assertNotContains(response, "
Source
", html=True) self.assertContains(response, "Confirm delete count") self.assertContains(response, "Type 1 to confirm the current number of planned deletions.") + self.assertContains(response, "This permanently deletes the snapshot directories listed in Would Delete.") + self.assertContains(response, 'class="danger"', html=False) + self.assertContains(response, "Cancel") def test_retention_plan_warns_when_scheduled_prune_limit_is_exceeded(self) -> None: self.client.force_login(self.staff_user) @@ -1910,6 +1915,8 @@ class ViewTests(TestCase): self.assertContains(response, "excluded from retention cleanup") self.assertContains(response, "Delete incomplete snapshots") self.assertContains(response, "Type 1 to confirm the current number of incomplete snapshots.") + self.assertContains(response, "This deletes only incomplete snapshot directories") + self.assertContains(response, 'class="danger"', html=False) def test_incomplete_cleanup_deletes_incomplete_snapshot_after_confirmation(self) -> None: self.client.force_login(self.staff_user) -- 2.43.0 From 0f0de5dc30b85fbff776c9eea1eb0b5bda25ec00 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 14:22:11 +0200 Subject: [PATCH 3/4] (ui) Standardize list filter actions Give run, snapshot, schedule, purged snapshot, and log filters the same responsive form layout with consistent Apply/Clear actions. Refs #25 --- .../templates/pobsync_backend/base.html | 14 ++++++++++++++ .../templates/pobsync_backend/logs.html | 5 +++-- .../pobsync_backend/purged_snapshots.html | 4 ++-- .../templates/pobsync_backend/runs_list.html | 4 ++-- .../templates/pobsync_backend/schedules_list.html | 4 ++-- .../templates/pobsync_backend/snapshots_list.html | 4 ++-- src/pobsync_backend/tests/test_views.py | 15 +++++++++++++++ 7 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 3d3cd22..7a343fe 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -690,6 +690,13 @@ .message.error { border-color: #e8b4b4; background: #fff0f0; color: var(--failed); } .message.warning { border-color: #e7cf8a; background: #fff8df; color: var(--running); } .form-grid { display: grid; gap: 15px; max-width: 720px; } + .filter-form { + align-items: end; + display: grid; + gap: 15px; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + max-width: none; + } .form-actions { align-items: center; border-top: 1px solid var(--border); @@ -700,6 +707,13 @@ padding-top: 15px; } .form-actions .button-link.secondary { margin-left: auto; } + .filter-form .form-actions { + border-top: 0; + justify-content: flex-end; + margin-top: 0; + padding-top: 0; + } + .filter-form .form-actions .button-link.secondary { margin-left: 0; } .field { display: grid; gap: 6px; } .field label { font-weight: 700; } .field input[type="text"], .field input[type="number"], .field select, .field textarea { diff --git a/src/pobsync_backend/templates/pobsync_backend/logs.html b/src/pobsync_backend/templates/pobsync_backend/logs.html index 8eb57d2..f917473 100644 --- a/src/pobsync_backend/templates/pobsync_backend/logs.html +++ b/src/pobsync_backend/templates/pobsync_backend/logs.html @@ -16,7 +16,7 @@

Filter

-
+
-
+
+ Clear
diff --git a/src/pobsync_backend/templates/pobsync_backend/purged_snapshots.html b/src/pobsync_backend/templates/pobsync_backend/purged_snapshots.html index d6ed8ce..f623dd7 100644 --- a/src/pobsync_backend/templates/pobsync_backend/purged_snapshots.html +++ b/src/pobsync_backend/templates/pobsync_backend/purged_snapshots.html @@ -16,7 +16,7 @@

Filters

-
+
-
+
Clear
diff --git a/src/pobsync_backend/templates/pobsync_backend/runs_list.html b/src/pobsync_backend/templates/pobsync_backend/runs_list.html index 9be3a57..f366fb4 100644 --- a/src/pobsync_backend/templates/pobsync_backend/runs_list.html +++ b/src/pobsync_backend/templates/pobsync_backend/runs_list.html @@ -16,7 +16,7 @@

Filters

- +
-
+
Clear
diff --git a/src/pobsync_backend/templates/pobsync_backend/schedules_list.html b/src/pobsync_backend/templates/pobsync_backend/schedules_list.html index ada648b..9600b22 100644 --- a/src/pobsync_backend/templates/pobsync_backend/schedules_list.html +++ b/src/pobsync_backend/templates/pobsync_backend/schedules_list.html @@ -16,7 +16,7 @@

Filters

- +
-
+
Clear
diff --git a/src/pobsync_backend/templates/pobsync_backend/snapshots_list.html b/src/pobsync_backend/templates/pobsync_backend/snapshots_list.html index a6d8e7c..7676452 100644 --- a/src/pobsync_backend/templates/pobsync_backend/snapshots_list.html +++ b/src/pobsync_backend/templates/pobsync_backend/snapshots_list.html @@ -16,7 +16,7 @@

Filters

- +
-
+
Clear
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 5099734..4ac4bb4 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -233,6 +233,9 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, "Runs") self.assertContains(response, "Review queued, running, completed") + self.assertContains(response, "Apply filters") + self.assertContains(response, reverse("runs_list")) + self.assertContains(response, "Clear") self.assertContains(response, f"Run {failed.id}") self.assertContains(response, "web-01") self.assertContains(response, "needed") @@ -275,6 +278,9 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, "Snapshots") self.assertContains(response, "Browse discovered scheduled, manual, and incomplete snapshots") + self.assertContains(response, "Apply filters") + self.assertContains(response, reverse("snapshots_list")) + self.assertContains(response, "Clear") self.assertContains(response, manual.dirname) self.assertContains(response, "web-01") self.assertNotContains(response, scheduled.dirname) @@ -291,6 +297,9 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, "Schedules") self.assertContains(response, "Review configured backup schedules") + self.assertContains(response, "Apply filters") + self.assertContains(response, reverse("schedules_list")) + self.assertContains(response, "Clear") self.assertContains(response, "web-01") self.assertContains(response, "15 2 * * *") self.assertContains(response, "success") @@ -428,6 +437,9 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, "Logs") self.assertContains(response, "Filter pobsync service logs") + self.assertContains(response, "Filter logs") + self.assertContains(response, reverse("logs")) + self.assertContains(response, "Clear") self.assertContains(response, "web-01 failed backup run 12") self.assertNotContains(response, "web-02 failed backup run 12") self.assertNotContains(response, "started") @@ -458,6 +470,9 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, "Purged Snapshots") self.assertContains(response, "Audit trail for snapshots removed") + self.assertContains(response, "Apply filters") + self.assertContains(response, reverse("purged_snapshots")) + self.assertContains(response, "Clear") self.assertContains(response, "20260518-021500Z__OLDSNAP") self.assertContains(response, "outside retention policy") self.assertContains(response, "Scheduled") -- 2.43.0 From 7a552715fe6b371a568bf68ad7bc3428e357d07e Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 14:25:26 +0200 Subject: [PATCH 4/4] (ui) Clarify run action flows Move run cancellation and review actions out of the page header into dedicated action panels with clearer operator copy and consistent form button styling. Refs #25 --- .../pobsync_backend/host_detail.html | 2 +- .../templates/pobsync_backend/run_detail.html | 45 +++++++++++++------ src/pobsync_backend/tests/test_views.py | 3 ++ 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html index 316fde3..76b67bb 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -433,7 +433,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 8f042f9..6e06b06 100644 --- a/src/pobsync_backend/templates/pobsync_backend/run_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/run_detail.html @@ -11,20 +11,6 @@
Back to host - {% if can_cancel %} -
- {% csrf_token %} - -
- {% endif %} - {% if run.status == "failed" or run.status == "warning" %} - {% if not run.reviewed_at %} -
- {% csrf_token %} - -
- {% endif %} - {% endif %}
@@ -35,6 +21,22 @@
Rsync
{{ run.rsync_exit_code|default:"" }}
+ {% if can_cancel %} +
+

Run Control

+

+ Cancelling a queued run stops it immediately. Cancelling a running run asks the worker to stop + and records the cancellation request on this run. +

+
+ {% csrf_token %} +
+ +
+
+
+ {% endif %} + {% if failure %}

Failure

@@ -46,6 +48,21 @@
{% endif %} + {% if run.status == "failed" or run.status == "warning" %} + {% if not run.reviewed_at %} +
+

Review Required

+

Mark this run as reviewed after you have checked the failure or warning and no longer need it in the action queue.

+
+ {% csrf_token %} +
+ +
+
+
+ {% endif %} + {% endif %} + {% if run.reviewed_at %}

Review

diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 4ac4bb4..e221eeb 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -1667,8 +1667,11 @@ class ViewTests(TestCase): response = self.client.get(reverse("run_detail", args=[run.id])) self.assertEqual(response.status_code, 200) + self.assertContains(response, "Run Control") + self.assertContains(response, "Cancelling a queued run stops it immediately") self.assertContains(response, "Cancel run") self.assertContains(response, reverse("cancel_run", args=[run.id])) + self.assertContains(response, 'class="danger"', html=False) def test_run_detail_renders_worker_execution_metadata(self) -> None: self.client.force_login(self.staff_user) -- 2.43.0