"""Textual app for managing Proxmox guests through cv4pve-cli.""" import subprocess import sys from datetime import datetime from textual import work from textual.app import App, ComposeResult, SuspendNotSupported from textual.containers import Horizontal, Vertical from textual.screen import ModalScreen from textual.widgets import ( Button, Checkbox, DataTable, Footer, Header, Input, OptionList, Select, Static, ) from textual.widgets.option_list import Option from cv4pve_tui import cli def _message(exc: Exception) -> str: return ( exc.summary if isinstance(exc, cli.CliError) else f"{type(exc).__name__}: {exc}" ) class ConfirmScreen(ModalScreen[bool]): BINDINGS = [("escape", "dismiss(False)", "No")] # noqa: RUF012 - Textual idiom def __init__(self, message: str) -> None: super().__init__() self.message = message def compose(self) -> ComposeResult: with Vertical(): yield Static(self.message) with Horizontal(): yield Button("Yes", id="yes", variant="error") yield Button("No", id="no") def on_button_pressed(self, event: Button.Pressed) -> None: self.dismiss(event.button.id == "yes") class EditScreen(ModalScreen): """Dismisses with a dict of validated values, or None when cancelled.""" BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom def __init__(self, guest: dict, config: dict, disks: dict[str, str]) -> None: super().__init__() self.guest, self.config, self.disks = guest, config, disks def compose(self) -> ComposeResult: with Vertical(): yield Static( f"Edit {self.guest['name']} ({self.guest['vmid']}); empty = unchanged" ) yield Input( placeholder=f"cores (now {self.config.get('cores', '?')})", id="cores" ) yield Input( placeholder=f"memory MiB (now {self.config.get('memory', '?')})", id="memory", ) yield Select( [(f"{k} ({v})", k) for k, v in self.disks.items()], prompt="disk", id="disk", ) yield Input(placeholder="grow by GiB", id="grow") with Horizontal(): yield Button("Apply", id="apply", variant="primary") yield Button("Cancel", id="cancel") def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "cancel": self.dismiss(None) return try: values = {name: self._number(name) for name in ("cores", "memory", "grow")} except ValueError as exc: self.app.notify(str(exc), severity="error") return disk = self.query_one("#disk", Select).value if values["grow"] is not None and disk is Select.NULL: self.app.notify("Choose a disk to grow", severity="error") return values["disk"] = None if disk is Select.NULL else disk self.dismiss(values) def _number(self, name: str) -> int | None: text = self.query_one(f"#{name}", Input).value.strip() if not text: return None if not text.isdecimal() or int(text) <= 0: raise ValueError(f"{name} must be a positive integer") return int(text) class ContextScreen(ModalScreen[str | None]): """Dismisses with the chosen context name, or None when cancelled.""" BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom def __init__(self, contexts: list[dict]) -> None: super().__init__() self.contexts = contexts def compose(self) -> ComposeResult: with Vertical(): yield OptionList( *( Option( f"{'*' if c['current'] else ' '} {c['name']} {c['host']}", id=c["name"], ) for c in self.contexts ), id="contexts", ) def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: self.dismiss(event.option.id) class BackupScreen(ModalScreen[str | None]): """Dismisses with the chosen storage, or None when cancelled.""" BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom def __init__(self, guest: dict, storages: list[str], chosen: str) -> None: super().__init__() self.guest, self.storages, self.chosen = guest, storages, chosen def compose(self) -> ComposeResult: with Vertical(): yield Static( f"Back up {self.guest['name']} ({self.guest['vmid']}) now: mode snapshot, zstd" ) yield Select( [(s, s) for s in self.storages], value=self.chosen, allow_blank=False, id="storage", ) with Horizontal(): yield Button("Yes", id="yes", variant="primary") yield Button("No", id="no") def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "yes": self.dismiss(self.query_one("#storage", Select).value) else: self.dismiss(None) _IPV4_MODES = [("DHCP", "dhcp"), ("Static", "static"), ("None", "none")] _IPV6_MODES = [("SLAAC", "auto"), *_IPV4_MODES] class NetScreen(ModalScreen): """Edit bridge, VLAN, firewall and IP of one interface; confirms, then applies.""" BINDINGS = [("escape", "dismiss", "Cancel")] # noqa: RUF012 - Textual idiom DEFAULT_CSS = "NetScreen Horizontal { height: auto; }" def __init__( self, guest: dict, config: dict, bridges: list[str], running: bool ) -> None: super().__init__() self.guest, self.config, self.running = guest, config, running self.nets = cli.nets(config) self.is_vm = cli.kind(guest) == "vm" self.ip_enabled = not self.is_vm or cli.has_cloudinit(config) # a bridge the node no longer lists must stay selectable, or Select rejects the prefill used = {n.get("bridge") for n in self.nets.values()} - {None} self.bridges = sorted(set(bridges) | used) def compose(self) -> ComposeResult: with Vertical(): yield Static( f"Network of {self.guest['name']} ({self.guest['vmid']})", markup=False ) yield Select( [(k, k) for k in self.nets], value=next(iter(self.nets)), allow_blank=False, id="iface", ) with Horizontal(): yield Select( [(b, b) for b in self.bridges], allow_blank=False, id="bridge" ) yield Input(placeholder="VLAN tag (empty = none)", id="tag") yield Checkbox("Firewall", id="firewall") if not self.ip_enabled: yield Static( "IP is configured inside the VM (no cloud-init drive)", id="ip_note" ) for fam, modes in (("ipv4", _IPV4_MODES), ("ipv6", _IPV6_MODES)): with Horizontal(): yield Select(modes, allow_blank=False, id=fam) yield Input(placeholder="address/prefix", id=f"{fam}_cidr") yield Input(placeholder="gateway", id=f"{fam}_gw") with Horizontal(): yield Button("Apply", id="apply", variant="primary") yield Button("Cancel", id="cancel") def on_mount(self) -> None: for wid in ("ipv4", "ipv4_cidr", "ipv4_gw", "ipv6", "ipv6_cidr", "ipv6_gw"): self.query_one(f"#{wid}").disabled = not self.ip_enabled self._prefill() def on_select_changed(self, event: Select.Changed) -> None: if event.select.id == "iface": self._prefill() def _ip_cfg(self, iface: str) -> dict: """The dict holding the IP keys: the VM's ipconfigN, or the LXC net itself.""" if self.is_vm: return cli.parse_net(str(self.config.get(f"ipconfig{iface[3:]}", ""))) return self.nets[iface] def _prefill(self) -> None: iface = self.query_one("#iface", Select).value net = self.nets[iface] self.query_one("#bridge", Select).value = net.get("bridge", self.bridges[0]) self.query_one("#tag", Input).value = net.get("tag", "") self.query_one("#firewall", Checkbox).value = net.get("firewall") == "1" for fam, family in (("ipv4", 4), ("ipv6", 6)): mode, cidr, gw = cli.ip_mode(self._ip_cfg(iface), family) self.query_one(f"#{fam}", Select).value = mode self.query_one(f"#{fam}_cidr", Input).value = cidr self.query_one(f"#{fam}_gw", Input).value = gw def _changes(self) -> tuple[dict, dict]: """(changes, old) as raw netN/ipconfigN strings, None = absent; ValueError on bad input.""" iface = self.query_one("#iface", Select).value text = self.query_one("#tag", Input).value.strip() if text and not text.isdecimal(): raise ValueError("VLAN tag must be 1-4094") current = self.nets[iface] link = ( self.query_one("#bridge", Select).value, int(text) if text else None, self.query_one("#firewall", Checkbox).value, ) was = (current.get("bridge"), int(current["tag"]) if current.get("tag") else None, current.get("firewall") == "1") # fmt: skip # untouched fields are not rewritten (e.g. firewall=0 stays as it is) net = cli.apply_link(current, *link) if link != was else dict(current) ip_key = f"ipconfig{iface[3:]}" ip_old = self._ip_cfg(iface) if self.is_vm else {} ip = ip_old if self.is_vm else net # the dict that holds the IP keys if self.ip_enabled: for fam, family in (("ipv4", 4), ("ipv6", 6)): mode, cidr, gw = ( self.query_one(f"#{fam}{s}").value for s in ("", "_cidr", "_gw") ) wanted = (mode, cidr.strip(), gw.strip()) if wanted != cli.ip_mode( ip, family ): # untouched: e.g. ip6=manual stays ip = cli.apply_ip(ip, family, *wanted) if not self.is_vm: net = ip changes, old = {}, {} if net != self.nets[iface]: changes[iface] = cli.format_net(net) old[iface] = str(self.config[iface]) if self.is_vm and ip != ip_old: changes[ip_key] = cli.format_net(ip) or None old[ip_key] = self.config.get(ip_key) return changes, old def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "cancel": self.dismiss() return try: changes, old = self._changes() except ValueError as exc: self.app.notify(str(exc), severity="error") return if not changes: self.app.notify("No change") return lines = [ f"{k}\n from {old[k] or '(not set)'}\n to {v or '(removed)'}" for k, v in changes.items() ] notes = [] if self.running and any(k.startswith("net") for k in changes): notes.append( "Link changes apply immediately and can cut the guest's network." ) if ( self.running and not self.is_vm and any(_ip_changed(old[k], v) for k, v in changes.items()) ): # seen live: static -> dhcp left the CT without IPv4 until it was rebooted notes.append( "IP changes on a running container may need a reboot of the container " "(e.g. DHCP is not restarted)." ) if any(k.startswith("ipconfig") for k in changes): notes.append("IP settings (cloud-init) apply at next boot.") who = f"{self.guest['name']} ({self.guest['vmid']})" self.app.push_screen( ConfirmScreen("\n".join([f"Change network of {who}?", *lines, *notes])), lambda ok: ( self.app.start_net_change(self.guest, changes, old, self) if ok else None ), ) class InfoScreen(ModalScreen): """Backups, backup job and network of one guest (read-only), plus backup now.""" BINDINGS = [ # noqa: RUF012 - Textual idiom ("escape", "dismiss", "Close"), ("g", "reload", "Refresh"), ("b", "backup", "Backup now"), ("a", "toggle_all", "All backups"), ("n", "net", "Edit network"), ("u", "undo", "Undo network"), ] DEFAULT_CSS = "InfoScreen DataTable { height: auto; max-height: 12; }" def __init__(self, guest: dict) -> None: super().__init__() self.guest = guest self.storages: list[str] = [] self.jobs: list[dict] = [] self.backups: list[dict] = [] self.show_all = False self.config: dict = {} self.running = False def compose(self) -> ComposeResult: with Vertical(): yield Static(f"{self.guest['name']} ({self.guest['vmid']})", markup=False) yield Static("", id="jobs", markup=False) yield DataTable(id="backups") yield Static("", id="hidden", markup=False) yield DataTable(id="nets") yield Static("", id="unavailable", markup=False) yield DataTable(id="addresses") yield Footer() def on_mount(self) -> None: self.query_one("#backups", DataTable).add_columns( "Date", "Storage", "Size (GiB)", "Format", "Notes" ) self.query_one("#nets", DataTable).add_columns( "Iface", "Bridge", "VLAN", "MAC", "IPv4", "IPv6", "Firewall" ) self.query_one("#addresses", DataTable).add_columns( "Interface", "MAC", "IPv4", "IPv6" ) self.action_reload() def action_reload(self) -> None: self._load_worker() @work(thread=True) def _load_worker(self) -> None: self.load() def load(self) -> None: """Runs in a worker thread.""" guest = self.guest try: config = cli.guest_config(guest) nets = cli.nets(config) storages = cli.backup_storages(guest) jobs = cli.backup_jobs() backups = cli.backups(guest, storages) except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app self.app.call_from_thread(self.app.notify, _message(exc), severity="error") return addresses, unavailable = [], "" try: # live status: the guest table's status may be stale running = cli.status(guest) == "running" except Exception as exc: # noqa: BLE001 - shown inline, the rest of the screen still works running, unavailable = False, f"unavailable: {_message(exc)}" if running: try: addresses = cli.interfaces(guest) except Exception as exc: # noqa: BLE001 - shown inline, the rest of the screen still works unavailable = f"unavailable: {_message(exc)}" self.running = running self.app.call_from_thread( self._show, config, nets, storages, jobs, backups, addresses, unavailable ) def _show( self, config, nets, storages, jobs, backups, addresses, unavailable ) -> None: if not self.is_attached: # closed while loading return try: self._fill_screen( config, nets, storages, jobs, backups, addresses, unavailable ) except Exception as exc: # noqa: BLE001 - odd API data must not kill the app self.app.notify(f"info: {_message(exc)}", severity="error") def _fill_screen( self, config, nets, storages, jobs, backups, addresses, unavailable ) -> None: self.storages, self.jobs, self.config = storages, jobs, config covering = cli.covering_jobs(jobs, self.guest) self.query_one("#jobs", Static).update( "; ".join(_job_text(j) for j in covering) or "No backup job covers this guest" ) self.backups = backups self._show_backups() table = self.query_one("#nets", DataTable).clear() for name, net in nets.items(): if cli.kind(self.guest) == "vm": # IP settings are cloud-init's ipconfigN mac = next(iter(net.values()), "-") ip = cli.parse_net(str(config.get(f"ipconfig{name[3:]}", ""))) else: mac, ip = net.get("hwaddr", "-"), net table.add_row( name, net.get("bridge", "-"), net.get("tag", "-"), mac, ip.get("ip", "-"), ip.get("ip6", "-"), "yes" if net.get("firewall") == "1" else "no", ) self.query_one("#unavailable", Static).update(unavailable) table = self.query_one("#addresses", DataTable).clear() for i in addresses: table.add_row( i["name"], i["mac"] or "-", ", ".join(i["ipv4"]), ", ".join(i["ipv6"]) ) def _show_backups(self) -> None: name = self.guest["name"] shown = [ b for b in self.backups if self.show_all or not _earlier_guest(b, name) ] table = self.query_one("#backups", DataTable).clear() for b in shown: table.add_row( datetime.fromtimestamp(b["ctime"]).strftime("%Y-%m-%d %H:%M") if b.get("ctime") else "-", b["storage"], f"{b['size'] / 2**30:.1f}" if b.get("size") is not None else "-", b.get("format") or "-", (b.get("notes") or "") + (" (earlier guest)" if _earlier_guest(b, name) else ""), ) hidden = len(self.backups) - len(shown) self.query_one("#hidden", Static).update( f"{hidden} backup(s) of an earlier guest with this VMID hidden - press a to show" if hidden else "" ) def action_toggle_all(self) -> None: self.show_all = not self.show_all self._show_backups() def _refuse_if_busy(self) -> bool: busy = self.guest["vmid"] in self.app.busy if busy: self.app.notify( f"{self.guest['name']} ({self.guest['vmid']}) is busy", severity="warning", ) return busy def action_net(self) -> None: if cli.nets(self.config) and not self._refuse_if_busy(): self._open_net() @work(thread=True) def _open_net(self) -> None: try: bridges = cli.bridges(self.guest) except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app self.app.call_from_thread(self.app.notify, _message(exc), severity="error") return self.app.call_from_thread( self.app.push_screen, NetScreen(self.guest, self.config, bridges, self.running), ) def action_undo(self) -> None: old = self.app.undo.get(self.guest["vmid"]) if old is None: self.app.notify("No network change to undo") return if self._refuse_if_busy(): return lines = [f"{k} = {v or '(remove)'}" for k, v in old.items()] self.app.push_screen( ConfirmScreen( "\n".join(["Restore the previous network settings?", *lines]) ), lambda ok: self.app.start_net_change(self.guest, old, None) if ok else None, ) def action_backup(self) -> None: if not self.storages: self.app.notify("No backup storage available", severity="warning") return chosen = cli.job_storage(self.jobs, self.guest) if chosen not in self.storages: chosen = self.storages[0] self.app.push_screen( BackupScreen(self.guest, self.storages, chosen), lambda storage: ( self.app.start_backup(self.guest, storage) if storage else None ), ) def _ip_changed(old: str | None, new: str | None) -> bool: before, after = cli.parse_net(old or ""), cli.parse_net(new or "") return any(before.get(k) != after.get(k) for k in ("ip", "gw", "ip6", "gw6")) def _earlier_guest(backup: dict, name: str) -> bool: """VMIDs are reused: notes (the job's {{guestname}}) naming another guest mean a deleted one.""" notes = backup.get("notes") or "" return bool(notes) and notes != name and not notes.startswith(f"{name} ") def _job_text(job: dict) -> str: keep = (job.get("prune-backups") or {}).get("keep-last") return f"{job['schedule']} to {job['storage']}" + ( f", keep-last {keep}" if keep else "" ) class GuestApp(App): BINDINGS = [ # noqa: RUF012 - Textual idiom ("s", "act('start')", "Start"), ("S", "act('shutdown')", "Shutdown"), ("x", "act('stop')", "Stop"), ("r", "act('reboot')", "Reboot"), ("e", "edit", "Edit"), ("i", "info", "Info"), ("t", "shell", "Shell"), ("g", "reload", "Refresh"), ("c", "context", "Context"), ("q", "quit", "Quit"), ] def __init__(self) -> None: super().__init__() self.guests: dict[str, dict] = {} self.busy: set[int] = set() self.undo: dict[int, dict] = {} # vmid -> values before the last network change # bumped on every context switch; replies started earlier come from the old cluster self.generation = 0 self.switching = False def compose(self) -> ComposeResult: yield Header() yield DataTable(id="guests", cursor_type="row") yield Footer() def on_mount(self) -> None: table = self.query_one("#guests", DataTable) table.add_columns( "VMID", "Name", "Type", "Node", "Status", "Cores", "Memory (MiB)", "Disk (GiB)", ) self._show_context() self.action_reload() @work(thread=True) def _show_context(self) -> None: self._read_context() def _read_context(self) -> None: """Runs in a worker thread: show the CLI's current context in the Header.""" gen = self.generation try: name = next(c["name"] for c in cli.contexts() if c["current"]) except StopIteration: name = "?" except Exception as exc: # noqa: BLE001 - any failure must reach the user, not kill the app self.call_from_thread(self.notify, _message(exc), severity="error") name = "?" self.call_from_thread(self._set_context_title, name, gen) def _set_context_title(self, name: str, gen: int) -> None: if gen == self.generation: self.sub_title = f"context: {name}" def _selected(self) -> dict | None: table = self.query_one("#guests", DataTable) if not table.row_count: return None return self.guests[ table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value ] def _fill(self, guests: list[dict], gen: int) -> None: if gen != self.generation: # listed before a context switch: old cluster return table = self.query_one("#guests", DataTable) current = self._selected() table.clear() self.guests = {str(g["vmid"]): g for g in guests} for g in guests: table.add_row( g["vmid"], g["name"], g["type"], g["node"], g["status"], g.get("maxcpu", "-"), _scaled(g.get("maxmem"), 2**20), _scaled(g.get("maxdisk"), 2**30), key=str(g["vmid"]), ) # fmt: skip if current and str(current["vmid"]) in self.guests: table.move_cursor(row=table.get_row_index(str(current["vmid"]))) def _reload(self, acted: dict | None = None) -> None: """Runs in a worker thread. `acted` gets its live status: the listing lags.""" gen = self.generation try: guests = cli.list_guests() for g in guests: if acted and g["vmid"] == acted["vmid"]: g["status"] = cli.status(g) except Exception as exc: # noqa: BLE001 - any failure must reach the user, not kill the app self.call_from_thread(self.notify, _message(exc), severity="error") return self.call_from_thread(self._fill, guests, gen) def action_reload(self) -> None: if self.switching: self.notify("Switching context; refresh after it ends", severity="warning") return self._reload_worker() @work(thread=True) def _reload_worker(self) -> None: self._reload() def action_context(self) -> None: if self.busy or self.switching: self.notify( "Busy (guest action or context switch); try again when it ends", severity="warning", ) return self._open_context() @work(thread=True) def _open_context(self) -> None: try: contexts = cli.contexts() except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app self.call_from_thread(self.notify, _message(exc), severity="error") return current = next((c["name"] for c in contexts if c["current"]), None) self.call_from_thread( self.push_screen, ContextScreen(contexts), lambda name: self._context_chosen(name, current), ) def _context_chosen(self, name: str | None, current: str | None) -> None: if name is None or name == current: return self.query_one( "#guests", DataTable ).clear() # old cluster's guests: not actionable self.guests = {} self.undo.clear() # VMIDs repeat across clusters: never undo onto another cluster self.generation += 1 self.switching = True self._switch_context(name) @work(thread=True) def _switch_context(self, name: str) -> None: try: try: cli.use_context(name) except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app self.call_from_thread(self.notify, _message(exc), severity="error") self._read_context() else: self.call_from_thread(setattr, self, "sub_title", f"context: {name}") self._reload() finally: self.call_from_thread(setattr, self, "switching", False) def _start_job(self, guest: dict, label: str, job) -> None: if guest["vmid"] in self.busy: self.notify( f"{guest['name']} ({guest['vmid']}) is busy", severity="warning" ) return self.busy.add(guest["vmid"]) self.notify(f"{label} {guest['name']} ({guest['vmid']}) ...") self._run_job(guest, label, job) @work(thread=True) def _run_job(self, guest: dict, label: str, job) -> None: try: job() except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app self.call_from_thread( self.notify, f"{label} failed: {_message(exc)}", severity="error" ) else: self.call_from_thread( self.notify, f"{label} done: {guest['name']} ({guest['vmid']})" ) finally: self._reload( acted=guest ) # before un-busying, so no context switch can start mid-reload self.busy.discard(guest["vmid"]) def action_act(self, name: str) -> None: guest = self._selected() if guest is None: return run = lambda: cli.action(name, guest) if name == "start": self._start_job(guest, name, run) return who = f"{guest['name']} ({guest['vmid']})" text = f"{name.capitalize()} {who}?" if name == "stop": text = f"Hard power-off {who}? The guest is killed without shutting down." self.push_screen( ConfirmScreen(text), lambda ok: self._start_job(guest, name, run) if ok else None, ) def action_edit(self) -> None: guest = self._selected() if guest is None: return if guest["vmid"] in self.busy: self.notify( f"{guest['name']} ({guest['vmid']}) is busy", severity="warning" ) return self._open_edit(guest) def action_info(self) -> None: guest = self._selected() if guest is not None: self.push_screen(InfoScreen(guest)) def start_backup(self, guest: dict, storage: str) -> None: who = f"{guest['name']} ({guest['vmid']})" if guest["vmid"] in self.busy: self.notify(f"{who} is busy", severity="warning") return self.busy.add(guest["vmid"]) self.notify(f"Backup started: {who} to {storage}") self._run_backup(guest, storage) @work(thread=True) def _run_backup(self, guest: dict, storage: str) -> None: who = f"{guest['name']} ({guest['vmid']})" try: cli.backup_now(guest, storage) except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app self.call_from_thread( self.notify, f"Backup failed: {_message(exc)}", severity="error" ) else: self.call_from_thread(self.notify, f"Backup done: {who}") finally: self._reload_infos(guest) self.busy.discard(guest["vmid"]) def _reload_infos(self, guest: dict) -> None: """Runs in a worker thread: reload the guest's open InfoScreens (call while still busy).""" for screen in list(self.screen_stack): if isinstance(screen, InfoScreen) and screen.guest["vmid"] == guest["vmid"]: screen.load() def start_net_change( self, guest: dict, changes: dict, entry: dict | None, screen=None ) -> None: """`entry` becomes the guest's undo entry on success (None clears it). `screen` (the NetScreen) is closed on success and stays open on failure. """ who = f"{guest['name']} ({guest['vmid']})" if guest["vmid"] in self.busy: self.notify(f"{who} is busy", severity="warning") return self.busy.add(guest["vmid"]) self.notify(f"Network change started: {who}") self._run_net(guest, changes, entry, screen) @work(thread=True) def _run_net(self, guest: dict, changes: dict, entry: dict | None, screen) -> None: who = f"{guest['name']} ({guest['vmid']})" done = False try: cli.set_net(guest, changes) except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app self.call_from_thread( self.notify, f"Network change failed: {_message(exc)}", severity="error" ) else: done = True if entry is None: self.undo.pop(guest["vmid"], None) else: self.undo[guest["vmid"]] = entry self.call_from_thread(self.notify, f"Network change done: {who}") finally: self._reload_infos(guest) if done and screen is not None: self.call_from_thread(self._close_net, screen) self.busy.discard(guest["vmid"]) def _close_net(self, screen) -> None: if self.screen is screen: # the user may have left it already screen.dismiss() def action_shell(self) -> None: guest = self._selected() if guest is None: return who = f"{guest['name']} ({guest['vmid']})" if guest["type"] != "lxc": self.notify(f"{who} is not a container", severity="warning") return if guest["status"] != "running": self.notify(f"{who} is not running", severity="warning") return if guest["vmid"] in self.busy: self.notify(f"{who} is busy", severity="warning") return cmd = ["ssh", "-t", f"root@{guest['node']}", "pct", "enter", str(guest["vmid"])] error = None try: with self.suspend(): try: # inside: Textual's suspend() skips the resume if its body raises _reset_terminal() code = subprocess.run(cmd, check=False).returncode except OSError as exc: error = _message(exc) except SuspendNotSupported as exc: error = _message(exc) else: if ( error is None and code == 255 ): # other codes are the shell's own last status error = "ssh failed (exit 255)" if error: self.notify(f"Shell in {who} failed: {error}", severity="error") self.action_reload() @work(thread=True) def _open_edit(self, guest: dict) -> None: try: config = cli.guest_config(guest) disks = cli.disks(config, cli.kind(guest)) except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app self.call_from_thread(self.notify, _message(exc), severity="error") return self.call_from_thread( self.push_screen, EditScreen(guest, config, disks), lambda v: self._edit_done(guest, v), ) def _edit_done(self, guest: dict, values: dict | None) -> None: if not values or not any(values[k] for k in ("cores", "memory", "grow")): return if values["grow"]: who = f"{guest['name']} ({guest['vmid']})" self.push_screen( ConfirmScreen( f"Grow {values['disk']} of {who} by {values['grow']} GiB? This cannot be undone." ), lambda ok: ( self._apply(guest, values) if ok else None ), # "No" cancels the whole edit ) else: self._apply(guest, values) def _apply(self, guest: dict, values: dict) -> None: cores, memory, grow = values["cores"], values["memory"], values["grow"] def job() -> None: if cores or memory: cli.set_resources(guest, cores=cores, memory_mb=memory) if grow: cli.resize_disk(guest, values["disk"], grow) if cores or memory: try: pending = cli.pending_keys(guest) except Exception as exc: # noqa: BLE001 - only a notice; the change itself succeeded self.call_from_thread( self.notify, f"pending check failed: {_message(exc)}", severity="warning", ) return if {"cores", "memory"} & set(pending): self.call_from_thread(self.notify, "Change applies at next reboot") self._start_job(guest, "edit", job) def _reset_terminal() -> None: """Full terminal reset (ESC c, what `reset` sends): clean screen, no leftover TUI modes.""" sys.stdout.write("\033c") sys.stdout.flush() def _scaled(value, unit: int): return "-" if value is None else round(value / unit) def main() -> None: GuestApp().run()