(refactor) Unify run progress panels

Use a shared Run Progress presentation for dry-runs and normal backup
runs so live run feedback is consistent across run types.

Keep mode-specific metrics while aligning status, mode, log, and warning
layout.

Refs #52
This commit is contained in:
2026-05-23 00:46:52 +02:00
parent df9ec5b04c
commit 3b77f2e5d0
5 changed files with 265 additions and 7 deletions

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,