diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index beef593..f2453d6 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -241,6 +241,13 @@ .stack { display: grid; gap: 5px; } .stack.spaced { margin-bottom: 14px; } .two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } + .panel-grid { + display: grid; + gap: 18px; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + margin-bottom: 18px; + } + .panel-grid .panel { margin-bottom: 0; } .actions { align-items: center; display: flex; @@ -303,6 +310,53 @@ display: grid; gap: 8px; } + .record-list { + display: grid; + gap: 10px; + } + .record-card { + border: 1px solid var(--border); + border-radius: var(--radius); + display: grid; + gap: 10px; + padding: 12px; + } + .record-card-header { + align-items: start; + display: flex; + gap: 12px; + justify-content: space-between; + } + .record-title { + display: grid; + gap: 3px; + min-width: 0; + } + .record-title a { + font-weight: 750; + overflow-wrap: anywhere; + } + .record-facts { + display: grid; + gap: 8px 16px; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + } + .record-fact { + display: grid; + gap: 2px; + min-width: 0; + } + .record-fact .label { + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + .record-fact strong, + .record-fact span { + overflow-wrap: anywhere; + } .action-row, .activity-row, .schedule-row { @@ -370,6 +424,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; @@ -639,8 +730,10 @@ display: grid; } .page-header .actions { justify-content: flex-start; } - .two-col { grid-template-columns: 1fr; } + .two-col, + .panel-grid { 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..316fde3 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -9,70 +9,8 @@

{{ host.host }}

{{ host.address }} · {{ host.enabled|yesno:"enabled,disabled" }}
-
- Edit config -
- {% csrf_token %} - -
- Plan retention - Edit schedule -
- {% csrf_token %} - -
-
- {% csrf_token %} - -
-
- {% csrf_token %} - -
-
-
-
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 +39,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 schedule

+ {% 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 %} +
+ +
+ +
+ +
+
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 %} @@ -214,104 +229,197 @@
Failed
{{ host_check_summary.failed }}
Skipped
{{ host_check_summary.skipped }}
- - - - - - - - - - - {% for check in host_checks %} - - - - - - - {% endfor %} - -
StatusCheckMessageDetail
{{ check.status }}{{ check.name }}{{ check.message }}{{ check.detail }}
+
+ {% for check in host_checks %} +
+
+
+ {{ check.name }} + {{ check.message }} +
+ {{ check.status }} +
+ {% if check.detail %} +
+ Detail + {{ check.detail }} +
+ {% endif %} +
+ {% endfor %} +
- {% if last_preflight %} +
-

Connection Preflight

-
-
Status: {% if last_preflight.ok %}ok{% else %}failed{% endif %}
-
Target: {{ last_preflight.target }}
-
Backup source: {{ last_preflight.source_root }}
-
Remote rsync: {{ last_preflight.rsync_binary }}
+

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 }}
- - - - - - - - - - + + + +
+

Connection Preflight & SSH

+ {% if last_preflight %} +
+
+ Preflight + + + {% if last_preflight.ok %}ok{% else %}failed{% endif %} + + +
+
Target{{ last_preflight.target }}
+
Backup source{{ last_preflight.source_root }}
+
Remote rsync{{ last_preflight.rsync_binary }}
+
+ {% else %} +

No connection preflight recorded yet.

+ {% endif %} +
+
+ {% csrf_token %} + + +
+ {% csrf_token %} + + +
+ {% if last_preflight.checks %} +
{% for check in last_preflight.checks %} -
- - - - - +
+ + {% if check.ok %}ok{% else %}failed{% endif %} + + + {{ check.name }} + {{ check.message }}{% if check.detail %} · {{ check.detail }}{% endif %} + +
{% endfor %} - -
StatusCheckMessageDetail
{% if check.ok %}ok{% else %}failed{% endif %}{{ check.name }}{{ check.message }}{{ check.detail }}
+
+ {% 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 %} + +
+
+
+
+ + {% if effective_config %} +
+

Effective Config

+

Runtime settings after global defaults and host overrides are combined.

+
+
+
+
+ Backup target + Source and destination used by rsync. +
+
+
+
Backup source:{{ effective_config.source_root }}
+
Destination subdir:{{ effective_config.destination_subdir|default:"none" }}
+
+
+
+
+
+ Connection + SSH and rsync execution settings. +
+
+
+
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
+
+
+
+
+
+ Selection & retention + Include/exclude rules and retention counts. +
+
+
+
+ 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 }}
+
Excludes:{{ effective_config.excludes|length }}
+
+
+
+ {% if effective_config.includes %} +
{{ effective_config.includes|join:"
" }}
+ {% else %} +
No include rules configured.
+ {% endif %} +
+
+ {% if effective_config.excludes %} +
{{ effective_config.excludes|join:"
" }}
+ {% else %} +
No exclude rules configured.
+ {% endif %} +
+
+
+
{% 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 %} - - -
-
- - {% 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 %} - -

Advanced Options

+

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 }} @@ -332,58 +440,88 @@
-

Latest Runs

- - - - - - - - - - - - {% for run in latest_runs %} - - - - - - - - {% empty %} - - {% endfor %} - -
StatusStartedEndedSnapshotBase
{{ run.status }}{{ run.started_at|default:"" }}{{ run.ended_at|default:"" }}{% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}{{ run.snapshot_path }}{% endif %}{{ run.base_path|default:"" }}
No backup runs recorded for this host.
+

Latest Runs View all

+
+ {% for run in latest_runs %} +
+
+
+ Run {{ run.id }} + {{ run.run_type }}{% if run.result.duration_seconds %} · {{ run.result.duration_seconds }}s{% endif %} +
+ {{ run.status }} +
+
+
+ Started + {{ run.started_at|default:run.created_at }} +
+
+ Ended + {{ run.ended_at|default:"running or queued" }} +
+
+ Snapshot + {% if run.snapshot %} + {{ run.snapshot.dirname }} + {% elif run.snapshot_path %} + {{ run.snapshot_path }} + {% else %} + none + {% endif %} +
+
+ Base + {{ run.base_path|default:"none" }} +
+
+
+ {% empty %} +

No backup runs recorded for this host.

+ {% endfor %} +
-

Snapshots

- - - - - - - - - - - - {% for snapshot in snapshots %} - - - - - - - - {% empty %} - - {% endfor %} - -
KindStatusStartedDirnameBase
{{ snapshot.kind }}{{ snapshot.status }}{{ snapshot.started_at|default:"" }}{{ snapshot.dirname }}{% if snapshot.base %}{{ snapshot.base.dirname }}{% else %}{{ snapshot.base_dirname }}{% endif %}
No snapshots discovered for this host.
+

Snapshots View all

+
+ {% for snapshot in snapshots %} +
+
+
+ {{ snapshot.dirname }} + {{ snapshot.kind }} +
+ {{ snapshot.status }} +
+
+
+ Started + {{ snapshot.started_at|default:"unknown" }} +
+
+ Ended + {{ snapshot.ended_at|default:"unknown" }} +
+
+ Base + {% if snapshot.base %} + {{ snapshot.base.dirname }} + {% elif snapshot.base_dirname %} + {{ snapshot.base_dirname }} + {% else %} + none + {% endif %} +
+
+ Path + {{ snapshot.path|default:"not recorded" }} +
+
+
+ {% empty %} +

No snapshots discovered for this host.

+ {% endfor %} +
{% endblock %} diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index e026558..1f63df6 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,10 +936,12 @@ 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])) + self.assertContains(response, f'{reverse("runs_list")}?host={host.host}', html=False) + self.assertContains(response, f'{reverse("snapshots_list")}?host={host.host}', html=False) def test_host_detail_renders_effective_config_preview(self) -> None: self.client.force_login(self.staff_user) @@ -1221,7 +1223,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")