Live usage: CPU % and Mem % in the table, in-use on the info screen #24

Merged
Duckboot merged 1 commits from feat/live-usage-12 into main 2026-11-03 00:14:43 +01:00
6 changed files with 356 additions and 12 deletions
Showing only changes of commit eec65e0585 - Show all commits

No files matched your search

+2 -1
View File
@@ -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 <name>`);
it stores no credentials itself. Tested with cv4pve-cli 2.4.0.
+39
View File
@@ -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/<node>/<qemu|lxc>/<vmid>/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: `<cpu*100:.1f> %`
- Memory: `<mem MiB> MiB (<pct:.0f> %)`
- Swap (CT): `<swap MiB> MiB`
- Disk rootfs (CT): `<disk GiB:.1f> GiB (<pct:.0f> %)`; other disks and all VM disks `-`
- otherwise `-`.
A failure of `cli.current` keeps today's behaviour (inline "unavailable", status
unknown -> not running).
+56 -8
View File
@@ -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)
+8 -3
View File
@@ -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*$")
+204
View File
@@ -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"]
+47
View File
@@ -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 = (