Add Textual TUI for managing Proxmox guests via cv4pve-cli

List VMs and LXC containers from the current cv4pve-cli context and
start, shutdown, stop and reboot them, change cores and memory, and
grow disks. The app shells out to `cv4pve-cli -o json` and stores no
credentials.

- Every mutating call uses --wait: without it cv4pve-cli exits 0 even
  when the task fails.
- shutdown, stop and reboot need confirmation; disk growth is
  increment-only and asks again because it cannot be undone.
- After an action the acted-on guest's status is read from
  status/current, because /cluster/resources lags a few seconds.
- Notifies when a cores/memory change is pending until reboot.

Tests mock cv4pve-cli (85 tests). Live-tested edit, shutdown, start
and reboot on CT 104 (mainframe), restored afterwards; disk resize
was not run live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-02 20:12:05 +01:00
1 parent 6f009c6782
commit f51aff3db5
10 files changed
+1612

No files matched your search

+278
View File
@@ -0,0 +1,278 @@
"""Textual app for managing Proxmox guests through cv4pve-cli."""
from textual import work
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, DataTable, Footer, Header, Input, Select, Static
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 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"),
("g", "reload", "Refresh"),
("q", "quit", "Quit"),
]
def __init__(self) -> None:
super().__init__()
self.guests: dict[str, dict] = {}
self.busy: set[int] = set()
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.action_reload()
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]) -> None:
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."""
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)
@work(thread=True)
def action_reload(self) -> None:
self._reload()
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.busy.discard(guest["vmid"])
self._reload(acted=guest)
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)
@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 _scaled(value, unit: int):
return "-" if value is None else round(value / unit)
def main() -> None:
GuestApp().run()