From 9e75273fc585ef13797a323cd4243469cf56a3f2 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 13:40:37 +0200 Subject: [PATCH] (ui) Promote host detail operator controls Add a first-screen host control workspace with status, backup actions, schedule state, and current activity so the host detail page behaves as the primary operator page instead of starting with raw configuration blocks. Refs #26 --- .../templates/pobsync_backend/base.html | 38 ++ .../pobsync_backend/host_detail.html | 338 +++++++++++------- src/pobsync_backend/tests/test_views.py | 7 +- 3 files changed, 251 insertions(+), 132 deletions(-) diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index beef593..31b3ecd 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -370,6 +370,43 @@ gap: 10px; justify-content: space-between; padding-top: 8px; + } + .host-control-grid { + display: grid; + gap: 14px; + grid-template-columns: minmax(280px, 1.25fr) repeat(3, minmax(220px, 1fr)); + margin-bottom: 20px; + } + .host-control-panel { + display: grid; + gap: 12px; + margin-bottom: 0; + } + .host-control-panel > h2:first-child { margin-bottom: 0; } + .host-control-primary { + display: grid; + gap: 8px; + } + .host-control-meta { + display: grid; + gap: 6px; + } + .host-control-meta > div { + align-items: baseline; + border-top: 1px solid var(--border); + display: flex; + gap: 10px; + justify-content: space-between; + padding-top: 7px; + } + .host-control-meta .label { + color: var(--muted); + font-size: 12px; + font-weight: 650; + text-transform: uppercase; + } + .host-control-meta strong { + text-align: right; } .status-summary { align-items: center; @@ -641,6 +678,7 @@ .page-header .actions { justify-content: flex-start; } .two-col { grid-template-columns: 1fr; } .dashboard-priority-grid { grid-template-columns: 1fr; } + .host-control-grid { grid-template-columns: 1fr; } .schedule-row { grid-template-columns: 1fr; } .schedule-time { justify-items: start; text-align: left; } .host-card-header { display: grid; } diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html index 60bbae8..899762b 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -32,47 +32,6 @@ -
-
Snapshots
{{ counts.snapshots }}
-
Runs
{{ counts.runs }}
-
Queued
{{ counts.queued_runs }}
-
Running
{{ counts.running_runs }}
-
Failed Runs
{{ counts.failed_runs }}
-
Incomplete
{{ counts.incomplete_snapshots }}
-
- -
-
-

Config

-
-
Address: {{ host.address }}
-
Enabled: {{ host.enabled|yesno:"yes,no" }}
-
SSH key: {{ host.ssh_credential|default:"global default" }}
-
SSH: {{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}
-
Backup source: {{ host.source_root|default:"global default" }}
-
Retention: daily {{ host.retention_daily }}, weekly {{ host.retention_weekly }}, monthly {{ host.retention_monthly }}, yearly {{ host.retention_yearly }}
-
-
- -
-

Schedule

- {% if schedule %} -
-
Schedule expression: {{ schedule.cron_expr }}
-
Evaluated by the pobsync scheduler service.
-
Enabled: {{ schedule.enabled|yesno:"yes,no" }}
-
Next run: {% if next_run_at %}{{ next_run_at|date:"Y-m-d H:i T" }} {{ scheduler_timezone }}{% endif %}
-
Prune: {{ schedule.prune|yesno:"yes,no" }}
-
Last status: {{ schedule.last_status|default:"" }}
-
Last started: {{ schedule.last_started_at|default:"" }}
-
Last finished: {{ schedule.last_finished_at|default:"" }}
-
- {% else %} -

No schedule configured.

- {% endif %} -
-
- {% if retention_warning.has_warning %}

Retention Warnings

@@ -101,60 +60,137 @@
{% endif %} - {% if effective_config %} -
-

Effective Config

-
-
-
Backup source: {{ effective_config.source_root }}
-
Destination subdir: {{ effective_config.destination_subdir|default:"none" }}
-
SSH: {{ effective_config.ssh.user }}@{{ host.address }}:{{ effective_config.ssh.port }}
-
SSH key: {{ effective_config.ssh.credential|default:"none selected" }}
-
SSH options: {{ effective_config.ssh.options|join:" " }}
-
Rsync binary: {{ effective_config.rsync.binary }}
-
Rsync args: {{ effective_config.rsync.args|join:" " }}
-
Timeout: {{ effective_config.rsync.timeout_seconds }}s
-
Bandwidth limit: {{ effective_config.rsync.bwlimit_kbps }} KB/s
-
- Retention: - d{{ effective_config.retention.daily }} - w{{ effective_config.retention.weekly }} - m{{ effective_config.retention.monthly }} - y{{ effective_config.retention.yearly }} -
-
-
-
Includes: {{ effective_config.includes|length }}
- {% if effective_config.includes %} -
{{ effective_config.includes|join:"
" }}
+
+
+

Host Status

+
+
+ {% if host.enabled %} + enabled {% else %} -
No include rules configured.
- {% endif %} -
Excludes: {{ effective_config.excludes|length }}
- {% if effective_config.excludes %} -
{{ effective_config.excludes|join:"
" }}
- {% else %} -
No exclude rules configured.
+ disabled {% endif %} + {{ host.address }}
+ {% if active_run %} + + {{ active_run.status }} + Run {{ active_run.id }} is active. + + {% elif counts.failed_runs %} + + failed + {{ counts.failed_runs }} failed run{{ counts.failed_runs|pluralize }} need review. + + {% elif retention_warning.has_warning %} + + warning + Retention needs attention. + + {% else %} + + ok + No active blockers for this host. + + {% endif %}
-
- {% endif %} +
+
Snapshots{{ counts.snapshots }}
+
Runs{{ counts.runs }}
+
Incomplete{{ counts.incomplete_snapshots }}
+
+ -
-

Snapshot Discovery

-
-
Backup root: {{ discovery.backup_root|default:"" }}
-
Host root: {{ discovery.host_root|default:"" }}
-
Status: {{ discovery.message }}
- {% if discovery.kind_counts %} -
On disk: - scheduled {{ discovery.kind_counts.scheduled|default:0 }}, - manual {{ discovery.kind_counts.manual|default:0 }}, - incomplete {{ discovery.kind_counts.incomplete|default:0 }} +
+

Backup Control

+
+ {% if active_run %} + {{ active_run.status }} + Run {{ active_run.id }} + {% elif has_global_config and host.enabled %} + {{ backup_gate.state }} + {{ backup_gate.message }} + {% elif not host.enabled %} + disabled + {% elif not has_global_config %} + missing global config + {% endif %} +
+
+
+ {% csrf_token %} + + + + +
+
+ {% csrf_token %} + + +
+
+ {% if active_run %} +

Wait for the active run to finish, or cancel it from the run detail page.

+ {% elif not can_queue_dry_run or not can_queue_real_backup %} + {% if not has_global_config %} +

Create the default global config before queueing backups.

+ {% elif not host.enabled %} +

Enable this host before queueing backups.

+ {% elif backup_gate.real_blockers %} +

Real backups are blocked by failed preflight checks. Dry-runs may still be available when storage-only checks fail.

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

Schedule Edit

+ {% if schedule %} +
+
Schedule expression{{ schedule.cron_expr }}
+
Next run{% if next_run_at %}{{ next_run_at|date:"Y-m-d H:i T" }}{% else %}none{% endif %}
+
Timezone{{ scheduler_timezone }}
+
Prune{{ schedule.prune|yesno:"yes,no" }}
+
Last status{{ schedule.last_status|default:"none" }}
+
+

Evaluated by the pobsync scheduler service.

+ {% else %} +

No schedule configured.

+ Add schedule + {% endif %} +
+ +
+

Current Activity

+ {% if latest_runs %} + {% with run=latest_runs.0 %} + + {{ run.status }} + + Run {{ run.id }} + {{ run.run_type }} ยท {{ run.started_at|default:run.created_at }} + + + {% endwith %} + {% else %} +

No backup runs recorded for this host.

+ {% endif %} + {% if stats_summary.latest_run.duration_seconds is not None %} +
+
Latest duration{{ stats_summary.latest_run.duration_seconds }}s
+
New data{{ stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}
{% endif %} -
+ +
+ +
+
Snapshots
{{ counts.snapshots }}
+
Runs
{{ counts.runs }}
+
Queued
{{ counts.queued_runs }}
+
Running
{{ counts.running_runs }}
+
Failed Runs
{{ counts.failed_runs }}
+
Incomplete
{{ counts.incomplete_snapshots }}
{% if stats_summary.runs %} @@ -268,50 +304,94 @@
{% endif %} -
-

Backup Control

-
- {% if active_run %} - {{ active_run.status }} - Run {{ active_run.id }} - {% elif has_global_config and host.enabled %} - {{ backup_gate.state }} - {{ backup_gate.message }} - {% elif not host.enabled %} - disabled - {% elif not has_global_config %} - missing global config - {% endif %} -
- -
-
- {% csrf_token %} - - - - -
-
- {% csrf_token %} - - -
+
+
+

Configuration

+
+
Address{{ host.address }}
+
SSH key{{ host.ssh_credential|default:"global default" }}
+
SSH{{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}
+
Backup source{{ host.source_root|default:"global default" }}
+
Retentiond{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}
+
+
- {% if active_run %} -

Wait for the active run to finish, or cancel it from the run detail page.

- {% elif not can_queue_dry_run or not can_queue_real_backup %} - {% if not has_global_config %} -

Create the default global config before queueing backups.

- {% elif not host.enabled %} -

Enable this host before queueing backups.

- {% elif backup_gate.real_blockers %} -

Real backups are blocked by failed preflight checks. Dry-runs may still be available when storage-only checks fail.

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

Snapshot Storage

+
+
Backup root{{ discovery.backup_root|default:"" }}
+
Host root{{ discovery.host_root|default:"" }}
+
Status{{ discovery.message }}
+ {% if discovery.kind_counts %} +
+ On disk + + scheduled {{ discovery.kind_counts.scheduled|default:0 }}, + manual {{ discovery.kind_counts.manual|default:0 }}, + incomplete {{ discovery.kind_counts.incomplete|default:0 }} + +
+ {% endif %} +
+
+
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
+
+
-

Advanced Options

+ {% if effective_config %} +
+

Effective Config

+
+
+
Backup source: {{ effective_config.source_root }}
+
Destination subdir: {{ effective_config.destination_subdir|default:"none" }}
+
SSH: {{ effective_config.ssh.user }}@{{ host.address }}:{{ effective_config.ssh.port }}
+
SSH key: {{ effective_config.ssh.credential|default:"none selected" }}
+
SSH options: {{ effective_config.ssh.options|join:" " }}
+
Rsync binary: {{ effective_config.rsync.binary }}
+
Rsync args: {{ effective_config.rsync.args|join:" " }}
+
Timeout: {{ effective_config.rsync.timeout_seconds }}s
+
Bandwidth limit: {{ effective_config.rsync.bwlimit_kbps }} KB/s
+
+ Retention: + d{{ effective_config.retention.daily }} + w{{ effective_config.retention.weekly }} + m{{ effective_config.retention.monthly }} + y{{ effective_config.retention.yearly }} +
+
+
+
Includes: {{ effective_config.includes|length }}
+ {% if effective_config.includes %} +
{{ effective_config.includes|join:"
" }}
+ {% else %} +
No include rules configured.
+ {% endif %} +
Excludes: {{ effective_config.excludes|length }}
+ {% if effective_config.excludes %} +
{{ effective_config.excludes|join:"
" }}
+ {% else %} +
No exclude rules configured.
+ {% endif %} +
+
+
+ {% endif %} + +
+

Backup Options

+

Use this when the quick actions above need a custom label, include/exclude override, or prune limit.

{% csrf_token %} {{ manual_backup_form.non_field_errors }} diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index e026558..c5d73ae 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -923,7 +923,7 @@ class ViewTests(TestCase): self.assertContains(response, "15 2 * * *") self.assertContains(response, "Schedule expression") self.assertContains(response, "Evaluated by the pobsync scheduler service.") - self.assertContains(response, "Next run:") + self.assertContains(response, "Next run") self.assertContains(response, "UTC") self.assertContains(response, "20260519-021500Z__ABCDEFGH") self.assertContains(response, "Discover snapshots") @@ -936,7 +936,7 @@ class ViewTests(TestCase): self.assertContains(response, "Host Check") self.assertContains(response, reverse("prepare_host_directories", args=[host.host])) self.assertContains(response, "warning") - self.assertContains(response, "Snapshot Discovery") + self.assertContains(response, "Snapshot Storage") 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])) @@ -1221,7 +1221,8 @@ class ViewTests(TestCase): response = self.client.get(reverse("host_detail", args=[host.host])) self.assertEqual(response.status_code, 200) - self.assertContains(response, f"Host root: {backup_root / host.host}") + self.assertContains(response, "Host root") + self.assertContains(response, str(backup_root / host.host)) self.assertContains(response, "Found 2 snapshot directories") self.assertContains(response, "scheduled 1") self.assertContains(response, "incomplete 1")