From eec65e0585c9d35edd947cb4dbaab4b2adbb5e0f Mon Sep 17 00:00:00 2001 From: Per-Arne Hellarvik Date: Mon, 2 Nov 2026 23:52:32 +0100 Subject: [PATCH] Show live usage: CPU % and Mem % in the table, in-use on the info screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guest table gets two last columns, CPU % (cpu * 100) and Mem % (mem / maxmem), each with one decimal. They show "-" for a guest that is not running or lacks the field. The info screen's Resources table gets a header and an "In use" column: CPU %, memory MiB with %, swap for containers, and rootfs used GiB with % for containers. VM disks show "-", because the API reports 0 without guest-agent disk info. The info screen reads status and usage in one status/current call (new cli.current). After an action, the acted guest's status and usage both come from status/current, so a guest that has just started no longer shows the lagging listing's 0.0 %. One blind-written test expected a VM's CPU cell to read "4 cores". VMs have always shown sockets ("4 cores × 1 socket", as the resources spec and its tests define), so that expectation was corrected. Closes #12 Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- docs/plans/guest-tui-mvp.md | 39 +++++++ src/cv4pve_tui/app.py | 64 +++++++++-- src/cv4pve_tui/cli.py | 11 +- tests/test_app.py | 204 ++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 47 +++++++++ 6 files changed, 356 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index aa86ba3..f7dbee2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ Textual TUI for [cv4pve-cli](https://github.com/Corsinvest/cv4pve-cli): list Proxmox VE guests (VMs and LXC), start/shutdown/stop/reboot them, and change cores, memory and -disk size. +disk size. The table shows CPU % and Mem % of running guests and the info screen shows +in-use values (refresh with `g`). It shells out to `cv4pve-cli` and uses its current context (`cv4pve-cli config use `); it stores no credentials itself. Tested with cv4pve-cli 2.4.0. diff --git a/docs/plans/guest-tui-mvp.md b/docs/plans/guest-tui-mvp.md index abbe00f..8ba747f 100644 --- a/docs/plans/guest-tui-mvp.md +++ b/docs/plans/guest-tui-mvp.md @@ -512,3 +512,42 @@ CLI facts (cv4pve-cli 2.4.0, read-only probes): - [x] Live on CT 104: take `tui_live_test`, listed; roll back (CT running -> started again); delete; list back to the baseline (no snapshots); CT running. - [ ] PR with `Closes #8`, merge after user OK. + +--- + +# Live usage: CPU %, memory used, disk used (issue #12) + +## Decision (user-approved) + +Usage in the main table and in the info screen's Resources section. Values refresh with +the listing (`g`, after actions); no auto-refresh and no rrddata trend (out of scope). + +CLI facts (read-only probes): + +- `get guests -o json` per guest: `cpu` (fraction of the guest's CPUs, 0..1; the web UI + shows `cpu*100` %), `mem`, `maxmem` (bytes), `disk`, `maxdisk` (bytes). Stopped guests + have 0s. VMs report `disk: 0` (no guest-agent disk info) - shown as `-`, not 0. +- `api get /nodes////status/current -o json` -> key/value list: + `status`, `cpu`, `cpus`, `mem`, `maxmem`, `disk`, `maxdisk`, `uptime`; CTs also + `swap`, `maxswap`; VMs also `freemem`, `balloon`. + +### `cli.py` + +- `current(guest) -> dict` - the status/current key/value list as a dict. +- `status(guest)` keeps its behaviour (returns `current(guest)["status"]`), argv + unchanged. + +### `app.py` + +- Main table: two new columns at the end, "CPU %" and "Mem %": `cpu*100` and + `mem/maxmem*100`, one decimal; `-` for a guest that is not running or lacks the field. +- Info screen Resources table gets a header and a third column: Resource | Configured | + In use. The load uses `cli.current(guest)` (one call) for both the live status and the + usage. In use, only when running: + - CPU: ` %` + - Memory: ` MiB ( %)` + - Swap (CT): ` MiB` + - Disk rootfs (CT): ` GiB ( %)`; other disks and all VM disks `-` + - otherwise `-`. + A failure of `cli.current` keeps today's behaviour (inline "unavailable", status + unknown -> not running). diff --git a/src/cv4pve_tui/app.py b/src/cv4pve_tui/app.py index c4c9654..c04b76a 100644 --- a/src/cv4pve_tui/app.py +++ b/src/cv4pve_tui/app.py @@ -458,7 +458,7 @@ class InfoScreen(ModalScreen): 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") + yield DataTable(id="resources", cursor_type="none") with Vertical(id="backup_section", classes="section") as backup: backup.border_title = "Backups" yield Static("", id="jobs", markup=False) @@ -478,7 +478,9 @@ class InfoScreen(ModalScreen): yield Footer() def on_mount(self) -> None: - self.query_one("#resources", DataTable).add_columns("Resource", "Value") + self.query_one("#resources", DataTable).add_columns( + "Resource", "Configured", "In use" + ) self.query_one("#backups", DataTable).add_columns( "Date", "Storage", "Size (GiB)", "Format", "Notes" ) @@ -515,9 +517,10 @@ class InfoScreen(ModalScreen): except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app self.app.call_from_thread(self.app.notify, _message(exc), severity="error") return - addresses, unavailable = [], "" + addresses, unavailable, usage = [], "", {} try: # live status: the guest table's status may be stale - running = cli.status(guest) == "running" + usage = cli.current(guest) + running = usage.get("status") == "running" except Exception as exc: # noqa: BLE001 - shown inline, the rest of the screen still works running, unavailable = False, f"unavailable: {_message(exc)}" if running: @@ -533,7 +536,7 @@ class InfoScreen(ModalScreen): self.running = running self.app.call_from_thread( self._show, - (config, nets, storages, jobs, backups, addresses, unavailable), + (config, nets, storages, jobs, backups, addresses, unavailable, usage), snaps, snap_note, ) @@ -548,7 +551,7 @@ class InfoScreen(ModalScreen): self.app.notify(f"info: {_message(exc)}", severity="error") def _fill_screen( - self, config, nets, storages, jobs, backups, addresses, unavailable + self, config, nets, storages, jobs, backups, addresses, unavailable, usage ) -> None: self.storages, self.jobs, self.config = storages, jobs, config covering = cli.covering_jobs(jobs, self.guest) @@ -557,8 +560,9 @@ class InfoScreen(ModalScreen): or "No backup job covers this guest" ) table = self.query_one("#resources", DataTable).clear() + in_use = _in_use(usage, cli.kind(self.guest)) if self.running else {} for name, value in _resources(config, cli.kind(self.guest)): - _add_plain(table, name, value) + _add_plain(table, name, value, in_use.get(name, "-")) self.backups = backups self._show_backups() table = self.query_one("#nets", DataTable).clear() @@ -846,6 +850,8 @@ class GuestApp(App): "Cores", "Memory (MiB)", "Disk (GiB)", + "CPU %", + "Mem %", ) self._show_context() self.action_reload() @@ -902,6 +908,7 @@ class GuestApp(App): "!" if g["vmid"] in self.unprotected else "", g["vmid"], g["name"], g["type"], g["node"], g["status"], g.get("maxcpu", "-"), _scaled(g.get("maxmem"), 2**20), _scaled(g.get("maxdisk"), 2**30), + *_percents(g), key=str(g["vmid"]), ) # fmt: skip if current and str(current["vmid"]) in self.guests: @@ -915,7 +922,14 @@ class GuestApp(App): guests = cli.list_guests() for g in guests: if acted and g["vmid"] == acted["vmid"]: - g["status"] = cli.status(g) + live = cli.current(g) # status and usage of the acted guest lag too + g.update( + { + k: live[k] + for k in ("status", "cpu", "mem", "maxmem") + if k in live + } + ) except Exception as exc: # noqa: BLE001 - any failure must reach the user, not kill the app self.call_from_thread(self.notify, _message(exc), severity="error") return @@ -1241,6 +1255,40 @@ def _resources(config: dict, kind: str) -> list[tuple[str, str]]: return rows +def _pct(part, whole) -> float | None: + return part / whole * 100 if part is not None and whole else None + + +def _percents(g: dict) -> list[str]: + """CPU % and Mem % cells of the listing; "-" unless running and the field is known.""" + if g["status"] != "running": + return ["-", "-"] + cpu = g["cpu"] * 100 if g.get("cpu") is not None else None + mem = _pct(g.get("mem"), g.get("maxmem")) + return ["-" if v is None else f"{v:.1f}" for v in (cpu, mem)] + + +def _in_use(usage: dict, kind: str) -> dict[str, str]: + """In-use cells of a running guest by Resource name; missing fields stay absent ("-").""" + out = {} + if usage.get("cpu") is not None: + out["CPU"] = f"{usage['cpu'] * 100:.1f} %" + if usage.get("mem") is not None: + pct = _pct(usage["mem"], usage.get("maxmem")) + out["Memory"] = f"{usage['mem'] / 2**20:.0f} MiB" + ( + f" ({pct:.0f} %)" if pct is not None else "" + ) + if kind == "ct": + if usage.get("swap") is not None: + out["Swap"] = f"{usage['swap'] / 2**20:.0f} MiB" + if usage.get("disk") is not None: + pct = _pct(usage["disk"], usage.get("maxdisk")) + out["Disk rootfs"] = f"{usage['disk'] / 2**30:.1f} GiB" + ( + f" ({pct:.0f} %)" if pct is not None else "" + ) + return out + + 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/src/cv4pve_tui/cli.py b/src/cv4pve_tui/cli.py index 6a5cc9e..920d8e2 100644 --- a/src/cv4pve_tui/cli.py +++ b/src/cv4pve_tui/cli.py @@ -133,12 +133,17 @@ def pending_keys(guest: dict) -> list[str]: return [i["key"] for i in items if i.get("pending") is not None] -def status(guest: dict) -> str: - """Live status; get guests (/cluster/resources) lags a few seconds behind actions.""" +def current(guest: dict) -> dict: + """status/current as a dict: live status plus cpu, mem, maxmem, disk, ...""" path = f"/nodes/{guest['node']}/{_api_type(guest)}/{guest['vmid']}/status/current" return { i["key"]: i["value"] for i in json.loads(run("api", "get", path, "-o", "json")) - }["status"] + } + + +def status(guest: dict) -> str: + """Live status; get guests (/cluster/resources) lags a few seconds behind actions.""" + return current(guest)["status"] _CONTEXT_LINE = re.compile(r"^(\*)?\s*(\S+)\s+(\S+)\s+\((.*)\)\s*$") diff --git a/tests/test_app.py b/tests/test_app.py index 44eda92..68519e6 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -71,6 +71,9 @@ class Fake: self.take_calls, self.rollback_calls, self.delete_calls = [], [], [] self.snap_error_op = None self.snap_hook = None + # issue #12 (live usage); NB the name "current" is taken by the context-name attribute above + self.usage = {} # vmid -> overrides merged into the status/current dict + self.current_error, self.current_calls = None, [] def list_guests(self): self.list_calls += 1 @@ -112,6 +115,17 @@ class Fake: self.status_calls.append(guest["vmid"]) return self.live_status.get(guest["vmid"], guest["status"]) + def current_of(self, guest): + """Stands in for cli.current: status/current-like dict built from the guest dict.""" + self.status_calls.append(guest["vmid"]) + self.current_calls.append(guest["vmid"]) + if self.current_error: + raise self.current_error + data = {k: v for k, v in guest.items() + if k in ("cpu", "mem", "maxmem", "disk", "maxdisk")} # fmt: skip + data["status"] = self.live_status.get(guest["vmid"], guest["status"]) + return data | self.usage.get(guest["vmid"], {}) + def not_backed_up(self): if self.unprotected_error: raise self.unprotected_error @@ -227,6 +241,7 @@ VM_CONFIG = { def fake(monkeypatch): f = Fake() # raising=False: the new names do not exist before the implementation (red run) + monkeypatch.setattr(cli, "current", f.current_of, raising=False) for name in ( "interfaces", "backup_storages", @@ -2646,3 +2661,192 @@ async def test_info_screen_scrolls_on_short_terminals(fake): async with app.run_test(size=(100, 24)) as pilot: await open_info(app, pilot) assert isinstance(app.screen.query_one("#info_body"), VerticalScroll) + + +# --- live usage: CPU %, memory used, disk used (issue #12) --------------------------------- +# spec: docs/plans/guest-tui-mvp.md, "Live usage" > app.py + +GIB, MIB = 2**30, 2**20 + + +def column_labels(table): + return [str(c.label) for c in table.columns.values()] + + +async def table_cells(app, pilot, guests): + """Open the app with the given guests; return {vmid: row cells as str}.""" + fake_rows = {} + await settle(app, pilot) + table = app.query_one("#guests", DataTable) + for key in table.rows: + fake_rows[key.value] = [str(c) for c in table.get_row(key.value)] + return fake_rows + + +async def test_table_has_cpu_and_mem_percent_as_last_two_columns(fake): + app = make_app() + async with app.run_test() as pilot: + await settle(app, pilot) + assert column_labels(app.query_one("#guests", DataTable))[-2:] == [ + "CPU %", + "Mem %", + ] + + +async def test_table_running_guest_shows_cpu_and_mem_percent_one_decimal(fake): + # cpu is a 0..1 fraction -> x100; mem 932048896 of 8 GiB = 10.85 % + fake.guests = [dict(VM, cpu=0.0538, mem=932048896), CT] + app = make_app() + async with app.run_test() as pilot: + rows = await table_cells(app, pilot, fake.guests) + assert rows["108"][-2:] == ["5.4", "10.9"] + + +async def test_table_idle_running_guest_shows_zero_not_dash(fake): + fake.guests = [dict(VM, cpu=0.0, mem=0), CT] + app = make_app() + async with app.run_test() as pilot: + rows = await table_cells(app, pilot, fake.guests) + assert rows["108"][-2:] == ["0.0", "0.0"] + + +async def test_table_stopped_guest_shows_dashes(fake): + # stopped guests report 0s (or stale values); both columns are "-" + fake.guests = [VM, dict(CT, cpu=0.5, mem=256 * MIB)] + app = make_app() + async with app.run_test() as pilot: + rows = await table_cells(app, pilot, fake.guests) + assert rows["100"][-2:] == ["-", "-"] + + +async def test_table_running_guest_missing_fields_shows_dashes(fake): + app = make_app() # VM 108 is running but has no cpu/mem in the fixture + async with app.run_test() as pilot: + rows = await table_cells(app, pilot, fake.guests) + assert rows["108"][-2:] == ["-", "-"] + + +async def test_table_running_guest_with_only_cpu_shows_dash_for_mem(fake): + fake.guests = [dict(VM, cpu=0.25), CT] + app = make_app() + async with app.run_test() as pilot: + rows = await table_cells(app, pilot, fake.guests) + assert rows["108"][-2:] == ["25.0", "-"] + + +def in_use_rows(app): + t = app.screen.query_one("#resources", DataTable) + return {cells(t, i)[0]: cells(t, i)[2] for i in range(t.row_count)} + + +CT_RESOURCES = {"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 +CT_USAGE = {"cpu": 0.0538, "cpus": 1, "mem": 256 * MIB, "maxmem": 512 * MIB, "swap": 64 * MIB, + "maxswap": 256 * MIB, "disk": 2 * GIB, "maxdisk": 8 * GIB} # fmt: skip + + +def running_ct(fake): + fake.net_configs[100] = CT_RESOURCES + fake.disk_map = {"rootfs": "8G", "mp0": "32G"} + fake.live_status = {100: "running"} + fake.usage[100] = CT_USAGE + + +async def test_info_resources_table_has_header_and_three_columns(fake): + app = make_app() + async with app.run_test(size=SIZE) as pilot: + await open_info(app, pilot) + t = app.screen.query_one("#resources", DataTable) + assert t.show_header is True + assert column_labels(t) == ["Resource", "Configured", "In use"] + + +async def test_info_configured_column_unchanged_with_usage(fake): + running_ct(fake) + app = make_app() + async with app.run_test(size=SIZE) as pilot: + await open_info(app, pilot, down=1) + rows = resource_rows(app) + assert rows["CPU"] == "1 core" and rows["Memory"] == "512 MiB" + assert rows["Swap"] == "256 MiB" + assert rows["Disk rootfs"] == "8G" and rows["Disk mp0"] == "32G" + + +async def test_info_running_ct_in_use_column(fake): + running_ct(fake) + app = make_app() + async with app.run_test(size=SIZE) as pilot: + await open_info(app, pilot, down=1) + use = in_use_rows(app) + assert use["CPU"] == "5.4 %" + assert use["Memory"] == "256 MiB (50 %)" + assert use["Swap"] == "64 MiB" + assert use["Disk rootfs"] == "2.0 GiB (25 %)" + assert use["Disk mp0"] == "-" # only rootfs usage is known + + +async def test_info_running_vm_cpu_memory_in_use_and_disks_dash(fake): + # VMs have no guest-agent disk info: the disk In use is "-" even when the API sends a value + fake.net_configs[108] = { + "memory": 8192, + "scsi0": "local-lvm:vm-108-disk-0,size=10G", + } + fake.usage[108] = {"cpu": 0.1, "cpus": 4, "mem": 2 * GIB, "maxmem": 8 * GIB, + "disk": 3 * GIB, "maxdisk": 10 * GIB} # fmt: skip + app = make_app() + async with app.run_test(size=SIZE) as pilot: + await open_info(app, pilot) + use = in_use_rows(app) + assert use["CPU"] == "10.0 %" + assert use["Memory"] == "2048 MiB (25 %)" + assert use["Disk scsi0"] == "-" + + +async def test_info_stopped_guest_in_use_all_dashes(fake): + fake.net_configs[100] = CT_RESOURCES + fake.disk_map = {"rootfs": "8G", "mp0": "32G"} + fake.usage[100] = CT_USAGE # stale non-zero values; the guest is stopped + app = make_app() + async with app.run_test(size=SIZE) as pilot: + await open_info(app, pilot, down=1) + use = in_use_rows(app) + assert {"CPU", "Memory", "Swap", "Disk rootfs"} <= set(use) + assert set(use.values()) == {"-"} + + +async def test_info_load_uses_one_current_call_for_status_and_usage(fake): + running_ct(fake) + app = make_app() + async with app.run_test(size=SIZE) as pilot: + await open_info(app, pilot, down=1) + assert fake.current_calls == [100] + assert fake.iface_calls == [100] # live status from the same call -> running + + +async def test_info_current_failure_is_inline_unavailable_and_in_use_dashes(fake): + fake.current_error = cli_error("Error: usage-probe exploded") + app = make_app() + async with app.run_test(size=SIZE) as pilot: + await open_info(app, pilot) + assert isinstance(app.screen, appmod.InfoScreen) and app.is_running + text = text_of(app.screen).lower() + assert "unavailable" in text and "usage-probe exploded" in text + assert set(in_use_rows(app).values()) == {"-"} + # configured column still loads (a VM always shows sockets, see the #resources tests) + assert resource_rows(app)["CPU"] == "4 cores × 1 socket" + assert fake.iface_calls == [] # status unknown -> treated as not running + + +async def test_table_usage_of_acted_guest_comes_from_live_status(fake): + # the listing lags after an action: CPU %/Mem % of the acted guest must be live too + fake.guests = [dict(CT, status="stopped", cpu=0, mem=0)] + app = make_app() + async with app.run_test(size=SIZE) as pilot: + await settle(app, pilot) + fake.live_status = {100: "running"} + fake.usage = {100: {"cpu": 0.5, "mem": 256 * 2**20, "maxmem": 512 * 2**20}} + await pilot.press("s") # start CT 100; its listing still says stopped, 0, 0 + await settle(app, pilot) + row = [str(c) for c in app.query_one("#guests", DataTable).get_row("100")] + assert "running" in row + assert row[-2:] == ["50.0", "50.0"] diff --git a/tests/test_cli.py b/tests/test_cli.py index 61da543..834bf0e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -374,6 +374,53 @@ def test_status_reads_live_status_current(fake): ] +# --- current (issue #12; spec: "Live usage" > cli.py) ------------------------------ + + +def test_current_runs_status_current_for_lxc(fake): + fake.result = (json.dumps([{"key": "status", "value": "running"}]), "", 0) + cli.current(CT) + assert fake.argv == [ + "cv4pve-cli", "api", "get", "/nodes/mainframe/lxc/100/status/current", "-o", "json", + ] # fmt: skip + + +def test_current_runs_status_current_for_qemu(fake): + fake.result = (json.dumps([{"key": "status", "value": "running"}]), "", 0) + cli.current(VM) + assert fake.argv[3] == "/nodes/mainframe/qemu/108/status/current" + + +def test_current_flattens_key_value_list_to_dict(fake): + fake.result = ( + json.dumps([ + {"key": "status", "value": "running"}, {"key": "cpu", "value": 0.0538}, + {"key": "mem", "value": 932048896}, {"key": "maxmem", "value": 8589934592}, + {"key": "swap", "value": 0}, + ]), + "", 0, + ) # fmt: skip + assert cli.current(CT) == { + "status": "running", "cpu": 0.0538, "mem": 932048896, + "maxmem": 8589934592, "swap": 0, + } # fmt: skip + + +def test_current_cli_failure_raises_clierror(monkeypatch): + FakeRun(monkeypatch, "Error: nope", "", 5) + with pytest.raises(cli.CliError): + cli.current(CT) + + +def test_status_still_returns_status_from_current_payload(fake): + fake.result = ( + json.dumps([{"key": "cpu", "value": 0.5}, {"key": "status", "value": "running"}]), + "", 0, + ) # fmt: skip + assert cli.status(VM) == "running" + assert fake.argv[3] == "/nodes/mainframe/qemu/108/status/current" + + # --- contexts / use_context (spec: "Context switching" > cli.py) ------------- CONTEXT_LIST = (