Add context listing and switching to the TUI

Press c to list cv4pve-cli contexts and switch between them. The
active context is always shown in the header.

- Switching runs `cv4pve-cli config use`, which changes the global
  current context. cv4pve-cli 2.4.0 has no per-call context option.
- `config list` has no JSON output, so its text lines are parsed.
- The guest table is cleared as soon as another context is chosen, so
  the old cluster's guests cannot be acted on.
- A generation counter drops guest listings that started before a
  switch, and g and c are refused while a switch runs. Without this,
  a reload already in flight could refill the table with the old
  cluster's guests, and VMIDs collide across clusters.

103 tests pass. The switch was tested live from mainframe to
mainframe-edge and back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #1.
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-02 20:22:53 +01:00
1 parent f51aff3db5
commit 62b79ab29e
6 files changed
+543 -9

No files matched your search

+125 -5
View File
@@ -4,7 +4,17 @@ 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 textual.widgets import (
Button,
DataTable,
Footer,
Header,
Input,
OptionList,
Select,
Static,
)
from textual.widgets.option_list import Option
from cv4pve_tui import cli
@@ -89,6 +99,32 @@ class EditScreen(ModalScreen):
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 GuestApp(App):
BINDINGS = [ # noqa: RUF012 - Textual idiom
("s", "act('start')", "Start"),
@@ -97,6 +133,7 @@ class GuestApp(App):
("r", "act('reboot')", "Reboot"),
("e", "edit", "Edit"),
("g", "reload", "Refresh"),
("c", "context", "Context"),
("q", "quit", "Quit"),
]
@@ -104,6 +141,9 @@ class GuestApp(App):
super().__init__()
self.guests: dict[str, dict] = {}
self.busy: set[int] = set()
# 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()
@@ -122,8 +162,29 @@ class GuestApp(App):
"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:
@@ -132,7 +193,9 @@ class GuestApp(App):
table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
]
def _fill(self, guests: list[dict]) -> None:
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()
@@ -148,6 +211,7 @@ class GuestApp(App):
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:
@@ -156,12 +220,66 @@ class GuestApp(App):
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)
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 action_reload(self) -> None:
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.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(
@@ -185,8 +303,10 @@ class GuestApp(App):
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"])
self._reload(acted=guest)
def action_act(self, name: str) -> None:
guest = self._selected()
+18
View File
@@ -138,3 +138,21 @@ def status(guest: dict) -> str:
return {
i["key"]: i["value"] for i in json.loads(run("api", "get", path, "-o", "json"))
}["status"]
_CONTEXT_LINE = re.compile(r"^(\*)?\s*(\S+)\s+(\S+)\s+\((.*)\)\s*$")
def contexts() -> list[dict]:
"""Parse `config list` (text only); lines that do not look like a context are skipped."""
found = (_CONTEXT_LINE.match(line) for line in run("config", "list").splitlines())
return [
{"name": m[2], "host": m[3], "auth": m[4], "current": bool(m[1])}
for m in found
if m
]
def use_context(name: str) -> None:
"""Changes cv4pve-cli's GLOBAL current context."""
run("config", "use", name)