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
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
Textual TUI for [cv4pve-cli](https://github.com/Corsinvest/cv4pve-cli): list Proxmox VE
|
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
|
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 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.
|
it stores no credentials itself. Tested with cv4pve-cli 2.4.0.
|
||||||
|
|||||||
@@ -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
|
- [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.
|
again); delete; list back to the baseline (no snapshots); CT running.
|
||||||
- [ ] PR with `Closes #8`, merge after user OK.
|
- [ ] 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
@@ -458,7 +458,7 @@ class InfoScreen(ModalScreen):
|
|||||||
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:
|
with Vertical(id="resources_section", classes="section") as res:
|
||||||
res.border_title = "Resources"
|
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:
|
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)
|
||||||
@@ -478,7 +478,9 @@ 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("#resources", DataTable).add_columns(
|
||||||
|
"Resource", "Configured", "In use"
|
||||||
|
)
|
||||||
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"
|
||||||
)
|
)
|
||||||
@@ -515,9 +517,10 @@ class InfoScreen(ModalScreen):
|
|||||||
except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app
|
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")
|
self.app.call_from_thread(self.app.notify, _message(exc), severity="error")
|
||||||
return
|
return
|
||||||
addresses, unavailable = [], ""
|
addresses, unavailable, usage = [], "", {}
|
||||||
try: # live status: the guest table's status may be stale
|
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
|
except Exception as exc: # noqa: BLE001 - shown inline, the rest of the screen still works
|
||||||
running, unavailable = False, f"unavailable: {_message(exc)}"
|
running, unavailable = False, f"unavailable: {_message(exc)}"
|
||||||
if running:
|
if running:
|
||||||
@@ -533,7 +536,7 @@ class InfoScreen(ModalScreen):
|
|||||||
self.running = running
|
self.running = running
|
||||||
self.app.call_from_thread(
|
self.app.call_from_thread(
|
||||||
self._show,
|
self._show,
|
||||||
(config, nets, storages, jobs, backups, addresses, unavailable),
|
(config, nets, storages, jobs, backups, addresses, unavailable, usage),
|
||||||
snaps,
|
snaps,
|
||||||
snap_note,
|
snap_note,
|
||||||
)
|
)
|
||||||
@@ -548,7 +551,7 @@ class InfoScreen(ModalScreen):
|
|||||||
self.app.notify(f"info: {_message(exc)}", severity="error")
|
self.app.notify(f"info: {_message(exc)}", severity="error")
|
||||||
|
|
||||||
def _fill_screen(
|
def _fill_screen(
|
||||||
self, config, nets, storages, jobs, backups, addresses, unavailable
|
self, config, nets, storages, jobs, backups, addresses, unavailable, usage
|
||||||
) -> None:
|
) -> None:
|
||||||
self.storages, self.jobs, self.config = storages, jobs, config
|
self.storages, self.jobs, self.config = storages, jobs, config
|
||||||
covering = cli.covering_jobs(jobs, self.guest)
|
covering = cli.covering_jobs(jobs, self.guest)
|
||||||
@@ -557,8 +560,9 @@ class InfoScreen(ModalScreen):
|
|||||||
or "No backup job covers this guest"
|
or "No backup job covers this guest"
|
||||||
)
|
)
|
||||||
table = self.query_one("#resources", DataTable).clear()
|
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)):
|
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.backups = backups
|
||||||
self._show_backups()
|
self._show_backups()
|
||||||
table = self.query_one("#nets", DataTable).clear()
|
table = self.query_one("#nets", DataTable).clear()
|
||||||
@@ -846,6 +850,8 @@ class GuestApp(App):
|
|||||||
"Cores",
|
"Cores",
|
||||||
"Memory (MiB)",
|
"Memory (MiB)",
|
||||||
"Disk (GiB)",
|
"Disk (GiB)",
|
||||||
|
"CPU %",
|
||||||
|
"Mem %",
|
||||||
)
|
)
|
||||||
self._show_context()
|
self._show_context()
|
||||||
self.action_reload()
|
self.action_reload()
|
||||||
@@ -902,6 +908,7 @@ class GuestApp(App):
|
|||||||
"!" if g["vmid"] in self.unprotected else "",
|
"!" if g["vmid"] in self.unprotected else "",
|
||||||
g["vmid"], g["name"], g["type"], g["node"], g["status"],
|
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),
|
g.get("maxcpu", "-"), _scaled(g.get("maxmem"), 2**20), _scaled(g.get("maxdisk"), 2**30),
|
||||||
|
*_percents(g),
|
||||||
key=str(g["vmid"]),
|
key=str(g["vmid"]),
|
||||||
) # fmt: skip
|
) # fmt: skip
|
||||||
if current and str(current["vmid"]) in self.guests:
|
if current and str(current["vmid"]) in self.guests:
|
||||||
@@ -915,7 +922,14 @@ class GuestApp(App):
|
|||||||
guests = cli.list_guests()
|
guests = cli.list_guests()
|
||||||
for g in guests:
|
for g in guests:
|
||||||
if acted and g["vmid"] == acted["vmid"]:
|
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
|
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")
|
self.call_from_thread(self.notify, _message(exc), severity="error")
|
||||||
return
|
return
|
||||||
@@ -1241,6 +1255,40 @@ def _resources(config: dict, kind: str) -> list[tuple[str, str]]:
|
|||||||
return rows
|
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:
|
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)
|
||||||
|
|||||||
@@ -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]
|
return [i["key"] for i in items if i.get("pending") is not None]
|
||||||
|
|
||||||
|
|
||||||
def status(guest: dict) -> str:
|
def current(guest: dict) -> dict:
|
||||||
"""Live status; get guests (/cluster/resources) lags a few seconds behind actions."""
|
"""status/current as a dict: live status plus cpu, mem, maxmem, disk, ..."""
|
||||||
path = f"/nodes/{guest['node']}/{_api_type(guest)}/{guest['vmid']}/status/current"
|
path = f"/nodes/{guest['node']}/{_api_type(guest)}/{guest['vmid']}/status/current"
|
||||||
return {
|
return {
|
||||||
i["key"]: i["value"] for i in json.loads(run("api", "get", path, "-o", "json"))
|
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*$")
|
_CONTEXT_LINE = re.compile(r"^(\*)?\s*(\S+)\s+(\S+)\s+\((.*)\)\s*$")
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ class Fake:
|
|||||||
self.take_calls, self.rollback_calls, self.delete_calls = [], [], []
|
self.take_calls, self.rollback_calls, self.delete_calls = [], [], []
|
||||||
self.snap_error_op = None
|
self.snap_error_op = None
|
||||||
self.snap_hook = 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):
|
def list_guests(self):
|
||||||
self.list_calls += 1
|
self.list_calls += 1
|
||||||
@@ -112,6 +115,17 @@ class Fake:
|
|||||||
self.status_calls.append(guest["vmid"])
|
self.status_calls.append(guest["vmid"])
|
||||||
return self.live_status.get(guest["vmid"], guest["status"])
|
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):
|
def not_backed_up(self):
|
||||||
if self.unprotected_error:
|
if self.unprotected_error:
|
||||||
raise self.unprotected_error
|
raise self.unprotected_error
|
||||||
@@ -227,6 +241,7 @@ VM_CONFIG = {
|
|||||||
def fake(monkeypatch):
|
def fake(monkeypatch):
|
||||||
f = Fake()
|
f = Fake()
|
||||||
# raising=False: the new names do not exist before the implementation (red run)
|
# 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 (
|
for name in (
|
||||||
"interfaces",
|
"interfaces",
|
||||||
"backup_storages",
|
"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:
|
async with app.run_test(size=(100, 24)) as pilot:
|
||||||
await open_info(app, pilot)
|
await open_info(app, pilot)
|
||||||
assert isinstance(app.screen.query_one("#info_body"), VerticalScroll)
|
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"]
|
||||||
@@ -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) -------------
|
# --- contexts / use_context (spec: "Context switching" > cli.py) -------------
|
||||||
|
|
||||||
CONTEXT_LIST = (
|
CONTEXT_LIST = (
|
||||||
|
|||||||
Reference in new issue
Block a user