Files
cv4pve-cli-tui/src/cv4pve_tui/app.py
T
DuckbootandClaude Opus 5.5 5038148686 Add network editing with VLAN, firewall, IPv4/IPv6 and undo
Press n in the info screen to edit one interface: bridge, VLAN tag,
firewall, IPv4 (DHCP, static or none) and IPv6 (SLAAC, DHCP, static or
none). The netN string is parsed, the chosen fields are changed and
the string is written back, so hwaddr, model=MAC, mtu, rate, trunks
and type are kept. For VMs, IP settings go to cloud-init ipconfigN and
only when the VM has a cloud-init drive.

A confirm shows the old and new raw values before anything is sent.
Press u to restore the values from before the last change to that
guest in this session.

Review fixes:
- Undo entries are cleared on a context switch, because VMIDs repeat
  across clusters.
- has_cloudinit only looks at disks.
- CIDR needs a decimal prefix: no netmask form and no /0.
- Fields you did not touch are not rewritten.

Live test on CT 104: firewall, VLAN 99, IPv6 DHCP and static IPv4 were
each applied and undone, and net0 ended identical to the baseline.
Going from static back to DHCP left the CT without IPv4 until a
reboot, so the confirm now warns that IP changes on a running
container may need one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:45:06 +01:00

945 lines
35 KiB
Python

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