Show live usage: CPU % and Mem % in the table, in-use on the info screen
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 <noreply@anthropic.com>
This commit was merged in pull request #24.
This commit is contained in:
1 parent
8c2b48a04c
commit
eec65e0585
6 files changed
+356
-12
No files matched your search
+56
-8
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user