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)
|
rsync_opts+=(--verbose --stats)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Overall progress for TUI progress bar
|
||||||
|
rsync_opts+=(--info=progress2 --no-inc-recursive)
|
||||||
|
|
||||||
rsync_opts+=(-e "$rsync_ssh")
|
rsync_opts+=(-e "$rsync_ssh")
|
||||||
|
|
||||||
# Ensure source ends with /
|
# Ensure source ends with /
|
||||||
@@ -123,6 +126,9 @@ rsync_local() {
|
|||||||
rsync_opts+=(--verbose --stats)
|
rsync_opts+=(--verbose --stats)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Overall progress for TUI progress bar
|
||||||
|
rsync_opts+=(--info=progress2 --no-inc-recursive)
|
||||||
|
|
||||||
# Ensure source ends with /
|
# Ensure source ends with /
|
||||||
[[ "$source_dir" != */ ]] && source_dir="$source_dir/"
|
[[ "$source_dir" != */ ]] && source_dir="$source_dir/"
|
||||||
|
|
||||||
|
|||||||
@@ -286,6 +286,16 @@ SelectionList {
|
|||||||
margin: 1 0;
|
margin: 1 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
#rt-progress {
|
||||||
|
margin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#rt-progress-label {
|
||||||
|
height: 1;
|
||||||
|
color: #00cc00;
|
||||||
|
}
|
||||||
|
|
||||||
/* Wizard */
|
/* Wizard */
|
||||||
#wizard {
|
#wizard {
|
||||||
width: 60;
|
width: 60;
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.screen import Screen
|
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.containers import Vertical, Horizontal
|
||||||
from textual.timer import Timer
|
from textual.timer import Timer
|
||||||
|
|
||||||
from tui.jobs import job_manager
|
from tui.jobs import job_manager
|
||||||
from tui.widgets import ConfirmDialog
|
from tui.widgets import ConfirmDialog
|
||||||
|
|
||||||
|
_PROGRESS_RE = re.compile(r"(\d+)%")
|
||||||
|
|
||||||
|
|
||||||
class RunningTasksScreen(Screen):
|
class RunningTasksScreen(Screen):
|
||||||
|
|
||||||
@@ -25,6 +28,8 @@ class RunningTasksScreen(Screen):
|
|||||||
yield Button("Kill Job", variant="error", id="btn-rt-kill")
|
yield Button("Kill Job", variant="error", id="btn-rt-kill")
|
||||||
yield Button("Clear Finished", variant="warning", id="btn-rt-clear")
|
yield Button("Clear Finished", variant="warning", id="btn-rt-clear")
|
||||||
yield Button("Back", id="btn-rt-back")
|
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 RichLog(id="rt-log-viewer", wrap=True, highlight=True)
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
@@ -37,6 +42,9 @@ class RunningTasksScreen(Screen):
|
|||||||
self._log_timer: Timer | None = None
|
self._log_timer: Timer | None = None
|
||||||
self._viewing_job_id: str | None = None
|
self._viewing_job_id: str | None = None
|
||||||
self._log_file_pos: int = 0
|
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:
|
def _format_duration(self, job) -> str:
|
||||||
end = job.finished_at or datetime.now()
|
end = job.finished_at or datetime.now()
|
||||||
@@ -95,24 +103,67 @@ class RunningTasksScreen(Screen):
|
|||||||
log_viewer.clear()
|
log_viewer.clear()
|
||||||
self._viewing_job_id = job_id
|
self._viewing_job_id = job_id
|
||||||
self._log_file_pos = 0
|
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
|
# Load existing content from log file
|
||||||
if job._log_file and Path(job._log_file).is_file():
|
if job._log_file and Path(job._log_file).is_file():
|
||||||
try:
|
try:
|
||||||
content = Path(job._log_file).read_text()
|
raw = Path(job._log_file).read_bytes()
|
||||||
self._log_file_pos = len(content.encode())
|
self._log_file_pos = len(raw)
|
||||||
for line in content.splitlines():
|
content = raw.decode(errors="replace")
|
||||||
log_viewer.write(line)
|
self._process_log_content(content, log_viewer)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
elif job.output:
|
elif job.output:
|
||||||
for line in job.output:
|
for line in job.output:
|
||||||
log_viewer.write(line)
|
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
|
# Start polling for new content if job is running
|
||||||
if self._log_timer:
|
if self._log_timer:
|
||||||
self._log_timer.stop()
|
self._log_timer.stop()
|
||||||
if job.status == "running":
|
if is_running:
|
||||||
self._log_timer = self.set_interval(0.3, self._poll_log)
|
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:
|
def _poll_log(self) -> None:
|
||||||
if not self._viewing_job_id:
|
if not self._viewing_job_id:
|
||||||
return
|
return
|
||||||
@@ -127,16 +178,18 @@ class RunningTasksScreen(Screen):
|
|||||||
return
|
return
|
||||||
if job._log_file and Path(job._log_file).is_file():
|
if job._log_file and Path(job._log_file).is_file():
|
||||||
try:
|
try:
|
||||||
with open(job._log_file, "r") as f:
|
with open(job._log_file, "rb") as f:
|
||||||
f.seek(self._log_file_pos)
|
f.seek(self._log_file_pos)
|
||||||
new_data = f.read()
|
new_raw = f.read()
|
||||||
if new_data:
|
if new_raw:
|
||||||
self._log_file_pos += len(new_data.encode())
|
self._log_file_pos += len(new_raw)
|
||||||
for line in new_data.splitlines():
|
new_data = new_raw.decode(errors="replace")
|
||||||
log_viewer.write(line)
|
self._process_log_content(new_data, log_viewer)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
if job.status != "running":
|
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:
|
if self._log_timer:
|
||||||
self._log_timer.stop()
|
self._log_timer.stop()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user