From 3cac7b61aca015962596e506bb7ccb65304dbfcd Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 15:10:37 +0200 Subject: [PATCH 1/2] (feature) Add live refresh for run detail status Add a server-rendered run detail partial and a small vanilla JavaScript refresh hook so active backup runs update status, controls, timing, and rsync log output without a full page reload. Document the Django-template-first refresh pattern for future control panel work. Refs #36 --- docs/development.md | 10 ++ .../templates/pobsync_backend/base.html | 24 +++ .../partials/run_detail_live.html | 150 +++++++++++++++++ .../templates/pobsync_backend/run_detail.html | 156 +----------------- src/pobsync_backend/tests/test_views.py | 40 +++++ src/pobsync_backend/views.py | 20 ++- src/pobsync_server/urls.py | 1 + 7 files changed, 249 insertions(+), 152 deletions(-) create mode 100644 src/pobsync_backend/templates/pobsync_backend/partials/run_detail_live.html diff --git a/docs/development.md b/docs/development.md index a221f9d..0232d26 100644 --- a/docs/development.md +++ b/docs/development.md @@ -50,6 +50,16 @@ python3 manage.py showmigrations pobsync_backend The short `pobsync` aliases are limited to operational actions that are useful while debugging a running install. Configuration aliases are intentionally not public commands; use the Django UI or explicit management commands instead. +## UI Refresh Pattern + +The control panel stays Django-template-first. Pages that need live status should expose a small server-rendered partial +view and opt into refresh with `data-refresh-url` and `data-refresh-interval` on the container that should be replaced. +The shared script in `base.html` polls only those explicit regions, skips refreshes while the browser tab is hidden, and +lets the partial response turn polling off with the `X-Pobsync-Refresh-Active: false` header. + +Use this for operational status surfaces such as running backup details. Avoid refreshing form-heavy sections while an +operator might be typing. + Worker and scheduler commands are normally run by systemd services: ``` diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 8f6337a..f538600 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -920,5 +920,29 @@ {% endif %} {% block content %}{% endblock %} + diff --git a/src/pobsync_backend/templates/pobsync_backend/partials/run_detail_live.html b/src/pobsync_backend/templates/pobsync_backend/partials/run_detail_live.html new file mode 100644 index 0000000..8f7782f --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/partials/run_detail_live.html @@ -0,0 +1,150 @@ +
+
Host
{{ run.host.host }}
+
Status
{{ run.status }}
+
Type
{{ run.run_type }}
+
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

+
+
Category: {{ failure.category|default:"unknown" }}
+
Summary: {{ failure_summary }}
+
Hint: {{ failure.hint|default:"" }}
+
+
+{% 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

+
+
Reviewed: {{ run.reviewed_at }}
+
Reviewed by: {{ run.reviewed_by|default:"unknown" }}
+
+
+{% endif %} + +{% if dry_run_summary %} +
+

Dry Run Summary

+
+
Status
{{ dry_run_summary.status }}
+
Files Seen
{{ dry_run_summary.files_seen|default:"unknown" }}
+
Would Transfer
{{ dry_run_summary.files_would_transfer|default:"unknown" }}
+
Transfer Estimate
{{ dry_run_summary.transfer_estimate_bytes|filesizeformat }}
+
Total Size
{{ dry_run_summary.total_file_size_bytes|filesizeformat }}
+
Link-Dest Saving
{{ dry_run_summary.link_dest_estimated_savings_bytes|filesizeformat }}
+
+
+ {% if dry_run_summary.duration_seconds is not None %} +
Duration: {{ dry_run_summary.duration_seconds }}s
+ {% endif %} +
+ Log: + {% if dry_run_summary.log_available %} + Open full rsync log + {% elif rsync_log_path %} + {{ rsync_log_path }} (missing) + {% else %} + not recorded yet + {% endif %} +
+ {% if dry_run_summary.warnings %} +
Warnings:
+
    + {% for warning in dry_run_summary.warnings %} +
  • {{ warning }}
  • + {% endfor %} +
+ {% else %} +
Warnings: none recorded
+ {% endif %} +
+
+{% endif %} + +
+
+

Timing

+
+
Created: {{ run.created_at }}
+
Started: {{ run.started_at|default:"" }}
+
Ended: {{ run.ended_at|default:"" }}
+ {% if execution %} +
Worker: {{ execution.worker_host|default:"unknown" }}{% if execution.worker_pid %} pid {{ execution.worker_pid }}{% endif %}
+
Worker heartbeat: {{ execution.heartbeat_at|default:"" }}
+ {% endif %} +
+
+ +
+

Snapshot

+
+
Snapshot: {% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}{{ run.snapshot_path|default:"" }}{% endif %}
+
Base: {{ run.base_path|default:"" }}
+
+ Rsync log: + {% if rsync_log_exists %} + {{ rsync_log_path }} + {% elif rsync_log_path %} + {{ rsync_log_path }} (missing) + {% else %} + none + {% endif %} +
+
+
+
+ +
+

Rsync Log

+
+ {% if rsync_log_exists %} +
Open full rsync log
+
{{ rsync_log_path }}
+ {% elif rsync_log_path %} +
{{ rsync_log_path }} (missing)
+ {% else %} +
No rsync log path recorded yet.
+ {% endif %} +
+ {% if rsync_log_tail %} +
{% for line in rsync_log_tail %}{{ line }}{% if not forloop.last %}
+{% endif %}{% endfor %}
+ {% else %} +

No recent rsync log output recorded yet.

+ {% endif %} +
diff --git a/src/pobsync_backend/templates/pobsync_backend/run_detail.html b/src/pobsync_backend/templates/pobsync_backend/run_detail.html index 6e06b06..5f0e115 100644 --- a/src/pobsync_backend/templates/pobsync_backend/run_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/run_detail.html @@ -14,135 +14,13 @@ -
-
Host
{{ run.host.host }}
-
Status
{{ run.status }}
-
Type
{{ run.run_type }}
-
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

-
-
Category: {{ failure.category|default:"unknown" }}
-
Summary: {{ failure_summary }}
-
Hint: {{ failure.hint|default:"" }}
-
-
- {% 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

-
-
Reviewed: {{ run.reviewed_at }}
-
Reviewed by: {{ run.reviewed_by|default:"unknown" }}
-
-
- {% endif %} - - {% if dry_run_summary %} -
-

Dry Run Summary

-
-
Status
{{ dry_run_summary.status }}
-
Files Seen
{{ dry_run_summary.files_seen|default:"unknown" }}
-
Would Transfer
{{ dry_run_summary.files_would_transfer|default:"unknown" }}
-
Transfer Estimate
{{ dry_run_summary.transfer_estimate_bytes|filesizeformat }}
-
Total Size
{{ dry_run_summary.total_file_size_bytes|filesizeformat }}
-
Link-Dest Saving
{{ dry_run_summary.link_dest_estimated_savings_bytes|filesizeformat }}
-
-
- {% if dry_run_summary.duration_seconds is not None %} -
Duration: {{ dry_run_summary.duration_seconds }}s
- {% endif %} -
- Log: - {% if dry_run_summary.log_available %} - Open full rsync log - {% elif rsync_log_path %} - {{ rsync_log_path }} (missing) - {% else %} - not recorded yet - {% endif %} -
- {% if dry_run_summary.warnings %} -
Warnings:
-
    - {% for warning in dry_run_summary.warnings %} -
  • {{ warning }}
  • - {% endfor %} -
- {% else %} -
Warnings: none recorded
- {% endif %} -
-
- {% endif %} - -
-
-

Timing

-
-
Created: {{ run.created_at }}
-
Started: {{ run.started_at|default:"" }}
-
Ended: {{ run.ended_at|default:"" }}
- {% if execution %} -
Worker: {{ execution.worker_host|default:"unknown" }}{% if execution.worker_pid %} pid {{ execution.worker_pid }}{% endif %}
-
Worker heartbeat: {{ execution.heartbeat_at|default:"" }}
- {% endif %} -
-
- -
-

Snapshot

-
-
Snapshot: {% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}{{ run.snapshot_path|default:"" }}{% endif %}
-
Base: {{ run.base_path|default:"" }}
-
- Rsync log: - {% if rsync_log_exists %} - {{ rsync_log_path }} - {% elif rsync_log_path %} - {{ rsync_log_path }} (missing) - {% else %} - none - {% endif %} -
-
-
+
+ {% include "pobsync_backend/partials/run_detail_live.html" %}
{% if requested %} @@ -168,26 +46,6 @@ {% endif %} -
-

Rsync Log

-
- {% if rsync_log_exists %} -
Open full rsync log
-
{{ rsync_log_path }}
- {% elif rsync_log_path %} -
{{ rsync_log_path }} (missing)
- {% else %} -
No rsync log path recorded yet.
- {% endif %} -
- {% if rsync_log_tail %} -
{% for line in rsync_log_tail %}{{ line }}{% if not forloop.last %}
-{% endif %}{% endfor %}
- {% else %} -

No recent rsync log output recorded yet.

- {% endif %} -
- {% if stats %}

Stats

diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 8cc5d0d..451ebe4 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -1706,6 +1706,46 @@ class ViewTests(TestCase): self.assertContains(response, reverse("cancel_run", args=[run.id])) self.assertContains(response, 'class="danger"', html=False) + def test_run_detail_enables_live_refresh_for_active_run(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + run = BackupRun.objects.create(host=host, status=BackupRun.Status.RUNNING) + + response = self.client.get(reverse("run_detail", args=[run.id])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, f'data-refresh-url="{reverse("run_detail_live", args=[run.id])}"', html=False) + self.assertContains(response, 'data-refresh-interval="5000"', html=False) + self.assertContains(response, 'data-refresh-active="true"', html=False) + + def test_run_detail_live_returns_partial_for_active_run(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + run = BackupRun.objects.create( + host=host, + status=BackupRun.Status.RUNNING, + result={"rsync": {"log_tail": ["sending incremental file list"]}}, + ) + + response = self.client.get(reverse("run_detail_live", args=[run.id])) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["X-Pobsync-Refresh-Active"], "true") + self.assertContains(response, "Run Control") + self.assertContains(response, "sending incremental file list") + self.assertNotContains(response, " None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS) + + response = self.client.get(reverse("run_detail_live", args=[run.id])) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["X-Pobsync-Refresh-Active"], "false") + self.assertNotContains(response, "Run Control") + def test_run_detail_renders_worker_execution_metadata(self) -> None: self.client.force_login(self.staff_user) host = HostConfig.objects.create(host="web-01", address="web-01.example.test") diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index d6187cc..7c679d9 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -657,6 +657,19 @@ def queue_manual_backup(request, host: str): @staff_member_required def run_detail(request, run_id: int): run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id) + return render(request, "pobsync_backend/run_detail.html", _run_detail_context(run)) + + +@staff_member_required +def run_detail_live(request, run_id: int): + run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id) + context = _run_detail_context(run) + response = render(request, "pobsync_backend/partials/run_detail_live.html", context) + response["X-Pobsync-Refresh-Active"] = "true" if context["can_auto_refresh"] else "false" + return response + + +def _run_detail_context(run: BackupRun) -> dict[str, object]: result = run.result if isinstance(run.result, dict) else {} run_stats = result.get("stats") if isinstance(result.get("stats"), dict) else {} rsync_result = result.get("rsync") if isinstance(result.get("rsync"), dict) else {} @@ -666,9 +679,11 @@ def run_detail(request, run_id: int): rsync_log_path = _run_rsync_log_path(run) rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path) requested = result.get("requested") if isinstance(result.get("requested"), dict) else {} - context = { + can_cancel = run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING} + return { "run": run, - "can_cancel": run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING}, + "can_cancel": can_cancel, + "can_auto_refresh": can_cancel, "requested": requested, "execution": execution, "stats": run_stats if isinstance(run_stats, dict) else {}, @@ -692,7 +707,6 @@ def run_detail(request, run_id: int): ), "result_json": _pretty_json(run.result), } - return render(request, "pobsync_backend/run_detail.html", context) @staff_member_required diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index da215fe..e272799 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -37,6 +37,7 @@ urlpatterns = [ path("hosts//schedule/", views.edit_host_schedule, name="edit_host_schedule"), path("runs/", views.runs_list, name="runs_list"), path("runs//", views.run_detail, name="run_detail"), + path("runs//live/", views.run_detail_live, name="run_detail_live"), path("runs//rsync-log/", views.run_rsync_log, name="run_rsync_log"), path("runs//cancel/", views.cancel_run, name="cancel_run"), path("runs//resolve-review/", views.resolve_run_review, name="resolve_run_review"), From ad45fbe46e61cc0675247d7f24bcf12e34d0cad6 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 15:17:11 +0200 Subject: [PATCH 2/2] (feature) Add live refresh for dashboard status panels Split dashboard priority and host status sections into server-rendered partials and wire them into the shared refresh hook so operational state updates without a full page reload. Refs #36 --- .../templates/pobsync_backend/dashboard.html | 269 ++---------------- .../partials/dashboard_hosts.html | 123 ++++++++ .../partials/dashboard_priority.html | 130 +++++++++ src/pobsync_backend/tests/test_views.py | 28 ++ src/pobsync_backend/views.py | 16 +- src/pobsync_server/urls.py | 2 + 6 files changed, 314 insertions(+), 254 deletions(-) create mode 100644 src/pobsync_backend/templates/pobsync_backend/partials/dashboard_hosts.html create mode 100644 src/pobsync_backend/templates/pobsync_backend/partials/dashboard_priority.html diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index cefa769..89e893d 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -32,136 +32,14 @@
{% endif %} -
-
-

Required Action

- {% if action_items %} -
- {% for item in action_items %} - - {{ item.label }} - - {{ item.host.host }} - {{ item.message }} - - - {% endfor %} -
- {% elif counts.hosts %} -

ok No queued, running, unreviewed warning/failed runs, or retention warnings.

- {% else %} -

Add a host to start tracking backup status here.

- {% endif %} - {% if counts.running_runs or counts.queued_runs %} - - {% endif %} -
- - - - - -
-

Storage Pressure

- {% if stats_summary.runs_sampled %} -
-
-
Backup root used
-
- {% if stats_summary.capacity.used_percent is not None %} - {{ stats_summary.capacity.used_percent|floatformat:1 }}% - {% else %} - unknown - {% endif %} -
- {% if stats_summary.capacity.used_percent is not None %} -
- -
- {% endif %} -
-
-
- Runway - - {% if stats_summary.estimated_days_until_full %} - {{ stats_summary.estimated_days_until_full }} days - {% elif stats_summary.estimated_runs_until_full %} - {{ stats_summary.estimated_runs_until_full }} runs - {% else %} - unknown - {% endif %} - -
-
- New data - {{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}/day -
-
- Available - {{ stats_summary.capacity.available_bytes|filesizeformat }} -
-
-
- {% else %} -

Storage pressure appears after the first completed backup with stats.

- {% endif %} -
-
+
+ {% include "pobsync_backend/partials/dashboard_priority.html" %} +
Hosts
{{ counts.enabled_hosts }}/{{ counts.hosts }}
@@ -216,128 +94,13 @@ {% endif %}
-
-

Hosts

-
- {% for host in hosts %} -
-
-
- {{ host.host }} - {{ host.address }} -
-
- {{ host.enabled|yesno:"enabled,disabled" }} - {% if host.queued_run_count %} - queued {{ host.queued_run_count }} - {% endif %} - {% if host.running_run_count %} - running {{ host.running_run_count }} - {% endif %} - {% if host.warning_run_count %} - warning {{ host.warning_run_count }} - {% endif %} - {% if host.failed_run_count %} - failed {{ host.failed_run_count }} - {% endif %} -
-
-
-
-
Backup activity
-
-
-
Latest Snapshot
-
- {% if host.latest_snapshot %} - {{ host.latest_snapshot.dirname }} -
{{ host.latest_snapshot.kind }} {{ host.latest_snapshot.status }}
- {% else %} - none - {% endif %} -
-
-
-
Last Good Backup
-
- {% if host.stats_summary.latest_good_run.id %} - Run {{ host.stats_summary.latest_good_run.id }} -
{{ host.stats_summary.latest_good_run.run_type }} {{ host.stats_summary.latest_good_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_good_run.duration_seconds is not None %}s{% endif %}
- {% else %} - none - {% endif %} -
-
-
-
Latest Issue
-
- {% if host.stats_summary.latest_problem_run.id %} - Run {{ host.stats_summary.latest_problem_run.id }} -
{{ host.stats_summary.latest_problem_run.status }}
-
{{ host.stats_summary.latest_problem_run.run_type }} {{ host.stats_summary.latest_problem_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_problem_run.duration_seconds is not None %}s{% endif %}
- {% else %} - none - {% endif %} -
-
-
-
Next Run
-
- {% if host.next_run_at %} - {{ host.next_run_at|date:"Y-m-d H:i T" }} -
{{ scheduler_timezone }}
- {% else %} - none - {% endif %} -
-
-
-
-
-
Snapshot health
-
-
-
Snapshots
-
{{ host.snapshot_count }}
-
-
-
Runs
-
{{ host.run_count }}
-
-
-
New Data
-
{{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}
-
-
-
Retention
-
d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}
-
-
-
-
- {% if host.retention_warning.has_warning %} -
- retention - {% if host.retention_warning.prune_exceeded %} - Scheduled prune would delete {{ host.retention_warning.delete_count }} snapshot(s), above max {{ host.retention_warning.max_delete }}. - {% endif %} - {% if host.retention_warning.incomplete_count %} - {{ host.retention_warning.incomplete_count }} incomplete snapshot(s) need review. -
- {% csrf_token %} - -
- {% endif %} - {% if host.retention_warning.error %} - {{ host.retention_warning.error }} - {% endif %} -
- {% endif %} -
- {% empty %} -

No hosts configured yet.

- {% endfor %} -
-
+
+ {% include "pobsync_backend/partials/dashboard_hosts.html" %} +
{% endblock %} diff --git a/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_hosts.html b/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_hosts.html new file mode 100644 index 0000000..f255a96 --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_hosts.html @@ -0,0 +1,123 @@ +
+

Hosts

+
+ {% for host in hosts %} +
+
+
+ {{ host.host }} + {{ host.address }} +
+
+ {{ host.enabled|yesno:"enabled,disabled" }} + {% if host.queued_run_count %} + queued {{ host.queued_run_count }} + {% endif %} + {% if host.running_run_count %} + running {{ host.running_run_count }} + {% endif %} + {% if host.warning_run_count %} + warning {{ host.warning_run_count }} + {% endif %} + {% if host.failed_run_count %} + failed {{ host.failed_run_count }} + {% endif %} +
+
+
+
+
Backup activity
+
+
+
Latest Snapshot
+
+ {% if host.latest_snapshot %} + {{ host.latest_snapshot.dirname }} +
{{ host.latest_snapshot.kind }} {{ host.latest_snapshot.status }}
+ {% else %} + none + {% endif %} +
+
+
+
Last Good Backup
+
+ {% if host.stats_summary.latest_good_run.id %} + Run {{ host.stats_summary.latest_good_run.id }} +
{{ host.stats_summary.latest_good_run.run_type }} {{ host.stats_summary.latest_good_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_good_run.duration_seconds is not None %}s{% endif %}
+ {% else %} + none + {% endif %} +
+
+
+
Latest Issue
+
+ {% if host.stats_summary.latest_problem_run.id %} + Run {{ host.stats_summary.latest_problem_run.id }} +
{{ host.stats_summary.latest_problem_run.status }}
+
{{ host.stats_summary.latest_problem_run.run_type }} {{ host.stats_summary.latest_problem_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_problem_run.duration_seconds is not None %}s{% endif %}
+ {% else %} + none + {% endif %} +
+
+
+
Next Run
+
+ {% if host.next_run_at %} + {{ host.next_run_at|date:"Y-m-d H:i T" }} +
{{ scheduler_timezone }}
+ {% else %} + none + {% endif %} +
+
+
+
+
+
Snapshot health
+
+
+
Snapshots
+
{{ host.snapshot_count }}
+
+
+
Runs
+
{{ host.run_count }}
+
+
+
New Data
+
{{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}
+
+
+
Retention
+
d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}
+
+
+
+
+ {% if host.retention_warning.has_warning %} +
+ retention + {% if host.retention_warning.prune_exceeded %} + Scheduled prune would delete {{ host.retention_warning.delete_count }} snapshot(s), above max {{ host.retention_warning.max_delete }}. + {% endif %} + {% if host.retention_warning.incomplete_count %} + {{ host.retention_warning.incomplete_count }} incomplete snapshot(s) need review. +
+ {% csrf_token %} + +
+ {% endif %} + {% if host.retention_warning.error %} + {{ host.retention_warning.error }} + {% endif %} +
+ {% endif %} +
+ {% empty %} +

No hosts configured yet.

+ {% endfor %} +
+
diff --git a/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_priority.html b/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_priority.html new file mode 100644 index 0000000..d17d01e --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_priority.html @@ -0,0 +1,130 @@ +
+
+

Required Action

+ {% if action_items %} +
+ {% for item in action_items %} + + {{ item.label }} + + {{ item.host.host }} + {{ item.message }} + + + {% endfor %} +
+ {% elif counts.hosts %} +

ok No queued, running, unreviewed warning/failed runs, or retention warnings.

+ {% else %} +

Add a host to start tracking backup status here.

+ {% endif %} + {% if counts.running_runs or counts.queued_runs %} + + {% endif %} +
+ + + + + +
+

Storage Pressure

+ {% if stats_summary.runs_sampled %} +
+
+
Backup root used
+
+ {% if stats_summary.capacity.used_percent is not None %} + {{ stats_summary.capacity.used_percent|floatformat:1 }}% + {% else %} + unknown + {% endif %} +
+ {% if stats_summary.capacity.used_percent is not None %} +
+ +
+ {% endif %} +
+
+
+ Runway + + {% if stats_summary.estimated_days_until_full %} + {{ stats_summary.estimated_days_until_full }} days + {% elif stats_summary.estimated_runs_until_full %} + {{ stats_summary.estimated_runs_until_full }} runs + {% else %} + unknown + {% endif %} + +
+
+ New data + {{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}/day +
+
+ Available + {{ stats_summary.capacity.available_bytes|filesizeformat }} +
+
+
+ {% else %} +

Storage pressure appears after the first completed backup with stats.

+ {% endif %} +
+
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 451ebe4..951fec8 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -161,6 +161,8 @@ class ViewTests(TestCase): self.assertContains(response, "1 backup run waiting.") self.assertContains(response, "Next Scheduled Work") self.assertContains(response, "Recent Activity") + self.assertContains(response, f'data-refresh-url="{reverse("dashboard_priority_live")}"', html=False) + self.assertContains(response, f'data-refresh-url="{reverse("dashboard_hosts_live")}"', html=False) self.assertContains(response, f'href="{reverse("runs_list")}"', html=False) self.assertContains(response, f'href="{reverse("runs_list")}?status=queued"', html=False) self.assertContains(response, f'href="{reverse("runs_list")}?status=running"', html=False) @@ -174,6 +176,32 @@ class ViewTests(TestCase): self.assertContains(response, f'href="{reverse("snapshots_list")}"', html=False) self.assertContains(response, f'href="{reverse("schedules_list")}"', html=False) + def test_dashboard_priority_live_returns_status_partial(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + BackupRun.objects.create(host=host, status=BackupRun.Status.RUNNING) + + response = self.client.get(reverse("dashboard_priority_live")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Required Action") + self.assertContains(response, "Recent Activity") + self.assertContains(response, "running") + self.assertNotContains(response, " None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + BackupRun.objects.create(host=host, status=BackupRun.Status.QUEUED) + + response = self.client.get(reverse("dashboard_hosts_live")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "web-01") + self.assertContains(response, "queued 1") + self.assertContains(response, "Snapshot health") + self.assertNotContains(response, " None: self.client.force_login(self.staff_user) GlobalConfig.objects.create(name="default", backup_root="/missing-backup-root") diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 7c679d9..75352ed 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -48,6 +48,20 @@ from .stats_summary import collect_dashboard_stats, collect_host_stats @staff_member_required def dashboard(request): + return render(request, "pobsync_backend/dashboard.html", _dashboard_context()) + + +@staff_member_required +def dashboard_priority_live(request): + return render(request, "pobsync_backend/partials/dashboard_priority.html", _dashboard_context()) + + +@staff_member_required +def dashboard_hosts_live(request): + return render(request, "pobsync_backend/partials/dashboard_hosts.html", _dashboard_context()) + + +def _dashboard_context() -> dict[str, object]: global_config = GlobalConfig.objects.filter(name="default").first() hosts = list( HostConfig.objects.select_related("schedule") @@ -109,7 +123,7 @@ def dashboard(request): ).count(), }, } - return render(request, "pobsync_backend/dashboard.html", context) + return context def _dashboard_action_items(hosts: list[HostConfig]) -> list[dict[str, object]]: diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index e272799..9e71b59 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -8,6 +8,8 @@ from pobsync_backend import api, views urlpatterns = [ path("", views.dashboard, name="dashboard"), + path("dashboard/priority-live/", views.dashboard_priority_live, name="dashboard_priority_live"), + path("dashboard/hosts-live/", views.dashboard_hosts_live, name="dashboard_hosts_live"), path("changelog/", views.changelog, name="changelog"), path("self-check/", views.self_check, name="self_check"), path("logs/", views.logs, name="logs"),