Boxed dialogs, Enter opens info, edit moved into info #6
No files matched your search
@@ -19,9 +19,10 @@ uv run cv4pve-tui
|
|||||||
| `S` | shutdown (confirm) |
|
| `S` | shutdown (confirm) |
|
||||||
| `x` | stop - hard power-off (confirm) |
|
| `x` | stop - hard power-off (confirm) |
|
||||||
| `r` | reboot (confirm) |
|
| `r` | reboot (confirm) |
|
||||||
| `e` | edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) |
|
| `Enter` | info: backups, backup job, network (config and runtime IPs) |
|
||||||
| `i` | info: backups, backup job, network (config and runtime IPs); `b` there = backup now (snapshot, zstd, storage choice); `a` there = also show backups of earlier guests that had the same VMID |
|
| `Enter` then `e` | in info: edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) |
|
||||||
| `i` then `n` | in info: edit network (bridge, VLAN, firewall, IPv4/IPv6; VM IP via cloud-init at next boot); `u` there = undo the last network change of this guest (this session only) |
|
| `Enter` then `b` | in info: backup now (snapshot, zstd, storage choice); `a` there = also show backups of earlier guests that had the same VMID |
|
||||||
|
| `Enter` then `n` | in info: edit network (bridge, VLAN, firewall, IPv4/IPv6; VM IP via cloud-init at next boot); `u` there = undo the last network change of this guest (this session only) |
|
||||||
| `t` | shell in a running LXC container (`ssh -t root@<node> pct enter <vmid>`) |
|
| `t` | shell in a running LXC container (`ssh -t root@<node> pct enter <vmid>`) |
|
||||||
| `g` | refresh |
|
| `g` | refresh |
|
||||||
| `c` | switch cv4pve-cli context (cluster) |
|
| `c` | switch cv4pve-cli context (cluster) |
|
||||||
|
|||||||
+18
-7
@@ -7,6 +7,7 @@ from datetime import datetime
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual import work
|
from textual import work
|
||||||
from textual.app import App, ComposeResult, SuspendNotSupported
|
from textual.app import App, ComposeResult, SuspendNotSupported
|
||||||
|
from textual.binding import Binding
|
||||||
from textual.containers import Horizontal, Vertical
|
from textual.containers import Horizontal, Vertical
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.widgets import (
|
from textual.widgets import (
|
||||||
@@ -381,6 +382,7 @@ class InfoScreen(ModalScreen):
|
|||||||
("a", "toggle_all", "All backups"),
|
("a", "toggle_all", "All backups"),
|
||||||
("n", "net", "Edit network"),
|
("n", "net", "Edit network"),
|
||||||
("u", "undo", "Undo network"),
|
("u", "undo", "Undo network"),
|
||||||
|
("e", "edit", "Edit resources"),
|
||||||
]
|
]
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
InfoScreen DataTable { height: auto; max-height: 12; }
|
InfoScreen DataTable { height: auto; max-height: 12; }
|
||||||
@@ -543,6 +545,9 @@ class InfoScreen(ModalScreen):
|
|||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def action_edit(self) -> None:
|
||||||
|
self.app.edit_guest(self.guest)
|
||||||
|
|
||||||
def action_toggle_all(self) -> None:
|
def action_toggle_all(self) -> None:
|
||||||
self.show_all = not self.show_all
|
self.show_all = not self.show_all
|
||||||
self._show_backups()
|
self._show_backups()
|
||||||
@@ -621,14 +626,18 @@ def _job_text(job: dict) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GuestTable(DataTable):
|
||||||
|
"""The guest table; shows its Enter (row selected -> info screen) in the footer."""
|
||||||
|
|
||||||
|
BINDINGS = [Binding("enter", "select_cursor", "Info")] # noqa: RUF012 - Textual idiom
|
||||||
|
|
||||||
|
|
||||||
class GuestApp(App):
|
class GuestApp(App):
|
||||||
BINDINGS = [ # noqa: RUF012 - Textual idiom
|
BINDINGS = [ # noqa: RUF012 - Textual idiom
|
||||||
("s", "act('start')", "Start"),
|
("s", "act('start')", "Start"),
|
||||||
("S", "act('shutdown')", "Shutdown"),
|
("S", "act('shutdown')", "Shutdown"),
|
||||||
("x", "act('stop')", "Stop"),
|
("x", "act('stop')", "Stop"),
|
||||||
("r", "act('reboot')", "Reboot"),
|
("r", "act('reboot')", "Reboot"),
|
||||||
("e", "edit", "Edit"),
|
|
||||||
("i", "info", "Info"),
|
|
||||||
("t", "shell", "Shell"),
|
("t", "shell", "Shell"),
|
||||||
("g", "reload", "Refresh"),
|
("g", "reload", "Refresh"),
|
||||||
("c", "context", "Context"),
|
("c", "context", "Context"),
|
||||||
@@ -655,7 +664,7 @@ class GuestApp(App):
|
|||||||
yield Header()
|
yield Header()
|
||||||
with Vertical(id="guests_section") as section:
|
with Vertical(id="guests_section") as section:
|
||||||
section.border_title = "Guests"
|
section.border_title = "Guests"
|
||||||
yield DataTable(id="guests", cursor_type="row")
|
yield GuestTable(id="guests", cursor_type="row")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
@@ -841,10 +850,8 @@ class GuestApp(App):
|
|||||||
lambda ok: self._start_job(guest, name, run) if ok else None,
|
lambda ok: self._start_job(guest, name, run) if ok else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def action_edit(self) -> None:
|
def edit_guest(self, guest: dict) -> None:
|
||||||
guest = self._selected()
|
"""Opened from the info screen (key e)."""
|
||||||
if guest is None:
|
|
||||||
return
|
|
||||||
if guest["vmid"] in self.busy:
|
if guest["vmid"] in self.busy:
|
||||||
self.notify(
|
self.notify(
|
||||||
f"{guest['name']} ({guest['vmid']}) is busy", severity="warning"
|
f"{guest['name']} ({guest['vmid']}) is busy", severity="warning"
|
||||||
@@ -857,6 +864,10 @@ class GuestApp(App):
|
|||||||
if guest is not None:
|
if guest is not None:
|
||||||
self.push_screen(InfoScreen(guest))
|
self.push_screen(InfoScreen(guest))
|
||||||
|
|
||||||
|
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||||
|
if event.data_table.id == "guests": # Enter on a guest row
|
||||||
|
self.action_info()
|
||||||
|
|
||||||
def start_backup(self, guest: dict, storage: str) -> None:
|
def start_backup(self, guest: dict, storage: str) -> None:
|
||||||
who = f"{guest['name']} ({guest['vmid']})"
|
who = f"{guest['name']} ({guest['vmid']})"
|
||||||
if guest["vmid"] in self.busy:
|
if guest["vmid"] in self.busy:
|
||||||
|
|||||||
+58
-10
@@ -378,6 +378,9 @@ async def test_busy_guest_refuses_second_action(fake):
|
|||||||
|
|
||||||
|
|
||||||
async def open_edit(app, pilot):
|
async def open_edit(app, pilot):
|
||||||
|
"""Edit lives in the info screen (user request): Enter, then e."""
|
||||||
|
await settle(app, pilot)
|
||||||
|
await pilot.press("enter")
|
||||||
await settle(app, pilot)
|
await settle(app, pilot)
|
||||||
await pilot.press("e")
|
await pilot.press("e")
|
||||||
await settle(app, pilot)
|
await settle(app, pilot)
|
||||||
@@ -443,7 +446,7 @@ async def test_edit_cancel_changes_nothing(fake):
|
|||||||
fill(app, cores="8")
|
fill(app, cores="8")
|
||||||
await press_button(app, pilot, "#cancel")
|
await press_button(app, pilot, "#cancel")
|
||||||
assert fake.sets == [] and fake.resizes == []
|
assert fake.sets == [] and fake.resizes == []
|
||||||
assert not isinstance(app.screen, ModalScreen)
|
assert isinstance(app.screen, appmod.InfoScreen) # edit closed, back in info
|
||||||
|
|
||||||
|
|
||||||
async def test_grow_needs_confirm_then_resizes(fake):
|
async def test_grow_needs_confirm_then_resizes(fake):
|
||||||
@@ -549,17 +552,20 @@ async def test_declining_grow_cancels_whole_edit(fake):
|
|||||||
assert fake.sets == [] and fake.resizes == []
|
assert fake.sets == [] and fake.resizes == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("key", ["S", "e"])
|
@pytest.mark.parametrize("keys", [["S"], ["enter", "e"]])
|
||||||
async def test_escape_closes_modal_without_cli_call(fake, key):
|
async def test_escape_closes_modal_without_cli_call(fake, keys):
|
||||||
app = make_app()
|
app = make_app()
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await settle(app, pilot)
|
await settle(app, pilot)
|
||||||
|
under = app.screen
|
||||||
|
for key in keys:
|
||||||
|
under = app.screen
|
||||||
await pilot.press(key)
|
await pilot.press(key)
|
||||||
await settle(app, pilot)
|
await settle(app, pilot)
|
||||||
assert isinstance(app.screen, ModalScreen)
|
assert isinstance(app.screen, (ConfirmScreen, EditScreen))
|
||||||
await pilot.press("escape")
|
await pilot.press("escape")
|
||||||
await settle(app, pilot)
|
await settle(app, pilot)
|
||||||
assert not isinstance(app.screen, ModalScreen)
|
assert app.screen is under # back where the dialog was opened from
|
||||||
assert fake.actions == [] and fake.sets == []
|
assert fake.actions == [] and fake.sets == []
|
||||||
|
|
||||||
|
|
||||||
@@ -1047,7 +1053,7 @@ async def open_info(app, pilot, down=0):
|
|||||||
await settle(app, pilot)
|
await settle(app, pilot)
|
||||||
for _ in range(down):
|
for _ in range(down):
|
||||||
await pilot.press("down")
|
await pilot.press("down")
|
||||||
await pilot.press("i")
|
await pilot.press("enter")
|
||||||
await settle(app, pilot)
|
await settle(app, pilot)
|
||||||
assert isinstance(app.screen, appmod.InfoScreen)
|
assert isinstance(app.screen, appmod.InfoScreen)
|
||||||
|
|
||||||
@@ -1165,7 +1171,7 @@ async def test_i_on_empty_table_does_nothing(fake):
|
|||||||
app = make_app()
|
app = make_app()
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await settle(app, pilot)
|
await settle(app, pilot)
|
||||||
await pilot.press("i")
|
await pilot.press("enter")
|
||||||
await settle(app, pilot)
|
await settle(app, pilot)
|
||||||
assert not isinstance(app.screen, appmod.InfoScreen)
|
assert not isinstance(app.screen, appmod.InfoScreen)
|
||||||
assert fake.backups_calls == []
|
assert fake.backups_calls == []
|
||||||
@@ -1798,7 +1804,7 @@ async def test_undo_entry_is_per_guest(fake):
|
|||||||
await pilot.press("escape") # close InfoScreen
|
await pilot.press("escape") # close InfoScreen
|
||||||
await settle(app, pilot)
|
await settle(app, pilot)
|
||||||
await pilot.press("up")
|
await pilot.press("up")
|
||||||
await pilot.press("i") # VM 108: no change was made there
|
await pilot.press("enter") # VM 108: no change was made there
|
||||||
await settle(app, pilot)
|
await settle(app, pilot)
|
||||||
assert isinstance(app.screen, appmod.InfoScreen)
|
assert isinstance(app.screen, appmod.InfoScreen)
|
||||||
await pilot.press("u")
|
await pilot.press("u")
|
||||||
@@ -1940,7 +1946,7 @@ async def test_edit_screen_fields_have_labels(fake):
|
|||||||
async def open_each_dialog(app, pilot):
|
async def open_each_dialog(app, pilot):
|
||||||
"""Yields after each dialog is open; closes it with escape afterwards."""
|
"""Yields after each dialog is open; closes it with escape afterwards."""
|
||||||
await settle(app, pilot)
|
await settle(app, pilot)
|
||||||
for keys in (["S"], ["e"], ["c"], ["i", "b"], ["i", "n"]):
|
for keys in (["S"], ["enter", "e"], ["c"], ["enter", "b"], ["enter", "n"]):
|
||||||
for k in keys:
|
for k in keys:
|
||||||
await pilot.press(k)
|
await pilot.press(k)
|
||||||
await settle(app, pilot)
|
await settle(app, pilot)
|
||||||
@@ -1970,7 +1976,7 @@ async def test_main_table_is_framed_with_title_and_context(fake):
|
|||||||
assert "mainframe" in str(frame.border_subtitle)
|
assert "mainframe" in str(frame.border_subtitle)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("keys", [["e"], ["i", "b"], ["i", "n"]])
|
@pytest.mark.parametrize("keys", [["enter", "e"], ["enter", "b"], ["enter", "n"]])
|
||||||
async def test_guest_name_with_markup_characters_does_not_break_dialogs(fake, keys):
|
async def test_guest_name_with_markup_characters_does_not_break_dialogs(fake, keys):
|
||||||
# border titles are parsed as markup; a name like "a[/b]" must be shown literally
|
# border titles are parsed as markup; a name like "a[/b]" must be shown literally
|
||||||
fake.guests = [dict(VM, name="a[/b]")]
|
fake.guests = [dict(VM, name="a[/b]")]
|
||||||
@@ -1986,3 +1992,45 @@ async def test_guest_name_with_markup_characters_does_not_break_dialogs(fake, ke
|
|||||||
"a[/b]" in Content.from_markup(title).plain
|
"a[/b]" in Content.from_markup(title).plain
|
||||||
) # what is shown, not how it is stored
|
) # what is shown, not how it is stored
|
||||||
assert app.is_running
|
assert app.is_running
|
||||||
|
|
||||||
|
|
||||||
|
# --- keys: Enter opens the info screen; edit lives there (user request) ------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enter_on_a_guest_opens_its_info_screen(fake):
|
||||||
|
app = make_app()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await settle(app, pilot)
|
||||||
|
await pilot.press("down") # CT 100
|
||||||
|
await pilot.press("enter")
|
||||||
|
await settle(app, pilot)
|
||||||
|
assert isinstance(app.screen, appmod.InfoScreen)
|
||||||
|
assert app.screen.guest["vmid"] == 100
|
||||||
|
|
||||||
|
|
||||||
|
async def test_i_and_e_do_nothing_on_the_main_screen(fake):
|
||||||
|
app = make_app()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await settle(app, pilot)
|
||||||
|
for key in ("i", "e"):
|
||||||
|
await pilot.press(key)
|
||||||
|
await settle(app, pilot)
|
||||||
|
assert not isinstance(app.screen, ModalScreen), key
|
||||||
|
|
||||||
|
|
||||||
|
async def test_e_in_info_screen_edits_that_guest_and_returns_to_info(fake):
|
||||||
|
app = make_app()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await open_edit(app, pilot)
|
||||||
|
fill(app, cores="8")
|
||||||
|
await press_button(app, pilot, "#apply")
|
||||||
|
assert fake.sets == [{"cores": 8}]
|
||||||
|
assert isinstance(app.screen, appmod.InfoScreen)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_footer_on_main_screen_shows_enter_for_info(fake):
|
||||||
|
app = make_app()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await settle(app, pilot)
|
||||||
|
shown = [ab.binding for ab in app.active_bindings.values() if ab.binding.show]
|
||||||
|
assert any(b.key == "enter" and b.description == "Info" for b in shown)
|
||||||
Reference in new issue
Block a user