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
+284
-4
@@ -8,10 +8,10 @@ import threading
|
||||
|
||||
import pytest
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, DataTable, Input, Select, Static
|
||||
from textual.widgets import Button, DataTable, Input, OptionList, Select, Static
|
||||
|
||||
from cv4pve_tui import cli
|
||||
from cv4pve_tui.app import ConfirmScreen, EditScreen, GuestApp
|
||||
from cv4pve_tui.app import ConfirmScreen, ContextScreen, EditScreen, GuestApp
|
||||
|
||||
VM = {"vmid": 108, "name": "docker", "node": "mainframe", "type": "qemu", "status": "running",
|
||||
"maxcpu": 4, "maxmem": 8192 * 2**20, "maxdisk": 10 * 2**30} # fmt: skip
|
||||
@@ -31,10 +31,24 @@ class Fake:
|
||||
self.action_error = None
|
||||
self.action_hook = None
|
||||
self.live_status, self.status_calls = {}, []
|
||||
self.current = "mainframe"
|
||||
self.names = ["mainframe", "mainframe-edge"]
|
||||
self.contexts_error = None
|
||||
self.contexts_calls = 0
|
||||
self.use_calls = []
|
||||
self.use_error = None
|
||||
self.use_hook = None
|
||||
self.guests_by_context = {}
|
||||
self.list_hook = None
|
||||
|
||||
def list_guests(self):
|
||||
self.list_calls += 1
|
||||
return list(self.guests)
|
||||
snapshot = list(self.guests_by_context.get(self.current, self.guests))
|
||||
if (
|
||||
self.list_hook
|
||||
): # runs after reading: simulates a slow reply from that cluster
|
||||
self.list_hook()
|
||||
return snapshot
|
||||
|
||||
def guest_config(self, guest):
|
||||
return {"cores": guest["maxcpu"]}
|
||||
@@ -64,12 +78,30 @@ class Fake:
|
||||
self.status_calls.append(guest["vmid"])
|
||||
return self.live_status.get(guest["vmid"], guest["status"])
|
||||
|
||||
def contexts(self):
|
||||
self.contexts_calls += 1
|
||||
if self.contexts_error:
|
||||
raise self.contexts_error
|
||||
return [
|
||||
{"name": n, "host": f"{n}.home.arpa:8006", "auth": "api-token",
|
||||
"current": n == self.current}
|
||||
for n in self.names
|
||||
] # fmt: skip
|
||||
|
||||
def use_context(self, name):
|
||||
self.use_calls.append(name)
|
||||
if self.use_hook:
|
||||
self.use_hook()
|
||||
if self.use_error:
|
||||
raise self.use_error
|
||||
self.current = name
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake(monkeypatch):
|
||||
f = Fake()
|
||||
for name in ("list_guests", "guest_config", "disks", "action", "set_resources",
|
||||
"resize_disk", "pending_keys", "status"): # fmt: skip
|
||||
"resize_disk", "pending_keys", "status", "contexts", "use_context"): # fmt: skip
|
||||
monkeypatch.setattr(cli, name, getattr(f, name))
|
||||
return f
|
||||
|
||||
@@ -447,3 +479,251 @@ async def test_table_shows_live_status_after_action_not_lagging_list(fake):
|
||||
assert "stopped" in [
|
||||
str(c) for c in app.query_one("#guests", DataTable).get_row("108")
|
||||
]
|
||||
|
||||
|
||||
# --- context switching ------------------------------------------------------
|
||||
# spec: docs/plans/guest-tui-mvp.md ("Context switching" > "app.py (UI contract)")
|
||||
|
||||
|
||||
async def open_contexts(app, pilot):
|
||||
await settle(app, pilot)
|
||||
await pilot.press("c")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, ContextScreen)
|
||||
return app.screen.query_one("#contexts", OptionList)
|
||||
|
||||
|
||||
async def choose(app, pilot, index):
|
||||
ol = app.screen.query_one("#contexts", OptionList)
|
||||
ol.focus()
|
||||
ol.highlighted = index
|
||||
await pilot.pause()
|
||||
await pilot.press("enter")
|
||||
await settle(app, pilot)
|
||||
|
||||
|
||||
async def test_sub_title_shows_active_context_after_mount(fake):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await settle(app, pilot)
|
||||
assert app.sub_title == "context: mainframe"
|
||||
|
||||
|
||||
async def test_contexts_failure_notifies_and_sub_title_is_unknown(fake):
|
||||
fake.contexts_error = cli.CliError("Error: no config")
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await settle(app, pilot)
|
||||
assert errors(app)
|
||||
assert app.sub_title == "context: ?"
|
||||
|
||||
|
||||
async def test_c_opens_context_screen_listing_contexts_with_current_marked(fake):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
ol = await open_contexts(app, pilot)
|
||||
assert ol.option_count == 2
|
||||
options = [ol.get_option_at_index(i) for i in range(2)]
|
||||
assert [o.id for o in options] == ["mainframe", "mainframe-edge"]
|
||||
assert "*" in str(options[0].prompt)
|
||||
assert "*" not in str(options[1].prompt)
|
||||
assert "mainframe-edge" in str(options[1].prompt)
|
||||
|
||||
|
||||
async def test_selecting_other_context_switches_and_reloads(fake):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_contexts(app, pilot)
|
||||
before = fake.list_calls
|
||||
await choose(app, pilot, 1)
|
||||
assert fake.use_calls == ["mainframe-edge"]
|
||||
assert app.sub_title == "context: mainframe-edge"
|
||||
assert fake.list_calls > before
|
||||
assert not isinstance(app.screen, ModalScreen)
|
||||
assert app.query_one("#guests", DataTable).row_count == 2
|
||||
|
||||
|
||||
async def test_table_is_cleared_while_switch_is_in_progress(fake):
|
||||
started, release = threading.Event(), threading.Event()
|
||||
fake.use_hook = lambda: (started.set(), release.wait(10))
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_contexts(app, pilot)
|
||||
assert app.query_one("#guests", DataTable).row_count == 2
|
||||
try:
|
||||
ol = app.screen.query_one("#contexts", OptionList)
|
||||
ol.focus()
|
||||
ol.highlighted = 1
|
||||
await pilot.pause()
|
||||
await pilot.press("enter")
|
||||
for _ in range(200):
|
||||
if started.is_set():
|
||||
break
|
||||
await pilot.pause(0.01)
|
||||
assert started.is_set()
|
||||
await pilot.pause()
|
||||
assert app.query_one("#guests", DataTable).row_count == 0
|
||||
finally:
|
||||
release.set()
|
||||
await settle(app, pilot)
|
||||
assert app.query_one("#guests", DataTable).row_count == 2
|
||||
|
||||
|
||||
async def test_selecting_current_context_makes_no_cli_call(fake):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_contexts(app, pilot)
|
||||
before = fake.list_calls
|
||||
await choose(app, pilot, 0)
|
||||
assert fake.use_calls == []
|
||||
assert not isinstance(app.screen, ModalScreen)
|
||||
assert app.sub_title == "context: mainframe"
|
||||
assert fake.list_calls == before
|
||||
|
||||
|
||||
async def test_escape_closes_context_screen_without_cli_call(fake):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_contexts(app, pilot)
|
||||
await pilot.press("escape")
|
||||
await settle(app, pilot)
|
||||
assert not isinstance(app.screen, ModalScreen)
|
||||
assert fake.use_calls == []
|
||||
assert app.sub_title == "context: mainframe"
|
||||
|
||||
|
||||
async def test_c_while_guest_busy_warns_and_opens_nothing(fake):
|
||||
started, release = threading.Event(), threading.Event()
|
||||
fake.action_hook = lambda: (started.set(), release.wait(10))
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await settle(app, pilot)
|
||||
try:
|
||||
await pilot.press("s")
|
||||
for _ in range(200):
|
||||
if started.is_set():
|
||||
break
|
||||
await pilot.pause(0.01)
|
||||
assert started.is_set()
|
||||
await pilot.press("c")
|
||||
await pilot.pause()
|
||||
assert not isinstance(app.screen, ContextScreen)
|
||||
assert any(sev == "warning" for _, sev in app.seen)
|
||||
finally:
|
||||
release.set()
|
||||
await settle(app, pilot)
|
||||
assert fake.use_calls == []
|
||||
|
||||
|
||||
async def test_use_context_failure_notifies_rereads_context_and_reloads(fake):
|
||||
err = cli.CliError("Error: Context 'mainframe-edge' not found.")
|
||||
|
||||
def hook(): # the CLI state differs from what the app assumed
|
||||
fake.current = "mainframe-edge"
|
||||
|
||||
fake.use_hook, fake.use_error = hook, err
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_contexts(app, pilot)
|
||||
contexts_before, list_before = fake.contexts_calls, fake.list_calls
|
||||
await choose(app, pilot, 1)
|
||||
assert any(err.summary in m for m in errors(app))
|
||||
assert fake.contexts_calls > contexts_before
|
||||
assert app.sub_title == "context: mainframe-edge" # whatever contexts() says
|
||||
assert fake.list_calls > list_before
|
||||
assert app.query_one("#guests", DataTable).row_count == 2
|
||||
|
||||
|
||||
# --- review finding: no old-cluster rows after a switch ----------------------
|
||||
|
||||
EDGE = {"vmid": 300, "name": "edge-vm", "node": "edge", "type": "qemu", "status": "running",
|
||||
"maxcpu": 2, "maxmem": 2**30, "maxdisk": 2**31} # fmt: skip
|
||||
|
||||
|
||||
async def until(pilot, cond):
|
||||
for _ in range(300):
|
||||
if cond():
|
||||
return
|
||||
await pilot.pause(0.01)
|
||||
raise AssertionError("condition not reached")
|
||||
|
||||
|
||||
def blocking_once():
|
||||
started, release = threading.Event(), threading.Event()
|
||||
|
||||
def hook():
|
||||
if not started.is_set():
|
||||
started.set()
|
||||
release.wait(10)
|
||||
|
||||
return hook, started, release
|
||||
|
||||
|
||||
def rows(app):
|
||||
return [k.value for k in app.query_one("#guests", DataTable).rows]
|
||||
|
||||
|
||||
async def switch_to_edge(app, pilot):
|
||||
await pilot.press("c")
|
||||
await until(pilot, lambda: isinstance(app.screen, ContextScreen))
|
||||
ol = app.screen.query_one("#contexts", OptionList)
|
||||
ol.focus()
|
||||
ol.highlighted = 1
|
||||
await pilot.pause()
|
||||
await pilot.press("enter")
|
||||
|
||||
|
||||
async def test_reload_in_flight_before_switch_cannot_restore_old_rows(fake):
|
||||
fake.guests_by_context = {"mainframe-edge": [EDGE]}
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await settle(app, pilot)
|
||||
hook, started, release = blocking_once()
|
||||
fake.list_hook = hook
|
||||
try:
|
||||
await pilot.press("g") # reads mainframe, then stalls
|
||||
await until(pilot, started.is_set)
|
||||
await switch_to_edge(app, pilot)
|
||||
await until(pilot, lambda: rows(app) == ["300"])
|
||||
finally:
|
||||
release.set()
|
||||
await settle(app, pilot)
|
||||
assert rows(app) == ["300"]
|
||||
assert app.sub_title == "context: mainframe-edge"
|
||||
|
||||
|
||||
async def test_refresh_refused_while_switching(fake):
|
||||
started, release = threading.Event(), threading.Event()
|
||||
fake.use_hook = lambda: (started.set(), release.wait(10))
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await settle(app, pilot)
|
||||
try:
|
||||
await switch_to_edge(app, pilot)
|
||||
await until(pilot, started.is_set)
|
||||
before = fake.list_calls
|
||||
await pilot.press("g")
|
||||
await pilot.pause()
|
||||
assert fake.list_calls == before
|
||||
assert any(sev == "warning" for _, sev in app.seen)
|
||||
finally:
|
||||
release.set()
|
||||
await settle(app, pilot)
|
||||
|
||||
|
||||
async def test_context_refused_while_post_action_reload_runs(fake):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await settle(app, pilot)
|
||||
hook, started, release = blocking_once()
|
||||
fake.list_hook = hook
|
||||
try:
|
||||
await pilot.press("s") # start is instant; the reload after it stalls
|
||||
await until(pilot, started.is_set)
|
||||
await pilot.press("c")
|
||||
await pilot.pause(0.1)
|
||||
assert not isinstance(app.screen, ContextScreen)
|
||||
assert any(sev == "warning" for _, sev in app.seen)
|
||||
finally:
|
||||
release.set()
|
||||
await settle(app, pilot)
|
||||
Reference in new issue
Block a user