Show configured resources on the info screen
A new "Resources" section at the top of the info screen shows CPU cores (with sockets for VMs), memory, swap for containers, and each disk with its size. The disks are the same ones the edit dialog offers, so cloud-init and CD drives are left out. A container without a cores limit shows "unlimited". The open info screen reloads after any guest job, so values edited with e show up straight away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #7.
This commit is contained in:
1 parent
0c3669271c
commit
5a3ca5a216
3 files changed
+107
-4
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