Show configured resources on the info screen #7
No files matched your search
@@ -19,7 +19,7 @@ uv run cv4pve-tui
|
||||
| `S` | shutdown (confirm) |
|
||||
| `x` | stop - hard power-off (confirm) |
|
||||
| `r` | reboot (confirm) |
|
||||
| `Enter` | info: backups, backup job, network (config and runtime IPs) |
|
||||
| `Enter` | info: resources (CPU, memory, swap, disks), backups, backup job, network (config and runtime IPs) |
|
||||
| `Enter` then `e` | in info: edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) |
|
||||
| `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) |
|
||||
|
||||
+32
-3
@@ -407,6 +407,9 @@ class InfoScreen(ModalScreen):
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical():
|
||||
yield Static(f"{self.guest['name']} ({self.guest['vmid']})", markup=False)
|
||||
with Vertical(id="resources_section", classes="section") as res:
|
||||
res.border_title = "Resources"
|
||||
yield DataTable(id="resources", show_header=False, cursor_type="none")
|
||||
with Vertical(id="backup_section", classes="section") as backup:
|
||||
backup.border_title = "Backups"
|
||||
yield Static("", id="jobs", markup=False)
|
||||
@@ -422,6 +425,7 @@ class InfoScreen(ModalScreen):
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#resources", DataTable).add_columns("Resource", "Value")
|
||||
self.query_one("#backups", DataTable).add_columns(
|
||||
"Date", "Storage", "Size (GiB)", "Format", "Notes"
|
||||
)
|
||||
@@ -488,6 +492,9 @@ class InfoScreen(ModalScreen):
|
||||
"; ".join(_job_text(j) for j in covering)
|
||||
or "No backup job covers this guest"
|
||||
)
|
||||
table = self.query_one("#resources", DataTable).clear()
|
||||
for name, value in _resources(config, cli.kind(self.guest)):
|
||||
_add_plain(table, name, value)
|
||||
self.backups = backups
|
||||
self._show_backups()
|
||||
table = self.query_one("#nets", DataTable).clear()
|
||||
@@ -828,9 +835,11 @@ 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._reload_infos(
|
||||
guest
|
||||
) # e.g. edited cores show up in an open info screen
|
||||
# before un-busying, so no context switch can start mid-reload
|
||||
self._reload(acted=guest)
|
||||
self.busy.discard(guest["vmid"])
|
||||
|
||||
def action_act(self, name: str) -> None:
|
||||
@@ -1035,6 +1044,26 @@ def _reset_terminal() -> None:
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _resources(config: dict, kind: str) -> list[tuple[str, str]]:
|
||||
"""Configured CPU, memory (and CT swap) and disks, as (name, value) rows."""
|
||||
cores = config.get("cores")
|
||||
if cores is None and kind == "ct":
|
||||
cpu = "unlimited" # no cores limit: the container may use all host cores
|
||||
else:
|
||||
cores = int(cores or 1)
|
||||
cpu = f"{cores} core{'s' if cores != 1 else ''}"
|
||||
if kind == "vm":
|
||||
sockets = int(config.get("sockets") or 1)
|
||||
cpu += f" × {sockets} socket{'s' if sockets != 1 else ''}"
|
||||
rows = [("CPU", cpu)]
|
||||
if config.get("memory") is not None:
|
||||
rows.append(("Memory", f"{config['memory']} MiB"))
|
||||
if kind == "ct" and config.get("swap") is not None:
|
||||
rows.append(("Swap", f"{config['swap']} MiB"))
|
||||
rows += [(f"Disk {name}", size) for name, size in cli.disks(config, kind).items()]
|
||||
return rows
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -2034,3 +2034,77 @@ async def test_footer_on_main_screen_shows_enter_for_info(fake):
|
||||
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)
|
||||
|
||||
|
||||
# --- info screen: resources (CPU, RAM, disk) ------------------------------------------
|
||||
|
||||
|
||||
def resource_rows(app):
|
||||
t = app.screen.query_one("#resources", DataTable)
|
||||
return {cells(t, i)[0]: cells(t, i)[1] for i in range(t.row_count)}
|
||||
|
||||
|
||||
async def test_info_shows_vm_resources(fake):
|
||||
fake.net_configs[108] = {"sockets": 2, "memory": 8192,
|
||||
"scsi0": "local-lvm:vm-108-disk-0,discard=on,size=10G,ssd=1",
|
||||
"ide2": "local-lvm:vm-108-cloudinit,media=cdrom"} # fmt: skip
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_info(app, pilot) # VM 108, cores 4 in the fake config
|
||||
assert "Resources" in str(
|
||||
app.screen.query_one("#resources_section").border_title
|
||||
)
|
||||
rows = resource_rows(app)
|
||||
assert rows["CPU"] == "4 cores × 2 sockets"
|
||||
assert rows["Memory"] == "8192 MiB"
|
||||
assert rows["Disk scsi0"] == "10G"
|
||||
assert not any(
|
||||
"ide2" in k for k in rows
|
||||
) # cloud-init drive is not a disk to show
|
||||
|
||||
|
||||
async def test_info_shows_ct_resources_with_swap_and_mount_points(fake):
|
||||
fake.net_configs[100] = {"memory": 512, "swap": 256,
|
||||
"rootfs": "local-lvm:vm-100-disk-0,size=8G",
|
||||
"mp0": "local-lvm:vm-100-disk-1,mp=/data,size=32G"} # fmt: skip
|
||||
fake.disk_map = {
|
||||
"rootfs": "8G",
|
||||
"mp0": "32G",
|
||||
} # cli.disks is faked; its parsing has cli tests
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_info(app, pilot, down=1) # CT 100, cores 1
|
||||
rows = resource_rows(app)
|
||||
assert rows["CPU"] == "1 core"
|
||||
assert rows["Memory"] == "512 MiB"
|
||||
assert rows["Swap"] == "256 MiB"
|
||||
assert rows["Disk rootfs"] == "8G" and rows["Disk mp0"] == "32G"
|
||||
|
||||
|
||||
async def test_info_ct_without_cores_is_unlimited(fake, monkeypatch):
|
||||
monkeypatch.setattr(cli, "guest_config", lambda guest: {"memory": 512})
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_info(app, pilot, down=1)
|
||||
assert resource_rows(app)["CPU"] == "unlimited"
|
||||
|
||||
|
||||
async def test_info_resources_reload_after_edit(fake, monkeypatch):
|
||||
config = {
|
||||
"cores": 4,
|
||||
"memory": 8192,
|
||||
} # what Proxmox stores; set_resources changes it
|
||||
monkeypatch.setattr(cli, "guest_config", lambda guest: dict(config))
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"set_resources",
|
||||
lambda guest, cores=None, memory_mb=None: config.update(cores=cores),
|
||||
)
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_edit(app, pilot) # Enter, then e on VM 108
|
||||
fill(app, cores="8")
|
||||
await press_button(app, pilot, "#apply")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.InfoScreen)
|
||||
assert resource_rows(app)["CPU"].startswith("8 cores")
|
||||
Reference in new issue
Block a user