diff --git a/tui/app.py b/tui/app.py index 81adbcd..98add18 100644 --- a/tui/app.py +++ b/tui/app.py @@ -15,6 +15,8 @@ from tui.screens.schedule_edit import ScheduleEditScreen from tui.screens.logs import LogsScreen from tui.screens.settings import SettingsScreen from tui.screens.wizard import WizardScreen +from tui.screens.running_tasks import RunningTasksScreen +from tui.jobs import job_manager, JobFinished class GnizaApp(App): @@ -26,6 +28,7 @@ class GnizaApp(App): "main": MainMenuScreen, "backup": BackupScreen, "restore": RestoreScreen, + "running_tasks": RunningTasksScreen, "targets": TargetsScreen, "target_edit": TargetEditScreen, "remotes": RemotesScreen, @@ -44,3 +47,16 @@ class GnizaApp(App): self.push_screen("wizard") else: self.push_screen("main") + + def on_job_finished(self, message: JobFinished) -> None: + job = job_manager.get_job(message.job_id) + if not job: + return + if message.return_code == 0: + self.notify(f"{job.label} completed successfully") + else: + self.notify(f"{job.label} failed (exit code {message.return_code})", severity="error") + + async def action_quit(self) -> None: + job_manager.kill_running() + self.exit() diff --git a/tui/backend.py b/tui/backend.py index 16d9999..7467909 100644 --- a/tui/backend.py +++ b/tui/backend.py @@ -24,6 +24,15 @@ async def run_cli(*args: str) -> tuple[int, str, str]: return proc.returncode or 0, stdout.decode(), stderr.decode() +async def start_cli_process(*args: str) -> asyncio.subprocess.Process: + cmd = [_gniza_bin(), "--cli"] + list(args) + return await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + + async def stream_cli(callback, *args: str) -> int: cmd = [_gniza_bin(), "--cli"] + list(args) proc = await asyncio.create_subprocess_exec( diff --git a/tui/gniza.tcss b/tui/gniza.tcss index a81c1ca..3839cfc 100644 --- a/tui/gniza.tcss +++ b/tui/gniza.tcss @@ -51,6 +51,7 @@ DataTable { #snapshots-screen, #targets-screen, #remotes-screen, +#running-tasks-screen, #logs-screen { padding: 1 2; overflow-y: auto; @@ -110,6 +111,7 @@ SelectionList { #ret-buttons, #targets-buttons, #remotes-buttons, +#rt-buttons, #logs-buttons, #snapshots-buttons, #sched-buttons, @@ -126,6 +128,7 @@ SelectionList { #ret-buttons Button, #targets-buttons Button, #remotes-buttons Button, +#rt-buttons Button, #logs-buttons Button, #snapshots-buttons Button, #sched-buttons Button, diff --git a/tui/jobs.py b/tui/jobs.py new file mode 100644 index 0000000..7d8e24c --- /dev/null +++ b/tui/jobs.py @@ -0,0 +1,89 @@ +import asyncio +import uuid +from dataclasses import dataclass, field +from datetime import datetime + +from textual.message import Message + +from tui.backend import start_cli_process + +MAX_OUTPUT_LINES = 10_000 + + +class JobFinished(Message): + def __init__(self, job_id: str, return_code: int) -> None: + super().__init__() + self.job_id = job_id + self.return_code = return_code + + +@dataclass +class Job: + id: str + kind: str + label: str + status: str = "running" + started_at: datetime = field(default_factory=datetime.now) + finished_at: datetime | None = None + return_code: int | None = None + output: list[str] = field(default_factory=list) + _proc: asyncio.subprocess.Process | None = field(default=None, repr=False) + + +class JobManager: + + def __init__(self) -> None: + self._jobs: dict[str, Job] = {} + + def create_job(self, kind: str, label: str) -> Job: + job = Job(id=uuid.uuid4().hex[:8], kind=kind, label=label) + self._jobs[job.id] = job + return job + + def get_job(self, job_id: str) -> Job | None: + return self._jobs.get(job_id) + + def list_jobs(self) -> list[Job]: + return list(self._jobs.values()) + + def running_count(self) -> int: + return sum(1 for j in self._jobs.values() if j.status == "running") + + def remove_finished(self) -> None: + self._jobs = {k: v for k, v in self._jobs.items() if v.status == "running"} + + async def run_job(self, app, job: Job, *cli_args: str) -> int: + proc = await start_cli_process(*cli_args) + job._proc = proc + try: + while True: + line = await proc.stdout.readline() + if not line: + break + text = line.decode().rstrip("\n") + if len(job.output) < MAX_OUTPUT_LINES: + job.output.append(text) + await proc.wait() + rc = proc.returncode if proc.returncode is not None else 1 + job.return_code = rc + job.status = "success" if rc == 0 else "failed" + except Exception: + job.status = "failed" + job.return_code = job.return_code if job.return_code is not None else 1 + finally: + job.finished_at = datetime.now() + job._proc = None + rc = job.return_code if job.return_code is not None else 1 + app.post_message(JobFinished(job.id, rc)) + return job.return_code if job.return_code is not None else 1 + + def kill_running(self) -> None: + for job in self._jobs.values(): + if job._proc is not None: + try: + job._proc.terminate() + except ProcessLookupError: + pass + + +job_manager = JobManager() diff --git a/tui/screens/__init__.py b/tui/screens/__init__.py index e2f7476..b52a73b 100644 --- a/tui/screens/__init__.py +++ b/tui/screens/__init__.py @@ -12,3 +12,4 @@ from tui.screens.schedule_edit import ScheduleEditScreen from tui.screens.logs import LogsScreen from tui.screens.settings import SettingsScreen from tui.screens.wizard import WizardScreen +from tui.screens.running_tasks import RunningTasksScreen diff --git a/tui/screens/backup.py b/tui/screens/backup.py index 4fdd060..0afe88b 100644 --- a/tui/screens/backup.py +++ b/tui/screens/backup.py @@ -5,8 +5,8 @@ from textual.containers import Vertical, Horizontal from textual import work from tui.config import list_conf_dir, has_targets, has_remotes -from tui.backend import stream_cli -from tui.widgets import ConfirmDialog, OperationLog +from tui.jobs import job_manager +from tui.widgets import ConfirmDialog class BackupScreen(Screen): @@ -67,28 +67,26 @@ class BackupScreen(Screen): @work async def _do_backup(self, target: str, remote: str) -> None: - log_screen = OperationLog(f"Backup: {target}") - self.app.push_screen(log_screen) + job = job_manager.create_job("backup", f"Backup: {target}") + self.notify("Backup started -- view in Running Tasks") args = ["backup", f"--target={target}"] if remote: args.append(f"--remote={remote}") - rc = await stream_cli(log_screen.write, *args) + rc = await job_manager.run_job(self.app, job, *args) if rc == 0: - log_screen.write("\n[green]Backup completed successfully.[/green]") + self.notify("Backup completed successfully", severity="information") else: - log_screen.write(f"\n[red]Backup failed (exit code {rc}).[/red]") - log_screen.finish() + self.notify(f"Backup failed (exit code {rc})", severity="error") @work async def _do_backup_all(self) -> None: - log_screen = OperationLog("Backup All Targets") - self.app.push_screen(log_screen) - rc = await stream_cli(log_screen.write, "backup", "--all") + job = job_manager.create_job("backup", "Backup All Targets") + self.notify("Backup All started -- view in Running Tasks") + rc = await job_manager.run_job(self.app, job, "backup", "--all") if rc == 0: - log_screen.write("\n[green]All backups completed.[/green]") + self.notify("All backups completed", severity="information") else: - log_screen.write(f"\n[red]Backup failed (exit code {rc}).[/red]") - log_screen.finish() + self.notify(f"Backup failed (exit code {rc})", severity="error") def action_go_back(self) -> None: self.app.pop_screen() diff --git a/tui/screens/main_menu.py b/tui/screens/main_menu.py index 4980b6d..f2fb921 100644 --- a/tui/screens/main_menu.py +++ b/tui/screens/main_menu.py @@ -29,6 +29,7 @@ LOGO = """\ MENU_ITEMS = [ ("backup", "Backup"), ("restore", "Restore"), + ("running_tasks", "Running Tasks"), ("targets", "Targets"), ("remotes", "Remotes"), ("schedule", "Schedules"), @@ -50,7 +51,7 @@ class MainMenuScreen(Screen): menu_items = [] for mid, label in MENU_ITEMS: menu_items.append(Option(label, id=mid)) - if mid == "restore": + if mid == "running_tasks": menu_items.append(None) yield OptionList(*menu_items, id="menu-list") yield Footer() diff --git a/tui/screens/restore.py b/tui/screens/restore.py index 7be6ba3..98cde28 100644 --- a/tui/screens/restore.py +++ b/tui/screens/restore.py @@ -5,8 +5,9 @@ from textual.containers import Vertical, Horizontal from textual import work, on from tui.config import list_conf_dir, parse_conf, CONFIG_DIR -from tui.backend import run_cli, stream_cli -from tui.widgets import ConfirmDialog, OperationLog, FolderPicker +from tui.backend import run_cli +from tui.jobs import job_manager +from tui.widgets import ConfirmDialog, FolderPicker class RestoreScreen(Screen): @@ -144,19 +145,18 @@ class RestoreScreen(Screen): @work async def _do_restore(self, target: str, remote: str, snapshot: str, dest: str, skip_mysql: bool = False) -> None: - log_screen = OperationLog(f"Restore: {target}") - self.app.push_screen(log_screen) + job = job_manager.create_job("restore", f"Restore: {target}") + self.notify("Restore started -- view in Running Tasks") args = ["restore", f"--target={target}", f"--remote={remote}", f"--snapshot={snapshot}"] if dest: args.append(f"--dest={dest}") if skip_mysql: args.append("--skip-mysql") - rc = await stream_cli(log_screen.write, *args) + rc = await job_manager.run_job(self.app, job, *args) if rc == 0: - log_screen.write("\n[green]Restore completed successfully.[/green]") + self.notify("Restore completed successfully", severity="information") else: - log_screen.write(f"\n[red]Restore failed (exit code {rc}).[/red]") - log_screen.finish() + self.notify(f"Restore failed (exit code {rc})", severity="error") def action_go_back(self) -> None: self.app.pop_screen() diff --git a/tui/screens/running_tasks.py b/tui/screens/running_tasks.py new file mode 100644 index 0000000..aa4ac64 --- /dev/null +++ b/tui/screens/running_tasks.py @@ -0,0 +1,86 @@ +from datetime import datetime + +from textual.app import ComposeResult +from textual.screen import Screen +from textual.widgets import Header, Footer, Static, Button, DataTable +from textual.containers import Vertical, Horizontal + +from tui.jobs import job_manager +from tui.widgets import OperationLog + + +class RunningTasksScreen(Screen): + + BINDINGS = [("escape", "go_back", "Back")] + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with Vertical(id="running-tasks-screen"): + yield Static("Running Tasks", id="screen-title") + yield DataTable(id="rt-table") + with Horizontal(id="rt-buttons"): + yield Button("View Log", variant="primary", id="btn-rt-view") + yield Button("Clear Finished", variant="warning", id="btn-rt-clear") + yield Button("Back", id="btn-rt-back") + yield Footer() + + def on_mount(self) -> None: + table = self.query_one("#rt-table", DataTable) + table.add_columns("Status", "Job", "Started", "Duration") + table.cursor_type = "row" + self._refresh_table() + self._timer = self.set_interval(1, self._refresh_table) + + def _format_duration(self, job) -> str: + end = job.finished_at or datetime.now() + delta = end - job.started_at + secs = int(delta.total_seconds()) + if secs < 60: + return f"{secs}s" + mins, s = divmod(secs, 60) + if mins < 60: + return f"{mins}m {s}s" + hours, m = divmod(mins, 60) + return f"{hours}h {m}m" + + def _refresh_table(self) -> None: + table = self.query_one("#rt-table", DataTable) + # Preserve cursor position + old_row = table.cursor_coordinate.row if table.row_count > 0 else 0 + table.clear() + for job in job_manager.list_jobs(): + if job.status == "running": + icon = "... " + elif job.status == "success": + icon = " ok " + else: + icon = " X " + started = job.started_at.strftime("%H:%M:%S") + table.add_row(icon, job.label, started, self._format_duration(job), key=job.id) + if table.row_count > 0: + table.move_cursor(row=min(old_row, table.row_count - 1)) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-rt-back": + self.app.pop_screen() + elif event.button.id == "btn-rt-clear": + job_manager.remove_finished() + self._refresh_table() + elif event.button.id == "btn-rt-view": + table = self.query_one("#rt-table", DataTable) + if table.row_count == 0: + self.notify("No jobs to view", severity="warning") + return + row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate) + job_id = str(row_key) + job = job_manager.get_job(job_id) + if job: + log_screen = OperationLog( + title=job.label, + show_spinner=job.status == "running", + job_id=job.id, + ) + self.app.push_screen(log_screen) + + def action_go_back(self) -> None: + self.app.pop_screen() diff --git a/tui/widgets/operation_log.py b/tui/widgets/operation_log.py index f54c045..ba0a6e2 100644 --- a/tui/widgets/operation_log.py +++ b/tui/widgets/operation_log.py @@ -33,12 +33,15 @@ class OperationLog(ModalScreen[None]): BINDINGS = [("escape", "close", "Close")] - def __init__(self, title: str = "Operation Output", show_spinner: bool = True): + def __init__(self, title: str = "Operation Output", show_spinner: bool = True, job_id: str | None = None): super().__init__() self._title = title self._show_spinner = show_spinner + self._job_id = job_id self._mounted_event = asyncio.Event() self._buffer: list[str] = [] + self._poll_timer: Timer | None = None + self._last_line_count: int = 0 def compose(self) -> ComposeResult: with Vertical(id="op-log"): @@ -50,8 +53,20 @@ class OperationLog(ModalScreen[None]): yield SpinnerWidget("arrow3", id="ol-spinner") def on_mount(self) -> None: - # Flush any buffered writes log = self.query_one("#ol-log", RichLog) + # If attached to a job, load existing output and start polling + if self._job_id: + from tui.jobs import job_manager + job = job_manager.get_job(self._job_id) + if job: + for line in job.output: + self._write_to_log(log, line) + self._last_line_count = len(job.output) + if job.status != "running": + self.finish() + else: + self._poll_timer = self.set_interval(0.2, self._poll_job) + # Flush any buffered writes for text in self._buffer: self._write_to_log(log, text) self._buffer.clear() @@ -84,3 +99,21 @@ class OperationLog(ModalScreen[None]): self._write_to_log(log, text) except Exception: self._buffer.append(text) + + def _poll_job(self) -> None: + from tui.jobs import job_manager + job = job_manager.get_job(self._job_id) + if not job: + return + new_lines = job.output[self._last_line_count:] + self._last_line_count = len(job.output) + for line in new_lines: + self.write(line) + if job.status != "running": + if job.return_code == 0: + self.write("\n[green]Operation completed successfully.[/green]") + else: + self.write(f"\n[red]Operation failed (exit code {job.return_code}).[/red]") + self.finish() + if self._poll_timer: + self._poll_timer.stop()