Add rsync progress bar to Running Tasks screen
- Add --info=progress2 --no-inc-recursive to rsync for overall progress - Parse rsync progress output (percentage, speed, ETA) from log file - Show ProgressBar widget and progress label below buttons - Progress bar auto-hides when job finishes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,9 @@ rsync_to_remote() {
|
||||
rsync_opts+=(--verbose --stats)
|
||||
fi
|
||||
|
||||
# Overall progress for TUI progress bar
|
||||
rsync_opts+=(--info=progress2 --no-inc-recursive)
|
||||
|
||||
rsync_opts+=(-e "$rsync_ssh")
|
||||
|
||||
# Ensure source ends with /
|
||||
@@ -123,6 +126,9 @@ rsync_local() {
|
||||
rsync_opts+=(--verbose --stats)
|
||||
fi
|
||||
|
||||
# Overall progress for TUI progress bar
|
||||
rsync_opts+=(--info=progress2 --no-inc-recursive)
|
||||
|
||||
# Ensure source ends with /
|
||||
[[ "$source_dir" != */ ]] && source_dir="$source_dir/"
|
||||
|
||||
|
||||
@@ -286,6 +286,16 @@ SelectionList {
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
#rt-progress {
|
||||
margin: 0 0;
|
||||
}
|
||||
|
||||
#rt-progress-label {
|
||||
height: 1;
|
||||
color: #00cc00;
|
||||
}
|
||||
|
||||
/* Wizard */
|
||||
#wizard {
|
||||
width: 60;
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Static, Button, DataTable, RichLog
|
||||
from textual.widgets import Header, Footer, Static, Button, DataTable, RichLog, ProgressBar
|
||||
from textual.containers import Vertical, Horizontal
|
||||
from textual.timer import Timer
|
||||
|
||||
from tui.jobs import job_manager
|
||||
from tui.widgets import ConfirmDialog
|
||||
|
||||
_PROGRESS_RE = re.compile(r"(\d+)%")
|
||||
|
||||
|
||||
class RunningTasksScreen(Screen):
|
||||
|
||||
@@ -25,6 +28,8 @@ class RunningTasksScreen(Screen):
|
||||
yield Button("Kill Job", variant="error", id="btn-rt-kill")
|
||||
yield Button("Clear Finished", variant="warning", id="btn-rt-clear")
|
||||
yield Button("Back", id="btn-rt-back")
|
||||
yield Static("", id="rt-progress-label")
|
||||
yield ProgressBar(id="rt-progress", total=100, show_eta=False)
|
||||
yield RichLog(id="rt-log-viewer", wrap=True, highlight=True)
|
||||
yield Footer()
|
||||
|
||||
@@ -37,6 +42,9 @@ class RunningTasksScreen(Screen):
|
||||
self._log_timer: Timer | None = None
|
||||
self._viewing_job_id: str | None = None
|
||||
self._log_file_pos: int = 0
|
||||
# Hide progress bar initially
|
||||
self.query_one("#rt-progress", ProgressBar).display = False
|
||||
self.query_one("#rt-progress-label", Static).display = False
|
||||
|
||||
def _format_duration(self, job) -> str:
|
||||
end = job.finished_at or datetime.now()
|
||||
@@ -95,24 +103,67 @@ class RunningTasksScreen(Screen):
|
||||
log_viewer.clear()
|
||||
self._viewing_job_id = job_id
|
||||
self._log_file_pos = 0
|
||||
# Reset progress bar
|
||||
progress = self.query_one("#rt-progress", ProgressBar)
|
||||
label = self.query_one("#rt-progress-label", Static)
|
||||
progress.update(progress=0)
|
||||
# Load existing content from log file
|
||||
if job._log_file and Path(job._log_file).is_file():
|
||||
try:
|
||||
content = Path(job._log_file).read_text()
|
||||
self._log_file_pos = len(content.encode())
|
||||
for line in content.splitlines():
|
||||
log_viewer.write(line)
|
||||
raw = Path(job._log_file).read_bytes()
|
||||
self._log_file_pos = len(raw)
|
||||
content = raw.decode(errors="replace")
|
||||
self._process_log_content(content, log_viewer)
|
||||
except OSError:
|
||||
pass
|
||||
elif job.output:
|
||||
for line in job.output:
|
||||
log_viewer.write(line)
|
||||
# Show/hide progress bar based on job status
|
||||
is_running = job.status == "running"
|
||||
progress.display = is_running
|
||||
label.display = is_running
|
||||
# Start polling for new content if job is running
|
||||
if self._log_timer:
|
||||
self._log_timer.stop()
|
||||
if job.status == "running":
|
||||
if is_running:
|
||||
self._log_timer = self.set_interval(0.3, self._poll_log)
|
||||
|
||||
def _process_log_content(self, content: str, log_viewer: RichLog) -> None:
|
||||
"""Process log content, extracting rsync progress and writing log lines."""
|
||||
for line in content.split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
# rsync --info=progress2 uses \r to update in place
|
||||
if "\r" in line:
|
||||
parts = line.split("\r")
|
||||
# Extract progress from the last \r segment
|
||||
last = parts[-1].strip()
|
||||
if last:
|
||||
self._update_progress(last)
|
||||
# Write non-progress parts as log lines
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if part and not _PROGRESS_RE.search(part):
|
||||
log_viewer.write(part)
|
||||
else:
|
||||
log_viewer.write(line)
|
||||
|
||||
def _update_progress(self, text: str) -> None:
|
||||
"""Parse rsync progress2 line and update progress bar."""
|
||||
m = _PROGRESS_RE.search(text)
|
||||
if not m:
|
||||
return
|
||||
pct = int(m.group(1))
|
||||
try:
|
||||
progress = self.query_one("#rt-progress", ProgressBar)
|
||||
label = self.query_one("#rt-progress-label", Static)
|
||||
progress.update(progress=pct)
|
||||
# Show the raw progress info as label
|
||||
label.update(f" {text.strip()}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _poll_log(self) -> None:
|
||||
if not self._viewing_job_id:
|
||||
return
|
||||
@@ -127,16 +178,18 @@ class RunningTasksScreen(Screen):
|
||||
return
|
||||
if job._log_file and Path(job._log_file).is_file():
|
||||
try:
|
||||
with open(job._log_file, "r") as f:
|
||||
with open(job._log_file, "rb") as f:
|
||||
f.seek(self._log_file_pos)
|
||||
new_data = f.read()
|
||||
if new_data:
|
||||
self._log_file_pos += len(new_data.encode())
|
||||
for line in new_data.splitlines():
|
||||
log_viewer.write(line)
|
||||
new_raw = f.read()
|
||||
if new_raw:
|
||||
self._log_file_pos += len(new_raw)
|
||||
new_data = new_raw.decode(errors="replace")
|
||||
self._process_log_content(new_data, log_viewer)
|
||||
except OSError:
|
||||
pass
|
||||
if job.status != "running":
|
||||
self.query_one("#rt-progress", ProgressBar).display = False
|
||||
self.query_one("#rt-progress-label", Static).display = False
|
||||
if self._log_timer:
|
||||
self._log_timer.stop()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user