(refactor) Unify run progress panels #57
@@ -559,6 +559,15 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
.refresh-controls {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.refresh-controls h2 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
.trend-bars {
|
.trend-bars {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
@@ -837,6 +846,10 @@
|
|||||||
.page-header .actions { justify-content: flex-start; }
|
.page-header .actions { justify-content: flex-start; }
|
||||||
.two-col,
|
.two-col,
|
||||||
.panel-grid { grid-template-columns: 1fr; }
|
.panel-grid { grid-template-columns: 1fr; }
|
||||||
|
.refresh-controls {
|
||||||
|
align-items: stretch;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
.dashboard-priority-grid { grid-template-columns: 1fr; }
|
.dashboard-priority-grid { grid-template-columns: 1fr; }
|
||||||
.host-control-grid { grid-template-columns: 1fr; }
|
.host-control-grid { grid-template-columns: 1fr; }
|
||||||
.schedule-row { grid-template-columns: 1fr; }
|
.schedule-row { grid-template-columns: 1fr; }
|
||||||
@@ -922,8 +935,20 @@
|
|||||||
</main>
|
</main>
|
||||||
<script>
|
<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) => {
|
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 {
|
try {
|
||||||
const response = await fetch(region.dataset.refreshUrl, {
|
const response = await fetch(region.dataset.refreshUrl, {
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
@@ -933,13 +958,26 @@
|
|||||||
region.innerHTML = await response.text();
|
region.innerHTML = await response.text();
|
||||||
const refreshActive = response.headers.get("X-Pobsync-Refresh-Active");
|
const refreshActive = response.headers.get("X-Pobsync-Refresh-Active");
|
||||||
if (refreshActive) region.dataset.refreshActive = refreshActive;
|
if (refreshActive) region.dataset.refreshActive = refreshActive;
|
||||||
|
updateRefreshControls(region);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Keep the current server-rendered content visible if a refresh fails.
|
// 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) => {
|
document.querySelectorAll("[data-refresh-url]").forEach((region) => {
|
||||||
const interval = Number.parseInt(region.dataset.refreshInterval || "5000", 10);
|
const interval = Number.parseInt(region.dataset.refreshInterval || "5000", 10);
|
||||||
|
updateRefreshControls(region);
|
||||||
window.setInterval(() => refreshRegion(region), Number.isFinite(interval) ? interval : 5000);
|
window.setInterval(() => refreshRegion(region), Number.isFinite(interval) ? interval : 5000);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -59,9 +59,13 @@
|
|||||||
|
|
||||||
{% if dry_run_summary %}
|
{% if dry_run_summary %}
|
||||||
<section class="panel highlight {{ dry_run_summary.highlight_class }}">
|
<section class="panel highlight {{ dry_run_summary.highlight_class }}">
|
||||||
<h2>Dry Run Summary</h2>
|
<h2>Run Progress</h2>
|
||||||
<section class="grid" aria-label="Dry run summary">
|
<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">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">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">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">Transfer Estimate</div><div class="value">{{ dry_run_summary.transfer_estimate_bytes|filesizeformat }}</div></div>
|
||||||
@@ -96,6 +100,74 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% 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">
|
<div class="two-col">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Timing</h2>
|
<h2>Timing</h2>
|
||||||
|
|||||||
@@ -14,10 +14,22 @@
|
|||||||
</section>
|
</section>
|
||||||
</header>
|
</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
|
<div
|
||||||
|
id="run-live-region"
|
||||||
data-refresh-url="{% url 'run_detail_live' run.id %}"
|
data-refresh-url="{% url 'run_detail_live' run.id %}"
|
||||||
data-refresh-interval="5000"
|
data-refresh-interval="5000"
|
||||||
data-refresh-active="{{ can_auto_refresh|yesno:'true,false' }}"
|
data-refresh-active="{{ can_auto_refresh|yesno:'true,false' }}"
|
||||||
|
data-refresh-paused="false"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
{% include "pobsync_backend/partials/run_detail_live.html" %}
|
{% include "pobsync_backend/partials/run_detail_live.html" %}
|
||||||
|
|||||||
@@ -1513,7 +1513,8 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "--archive")
|
self.assertContains(response, "--archive")
|
||||||
self.assertContains(response, "Rsync Log")
|
self.assertContains(response, "Rsync Log")
|
||||||
self.assertContains(response, "sending incremental file list")
|
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, "Files Seen")
|
||||||
self.assertContains(response, "Would Transfer")
|
self.assertContains(response, "Would Transfer")
|
||||||
self.assertContains(response, "Transfer Estimate")
|
self.assertContains(response, "Transfer Estimate")
|
||||||
@@ -1563,7 +1564,8 @@ class ViewTests(TestCase):
|
|||||||
response = self.client.get(reverse("run_detail", args=[run.id]))
|
response = self.client.get(reverse("run_detail", args=[run.id]))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
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, "failed")
|
||||||
self.assertContains(response, "Files Seen")
|
self.assertContains(response, "Files Seen")
|
||||||
self.assertContains(response, "25")
|
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, 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-interval="5000"', html=False)
|
||||||
self.assertContains(response, 'data-refresh-active="true"', 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:
|
def test_run_detail_live_returns_partial_for_active_run(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -1767,6 +1771,45 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "sending incremental file list")
|
self.assertContains(response, "sending incremental file list")
|
||||||
self.assertNotContains(response, "<html", html=False)
|
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:
|
def test_run_detail_live_stops_refresh_for_terminal_run(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")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import json
|
import json
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
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_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_exists": bool(rsync_log_path and rsync_log_path.exists()),
|
||||||
"rsync_log_tail": rsync_log_tail,
|
"rsync_log_tail": rsync_log_tail,
|
||||||
|
"live_progress": _run_live_progress(run, rsync_log_path),
|
||||||
"dry_run_summary": _dry_run_summary(
|
"dry_run_summary": _dry_run_summary(
|
||||||
result=result,
|
result=result,
|
||||||
requested=requested,
|
requested=requested,
|
||||||
@@ -1260,6 +1262,97 @@ def _run_rsync_log_tail(rsync_result: dict, log_path: Path | None, *, max_lines:
|
|||||||
return []
|
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(
|
def _dry_run_summary(
|
||||||
*,
|
*,
|
||||||
result: dict,
|
result: dict,
|
||||||
|
|||||||
Reference in New Issue
Block a user