Merge pull request '(refactor) Unify run progress panels' (#57) from issue-52-live-normal-runs into master

Reviewed-on: #57
This commit was merged in pull request #57.
This commit is contained in:
2026-05-23 00:48:22 +02:00
5 changed files with 265 additions and 7 deletions

View File

@@ -541,7 +541,7 @@
.status-summary.failed { border-color: #e8b4b4; background: #fff7f7; color: var(--failed); }
.status-summary.warning,
.status-summary.running { border-color: #e7cf8a; background: #fffaf0; color: var(--running); }
.status-summary.queued { border-color: #b5cdea; background: #eef6ff; color: var(--link); }
.status-summary.queued { border-color: #b5cdea; background: #eef6ff; color: var(--link); }
a.status-summary {
color: inherit;
text-decoration: none;
@@ -552,13 +552,22 @@
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.operator-state {
.operator-state {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.refresh-controls {
align-items: center;
display: flex;
gap: 14px;
justify-content: space-between;
}
.refresh-controls h2 {
margin-bottom: 4px;
}
.trend-bars {
display: grid;
gap: 5px;
@@ -837,6 +846,10 @@
.page-header .actions { justify-content: flex-start; }
.two-col,
.panel-grid { grid-template-columns: 1fr; }
.refresh-controls {
align-items: stretch;
display: grid;
}
.dashboard-priority-grid { grid-template-columns: 1fr; }
.host-control-grid { grid-template-columns: 1fr; }
.schedule-row { grid-template-columns: 1fr; }
@@ -922,8 +935,20 @@
</main>
<script>
(() => {
const updateRefreshControls = (region) => {
const toggle = document.querySelector(`[data-refresh-toggle][data-refresh-target="${region.id}"]`);
const state = document.querySelector(`[data-refresh-state="${region.id}"]`);
const paused = region.dataset.refreshPaused === "true";
const active = region.dataset.refreshActive === "true";
if (state) state.textContent = paused ? "paused" : (active ? "on" : "off");
if (toggle) {
toggle.textContent = paused ? "Resume refresh" : "Pause refresh";
toggle.disabled = !active && !paused;
}
};
const refreshRegion = async (region) => {
if (region.dataset.refreshActive !== "true" || document.hidden) return;
if (region.dataset.refreshActive !== "true" || region.dataset.refreshPaused === "true" || document.hidden) return;
try {
const response = await fetch(region.dataset.refreshUrl, {
credentials: "same-origin",
@@ -933,13 +958,26 @@
region.innerHTML = await response.text();
const refreshActive = response.headers.get("X-Pobsync-Refresh-Active");
if (refreshActive) region.dataset.refreshActive = refreshActive;
updateRefreshControls(region);
} catch (error) {
// Keep the current server-rendered content visible if a refresh fails.
}
};
document.addEventListener("click", (event) => {
const toggle = event.target.closest("[data-refresh-toggle]");
if (!toggle) return;
const region = document.getElementById(toggle.dataset.refreshTarget);
if (!region) return;
const paused = region.dataset.refreshPaused === "true";
region.dataset.refreshPaused = paused ? "false" : "true";
if (paused && region.dataset.refreshActive === "true") refreshRegion(region);
updateRefreshControls(region);
});
document.querySelectorAll("[data-refresh-url]").forEach((region) => {
const interval = Number.parseInt(region.dataset.refreshInterval || "5000", 10);
updateRefreshControls(region);
window.setInterval(() => refreshRegion(region), Number.isFinite(interval) ? interval : 5000);
});
})();

View File

@@ -59,9 +59,13 @@
{% 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">
<h2>Run Progress</h2>
<section class="grid" aria-label="Run progress">
<div class="metric"><div class="label">Status</div><div class="value">{{ dry_run_summary.status }}</div></div>
<div class="metric">
<div class="label">Mode</div>
<div class="value">dry run</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>
@@ -96,6 +100,74 @@
</section>
{% endif %}
{% if live_progress %}
<section class="panel highlight running">
<h2>Run Progress</h2>
<section class="grid" aria-label="Run progress">
<div class="metric">
<div class="label">Status</div>
<div class="value">{{ run.status }}</div>
</div>
<div class="metric">
<div class="label">Mode</div>
<div class="value">backup</div>
</div>
<div class="metric">
<div class="label">Phase</div>
<div class="value">{{ live_progress.phase }}</div>
</div>
<div class="metric">
<div class="label">Rsync PID</div>
<div class="value">{{ live_progress.rsync_pid|default:"" }}</div>
</div>
<div class="metric">
<div class="label">Log Updated</div>
<div class="value">
{% if live_progress.log.exists %}
{{ live_progress.log.seconds_since_modified }}s ago
{% else %}
missing
{% endif %}
</div>
</div>
<div class="metric">
<div class="label">Log Size</div>
<div class="value">{{ live_progress.log.size_bytes|filesizeformat }}</div>
</div>
{% if live_progress.snapshot.exists %}
<div class="metric">
<div class="label">Data Files</div>
<div class="value">{% if live_progress.snapshot.scan_limited %}at least {% endif %}{{ live_progress.snapshot.files }}</div>
</div>
<div class="metric">
<div class="label">Data Size</div>
<div class="value">{% if live_progress.snapshot.scan_limited %}at least {% endif %}{{ live_progress.snapshot.apparent_size_bytes|filesizeformat }}</div>
</div>
{% endif %}
</section>
<div class="stack">
{% if live_progress.snapshot.path %}
<div><strong>Snapshot path:</strong> {{ live_progress.snapshot.path }}</div>
{% endif %}
{% if live_progress.snapshot.scan_limited %}
<div class="muted">Progress scan was capped to keep the UI responsive.</div>
{% endif %}
{% if live_progress.log.path %}
<div>
<strong>Log:</strong>
{% if live_progress.log.exists %}
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
{% else %}
<span class="muted">{{ live_progress.log.path }} (missing)</span>
{% endif %}
</div>
<div><strong>Log path:</strong> {{ live_progress.log.path }}</div>
{% endif %}
<div><strong>Warnings:</strong> none recorded</div>
</div>
</section>
{% endif %}
<div class="two-col">
<section class="panel">
<h2>Timing</h2>

View File

@@ -14,10 +14,22 @@
</section>
</header>
{% if can_auto_refresh %}
<section class="panel refresh-controls" aria-label="Live refresh controls">
<div>
<h2>Live Updates</h2>
<p class="muted">Auto-refresh is <strong data-refresh-state="run-live-region">on</strong> while this run is active.</p>
</div>
<button type="button" class="secondary" data-refresh-toggle data-refresh-target="run-live-region">Pause refresh</button>
</section>
{% endif %}
<div
id="run-live-region"
data-refresh-url="{% url 'run_detail_live' run.id %}"
data-refresh-interval="5000"
data-refresh-active="{{ can_auto_refresh|yesno:'true,false' }}"
data-refresh-paused="false"
aria-live="polite"
>
{% include "pobsync_backend/partials/run_detail_live.html" %}

View File

@@ -1513,7 +1513,8 @@ class ViewTests(TestCase):
self.assertContains(response, "--archive")
self.assertContains(response, "Rsync Log")
self.assertContains(response, "sending incremental file list")
self.assertContains(response, "Dry Run Summary")
self.assertContains(response, "Run Progress")
self.assertContains(response, "dry run")
self.assertContains(response, "Files Seen")
self.assertContains(response, "Would Transfer")
self.assertContains(response, "Transfer Estimate")
@@ -1563,7 +1564,8 @@ class ViewTests(TestCase):
response = self.client.get(reverse("run_detail", args=[run.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Dry Run Summary")
self.assertContains(response, "Run Progress")
self.assertContains(response, "dry run")
self.assertContains(response, "failed")
self.assertContains(response, "Files Seen")
self.assertContains(response, "25")
@@ -1749,6 +1751,8 @@ class ViewTests(TestCase):
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)
self.assertContains(response, "Live Updates")
self.assertContains(response, "Pause refresh")
def test_run_detail_live_returns_partial_for_active_run(self) -> None:
self.client.force_login(self.staff_user)
@@ -1767,6 +1771,45 @@ class ViewTests(TestCase):
self.assertContains(response, "sending incremental file list")
self.assertNotContains(response, "<html", html=False)
def test_run_detail_live_shows_progress_for_running_real_run(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
with TemporaryDirectory() as tmp:
snapshot_path = Path(tmp) / "backups" / host.host / ".incomplete" / "20260523-010000Z__ABCDEFGH"
data_path = snapshot_path / "data"
log_path = snapshot_path / "meta" / "rsync.log"
data_path.mkdir(parents=True)
log_path.parent.mkdir(parents=True)
(data_path / "payload.txt").write_text("payload", encoding="utf-8")
log_path.write_text("sending incremental file list\npayload.txt\n", encoding="utf-8")
run = BackupRun.objects.create(
host=host,
status=BackupRun.Status.RUNNING,
snapshot_path=str(snapshot_path),
result={
"requested": {"dry_run": False},
"execution": {
"phase": "rsync",
"snapshot": str(snapshot_path),
"log": str(log_path),
"heartbeat_at": "2026-05-23T01:00:00+02:00",
},
"rsync": {"pid": 1234, "pgid": 1234, "command": ["rsync"]},
},
)
response = self.client.get(reverse("run_detail_live", args=[run.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Run Progress")
self.assertContains(response, "backup")
self.assertContains(response, "rsync")
self.assertContains(response, "1234")
self.assertContains(response, "Data Files")
self.assertContains(response, "Open full rsync log")
self.assertContains(response, "payload.txt")
self.assertContains(response, "sending incremental file list")
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")

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import os
import json
import shlex
import shutil
@@ -711,6 +712,7 @@ def _run_detail_context(run: BackupRun) -> dict[str, object]:
"rsync_log_path": str(rsync_log_path) if rsync_log_path is not None else "",
"rsync_log_exists": bool(rsync_log_path and rsync_log_path.exists()),
"rsync_log_tail": rsync_log_tail,
"live_progress": _run_live_progress(run, rsync_log_path),
"dry_run_summary": _dry_run_summary(
result=result,
requested=requested,
@@ -1260,6 +1262,97 @@ def _run_rsync_log_tail(rsync_result: dict, log_path: Path | None, *, max_lines:
return []
def _run_live_progress(run: BackupRun, log_path: Path | None) -> dict[str, object]:
if run.status not in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING}:
return {}
result = run.result if isinstance(run.result, dict) else {}
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
if requested.get("dry_run") or result.get("dry_run"):
return {}
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
rsync = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
progress: dict[str, object] = {
"phase": execution.get("phase") or ("queued" if run.status == BackupRun.Status.QUEUED else "running"),
"worker_pid": execution.get("worker_pid"),
"rsync_pid": rsync.get("pid"),
"rsync_pgid": rsync.get("pgid"),
}
log_stats = _live_log_stats(log_path)
if log_stats:
progress["log"] = log_stats
snapshot_path = _run_progress_snapshot_path(run, execution)
if snapshot_path is not None:
progress["snapshot"] = {
"path": str(snapshot_path),
**_scan_snapshot_progress(snapshot_path / "data" if (snapshot_path / "data").exists() else snapshot_path),
}
return progress
def _run_progress_snapshot_path(run: BackupRun, execution: dict) -> Path | None:
snapshot = execution.get("snapshot")
if isinstance(snapshot, str) and snapshot:
return Path(snapshot)
if run.snapshot_path:
return Path(run.snapshot_path)
return None
def _live_log_stats(log_path: Path | None) -> dict[str, object]:
if log_path is None:
return {}
try:
stat = log_path.stat()
except OSError:
return {"path": str(log_path), "exists": False}
modified_at = timezone.datetime.fromtimestamp(stat.st_mtime, tz=timezone.get_current_timezone())
return {
"path": str(log_path),
"exists": True,
"size_bytes": stat.st_size,
"modified_at": modified_at,
"seconds_since_modified": max(0, int((timezone.now() - modified_at).total_seconds())),
}
def _scan_snapshot_progress(data_path: Path, *, max_entries: int = 20000) -> dict[str, object]:
progress: dict[str, object] = {
"data_path": str(data_path),
"exists": data_path.exists(),
"files": 0,
"directories": 0,
"apparent_size_bytes": 0,
"scan_limited": False,
}
if not data_path.exists():
return progress
entries_seen = 0
for root, dirnames, filenames in os.walk(data_path):
progress["directories"] = int(progress["directories"]) + len(dirnames)
entries_seen += len(dirnames)
for filename in filenames:
file_path = Path(root) / filename
try:
file_stat = file_path.lstat()
except OSError:
continue
progress["files"] = int(progress["files"]) + 1
progress["apparent_size_bytes"] = int(progress["apparent_size_bytes"]) + int(file_stat.st_size)
entries_seen += 1
if entries_seen >= max_entries:
progress["scan_limited"] = True
return progress
if entries_seen >= max_entries:
progress["scan_limited"] = True
return progress
return progress
def _dry_run_summary(
*,
result: dict,