Show configured resources on the info screen #7

Merged
Duckboot merged 1 commits from feat/info-resources into main 2026-11-02 22:48:49 +01:00
3 changed files with 107 additions and 4 deletions

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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)
+74
View File
@@ -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")