From 3cac7b61aca015962596e506bb7ccb65304dbfcd Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 15:10:37 +0200 Subject: [PATCH] (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"),