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>
945 lines
35 KiB
Python
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()
|