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:
16
tui/app.py
16
tui/app.py
@@ -15,6 +15,8 @@ from tui.screens.schedule_edit import ScheduleEditScreen
|
|||||||
from tui.screens.logs import LogsScreen
|
from tui.screens.logs import LogsScreen
|
||||||
from tui.screens.settings import SettingsScreen
|
from tui.screens.settings import SettingsScreen
|
||||||
from tui.screens.wizard import WizardScreen
|
from tui.screens.wizard import WizardScreen
|
||||||
|
from tui.screens.running_tasks import RunningTasksScreen
|
||||||
|
from tui.jobs import job_manager, JobFinished
|
||||||
|
|
||||||
|
|
||||||
class GnizaApp(App):
|
class GnizaApp(App):
|
||||||
@@ -26,6 +28,7 @@ class GnizaApp(App):
|
|||||||
"main": MainMenuScreen,
|
"main": MainMenuScreen,
|
||||||
"backup": BackupScreen,
|
"backup": BackupScreen,
|
||||||
"restore": RestoreScreen,
|
"restore": RestoreScreen,
|
||||||
|
"running_tasks": RunningTasksScreen,
|
||||||
"targets": TargetsScreen,
|
"targets": TargetsScreen,
|
||||||
"target_edit": TargetEditScreen,
|
"target_edit": TargetEditScreen,
|
||||||
"remotes": RemotesScreen,
|
"remotes": RemotesScreen,
|
||||||
@@ -44,3 +47,16 @@ class GnizaApp(App):
|
|||||||
self.push_screen("wizard")
|
self.push_screen("wizard")
|
||||||
else:
|
else:
|
||||||
self.push_screen("main")
|
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()
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ async def run_cli(*args: str) -> tuple[int, str, str]:
|
|||||||
return proc.returncode or 0, stdout.decode(), stderr.decode()
|
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:
|
async def stream_cli(callback, *args: str) -> int:
|
||||||
cmd = [_gniza_bin(), "--cli"] + list(args)
|
cmd = [_gniza_bin(), "--cli"] + list(args)
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ DataTable {
|
|||||||
#snapshots-screen,
|
#snapshots-screen,
|
||||||
#targets-screen,
|
#targets-screen,
|
||||||
#remotes-screen,
|
#remotes-screen,
|
||||||
|
#running-tasks-screen,
|
||||||
#logs-screen {
|
#logs-screen {
|
||||||
padding: 1 2;
|
padding: 1 2;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -110,6 +111,7 @@ SelectionList {
|
|||||||
#ret-buttons,
|
#ret-buttons,
|
||||||
#targets-buttons,
|
#targets-buttons,
|
||||||
#remotes-buttons,
|
#remotes-buttons,
|
||||||
|
#rt-buttons,
|
||||||
#logs-buttons,
|
#logs-buttons,
|
||||||
#snapshots-buttons,
|
#snapshots-buttons,
|
||||||
#sched-buttons,
|
#sched-buttons,
|
||||||
@@ -126,6 +128,7 @@ SelectionList {
|
|||||||
#ret-buttons Button,
|
#ret-buttons Button,
|
||||||
#targets-buttons Button,
|
#targets-buttons Button,
|
||||||
#remotes-buttons Button,
|
#remotes-buttons Button,
|
||||||
|
#rt-buttons Button,
|
||||||
#logs-buttons Button,
|
#logs-buttons Button,
|
||||||
#snapshots-buttons Button,
|
#snapshots-buttons Button,
|
||||||
#sched-buttons Button,
|
#sched-buttons Button,
|
||||||
|
|||||||
89
tui/jobs.py
Normal file
89
tui/jobs.py
Normal 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()
|
||||||
@@ -12,3 +12,4 @@ from tui.screens.schedule_edit import ScheduleEditScreen
|
|||||||
from tui.screens.logs import LogsScreen
|
from tui.screens.logs import LogsScreen
|
||||||
from tui.screens.settings import SettingsScreen
|
from tui.screens.settings import SettingsScreen
|
||||||
from tui.screens.wizard import WizardScreen
|
from tui.screens.wizard import WizardScreen
|
||||||
|
from tui.screens.running_tasks import RunningTasksScreen
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from textual.containers import Vertical, Horizontal
|
|||||||
from textual import work
|
from textual import work
|
||||||
|
|
||||||
from tui.config import list_conf_dir, has_targets, has_remotes
|
from tui.config import list_conf_dir, has_targets, has_remotes
|
||||||
from tui.backend import stream_cli
|
from tui.jobs import job_manager
|
||||||
from tui.widgets import ConfirmDialog, OperationLog
|
from tui.widgets import ConfirmDialog
|
||||||
|
|
||||||
|
|
||||||
class BackupScreen(Screen):
|
class BackupScreen(Screen):
|
||||||
@@ -67,28 +67,26 @@ class BackupScreen(Screen):
|
|||||||
|
|
||||||
@work
|
@work
|
||||||
async def _do_backup(self, target: str, remote: str) -> None:
|
async def _do_backup(self, target: str, remote: str) -> None:
|
||||||
log_screen = OperationLog(f"Backup: {target}")
|
job = job_manager.create_job("backup", f"Backup: {target}")
|
||||||
self.app.push_screen(log_screen)
|
self.notify("Backup started -- view in Running Tasks")
|
||||||
args = ["backup", f"--target={target}"]
|
args = ["backup", f"--target={target}"]
|
||||||
if remote:
|
if remote:
|
||||||
args.append(f"--remote={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:
|
if rc == 0:
|
||||||
log_screen.write("\n[green]Backup completed successfully.[/green]")
|
self.notify("Backup completed successfully", severity="information")
|
||||||
else:
|
else:
|
||||||
log_screen.write(f"\n[red]Backup failed (exit code {rc}).[/red]")
|
self.notify(f"Backup failed (exit code {rc})", severity="error")
|
||||||
log_screen.finish()
|
|
||||||
|
|
||||||
@work
|
@work
|
||||||
async def _do_backup_all(self) -> None:
|
async def _do_backup_all(self) -> None:
|
||||||
log_screen = OperationLog("Backup All Targets")
|
job = job_manager.create_job("backup", "Backup All Targets")
|
||||||
self.app.push_screen(log_screen)
|
self.notify("Backup All started -- view in Running Tasks")
|
||||||
rc = await stream_cli(log_screen.write, "backup", "--all")
|
rc = await job_manager.run_job(self.app, job, "backup", "--all")
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
log_screen.write("\n[green]All backups completed.[/green]")
|
self.notify("All backups completed", severity="information")
|
||||||
else:
|
else:
|
||||||
log_screen.write(f"\n[red]Backup failed (exit code {rc}).[/red]")
|
self.notify(f"Backup failed (exit code {rc})", severity="error")
|
||||||
log_screen.finish()
|
|
||||||
|
|
||||||
def action_go_back(self) -> None:
|
def action_go_back(self) -> None:
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ LOGO = """\
|
|||||||
MENU_ITEMS = [
|
MENU_ITEMS = [
|
||||||
("backup", "Backup"),
|
("backup", "Backup"),
|
||||||
("restore", "Restore"),
|
("restore", "Restore"),
|
||||||
|
("running_tasks", "Running Tasks"),
|
||||||
("targets", "Targets"),
|
("targets", "Targets"),
|
||||||
("remotes", "Remotes"),
|
("remotes", "Remotes"),
|
||||||
("schedule", "Schedules"),
|
("schedule", "Schedules"),
|
||||||
@@ -50,7 +51,7 @@ class MainMenuScreen(Screen):
|
|||||||
menu_items = []
|
menu_items = []
|
||||||
for mid, label in MENU_ITEMS:
|
for mid, label in MENU_ITEMS:
|
||||||
menu_items.append(Option(label, id=mid))
|
menu_items.append(Option(label, id=mid))
|
||||||
if mid == "restore":
|
if mid == "running_tasks":
|
||||||
menu_items.append(None)
|
menu_items.append(None)
|
||||||
yield OptionList(*menu_items, id="menu-list")
|
yield OptionList(*menu_items, id="menu-list")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ from textual.containers import Vertical, Horizontal
|
|||||||
from textual import work, on
|
from textual import work, on
|
||||||
|
|
||||||
from tui.config import list_conf_dir, parse_conf, CONFIG_DIR
|
from tui.config import list_conf_dir, parse_conf, CONFIG_DIR
|
||||||
from tui.backend import run_cli, stream_cli
|
from tui.backend import run_cli
|
||||||
from tui.widgets import ConfirmDialog, OperationLog, FolderPicker
|
from tui.jobs import job_manager
|
||||||
|
from tui.widgets import ConfirmDialog, FolderPicker
|
||||||
|
|
||||||
|
|
||||||
class RestoreScreen(Screen):
|
class RestoreScreen(Screen):
|
||||||
@@ -144,19 +145,18 @@ class RestoreScreen(Screen):
|
|||||||
|
|
||||||
@work
|
@work
|
||||||
async def _do_restore(self, target: str, remote: str, snapshot: str, dest: str, skip_mysql: bool = False) -> None:
|
async def _do_restore(self, target: str, remote: str, snapshot: str, dest: str, skip_mysql: bool = False) -> None:
|
||||||
log_screen = OperationLog(f"Restore: {target}")
|
job = job_manager.create_job("restore", f"Restore: {target}")
|
||||||
self.app.push_screen(log_screen)
|
self.notify("Restore started -- view in Running Tasks")
|
||||||
args = ["restore", f"--target={target}", f"--remote={remote}", f"--snapshot={snapshot}"]
|
args = ["restore", f"--target={target}", f"--remote={remote}", f"--snapshot={snapshot}"]
|
||||||
if dest:
|
if dest:
|
||||||
args.append(f"--dest={dest}")
|
args.append(f"--dest={dest}")
|
||||||
if skip_mysql:
|
if skip_mysql:
|
||||||
args.append("--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:
|
if rc == 0:
|
||||||
log_screen.write("\n[green]Restore completed successfully.[/green]")
|
self.notify("Restore completed successfully", severity="information")
|
||||||
else:
|
else:
|
||||||
log_screen.write(f"\n[red]Restore failed (exit code {rc}).[/red]")
|
self.notify(f"Restore failed (exit code {rc})", severity="error")
|
||||||
log_screen.finish()
|
|
||||||
|
|
||||||
def action_go_back(self) -> None:
|
def action_go_back(self) -> None:
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|||||||
86
tui/screens/running_tasks.py
Normal file
86
tui/screens/running_tasks.py
Normal 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()
|
||||||
@@ -33,12 +33,15 @@ class OperationLog(ModalScreen[None]):
|
|||||||
|
|
||||||
BINDINGS = [("escape", "close", "Close")]
|
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__()
|
super().__init__()
|
||||||
self._title = title
|
self._title = title
|
||||||
self._show_spinner = show_spinner
|
self._show_spinner = show_spinner
|
||||||
|
self._job_id = job_id
|
||||||
self._mounted_event = asyncio.Event()
|
self._mounted_event = asyncio.Event()
|
||||||
self._buffer: list[str] = []
|
self._buffer: list[str] = []
|
||||||
|
self._poll_timer: Timer | None = None
|
||||||
|
self._last_line_count: int = 0
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Vertical(id="op-log"):
|
with Vertical(id="op-log"):
|
||||||
@@ -50,8 +53,20 @@ class OperationLog(ModalScreen[None]):
|
|||||||
yield SpinnerWidget("arrow3", id="ol-spinner")
|
yield SpinnerWidget("arrow3", id="ol-spinner")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
# Flush any buffered writes
|
|
||||||
log = self.query_one("#ol-log", RichLog)
|
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:
|
for text in self._buffer:
|
||||||
self._write_to_log(log, text)
|
self._write_to_log(log, text)
|
||||||
self._buffer.clear()
|
self._buffer.clear()
|
||||||
@@ -84,3 +99,21 @@ class OperationLog(ModalScreen[None]):
|
|||||||
self._write_to_log(log, text)
|
self._write_to_log(log, text)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._buffer.append(text)
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user