diff --git a/README.md b/README.md index 00593f6..a27bd86 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/src/cv4pve_tui/app.py b/src/cv4pve_tui/app.py index dbb6a9d..3260186 100644 --- a/src/cv4pve_tui/app.py +++ b/src/cv4pve_tui/app.py @@ -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) diff --git a/tests/test_app.py b/tests/test_app.py index 229f6f1..7d0f1c9 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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")