Compare commits
2
Commits
62490af913
...
0c3669271c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c3669271c | ||
|
|
9ff00e425f |
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) |
|
||||||
|
|||||||
+136
-48
@@ -4,8 +4,10 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
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 (
|
||||||
@@ -15,6 +17,7 @@ from textual.widgets import (
|
|||||||
Footer,
|
Footer,
|
||||||
Header,
|
Header,
|
||||||
Input,
|
Input,
|
||||||
|
Label,
|
||||||
OptionList,
|
OptionList,
|
||||||
Select,
|
Select,
|
||||||
Static,
|
Static,
|
||||||
@@ -30,17 +33,47 @@ def _message(exc: Exception) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConfirmScreen(ModalScreen[bool]):
|
class DialogScreen(ModalScreen):
|
||||||
|
"""A centred, titled box (#dialog) with labelled rows and right-aligned buttons."""
|
||||||
|
|
||||||
|
# every widget in a row gets an explicit width: Horizontal alone squeezes Select to 1
|
||||||
|
# and stretches Input to 100 %, pushing the rest off-screen
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
DialogScreen { align: center middle; }
|
||||||
|
DialogScreen #dialog {
|
||||||
|
width: 80; max-width: 100%; height: auto; max-height: 100%; overflow-y: auto;
|
||||||
|
border: round $accent; border-title-style: bold; padding: 0 1;
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
DialogScreen .row, DialogScreen .buttons { height: auto; }
|
||||||
|
DialogScreen .buttons { align-horizontal: right; }
|
||||||
|
DialogScreen .row Label { width: 14; height: 3; content-align: left middle; }
|
||||||
|
DialogScreen .row Select, DialogScreen .row Input { width: 1fr; }
|
||||||
|
DialogScreen .row Select.mode { width: 16; }
|
||||||
|
DialogScreen .sub {
|
||||||
|
height: auto; border: round $accent; border-title-style: bold; padding: 0 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def box(self, title: str) -> Vertical:
|
||||||
|
"""The #dialog container; use as `with self.box(title):` inside compose."""
|
||||||
|
box = Vertical(id="dialog")
|
||||||
|
# titles carry guest names: never parse them as markup
|
||||||
|
box.border_title = Text(title)
|
||||||
|
return box
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmScreen(DialogScreen):
|
||||||
BINDINGS = [("escape", "dismiss(False)", "No")] # noqa: RUF012 - Textual idiom
|
BINDINGS = [("escape", "dismiss(False)", "No")] # noqa: RUF012 - Textual idiom
|
||||||
|
|
||||||
def __init__(self, message: str) -> None:
|
def __init__(self, message: str, title: str = "Confirm") -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.message = message
|
self.message, self.title_text = message, title
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Vertical():
|
with self.box(self.title_text):
|
||||||
yield Static(self.message)
|
yield Static(self.message, markup=False)
|
||||||
with Horizontal():
|
with Horizontal(classes="buttons"):
|
||||||
yield Button("Yes", id="yes", variant="error")
|
yield Button("Yes", id="yes", variant="error")
|
||||||
yield Button("No", id="no")
|
yield Button("No", id="no")
|
||||||
|
|
||||||
@@ -48,7 +81,7 @@ class ConfirmScreen(ModalScreen[bool]):
|
|||||||
self.dismiss(event.button.id == "yes")
|
self.dismiss(event.button.id == "yes")
|
||||||
|
|
||||||
|
|
||||||
class EditScreen(ModalScreen):
|
class EditScreen(DialogScreen):
|
||||||
"""Dismisses with a dict of validated values, or None when cancelled."""
|
"""Dismisses with a dict of validated values, or None when cancelled."""
|
||||||
|
|
||||||
BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom
|
BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom
|
||||||
@@ -58,24 +91,29 @@ class EditScreen(ModalScreen):
|
|||||||
self.guest, self.config, self.disks = guest, config, disks
|
self.guest, self.config, self.disks = guest, config, disks
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Vertical():
|
with self.box(f"Edit resources: {self.guest['name']} ({self.guest['vmid']})"):
|
||||||
yield Static(
|
yield Static("Empty = unchanged")
|
||||||
f"Edit {self.guest['name']} ({self.guest['vmid']}); empty = unchanged"
|
with Horizontal(classes="row"):
|
||||||
)
|
yield Label("Cores")
|
||||||
yield Input(
|
yield Input(
|
||||||
placeholder=f"cores (now {self.config.get('cores', '?')})", id="cores"
|
placeholder=f"now {self.config.get('cores', '?')}", id="cores"
|
||||||
)
|
)
|
||||||
|
with Horizontal(classes="row"):
|
||||||
|
yield Label("Memory (MiB)")
|
||||||
yield Input(
|
yield Input(
|
||||||
placeholder=f"memory MiB (now {self.config.get('memory', '?')})",
|
placeholder=f"now {self.config.get('memory', '?')}", id="memory"
|
||||||
id="memory",
|
|
||||||
)
|
)
|
||||||
|
with Horizontal(classes="row"):
|
||||||
|
yield Label("Disk")
|
||||||
yield Select(
|
yield Select(
|
||||||
[(f"{k} ({v})", k) for k, v in self.disks.items()],
|
[(f"{k} ({v})", k) for k, v in self.disks.items()],
|
||||||
prompt="disk",
|
prompt="disk",
|
||||||
id="disk",
|
id="disk",
|
||||||
)
|
)
|
||||||
|
with Horizontal(classes="row"):
|
||||||
|
yield Label("Grow by (GiB)")
|
||||||
yield Input(placeholder="grow by GiB", id="grow")
|
yield Input(placeholder="grow by GiB", id="grow")
|
||||||
with Horizontal():
|
with Horizontal(classes="buttons"):
|
||||||
yield Button("Apply", id="apply", variant="primary")
|
yield Button("Apply", id="apply", variant="primary")
|
||||||
yield Button("Cancel", id="cancel")
|
yield Button("Cancel", id="cancel")
|
||||||
|
|
||||||
@@ -104,7 +142,7 @@ class EditScreen(ModalScreen):
|
|||||||
return int(text)
|
return int(text)
|
||||||
|
|
||||||
|
|
||||||
class ContextScreen(ModalScreen[str | None]):
|
class ContextScreen(DialogScreen):
|
||||||
"""Dismisses with the chosen context name, or None when cancelled."""
|
"""Dismisses with the chosen context name, or None when cancelled."""
|
||||||
|
|
||||||
BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom
|
BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom
|
||||||
@@ -114,7 +152,7 @@ class ContextScreen(ModalScreen[str | None]):
|
|||||||
self.contexts = contexts
|
self.contexts = contexts
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Vertical():
|
with self.box("Switch context"):
|
||||||
yield OptionList(
|
yield OptionList(
|
||||||
*(
|
*(
|
||||||
Option(
|
Option(
|
||||||
@@ -130,7 +168,7 @@ class ContextScreen(ModalScreen[str | None]):
|
|||||||
self.dismiss(event.option.id)
|
self.dismiss(event.option.id)
|
||||||
|
|
||||||
|
|
||||||
class BackupScreen(ModalScreen[str | None]):
|
class BackupScreen(DialogScreen):
|
||||||
"""Dismisses with the chosen storage, or None when cancelled."""
|
"""Dismisses with the chosen storage, or None when cancelled."""
|
||||||
|
|
||||||
BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom
|
BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom
|
||||||
@@ -140,17 +178,20 @@ class BackupScreen(ModalScreen[str | None]):
|
|||||||
self.guest, self.storages, self.chosen = guest, storages, chosen
|
self.guest, self.storages, self.chosen = guest, storages, chosen
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Vertical():
|
with self.box(f"Back up {self.guest['name']} ({self.guest['vmid']})"):
|
||||||
yield Static(
|
yield Static(
|
||||||
f"Back up {self.guest['name']} ({self.guest['vmid']}) now: mode snapshot, zstd"
|
f"Back up {self.guest['name']} ({self.guest['vmid']}) now: mode snapshot, zstd",
|
||||||
|
markup=False,
|
||||||
)
|
)
|
||||||
|
with Horizontal(classes="row"):
|
||||||
|
yield Label("Storage")
|
||||||
yield Select(
|
yield Select(
|
||||||
[(s, s) for s in self.storages],
|
[(s, s) for s in self.storages],
|
||||||
value=self.chosen,
|
value=self.chosen,
|
||||||
allow_blank=False,
|
allow_blank=False,
|
||||||
id="storage",
|
id="storage",
|
||||||
)
|
)
|
||||||
with Horizontal():
|
with Horizontal(classes="buttons"):
|
||||||
yield Button("Yes", id="yes", variant="primary")
|
yield Button("Yes", id="yes", variant="primary")
|
||||||
yield Button("No", id="no")
|
yield Button("No", id="no")
|
||||||
|
|
||||||
@@ -165,11 +206,10 @@ _IPV4_MODES = [("DHCP", "dhcp"), ("Static", "static"), ("None", "none")]
|
|||||||
_IPV6_MODES = [("SLAAC", "auto"), *_IPV4_MODES]
|
_IPV6_MODES = [("SLAAC", "auto"), *_IPV4_MODES]
|
||||||
|
|
||||||
|
|
||||||
class NetScreen(ModalScreen):
|
class NetScreen(DialogScreen):
|
||||||
"""Edit bridge, VLAN, firewall and IP of one interface; confirms, then applies."""
|
"""Edit bridge, VLAN, firewall and IP of one interface; confirms, then applies."""
|
||||||
|
|
||||||
BINDINGS = [("escape", "dismiss", "Cancel")] # noqa: RUF012 - Textual idiom
|
BINDINGS = [("escape", "dismiss", "Cancel")] # noqa: RUF012 - Textual idiom
|
||||||
DEFAULT_CSS = "NetScreen Horizontal { height: auto; }"
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, guest: dict, config: dict, bridges: list[str], running: bool
|
self, guest: dict, config: dict, bridges: list[str], running: bool
|
||||||
@@ -184,32 +224,38 @@ class NetScreen(ModalScreen):
|
|||||||
self.bridges = sorted(set(bridges) | used)
|
self.bridges = sorted(set(bridges) | used)
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Vertical():
|
with self.box(f"Edit network: {self.guest['name']} ({self.guest['vmid']})"):
|
||||||
yield Static(
|
with Horizontal(classes="row"):
|
||||||
f"Network of {self.guest['name']} ({self.guest['vmid']})", markup=False
|
yield Label("Interface")
|
||||||
)
|
|
||||||
yield Select(
|
yield Select(
|
||||||
[(k, k) for k in self.nets],
|
[(k, k) for k in self.nets],
|
||||||
value=next(iter(self.nets)),
|
value=next(iter(self.nets)),
|
||||||
allow_blank=False,
|
allow_blank=False,
|
||||||
id="iface",
|
id="iface",
|
||||||
)
|
)
|
||||||
with Horizontal():
|
with Horizontal(classes="row"):
|
||||||
|
yield Label("Bridge")
|
||||||
yield Select(
|
yield Select(
|
||||||
[(b, b) for b in self.bridges], allow_blank=False, id="bridge"
|
[(b, b) for b in self.bridges], allow_blank=False, id="bridge"
|
||||||
)
|
)
|
||||||
|
with Horizontal(classes="row"):
|
||||||
|
yield Label("VLAN")
|
||||||
yield Input(placeholder="VLAN tag (empty = none)", id="tag")
|
yield Input(placeholder="VLAN tag (empty = none)", id="tag")
|
||||||
yield Checkbox("Firewall", id="firewall")
|
with Horizontal(classes="row"):
|
||||||
|
yield Label("Firewall")
|
||||||
|
yield Checkbox("", id="firewall")
|
||||||
if not self.ip_enabled:
|
if not self.ip_enabled:
|
||||||
yield Static(
|
yield Static(
|
||||||
"IP is configured inside the VM (no cloud-init drive)", id="ip_note"
|
"IP is configured inside the VM (no cloud-init drive)", id="ip_note"
|
||||||
)
|
)
|
||||||
for fam, modes in (("ipv4", _IPV4_MODES), ("ipv6", _IPV6_MODES)):
|
for fam, modes in (("ipv4", _IPV4_MODES), ("ipv6", _IPV6_MODES)):
|
||||||
with Horizontal():
|
with Vertical(id=f"{fam}_section", classes="sub") as section:
|
||||||
yield Select(modes, allow_blank=False, id=fam)
|
section.border_title = fam.replace("ip", "IP")
|
||||||
|
with Horizontal(classes="row"):
|
||||||
|
yield Select(modes, allow_blank=False, id=fam, classes="mode")
|
||||||
yield Input(placeholder="address/prefix", id=f"{fam}_cidr")
|
yield Input(placeholder="address/prefix", id=f"{fam}_cidr")
|
||||||
yield Input(placeholder="gateway", id=f"{fam}_gw")
|
yield Input(placeholder="gateway", id=f"{fam}_gw")
|
||||||
with Horizontal():
|
with Horizontal(classes="buttons"):
|
||||||
yield Button("Apply", id="apply", variant="primary")
|
yield Button("Apply", id="apply", variant="primary")
|
||||||
yield Button("Cancel", id="cancel")
|
yield Button("Cancel", id="cancel")
|
||||||
|
|
||||||
@@ -314,7 +360,10 @@ class NetScreen(ModalScreen):
|
|||||||
notes.append("IP settings (cloud-init) apply at next boot.")
|
notes.append("IP settings (cloud-init) apply at next boot.")
|
||||||
who = f"{self.guest['name']} ({self.guest['vmid']})"
|
who = f"{self.guest['name']} ({self.guest['vmid']})"
|
||||||
self.app.push_screen(
|
self.app.push_screen(
|
||||||
ConfirmScreen("\n".join([f"Change network of {who}?", *lines, *notes])),
|
ConfirmScreen(
|
||||||
|
"\n".join([f"Change network of {who}?", *lines, *notes]),
|
||||||
|
"Network change",
|
||||||
|
),
|
||||||
lambda ok: (
|
lambda ok: (
|
||||||
self.app.start_net_change(self.guest, changes, old, self)
|
self.app.start_net_change(self.guest, changes, old, self)
|
||||||
if ok
|
if ok
|
||||||
@@ -333,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; }
|
||||||
@@ -447,7 +497,8 @@ class InfoScreen(ModalScreen):
|
|||||||
ip = cli.parse_net(str(config.get(f"ipconfig{name[3:]}", "")))
|
ip = cli.parse_net(str(config.get(f"ipconfig{name[3:]}", "")))
|
||||||
else:
|
else:
|
||||||
mac, ip = net.get("hwaddr", "-"), net
|
mac, ip = net.get("hwaddr", "-"), net
|
||||||
table.add_row(
|
_add_plain(
|
||||||
|
table,
|
||||||
name,
|
name,
|
||||||
net.get("bridge", "-"),
|
net.get("bridge", "-"),
|
||||||
net.get("tag", "-"),
|
net.get("tag", "-"),
|
||||||
@@ -461,8 +512,12 @@ class InfoScreen(ModalScreen):
|
|||||||
note.display = bool(unavailable) # no empty line when the addresses are known
|
note.display = bool(unavailable) # no empty line when the addresses are known
|
||||||
table = self.query_one("#addresses", DataTable).clear()
|
table = self.query_one("#addresses", DataTable).clear()
|
||||||
for i in addresses:
|
for i in addresses:
|
||||||
table.add_row(
|
_add_plain(
|
||||||
i["name"], i["mac"] or "-", ", ".join(i["ipv4"]), ", ".join(i["ipv6"])
|
table,
|
||||||
|
i["name"],
|
||||||
|
i["mac"] or "-",
|
||||||
|
", ".join(i["ipv4"]),
|
||||||
|
", ".join(i["ipv6"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _show_backups(self) -> None:
|
def _show_backups(self) -> None:
|
||||||
@@ -472,7 +527,8 @@ class InfoScreen(ModalScreen):
|
|||||||
]
|
]
|
||||||
table = self.query_one("#backups", DataTable).clear()
|
table = self.query_one("#backups", DataTable).clear()
|
||||||
for b in shown:
|
for b in shown:
|
||||||
table.add_row(
|
_add_plain(
|
||||||
|
table,
|
||||||
datetime.fromtimestamp(b["ctime"]).strftime("%Y-%m-%d %H:%M")
|
datetime.fromtimestamp(b["ctime"]).strftime("%Y-%m-%d %H:%M")
|
||||||
if b.get("ctime")
|
if b.get("ctime")
|
||||||
else "-",
|
else "-",
|
||||||
@@ -489,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()
|
||||||
@@ -528,7 +587,8 @@ class InfoScreen(ModalScreen):
|
|||||||
lines = [f"{k} = {v or '(remove)'}" for k, v in old.items()]
|
lines = [f"{k} = {v or '(remove)'}" for k, v in old.items()]
|
||||||
self.app.push_screen(
|
self.app.push_screen(
|
||||||
ConfirmScreen(
|
ConfirmScreen(
|
||||||
"\n".join(["Restore the previous network settings?", *lines])
|
"\n".join(["Restore the previous network settings?", *lines]),
|
||||||
|
"Undo network change",
|
||||||
),
|
),
|
||||||
lambda ok: self.app.start_net_change(self.guest, old, None) if ok else None,
|
lambda ok: self.app.start_net_change(self.guest, old, None) if ok else None,
|
||||||
)
|
)
|
||||||
@@ -566,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"),
|
||||||
@@ -589,12 +653,23 @@ class GuestApp(App):
|
|||||||
self.generation = 0
|
self.generation = 0
|
||||||
self.switching = False
|
self.switching = False
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
#guests_section {
|
||||||
|
border: round $accent; border-title-style: bold; padding: 0 1;
|
||||||
|
}
|
||||||
|
#guests_section DataTable { height: 1fr; }
|
||||||
|
"""
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header()
|
yield Header()
|
||||||
yield DataTable(id="guests", cursor_type="row")
|
with Vertical(id="guests_section") as section:
|
||||||
|
section.border_title = "Guests"
|
||||||
|
yield GuestTable(id="guests", cursor_type="row")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
|
# one place for every sub_title change: mount, switch and failed switch
|
||||||
|
self.watch(self, "sub_title", self._show_context_frame)
|
||||||
table = self.query_one("#guests", DataTable)
|
table = self.query_one("#guests", DataTable)
|
||||||
table.add_columns(
|
table.add_columns(
|
||||||
"VMID",
|
"VMID",
|
||||||
@@ -609,6 +684,11 @@ class GuestApp(App):
|
|||||||
self._show_context()
|
self._show_context()
|
||||||
self.action_reload()
|
self.action_reload()
|
||||||
|
|
||||||
|
def _show_context_frame(self, sub_title: str) -> None:
|
||||||
|
self.query_one("#guests_section").border_subtitle = sub_title.removeprefix(
|
||||||
|
"context: "
|
||||||
|
)
|
||||||
|
|
||||||
@work(thread=True)
|
@work(thread=True)
|
||||||
def _show_context(self) -> None:
|
def _show_context(self) -> None:
|
||||||
self._read_context()
|
self._read_context()
|
||||||
@@ -645,7 +725,7 @@ class GuestApp(App):
|
|||||||
table.clear()
|
table.clear()
|
||||||
self.guests = {str(g["vmid"]): g for g in guests}
|
self.guests = {str(g["vmid"]): g for g in guests}
|
||||||
for g in guests:
|
for g in guests:
|
||||||
table.add_row(
|
_add_plain(table,
|
||||||
g["vmid"], g["name"], g["type"], g["node"], g["status"],
|
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),
|
g.get("maxcpu", "-"), _scaled(g.get("maxmem"), 2**20), _scaled(g.get("maxdisk"), 2**30),
|
||||||
key=str(g["vmid"]),
|
key=str(g["vmid"]),
|
||||||
@@ -766,14 +846,12 @@ class GuestApp(App):
|
|||||||
if name == "stop":
|
if name == "stop":
|
||||||
text = f"Hard power-off {who}? The guest is killed without shutting down."
|
text = f"Hard power-off {who}? The guest is killed without shutting down."
|
||||||
self.push_screen(
|
self.push_screen(
|
||||||
ConfirmScreen(text),
|
ConfirmScreen(text, name.capitalize()),
|
||||||
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"
|
||||||
@@ -786,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:
|
||||||
@@ -913,7 +995,8 @@ class GuestApp(App):
|
|||||||
who = f"{guest['name']} ({guest['vmid']})"
|
who = f"{guest['name']} ({guest['vmid']})"
|
||||||
self.push_screen(
|
self.push_screen(
|
||||||
ConfirmScreen(
|
ConfirmScreen(
|
||||||
f"Grow {values['disk']} of {who} by {values['grow']} GiB? This cannot be undone."
|
f"Grow {values['disk']} of {who} by {values['grow']} GiB? This cannot be undone.",
|
||||||
|
"Grow disk",
|
||||||
),
|
),
|
||||||
lambda ok: (
|
lambda ok: (
|
||||||
self._apply(guest, values) if ok else None
|
self._apply(guest, values) if ok else None
|
||||||
@@ -952,6 +1035,11 @@ def _reset_terminal() -> None:
|
|||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _add_plain(table: DataTable, *cells, **kwargs) -> None:
|
||||||
|
"""DataTable parses str cells as markup; names and notes like "a[/b]" must stay literal."""
|
||||||
|
table.add_row(*(Text(c) if isinstance(c, str) else c for c in cells), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def _scaled(value, unit: int):
|
def _scaled(value, unit: int):
|
||||||
return "-" if value is None else round(value / unit)
|
return "-" if value is None else round(value / unit)
|
||||||
|
|
||||||
|
|||||||
+159
-8
@@ -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")
|
||||||
@@ -1883,3 +1889,148 @@ async def test_info_has_separate_titled_backup_and_network_sections(fake):
|
|||||||
"unavailable",
|
"unavailable",
|
||||||
"addresses",
|
"addresses",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- boxed design: every dialog a titled box, main table in a titled frame ----------
|
||||||
|
|
||||||
|
from textual.content import Content
|
||||||
|
from textual.widgets import Label
|
||||||
|
|
||||||
|
SIZE = (100, 40)
|
||||||
|
|
||||||
|
|
||||||
|
def visible(app, widget):
|
||||||
|
r = widget.region
|
||||||
|
return (
|
||||||
|
r.width >= 3
|
||||||
|
and r.height >= 1
|
||||||
|
and r.right <= app.size.width
|
||||||
|
and r.bottom <= app.size.height
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def label_texts(screen):
|
||||||
|
return {str(w.render()).strip() for w in screen.query(Label)}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_net_screen_shows_every_field_inside_the_screen(fake):
|
||||||
|
# bug: in the old rows Select got width 1 and Input 100 %, pushing fields off-screen
|
||||||
|
fake.net_configs[100] = dict(CT_CONFIG)
|
||||||
|
app = make_app()
|
||||||
|
async with app.run_test(size=SIZE) as pilot:
|
||||||
|
await open_net(app, pilot)
|
||||||
|
for wid in ("iface", "bridge", "tag", "firewall", "ipv4", "ipv4_cidr", "ipv4_gw",
|
||||||
|
"ipv6", "ipv6_cidr", "ipv6_gw", "apply", "cancel"): # fmt: skip
|
||||||
|
assert visible(app, app.screen.query_one(f"#{wid}")), wid
|
||||||
|
|
||||||
|
|
||||||
|
async def test_net_screen_labels_and_ip_sections(fake):
|
||||||
|
fake.net_configs[100] = dict(CT_CONFIG)
|
||||||
|
app = make_app()
|
||||||
|
async with app.run_test(size=SIZE) as pilot:
|
||||||
|
await open_net(app, pilot)
|
||||||
|
assert {"Interface", "Bridge", "VLAN"} <= label_texts(app.screen)
|
||||||
|
assert "IPv4" in str(app.screen.query_one("#ipv4_section").border_title)
|
||||||
|
assert "IPv6" in str(app.screen.query_one("#ipv6_section").border_title)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_edit_screen_fields_have_labels(fake):
|
||||||
|
app = make_app()
|
||||||
|
async with app.run_test(size=SIZE) as pilot:
|
||||||
|
await open_edit(app, pilot)
|
||||||
|
assert {"Cores", "Memory (MiB)", "Disk", "Grow by (GiB)"} <= label_texts(
|
||||||
|
app.screen
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def open_each_dialog(app, pilot):
|
||||||
|
"""Yields after each dialog is open; closes it with escape afterwards."""
|
||||||
|
await settle(app, pilot)
|
||||||
|
for keys in (["S"], ["enter", "e"], ["c"], ["enter", "b"], ["enter", "n"]):
|
||||||
|
for k in keys:
|
||||||
|
await pilot.press(k)
|
||||||
|
await settle(app, pilot)
|
||||||
|
yield keys
|
||||||
|
for _ in keys:
|
||||||
|
await pilot.press("escape")
|
||||||
|
await settle(app, pilot)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_every_dialog_is_a_titled_centred_box(fake):
|
||||||
|
fake.net_configs[108] = dict(VM_CONFIG)
|
||||||
|
app = make_app()
|
||||||
|
async with app.run_test(size=SIZE) as pilot:
|
||||||
|
async for keys in open_each_dialog(app, pilot):
|
||||||
|
box = app.screen.query_one("#dialog")
|
||||||
|
assert str(box.border_title).strip(), keys
|
||||||
|
assert box.region.width < app.size.width, keys # a box, not full screen
|
||||||
|
assert box.region.x > 0, keys # centred
|
||||||
|
|
||||||
|
|
||||||
|
async def test_main_table_is_framed_with_title_and_context(fake):
|
||||||
|
app = make_app()
|
||||||
|
async with app.run_test(size=SIZE) as pilot:
|
||||||
|
await settle(app, pilot)
|
||||||
|
frame = app.query_one("#guests_section")
|
||||||
|
assert "Guests" in str(frame.border_title)
|
||||||
|
assert "mainframe" in str(frame.border_subtitle)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("keys", [["enter", "e"], ["enter", "b"], ["enter", "n"]])
|
||||||
|
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
|
||||||
|
fake.guests = [dict(VM, name="a[/b]")]
|
||||||
|
fake.net_configs[108] = dict(VM_CONFIG)
|
||||||
|
app = make_app()
|
||||||
|
async with app.run_test(size=SIZE) as pilot:
|
||||||
|
await settle(app, pilot)
|
||||||
|
for k in keys:
|
||||||
|
await pilot.press(k)
|
||||||
|
await settle(app, pilot)
|
||||||
|
title = app.screen.query_one("#dialog").border_title
|
||||||
|
assert (
|
||||||
|
"a[/b]" in Content.from_markup(title).plain
|
||||||
|
) # what is shown, not how it is stored
|
||||||
|
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