issue-36-lightweight-live-refresh #43
@@ -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.
|
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.
|
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:
|
Worker and scheduler commands are normally run by systemd services:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -920,5 +920,29 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const refreshRegion = async (region) => {
|
||||||
|
if (region.dataset.refreshActive !== "true" || document.hidden) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(region.dataset.refreshUrl, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
region.innerHTML = await response.text();
|
||||||
|
const refreshActive = response.headers.get("X-Pobsync-Refresh-Active");
|
||||||
|
if (refreshActive) region.dataset.refreshActive = refreshActive;
|
||||||
|
} catch (error) {
|
||||||
|
// Keep the current server-rendered content visible if a refresh fails.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-refresh-url]").forEach((region) => {
|
||||||
|
const interval = Number.parseInt(region.dataset.refreshInterval || "5000", 10);
|
||||||
|
window.setInterval(() => refreshRegion(region), Number.isFinite(interval) ? interval : 5000);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<section class="grid" aria-label="Run summary">
|
||||||
|
<div class="metric"><div class="label">Host</div><div class="value">{{ run.host.host }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Status</div><div class="value"><span class="status {{ run.status }}">{{ run.status }}</span></div></div>
|
||||||
|
<div class="metric"><div class="label">Type</div><div class="value">{{ run.run_type }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Rsync</div><div class="value">{{ run.rsync_exit_code|default:"" }}</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if can_cancel %}
|
||||||
|
<section class="panel highlight warning">
|
||||||
|
<h2>Run Control</h2>
|
||||||
|
<p>
|
||||||
|
Cancelling a queued run stops it immediately. Cancelling a running run asks the worker to stop
|
||||||
|
and records the cancellation request on this run.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="{% url 'cancel_run' run.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="danger">Cancel run</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if failure %}
|
||||||
|
<section class="panel highlight failed">
|
||||||
|
<h2>Failure</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Category:</strong> {{ failure.category|default:"unknown" }}</div>
|
||||||
|
<div><strong>Summary:</strong> {{ failure_summary }}</div>
|
||||||
|
<div><strong>Hint:</strong> {{ failure.hint|default:"" }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if run.status == "failed" or run.status == "warning" %}
|
||||||
|
{% if not run.reviewed_at %}
|
||||||
|
<section class="panel highlight warning">
|
||||||
|
<h2>Review Required</h2>
|
||||||
|
<p>Mark this run as reviewed after you have checked the failure or warning and no longer need it in the action queue.</p>
|
||||||
|
<form method="post" action="{% url 'resolve_run_review' run.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="secondary">Mark reviewed</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if run.reviewed_at %}
|
||||||
|
<section class="panel highlight success">
|
||||||
|
<h2>Review</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Reviewed:</strong> {{ run.reviewed_at }}</div>
|
||||||
|
<div><strong>Reviewed by:</strong> {{ run.reviewed_by|default:"unknown" }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if dry_run_summary %}
|
||||||
|
<section class="panel highlight {{ dry_run_summary.highlight_class }}">
|
||||||
|
<h2>Dry Run Summary</h2>
|
||||||
|
<section class="grid" aria-label="Dry run summary">
|
||||||
|
<div class="metric"><div class="label">Status</div><div class="value">{{ dry_run_summary.status }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Files Seen</div><div class="value">{{ dry_run_summary.files_seen|default:"unknown" }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Would Transfer</div><div class="value">{{ dry_run_summary.files_would_transfer|default:"unknown" }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Transfer Estimate</div><div class="value">{{ dry_run_summary.transfer_estimate_bytes|filesizeformat }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Total Size</div><div class="value">{{ dry_run_summary.total_file_size_bytes|filesizeformat }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Link-Dest Saving</div><div class="value">{{ dry_run_summary.link_dest_estimated_savings_bytes|filesizeformat }}</div></div>
|
||||||
|
</section>
|
||||||
|
<div class="stack">
|
||||||
|
{% if dry_run_summary.duration_seconds is not None %}
|
||||||
|
<div><strong>Duration:</strong> {{ dry_run_summary.duration_seconds }}s</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<strong>Log:</strong>
|
||||||
|
{% if dry_run_summary.log_available %}
|
||||||
|
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
|
||||||
|
{% elif rsync_log_path %}
|
||||||
|
<span class="muted">{{ rsync_log_path }} (missing)</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">not recorded yet</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if dry_run_summary.warnings %}
|
||||||
|
<div><strong>Warnings:</strong></div>
|
||||||
|
<ul>
|
||||||
|
{% for warning in dry_run_summary.warnings %}
|
||||||
|
<li>{{ warning }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<div><strong>Warnings:</strong> none recorded</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="two-col">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Timing</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Created:</strong> {{ run.created_at }}</div>
|
||||||
|
<div><strong>Started:</strong> {{ run.started_at|default:"" }}</div>
|
||||||
|
<div><strong>Ended:</strong> {{ run.ended_at|default:"" }}</div>
|
||||||
|
{% if execution %}
|
||||||
|
<div><strong>Worker:</strong> {{ execution.worker_host|default:"unknown" }}{% if execution.worker_pid %} pid {{ execution.worker_pid }}{% endif %}</div>
|
||||||
|
<div><strong>Worker heartbeat:</strong> {{ execution.heartbeat_at|default:"" }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Snapshot</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Snapshot:</strong> {% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}{{ run.snapshot_path|default:"" }}{% endif %}</div>
|
||||||
|
<div><strong>Base:</strong> {{ run.base_path|default:"" }}</div>
|
||||||
|
<div>
|
||||||
|
<strong>Rsync log:</strong>
|
||||||
|
{% if rsync_log_exists %}
|
||||||
|
<a href="{% url 'run_rsync_log' run.id %}">{{ rsync_log_path }}</a>
|
||||||
|
{% elif rsync_log_path %}
|
||||||
|
<span class="muted">{{ rsync_log_path }} (missing)</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">none</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Rsync Log</h2>
|
||||||
|
<div class="stack spaced">
|
||||||
|
{% if rsync_log_exists %}
|
||||||
|
<div><a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a></div>
|
||||||
|
<div class="muted">{{ rsync_log_path }}</div>
|
||||||
|
{% elif rsync_log_path %}
|
||||||
|
<div class="muted">{{ rsync_log_path }} (missing)</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="muted">No rsync log path recorded yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if rsync_log_tail %}
|
||||||
|
<pre>{% for line in rsync_log_tail %}{{ line }}{% if not forloop.last %}
|
||||||
|
{% endif %}{% endfor %}</pre>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No recent rsync log output recorded yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
@@ -14,135 +14,13 @@
|
|||||||
</section>
|
</section>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="grid" aria-label="Run summary">
|
<div
|
||||||
<div class="metric"><div class="label">Host</div><div class="value">{{ run.host.host }}</div></div>
|
data-refresh-url="{% url 'run_detail_live' run.id %}"
|
||||||
<div class="metric"><div class="label">Status</div><div class="value"><span class="status {{ run.status }}">{{ run.status }}</span></div></div>
|
data-refresh-interval="5000"
|
||||||
<div class="metric"><div class="label">Type</div><div class="value">{{ run.run_type }}</div></div>
|
data-refresh-active="{{ can_auto_refresh|yesno:'true,false' }}"
|
||||||
<div class="metric"><div class="label">Rsync</div><div class="value">{{ run.rsync_exit_code|default:"" }}</div></div>
|
aria-live="polite"
|
||||||
</section>
|
>
|
||||||
|
{% include "pobsync_backend/partials/run_detail_live.html" %}
|
||||||
{% if can_cancel %}
|
|
||||||
<section class="panel highlight warning">
|
|
||||||
<h2>Run Control</h2>
|
|
||||||
<p>
|
|
||||||
Cancelling a queued run stops it immediately. Cancelling a running run asks the worker to stop
|
|
||||||
and records the cancellation request on this run.
|
|
||||||
</p>
|
|
||||||
<form method="post" action="{% url 'cancel_run' run.id %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="danger">Cancel run</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if failure %}
|
|
||||||
<section class="panel highlight failed">
|
|
||||||
<h2>Failure</h2>
|
|
||||||
<div class="stack">
|
|
||||||
<div><strong>Category:</strong> {{ failure.category|default:"unknown" }}</div>
|
|
||||||
<div><strong>Summary:</strong> {{ failure_summary }}</div>
|
|
||||||
<div><strong>Hint:</strong> {{ failure.hint|default:"" }}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if run.status == "failed" or run.status == "warning" %}
|
|
||||||
{% if not run.reviewed_at %}
|
|
||||||
<section class="panel highlight warning">
|
|
||||||
<h2>Review Required</h2>
|
|
||||||
<p>Mark this run as reviewed after you have checked the failure or warning and no longer need it in the action queue.</p>
|
|
||||||
<form method="post" action="{% url 'resolve_run_review' run.id %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="secondary">Mark reviewed</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if run.reviewed_at %}
|
|
||||||
<section class="panel highlight success">
|
|
||||||
<h2>Review</h2>
|
|
||||||
<div class="stack">
|
|
||||||
<div><strong>Reviewed:</strong> {{ run.reviewed_at }}</div>
|
|
||||||
<div><strong>Reviewed by:</strong> {{ run.reviewed_by|default:"unknown" }}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if dry_run_summary %}
|
|
||||||
<section class="panel highlight {{ dry_run_summary.highlight_class }}">
|
|
||||||
<h2>Dry Run Summary</h2>
|
|
||||||
<section class="grid" aria-label="Dry run summary">
|
|
||||||
<div class="metric"><div class="label">Status</div><div class="value">{{ dry_run_summary.status }}</div></div>
|
|
||||||
<div class="metric"><div class="label">Files Seen</div><div class="value">{{ dry_run_summary.files_seen|default:"unknown" }}</div></div>
|
|
||||||
<div class="metric"><div class="label">Would Transfer</div><div class="value">{{ dry_run_summary.files_would_transfer|default:"unknown" }}</div></div>
|
|
||||||
<div class="metric"><div class="label">Transfer Estimate</div><div class="value">{{ dry_run_summary.transfer_estimate_bytes|filesizeformat }}</div></div>
|
|
||||||
<div class="metric"><div class="label">Total Size</div><div class="value">{{ dry_run_summary.total_file_size_bytes|filesizeformat }}</div></div>
|
|
||||||
<div class="metric"><div class="label">Link-Dest Saving</div><div class="value">{{ dry_run_summary.link_dest_estimated_savings_bytes|filesizeformat }}</div></div>
|
|
||||||
</section>
|
|
||||||
<div class="stack">
|
|
||||||
{% if dry_run_summary.duration_seconds is not None %}
|
|
||||||
<div><strong>Duration:</strong> {{ dry_run_summary.duration_seconds }}s</div>
|
|
||||||
{% endif %}
|
|
||||||
<div>
|
|
||||||
<strong>Log:</strong>
|
|
||||||
{% if dry_run_summary.log_available %}
|
|
||||||
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
|
|
||||||
{% elif rsync_log_path %}
|
|
||||||
<span class="muted">{{ rsync_log_path }} (missing)</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="muted">not recorded yet</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if dry_run_summary.warnings %}
|
|
||||||
<div><strong>Warnings:</strong></div>
|
|
||||||
<ul>
|
|
||||||
{% for warning in dry_run_summary.warnings %}
|
|
||||||
<li>{{ warning }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<div><strong>Warnings:</strong> none recorded</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="two-col">
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Timing</h2>
|
|
||||||
<div class="stack">
|
|
||||||
<div><strong>Created:</strong> {{ run.created_at }}</div>
|
|
||||||
<div><strong>Started:</strong> {{ run.started_at|default:"" }}</div>
|
|
||||||
<div><strong>Ended:</strong> {{ run.ended_at|default:"" }}</div>
|
|
||||||
{% if execution %}
|
|
||||||
<div><strong>Worker:</strong> {{ execution.worker_host|default:"unknown" }}{% if execution.worker_pid %} pid {{ execution.worker_pid }}{% endif %}</div>
|
|
||||||
<div><strong>Worker heartbeat:</strong> {{ execution.heartbeat_at|default:"" }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Snapshot</h2>
|
|
||||||
<div class="stack">
|
|
||||||
<div><strong>Snapshot:</strong> {% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}{{ run.snapshot_path|default:"" }}{% endif %}</div>
|
|
||||||
<div><strong>Base:</strong> {{ run.base_path|default:"" }}</div>
|
|
||||||
<div>
|
|
||||||
<strong>Rsync log:</strong>
|
|
||||||
{% if rsync_log_exists %}
|
|
||||||
<a href="{% url 'run_rsync_log' run.id %}">{{ rsync_log_path }}</a>
|
|
||||||
{% elif rsync_log_path %}
|
|
||||||
<span class="muted">{{ rsync_log_path }} (missing)</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="muted">none</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if requested %}
|
{% if requested %}
|
||||||
@@ -168,26 +46,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Rsync Log</h2>
|
|
||||||
<div class="stack spaced">
|
|
||||||
{% if rsync_log_exists %}
|
|
||||||
<div><a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a></div>
|
|
||||||
<div class="muted">{{ rsync_log_path }}</div>
|
|
||||||
{% elif rsync_log_path %}
|
|
||||||
<div class="muted">{{ rsync_log_path }} (missing)</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="muted">No rsync log path recorded yet.</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if rsync_log_tail %}
|
|
||||||
<pre>{% for line in rsync_log_tail %}{{ line }}{% if not forloop.last %}
|
|
||||||
{% endif %}{% endfor %}</pre>
|
|
||||||
{% else %}
|
|
||||||
<p class="muted">No recent rsync log output recorded yet.</p>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% if stats %}
|
{% if stats %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Stats</h2>
|
<h2>Stats</h2>
|
||||||
|
|||||||
@@ -1706,6 +1706,46 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, reverse("cancel_run", args=[run.id]))
|
self.assertContains(response, reverse("cancel_run", args=[run.id]))
|
||||||
self.assertContains(response, 'class="danger"', html=False)
|
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, "<html", html=False)
|
||||||
|
|
||||||
|
def test_run_detail_live_stops_refresh_for_terminal_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.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:
|
def test_run_detail_renders_worker_execution_metadata(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|||||||
@@ -657,6 +657,19 @@ def queue_manual_backup(request, host: str):
|
|||||||
@staff_member_required
|
@staff_member_required
|
||||||
def run_detail(request, run_id: int):
|
def run_detail(request, run_id: int):
|
||||||
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
|
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 {}
|
result = run.result if isinstance(run.result, dict) else {}
|
||||||
run_stats = result.get("stats") if isinstance(result.get("stats"), 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 {}
|
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_path = _run_rsync_log_path(run)
|
||||||
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
|
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
|
||||||
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
|
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,
|
"run": run,
|
||||||
"can_cancel": run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING},
|
"can_cancel": can_cancel,
|
||||||
|
"can_auto_refresh": can_cancel,
|
||||||
"requested": requested,
|
"requested": requested,
|
||||||
"execution": execution,
|
"execution": execution,
|
||||||
"stats": run_stats if isinstance(run_stats, dict) else {},
|
"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),
|
"result_json": _pretty_json(run.result),
|
||||||
}
|
}
|
||||||
return render(request, "pobsync_backend/run_detail.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ urlpatterns = [
|
|||||||
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
|
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
|
||||||
path("runs/", views.runs_list, name="runs_list"),
|
path("runs/", views.runs_list, name="runs_list"),
|
||||||
path("runs/<int:run_id>/", views.run_detail, name="run_detail"),
|
path("runs/<int:run_id>/", views.run_detail, name="run_detail"),
|
||||||
|
path("runs/<int:run_id>/live/", views.run_detail_live, name="run_detail_live"),
|
||||||
path("runs/<int:run_id>/rsync-log/", views.run_rsync_log, name="run_rsync_log"),
|
path("runs/<int:run_id>/rsync-log/", views.run_rsync_log, name="run_rsync_log"),
|
||||||
path("runs/<int:run_id>/cancel/", views.cancel_run, name="cancel_run"),
|
path("runs/<int:run_id>/cancel/", views.cancel_run, name="cancel_run"),
|
||||||
path("runs/<int:run_id>/resolve-review/", views.resolve_run_review, name="resolve_run_review"),
|
path("runs/<int:run_id>/resolve-review/", views.resolve_run_review, name="resolve_run_review"),
|
||||||
|
|||||||
Reference in New Issue
Block a user