Add background jobs system with Running Tasks screen

Backup and restore operations now run as background jobs instead of
blocking modal screens. Users can navigate away and check progress
from a dedicated Running Tasks screen. OperationLog supports attaching
to running jobs with live output polling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-06 18:07:34 +02:00
parent 18af43936c
commit 8a83812584
10 changed files with 261 additions and 25 deletions

View File

@@ -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()

View File

@@ -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(

View File

@@ -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,

89
tui/jobs.py Normal file
View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()