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:
1 parent
f51aff3db5
commit
62b79ab29e
6 files changed
+543
-9
No files matched your search
+125
-5
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user