Show configured resources on the info screen #7
No files matched your search
@@ -19,7 +19,7 @@ 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) |
|
||||||
| `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 `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 `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) |
|
| `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:
|
def compose(self) -> ComposeResult:
|
||||||
with Vertical():
|
with Vertical():
|
||||||
yield Static(f"{self.guest['name']} ({self.guest['vmid']})", markup=False)
|
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:
|
with Vertical(id="backup_section", classes="section") as backup:
|
||||||
backup.border_title = "Backups"
|
backup.border_title = "Backups"
|
||||||
yield Static("", id="jobs", markup=False)
|
yield Static("", id="jobs", markup=False)
|
||||||
@@ -422,6 +425,7 @@ class InfoScreen(ModalScreen):
|
|||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
|
self.query_one("#resources", DataTable).add_columns("Resource", "Value")
|
||||||
self.query_one("#backups", DataTable).add_columns(
|
self.query_one("#backups", DataTable).add_columns(
|
||||||
"Date", "Storage", "Size (GiB)", "Format", "Notes"
|
"Date", "Storage", "Size (GiB)", "Format", "Notes"
|
||||||
)
|
)
|
||||||
@@ -488,6 +492,9 @@ class InfoScreen(ModalScreen):
|
|||||||
"; ".join(_job_text(j) for j in covering)
|
"; ".join(_job_text(j) for j in covering)
|
||||||
or "No backup job covers this guest"
|
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.backups = backups
|
||||||
self._show_backups()
|
self._show_backups()
|
||||||
table = self.query_one("#nets", DataTable).clear()
|
table = self.query_one("#nets", DataTable).clear()
|
||||||
@@ -828,9 +835,11 @@ class GuestApp(App):
|
|||||||
self.notify, f"{label} done: {guest['name']} ({guest['vmid']})"
|
self.notify, f"{label} done: {guest['name']} ({guest['vmid']})"
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
self._reload(
|
self._reload_infos(
|
||||||
acted=guest
|
guest
|
||||||
) # before un-busying, so no context switch can start mid-reload
|
) # 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"])
|
self.busy.discard(guest["vmid"])
|
||||||
|
|
||||||
def action_act(self, name: str) -> None:
|
def action_act(self, name: str) -> None:
|
||||||
@@ -1035,6 +1044,26 @@ def _reset_terminal() -> None:
|
|||||||
sys.stdout.flush()
|
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:
|
def _add_plain(table: DataTable, *cells, **kwargs) -> None:
|
||||||
"""DataTable parses str cells as markup; names and notes like "a[/b]" must stay literal."""
|
"""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)
|
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)
|
await settle(app, pilot)
|
||||||
shown = [ab.binding for ab in app.active_bindings.values() if ab.binding.show]
|
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)
|
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