Implement containers and vms plugins

This commit is contained in:
nicolargo
2026-07-14 22:46:46 +02:00
parent 2d6a5767a2
commit a991427785
22 changed files with 3721 additions and 13 deletions

View File

@@ -53,6 +53,18 @@ under the ``[containers]`` section:
# Define Podman sock
#podman_sock=unix:///run/user/1000/podman/podman.sock
.. note::
CPU and MEM alerts are raised **per container** and fire on ``warning``
and above; ``careful`` is colour-only (no history entry, no action). The
threshold keys are unchanged from earlier releases:
``cpu_careful/cpu_warning/cpu_critical``,
``mem_careful/mem_warning/mem_critical``, and the per-container overrides
``<name>_cpu_*`` / ``<name>_mem_*``. MEM thresholds are evaluated as a
percentage of each container's memory **limit**.
``disable_stats`` accepts any of
``name,status,uptime,cpu,mem,diskio,networkio,ports,command``.
You can use all the variables ({{foo}}) available in the containers plugin.
.. note::

View File

@@ -26,6 +26,15 @@ Configuration file options:
# Set the following key to True to display all VMs regarding their states
all=False
.. note::
The ``vms`` plugin is **disabled by default** (``disable=True``). It has
**no CPU/MEM thresholds or alerts** — VM rows are informational only (the
status is colour-coded: running is OK, starting/restarting is warning).
The TUI shows, per VM: engine (when more than one is active), name, status,
core count, CPU%, memory used / total, load (1/5/15 min, only when
available) and release.
You can use all the variables ({{foo}}) available in the containers plugin.
Filtering (for hide or show) is based on regular expression. Please be sure that your regular

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,273 @@
# Glances v5 — vms plugin port (G6A) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Port the v4 `vms` plugin to the v5 asyncio architecture — a collection of virtual machines fetched from two synchronous CLI engines (multipass, virsh), disabled by default, with **no alerts** (`EMITS_ALERTS=False`, mirroring v4's dead alert decorations).
**Architecture:** A collection plugin (`model_v5.py::PluginModel`, `IS_COLLECTION=True`, primary key `name`) whose `_grab_stats()` self-gates on `[vms] disable` (default True — mirrors the v4 default via the established `npu` pattern), then wraps the v4 CLI pipeline in `asyncio.to_thread`: it iterates the two v4 engine extensions (`multipass`, `virsh`) verbatim, injects `engine`/`engine_version`, sorts the list via the v4 `sort_vm_stats` rule (driven by the dynamic `glances_processes.sort_key`), and exposes the configured `max_name_size` through payload metadata. The base class turns the cumulative `cpu_time` counter into a per-second rate (`rate: True`). A dedicated `render_curses_v5.py` mirrors v4 `msg_curse()` (a `VMs` title line, a column header, one coloured row per VM), rendered full-width in the MAIN (RIGHT) column; it underlines the active sort column from the global `view["sort_key"]`, exactly like the processlist renderer.
**Tech Stack:** Python, multipass/virsh CLI (reused v4 engines), asyncio (to_thread), curses renderer v5, pytest
## Global Constraints
- **Mirror v4**: read the v4 `msg_curse()` + `update`/`update_local` + `vm_alert` before writing the renderer/model; divergent "clean generic" layouts are regressions.
- **Reuse the v4 CLI engines verbatim** (`glances/plugins/vms/engines/multipass.py`, `engines/virsh.py`) via `asyncio.to_thread`. Do **not** rewrite their parsing. The virsh **CVE-2026-46606** hardening (`_run_virsh` with `shell=False` + explicit arg lists) is inherited unchanged — do not touch it.
- **MAIN / RIGHT column, full-width** (not the 34-char LEFT-sidebar budget). `vms` is already registered in `RIGHT_SLOT` in `curses_renderer_v5.py` — no layout/orchestrator change is needed. There is **no** responsive column-drop logic in v4 `vms` (unlike processlist); mirror v4's fixed column set + conditional Engine/LOAD columns.
- **Disabled by default preserved**: v4 ships `[vms] disable=True`. v5's `discover_plugins` has **no per-plugin disable gate**, so the model self-gates in `_grab_stats` (mirror `glances/plugins/npu/model_v5.py::_is_enabled`). Absent / `disable=True` → the plugin yields an empty collection (renders nothing via the build_frame empty-collection rule).
- **`EMITS_ALERTS = False`** (mirror v4 dead alert decorations — no watched fields, no `_levels`, no history/action). Status colour is a rendering concern and stays.
- **Empty registry / empty stats must stay valid** (no engine binary present → empty collection, not a crash).
- **No dead code** — do not port v4's `items_history_list`, `sort_for_human` beyond the keys the derived sort key can take, or `get_export` override (the base `get_export` covers it).
- **Do not touch `NEWS.rst`** during development (release-time only).
- **No commits/push/PR** — stage only (`git add`), never `git commit`.
- Tests: `.venv/bin/python -m pytest`; lint `.venv/bin/python -m ruff check` + `ruff format`.
---
## Key implementation findings (decided, not open)
1. **Disabled-by-default has no v5 plumbing.** `glances/main_v5.py::discover_plugins` (lines 235281) discovers and instantiates every `model_v5.PluginModel` unconditionally — it never reads `[<plugin>] disable`. v4 `vms` defaults to `disable=True`. To preserve that default **without** inventing a global v5 feature, the model self-gates in `_grab_stats`, exactly like the already-shipped `npu` plugin (`glances/plugins/npu/model_v5.py::_is_enabled`, lines 97104): `str(self.config.get("vms","disable","True")).strip().lower() in ("false","0","no")`. This is a per-plugin, surgical honouring of the v4 default, tested by `test_disabled_by_default_returns_empty`.
2. **`cpu_time` rate keeps the same key name.** Declaring `cpu_time` with `rate: True` makes the base `_compute_rates_in_dict` (`base_v5.py` line 348: `stats[field_name] = max(0.0, delta / float(elapsed))`) **replace `cpu_time` in place** with the per-second rate — it does **not** create a `cpu_time_rate_per_sec` suffix key (that was the v4 `@_manage_rate` behaviour). On the first cycle, `elapsed<=0`, or when the raw value is non-numeric (multipass reports `cpu_time=None`), the base **deletes** `cpu_time` for that item (line 341/346). So the renderer reads `cpu_time` and shows `-` when absent — which is exactly the v4 outcome for multipass VMs. The renderer and tests use `cpu_time`, never the `_rate_per_sec` name.
3. **Sort underline is processlist-aligned via `view["sort_key"]` (payload carries NO sort_key).** The TUI passes a single global `view["sort_key"]` to every renderer (`glances_curses_v5.py::_render_view`, line 618) — the *process* sort key (`glances_processes.sort_key`), which is **dynamic** (default `auto` resolves to cpu/mem under load). The renderer reads `view.get("sort_key")` and underlines the matching column through a header→process-sort-key map (`_HEADER_SORT_FIELD`), exactly like `processlist`'s `_HEADER_SORT_KEY`. The mapping mirrors how `sort_vm_stats` maps the process key to a vms field: `Name → "name"`, `MEM/MAX → "memory_percent"`, `CPU% → "cpu_percent"` (the process key that sorts VMs by `cpu_time`); columns with no process-sort equivalent (Core, LOAD) are never underlined. The **model still pre-sorts** the data via `sort_vm_stats(glances_processes.sort_key)` (dynamic default preserved), but it does **not** expose a derived sort key — the payload shape carries no `sort_key`. Only `max_name_size` (a config value the pure renderer needs) is exposed via `_add_metadata`; it is not exportable (base `get_export` returns only `data` items), so it never leaks to exporters. This keeps vms and containers sort behaviour identical to processlist.
---
## File Structure
```
glances/plugins/vms/
__init__.py (v4 — untouched; kept for v4 runtime)
engines/__init__.py (v4 Protocol — untouched, reused)
engines/multipass.py (v4 — untouched, reused via to_thread)
engines/virsh.py (v4 — untouched, reused; CVE-2026-46606 hardening inherited)
model_v5.py (NEW — PluginModel: self-gate, engines, _collect, sort, metadata)
render_curses_v5.py (NEW — VMs title + header + per-VM rows)
tests/
test_plugin_vms_v5.py (NEW — model: identity/fields/gate/merge/rate/sort/metadata)
test_plugin_vms_render_curses_v5.py (NEW — renderer: title/header/rows/conditional cols/status colour/underline)
docs/aoa/vms.rst (update for v5; already in docs/aoa/index.rst — do NOT re-add)
conf/glances.conf ([vms] disable=True / max_name_size=20 / all=False already shipped — verify only)
```
---
### Task 1 — Model: identity, fields, self-gate, engines, `_collect`, sort, metadata
**Files:** `glances/plugins/vms/model_v5.py`, `tests/test_plugin_vms_v5.py`
**Interfaces:**
- Consumes: `StatsStoreV5`, `GlancesConfigV5`, v4 engines `MultipassVmExtension`/`VirshVmExtension` (`update(all_tag) -> (version:str, list[dict])`), `glances.processes.glances_processes` + `sort_stats`.
- Produces: `PluginModel` (`plugin_name="vms"`, `IS_COLLECTION=True`, `EMITS_ALERTS=False`, primary key `name`); payload `{"data":[...], "time_since_update":…, "max_name_size":…, "_levels":{}}` (**no `sort_key`** — the renderer reads the global `view["sort_key"]`, processlist-aligned); module-level `sort_vm_stats(stats) -> tuple[str, list]` (its returned key is consumed only to order the data, not exposed).
fields_description (from v4 — every field top-level, one primary key, `cpu_time` is the only rate; no watched fields):
```python
fields_description: ClassVar[dict[str, dict[str, Any]]] = {
"name": {"description": "VM name.", "unit": "string", "primary_key": True},
"id": {"description": "VM ID.", "unit": "string"},
"release": {"description": "VM release.", "unit": "string"},
"status": {"description": "VM status.", "unit": "string"},
"cpu_count": {"description": "VM CPU count.", "unit": "number"},
"cpu_time": {"description": "VM CPU time (per-second rate).", "unit": "percent", "rate": True},
"memory_usage": {"description": "VM memory usage.", "unit": "byte"},
"memory_total": {"description": "VM memory total.", "unit": "byte"},
"load_1min": {"description": "VM load, last 1 min (None if unsupported by the engine).", "unit": "float"},
"load_5min": {"description": "VM load, last 5 min (None if unsupported by the engine).", "unit": "float"},
"load_15min": {"description": "VM load, last 15 min (None if unsupported by the engine).", "unit": "float"},
"ipv4": {"description": "VM IPv4 address.", "unit": "string"},
"engine": {"description": "VM engine name.", "unit": "string"},
"engine_version": {"description": "VM engine version.", "unit": "string"},
}
```
`sort_vm_stats` — port **verbatim** from v4 (`glances/plugins/vms/__init__.py` lines 328350):
```python
def sort_vm_stats(stats: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
# Make VM sort follow the process sort (v4 parity).
if glances_processes.sort_key == "memory_percent":
sort_by, sort_by_secondary = "memory_usage", "cpu_time"
elif glances_processes.sort_key == "name":
sort_by, sort_by_secondary = "name", "cpu_time"
else:
sort_by, sort_by_secondary = "cpu_time", "memory_usage"
sort_stats_processes(
stats,
sorted_by=sort_by,
sorted_by_secondary=sort_by_secondary,
reverse=glances_processes.sort_key != "name",
)
return sort_by, stats
```
Model body (self-gate mirrors `npu`; `_collect` mirrors v4 `update_local`; `_add_metadata` exposes the derived key + name width):
```python
_DEFAULT_MAX_NAME_SIZE = 20
class PluginModel(GlancesPluginBase[list]):
plugin_name: ClassVar[str] = "vms"
IS_COLLECTION: ClassVar[bool] = True
EMITS_ALERTS: ClassVar[bool] = False
fields_description: ClassVar[dict[str, dict[str, Any]]] = {...} # as above
def __init__(self, store: Any, config: Any) -> None:
super().__init__(store, config)
# Mirror v4: build both engines unconditionally; each self-guards on
# its import_*_error_tag inside update() (returns ('', []) when the
# binary is absent). No construction gating — matches v4 __init__.
self.watchers: dict[str, VmsExtension] = {
"multipass": MultipassVmExtension(),
"virsh": VirshVmExtension(),
}
self._max_name_size = int(self.config.get("vms", "max_name_size", _DEFAULT_MAX_NAME_SIZE))
def _is_enabled(self) -> bool:
# Mirror v4 [vms] disable=True default (see npu/model_v5.py).
raw = self.config.get("vms", "disable", "True")
return str(raw).strip().lower() in ("false", "0", "no")
def _all_tag(self) -> bool:
raw = self.config.get("vms", "all", "False")
return str(raw).strip().lower() in ("true", "1", "yes")
def _collect(self) -> list:
stats: list[dict[str, Any]] = []
all_tag = self._all_tag()
for engine, watcher in self.watchers.items():
try:
version, vms = watcher.update(all_tag=all_tag)
except Exception as exc: # noqa: BLE001 — one bad engine must not kill the others
logger.debug("vms: engine %s update failed: %s", engine, exc)
continue
for vm in vms:
vm["engine"] = engine
vm["engine_version"] = version
stats.extend(vms)
# Pre-sort the list to follow the dynamic process sort key (v4
# parity). The returned key is not exposed — the renderer underlines
# from the global view["sort_key"], processlist-aligned.
_, stats = sort_vm_stats(stats)
return stats
async def _grab_stats(self) -> list:
if not self._is_enabled():
return []
return await asyncio.to_thread(self._collect)
def _add_metadata(self) -> None:
super()._add_metadata()
self._metadata["max_name_size"] = self._max_name_size
```
Steps:
- [ ] Write `tests/test_plugin_vms_v5.py` with `store` + `config` fixtures copied from an existing v5 collection test (`tests/test_plugin_diskio_v5.py` or `tests/test_plugin_wifi_v5.py`: `store()``StatsStoreV5()`; `config(tmp_path, monkeypatch)` writing an XDG-discovered `glances.conf` then `GlancesConfigV5()`), plus a `_cfg_with(tmp_path, monkeypatch, body)` helper that writes an arbitrary `[vms]` section. Add:
- `test_plugin_identity``plugin_name == "vms"`, `IS_COLLECTION is True`, `EMITS_ALERTS is False`, `_primary_key == "name"`.
- `test_fields_description` — keys equal the 14 declared names; `fd["name"]["primary_key"] is True`; `fd["cpu_time"]["rate"] is True`; no field has `"watched": True`.
- [ ] Run: `.venv/bin/python -m pytest tests/test_plugin_vms_v5.py::test_plugin_identity -v` → expect **FAIL** (module missing).
- [ ] Write COMPLETE `glances/plugins/vms/model_v5.py`: SPDX header (2026), `from __future__ import annotations`, imports (`asyncio`, `logging`, `typing`, `GlancesPluginBase`, the three engine imports mirroring v4 lines 1618, `glances_processes`, `sort_stats as sort_stats_processes`), `logger`, `_DEFAULT_MAX_NAME_SIZE`, the `fields_description`, the class body above, and the module-level `sort_vm_stats`.
- [ ] Run: `.venv/bin/python -m pytest tests/test_plugin_vms_v5.py -v` → expect **PASS**.
- [ ] Add `test_disabled_by_default_returns_empty` — plain `config` (no `[vms]` section) → `await p._grab_stats() == []` and `p._sort_key is None`.
- [ ] Add `test_enabled_merges_engines``_cfg_with(..., "[vms]\ndisable=False\n")`; monkeypatch `p.watchers = {"multipass": _FakeEngine("1.13", [ {"name":"vm-a","status":"running","cpu_count":2,"cpu_time":None,"memory_usage":1024,"memory_total":4096,"load_1min":None} ]), "virsh": _FakeEngine("9.0", [ {"name":"vm-b","status":"running","cpu_count":4,"cpu_time":1000,"memory_usage":2048,"memory_total":8192,"load_1min":None} ])}` (where `_FakeEngine(version, rows)` has `update(all_tag)` returning `(version, [dict(r) for r in rows])`). Await `_grab_stats()`; assert two items, each carrying its `engine`/`engine_version` (`vm-a``multipass`/`1.13`, `vm-b``virsh`/`9.0`).
- `test_one_engine_unavailable_still_returns_other` — one fake engine's `update` raises; assert the other engine's VM is still returned (non-empty).
- `test_no_engine_returns_empty` — both fake engines return `("", [])`; assert `await _grab_stats() == []` (with `disable=False`).
- [ ] Add `test_max_name_size_exposed_in_metadata` — enabled, one engine; run a full `await p.update()`; `payload = p.get_stats()`; assert `payload["max_name_size"] == 20` and `"sort_key" not in payload` (the payload carries no derived sort key — the renderer uses `view["sort_key"]`). The sort-underline behaviour is covered by the renderer tests in Task 2.
- [ ] Add `test_cpu_time_rate_derived` — enabled; a fake virsh engine whose `update` returns increasing cumulative `cpu_time` across calls (`1000` then `3000`) for `name="vm-b"`; monkeypatch `time.monotonic` (or drive two `await p.update()` calls with a controlled `time_since_update`) so `elapsed == 2.0`; after the **second** cycle assert `p.get_stats()["data"][0]["cpu_time"] == 1000.0` (`(3000-1000)/2`). Assert that after the **first** cycle `cpu_time` is **absent** from the item (base strips the rate field on the first sample). Mirror the rate-test pattern used in `tests/test_plugin_diskio_v5.py` for `read_bytes`.
- `test_multipass_cpu_time_none_absent` — a multipass fake returning `cpu_time=None`; after any cycle assert `"cpu_time"` not in the item (base deletes the non-numeric rate field).
- [ ] Run: `.venv/bin/python -m pytest tests/test_plugin_vms_v5.py -v` → expect **PASS**.
- [ ] `.venv/bin/python -m ruff check glances/plugins/vms/model_v5.py tests/test_plugin_vms_v5.py && .venv/bin/python -m ruff format glances/plugins/vms/model_v5.py tests/test_plugin_vms_v5.py`.
- [ ] `git add glances/plugins/vms/model_v5.py tests/test_plugin_vms_v5.py` — then STOP (no commit).
---
### Task 2 — Curses renderer (`VMs` title + header + per-VM rows)
**Files:** `glances/plugins/vms/render_curses_v5.py`, `tests/test_plugin_vms_render_curses_v5.py`
**Interfaces:**
- Consumes: collection payload `{"data":[...], "max_name_size":…}` (no `_levels``EMITS_ALERTS=False`; no `sort_key`) and the TUI `view` dict carrying the global `view["sort_key"]`.
- Produces: `render(payload, fields_desc=None, view=None) -> list[Row]` — a title Row, a column-header Row, one Row per VM. Sort-column underline is driven by `view["sort_key"]`, processlist-aligned.
Layout (mirror v4 `msg_curse`, `glances/plugins/vms/__init__.py` lines 204314; reference the processlist renderer for the header-underline pattern):
- Import `Cell`, `ColorRole`, `Row`, `title_role` from `glances.outputs.curses_renderer_v5`; `auto_unit` from `glances.globals` (`auto_unit(x)` returns `-` for None via `none_symbol='-'`).
- `_DEFAULT_MAX_NAME_SIZE = 20`. `_STATUS_WIDTH=10`, `_CORE_WIDTH=6`, `_CPU_WIDTH=6`, `_MEM_WIDTH=7`, `_LOAD_HEADER_WIDTH=17`, `_ENGINE_MIN_WIDTH=8`.
- `_sorted_by_label(process_sort_key) -> str` — maps the global process sort key to the vms human label, mirroring `sort_vm_stats`: `"memory_percent"``"memory consumption"`, `"name"``"VM name"`, else→`"CPU time"`. Used only for the title's "sorted by …" text (keeps the label consistent with the underlined column).
- `_HEADER_SORT_FIELD = {"Name":"name","CPU%":"cpu_percent","MEM/MAX":"memory_percent"}` — underline the header whose value equals the global `view["sort_key"]` (processlist-aligned; the map holds **process** sort keys, mirroring how `sort_vm_stats` maps `glances_processes.sort_key` to a vms field: `name`→Name, `memory_percent`→MEM, everything-else→CPU%). Columns with no process-sort equivalent (`Core`, `LOAD 1/5/15min`) are never underlined. (Note: v4 underlines only the `MEM` sub-cell; here MEM and MAX are one glued cell so the underline spans `MEM/MAX` — a cosmetic, intentional simplification.)
- `_status_role(status) -> ColorRole` (mirror v4 `vm_alert`): `"running"``ColorRole.OK`; `{"starting","restarting","delayed shutdown"}``ColorRole.WARNING`; else `ColorRole.DEFAULT` (v4 `INFO` — there is no `INFO` role in v5, DEFAULT is neutral). Compare case-insensitively on `str(status or "").lower()`.
- `render`:
- `items = [i for i in payload.get("data", []) if isinstance(i, dict)]` when `payload` is a dict, else `[]`. If `not items` → return `[]` (empty collection renders nothing — matches the build_frame rule; do not emit a bare title).
- `sort_key = (view or {}).get("sort_key")` (global process key, processlist-aligned — NOT from payload); `max_name_size = payload.get("max_name_size", _DEFAULT_MAX_NAME_SIZE)`.
- `show_engine = len({str(i.get("engine","")) for i in items}) > 1`.
- `name_w = min(max_name_size, max(len(str(i.get("name",""))) for i in items))` (mirror v4 line 231234; guard empty → `max_name_size`).
- `engine_w = max(_ENGINE_MIN_WIDTH, max(len(str(i.get("engine",""))) for i in items))` when `show_engine`.
- `show_load = items[0].get("load_1min") is not None` (v4 gates the LOAD columns on the first VM, line 253).
- **Title Row** (mirror v4 lines 214225): `Cell("VMs", color=ColorRole.HEADER, bold=True)`; if `len(items) > 1`: append `Cell(f"{len(items)}")` and `Cell(f"sorted by {_sorted_by_label(sort_key)}")`; if `not show_engine`: append `Cell(f"(served by {items[0].get('engine','')})")`.
- **Header Row** — a `_header(label, width, *, ljust=False)` helper building `Cell(text=label.ljust/rjust(width), color=ColorRole.HEADER, bold=True, underline=_HEADER_SORT_FIELD.get(label)==sort_key)`: `Engine`(ljust engine_w, only if `show_engine`), `Name`(ljust name_w), `Status`(rjust 10), `Core`(rjust 6), `CPU%`(rjust 6), `MEM/MAX`(the glued header `f"{'MEM':>7}/{'MAX':<7}"`), `LOAD 1/5/15min`(rjust 17, only if `show_load`), `Release`(plain).
- **Data Rows** — per VM (already sorted by the model):
- `Engine`(ljust engine_w) only if `show_engine`.
- `Name`: `Cell(str(vm.get("name",""))[:name_w].ljust(name_w))`.
- `Status`: `Cell(str(vm.get("status") or "")[:10].rjust(10), color=_status_role(vm.get("status")))`.
- `Core`: `Cell(_fmt(vm.get("cpu_count")).rjust(6))` where `_fmt(v)` = `str(v)` if `v` is not None else `"-"`.
- `CPU%`: `Cell(_fmt(vm.get("cpu_time")).rjust(6))` (absent for multipass / first cycle → `-`).
- `MEM/MAX`: one glued cell `Cell(f"{auto_unit(vm.get('memory_usage')):>7}/{auto_unit(vm.get('memory_total')):<7}")` (`auto_unit(None)``-`).
- `LOAD` only if `show_load`: try `Cell(f"{vm['load_1min']:>5.1f}/{vm['load_5min']:>5.1f}/{vm['load_15min']:>5.1f}")`; on `KeyError`/`TypeError` skip the cell (mirror v4 lines 300306).
- `Release`: `Cell(str(vm["release"]) if vm.get("release") is not None else "-")`.
- Return `[title_row, header_row, *data_rows]`.
Steps:
- [ ] Write `tests/test_plugin_vms_render_curses_v5.py` with `_payload(items, sort_key=None, max_name_size=20)` and `_flat(rows)` (concatenate every cell text) helpers, and a `_vm(**over)` factory returning a valid VM dict (defaults: `name="vm-a"`, `status="running"`, `cpu_count=2`, `cpu_time=None`, `memory_usage=1024`, `memory_total=4096`, `load_1min=None`, `engine="multipass"`, `release="24.04"`). Add:
- `test_empty_returns_nothing``render(_payload([])) == []`.
- `test_title_and_header_and_row` — one VM → flat text contains `"VMs"`, `"Name"`, `"Status"`, `"Core"`, `"CPU%"`, `"MEM"`, `"MAX"`, `"Release"`, and the VM name/status.
- `test_engine_column_hidden_single_engine` — two VMs both `engine="virsh"``"Engine"` **not** in the header; and title contains `"served by virsh"`.
- `test_engine_column_shown_multi_engine` — one `engine="multipass"`, one `engine="virsh"``"Engine"` in the header; title does **not** contain `"served by"`.
- `test_load_columns_hidden_when_load_none` — VM with `load_1min=None``"LOAD 1/5/15min"` not in header.
- `test_load_columns_shown_when_load_present` — VM with `load_1min=0.5, load_5min=0.4, load_15min=0.3``"LOAD 1/5/15min"` in header and `"0.5"` in a data row.
- `test_status_colour_running_ok``status="running"` → the status cell `.color == ColorRole.OK`.
- `test_status_colour_starting_warning``status="starting"` → status cell `.color == ColorRole.WARNING`.
- `test_status_colour_other_default``status="stopped"` → status cell `.color == ColorRole.DEFAULT`.
- `test_cpu_time_absent_shows_dash` — VM without a `cpu_time` key → the CPU% cell text stripped `== "-"`.
- `test_name_truncated_to_max_name_size``max_name_size=5`, VM `name="a-very-long-vm-name"` → the name cell text stripped has length ≤ 5.
- `test_sort_underline_name``render(_payload([_vm()]), view={"sort_key": "name"})` → the `Name` header cell `.underline is True`, `CPU%` header `.underline is False`.
- `test_sort_underline_cpu``view={"sort_key": "cpu_percent"}` → the `CPU%` header cell `.underline is True`, `Name` header `.underline is False`.
- `test_sort_underline_mem``view={"sort_key": "memory_percent"}` → the `MEM/MAX` header cell `.underline is True`.
- `test_no_view_no_underline``render(_payload([_vm()]))` (no `view`) → no header cell has `.underline is True`.
- [ ] Run: `.venv/bin/python -m pytest tests/test_plugin_vms_render_curses_v5.py::test_title_and_header_and_row -v` → expect **FAIL** (module missing).
- [ ] Write COMPLETE `glances/plugins/vms/render_curses_v5.py` per the layout above (SPDX header 2026, module docstring naming the v4 `msg_curse` reference + the MEM/MAX glue, the `view["sort_key"]` processlist-aligned underline, and the `max_name_size` metadata; the constants, `_HEADER_SORT_FIELD`, `_sorted_by_label`, `_status_role`, `_header`, `_fmt`, `render`).
- [ ] Run: `.venv/bin/python -m pytest tests/test_plugin_vms_render_curses_v5.py -v` → expect **PASS**.
- [ ] `.venv/bin/python -m ruff check glances/plugins/vms/render_curses_v5.py tests/test_plugin_vms_render_curses_v5.py && .venv/bin/python -m ruff format glances/plugins/vms/render_curses_v5.py tests/test_plugin_vms_render_curses_v5.py`.
- [ ] `git add glances/plugins/vms/render_curses_v5.py tests/test_plugin_vms_render_curses_v5.py` — then STOP (no commit).
---
### Task 3 — Config verification + docs + full-suite green
**Files:** `conf/glances.conf` (verify only), `docs/aoa/vms.rst`
**Interfaces:** none new — verification and documentation.
Steps:
- [ ] Verify `conf/glances.conf` `[vms]` already ships `disable=True`, `max_name_size=20`, `all=False` (confirmed present — do NOT re-add or change the default). Only touch it if a diff shows a key missing.
- [ ] Read `docs/aoa/vms.rst`. Update it for v5: confirm it states the plugin is **disabled by default** (`[vms] disable=True`), lists the columns (Engine/Name/Status/Core/CPU%/MEM/MAX/LOAD/Release), and notes there are **no thresholds/alerts** (`EMITS_ALERTS=False`). Do not add REST/threshold sections that do not apply. Confirm `vms` is already in `docs/aoa/index.rst` (line 47 — do NOT re-add).
- [ ] Run the v5 vms test set: `.venv/bin/python -m pytest tests/test_plugin_vms_v5.py tests/test_plugin_vms_render_curses_v5.py -v` → expect **PASS**.
- [ ] Run the full suite to confirm no regression: `.venv/bin/python -m pytest -q` → expect **PASS** (green, count unchanged aside from the new vms tests; a single pre-existing unrelated failure `tests/test_actions_sanitize.py::TestSecurePopen::test_pipe` may remain — it references none of the vms modules).
- [ ] `.venv/bin/python -m ruff check glances/plugins/vms/ tests/test_plugin_vms_v5.py tests/test_plugin_vms_render_curses_v5.py && .venv/bin/python -m ruff format --check glances/plugins/vms/ tests/test_plugin_vms_v5.py tests/test_plugin_vms_render_curses_v5.py`.
- [ ] `git add glances/plugins/vms/ tests/test_plugin_vms_v5.py tests/test_plugin_vms_render_curses_v5.py docs/aoa/vms.rst` (include `conf/glances.conf` only if it was edited) — then STOP (no commit).
---
## Final self-check (spec §6.2 / §7 coverage map)
| Spec requirement | Task |
| --- | --- |
| `PluginModel` collection, primary key `name`, `EMITS_ALERTS=False` | Task 1 |
| fields (14) incl. `cpu_time` `rate:True` → base-derived per-second rate (same key) | Task 1 |
| Reuse v4 multipass+virsh engines verbatim via `to_thread`; virsh CVE hardening inherited | Task 1 |
| Disabled by default preserved (self-gate mirroring `npu`, no v5 disable plumbing) | Task 1 + Key finding 1 |
| Merge engines + inject `engine`/`engine_version`; one-engine-failure tolerant; empty when no binary | Task 1 |
| Model pre-sort via `sort_vm_stats` (dynamic process-sort parity); `max_name_size` in metadata; payload carries no `sort_key` | Task 1 + Key finding 3 |
| Renderer: MAIN column full-width; Engine(>1)/Name/Status/Core/CPU%/MEM-MAX/LOAD(cond)/Release | Task 2 |
| Status colour via `vm_alert` mapping → `ColorRole` (running OK / starting… WARNING / else DEFAULT) | Task 2 |
| LOAD columns only when `load_1min is not None`; active sort-column header underlined from `view["sort_key"]` (processlist-aligned) | Task 2 |
| `cpu_time` absent (multipass/first cycle) → `-` | Task 2 + Key finding 2 |
| Config `[vms]` (disable/max_name_size/all) already shipped — verify | Task 3 |
| Docs `docs/aoa/vms.rst` updated for v5 (already in index) | Task 3 |
| Tests: identity/fields, gate, merge/partial-failure, rate, `max_name_size` metadata, renderer columns/colour/`view`-driven underline | Tasks 12 |

View File

@@ -0,0 +1,323 @@
# Glances v5 — G6A design (containers + vms)
**Date:** 2026-07-14
**Phase:** 2, group **G6A** (execution order G0→G1→G2→G3→G4A→G4B→G5→**G6A**→G6B→G6C→G7)
**Status:** design (approved decisions baked in; awaiting spec review before plans)
## 1. Goal & scope
Port the two v4 **external-integration** plugins `containers` and `vms` to the
Glances v5 asyncio architecture, mirroring v4 behaviour (per the "TUI v5 must
mirror v4" rule).
`containers` is the largest, most complex plugin of Phase 2: a 610-line
orchestrator plus three engine backends (`docker` 456, `podman` 466, `lxd`
367 lines) driven by per-container **background streaming threads**. `vms` is
far simpler: two synchronous CLI engines (multipass, virsh), disabled by
default, no threads.
The two plugins are independent; this single design doc covers both, with
**one execution plan per plugin** (mirrors G4B; keeps review checkpoints crisp,
especially for the thread-lifecycle work in `containers`).
## 2. Global constraints (apply to every task)
- **Mirror v4**: read the v4 `msg_curse()` + grabbers before writing each
renderer/model; divergent "clean generic" layouts are regressions.
- **Preserve v4 fetch performance for `containers`** (non-negotiable). The v4
streaming-thread + `ThreadPoolExecutor` architecture (issue #3559 perf fix)
must be reused **unchanged**. No per-cycle blocking-fan-out rewrite.
- **`containers`/`vms` render in the MAIN (RIGHT) column**, full-width and
responsive like `processlist`**NOT** the 34-char LEFT-sidebar budget.
Both names are already registered in `RIGHT_SLOT` in `curses_renderer_v5.py`
(above `processlist`), so no layout/orchestrator change is needed.
- **Empty registry / empty stats must stay valid** (no engine available →
empty collection, not a crash).
- **Alerts fire on `warning`+ only**; `careful` is colour-only (v5 engine
already collapses sub-warning levels — see `alerts_v5`).
- **No dead code**, no speculative config keys, surgical edits.
- **Do not touch `NEWS.rst`** during development (release-time only).
- **No commits/push/PR** — stage only.
- Tests: `.venv/bin/python -m pytest`; lint `ruff check` + `ruff format`.
## 3. Common porting pattern (from G4A/G4B)
Each plugin provides, under `glances/plugins/<name>/`:
- `model_v5.py::PluginModel(GlancesPluginBase[list])``plugin_name`,
`IS_COLLECTION = True`, `primary_key = "name"`, `fields_description`,
`async _grab_stats()` (wraps the v4 grabber in `asyncio.to_thread`), and —
where colouring/alerts apply — the base's `_derived_parameters()` threshold
machinery (per-primary-key overrides included).
- `render_curses_v5.py::render(payload, fields_desc=None, view=None)
-> list[Row]` — a header row then per-item rows, using
`Cell`/`Row`/`ColorRole`/`_LEVEL_TO_ROLE`/`title_role` from
`glances.outputs.curses_renderer_v5`.
- `tests/test_plugin_<name>_v5.py` and
`tests/test_plugin_<name>_render_curses_v5.py`.
Collection payload shape: `{"data": [...], **metadata, "_levels": {...}}` with
`_levels = {pk_value: {field: {level, prominent}}}`.
## 4. Fetch architecture — decision (Option A)
**`containers` reuses the v4 engine machinery unchanged.** The v4 fetch is what
gives the plugin its performance and cannot be swapped for a v5-native
per-cycle fan-out without regressing:
- Each engine (`DockerExtension`/`PodmanExtension`/`LxdExtension`) keeps its
`Protocol` contract (`stop()`, `update(all_tag) -> tuple[dict, list[dict]]`),
its module-level `disable_plugin_<engine>` import-error flag, and its inner
`*StatsFetcher` classes.
- **Streaming threads stay**: `ThreadedIterableStreamer` continuously pulls
`container.stats(stream=True)` per container in a background thread; the
update cycle only reads the **latest cached snapshot under lock — O(1),
non-blocking**. No N-blocking-calls-per-cycle.
- **`ThreadPoolExecutor(max_workers=6)`** for the concurrent `reload()` inspect
(issue #3559) stays.
- **Rate computation** (cpu% via `system_cpu_delta`, io/net cumulative-byte
deltas over `time_since_update`) stays inside the `StatsFetcher` classes —
it is **not** re-expressed as base-class `rate: True` fields (the base rate
mechanism cannot supply Docker's host-jiffies `system_cpu_delta`).
- The v5 `_grab_stats()` is a thin async wrapper: `await
asyncio.to_thread(self._update_watchers)` where `_update_watchers` runs the
v4 flatten/merge/inject-engine/sort pipeline over `self.watchers`.
**Considered and rejected:** (B) plain per-cycle snapshot with base
`rate: True` — regresses cpu% semantics (loses `system_cpu_delta`) and
reintroduces N blocking calls/cycle; (C) engines-without-persistent-threads
hybrid — keeps exact rates but still N blocking calls/cycle, perf "to be
validated" = risk. Both discarded to honour the non-regression constraint.
**`vms` engines** (multipass, virsh) are synchronous CLI callers with no
threads; they are reused as-is via `asyncio.to_thread`. The virsh
CVE-2026-46606 hardening (`_run_virsh` with `shell=False` + explicit arg
lists) is inherited unchanged.
## 5. New base_v5 mechanisms required by the port
The port needs two small, independent extension points on
`GlancesPluginBase`. Both are generic (default behaviour unchanged for every
other plugin) and delivered as the first tasks of the `containers` plan.
### 5.1 Plugin `stop()` teardown (required by Option A)
`GlancesPluginBase` (`base_v5.py`) currently has **no teardown hook**, and
`GlancesScheduler.stop()` (`scheduler_v5.py:157`) only cancels the per-plugin
loop tasks — it never lets a plugin release resources. Option A's streaming
threads would otherwise leak on shutdown. Minimal addition:
1. **`GlancesPluginBase.stop(self) -> None`** — a **no-op by default**
(extension point; future resource-holding plugins reuse it).
2. **`GlancesScheduler.stop()`** — after cancelling and draining the loop
tasks, call `entry.plugin.stop()` for every registered plugin, each guarded
(`try/except` + `logger.warning`) so one failing teardown cannot block the
rest.
3. **`containers` overrides `stop()`** to iterate `self.watchers` and call each
`watcher.stop()`; because `watcher.stop()` joins background threads
(blocking), the override runs them via `asyncio.to_thread`. **`vms` keeps
the base no-op** (no threads).
This is a base-level extension point, not a `containers`-specific hack — it
matches the "extensibility without touching the core repeatedly" principle and
is delivered as **the first task of the `containers` plan** (prerequisite for
A), with its own scheduler test (`stop()` is called on shutdown) before the
plugin work.
### 5.2 `threshold_field` schema alias (config non-regression)
The base resolves thresholds **strictly by field name**: for a watched field
`f`, `read_thresholds(field=f)` looks up `f_careful` / `f_warning` /
`f_critical` (plus per-pk `<pk>_f_<level>` and, non-strict, bare `<level>`).
But v4 `containers` decouples the **value field** from the **threshold key
prefix**: the values live under `cpu_percent` / `memory_usage`, while
`update_views` reads thresholds via `get_alert(header='cpu'|'mem')`, i.e. from
the shipped config keys `cpu_careful` / `mem_warning` / `<name>_cpu_critical`.
Ported naively (field `cpu_percent` → base looks up `cpu_percent_careful`),
**every existing `[containers] cpu_careful=…` config would silently stop being
read** — a threshold non-regression.
**Fix** — add an optional field-schema key **`threshold_field`**: when present,
the base passes it to `read_thresholds` / `read_thresholds_categorical` /
`_scan_pk_override_fields` **in place of the field name** for config-key
resolution (value lookup still uses the real field name). Default = the field
name, so every other plugin is unaffected. This preserves **both** the v4 API
field names (`cpu_percent` / `memory_usage`) **and** the v4 config keys
(`cpu_*` / `mem_*`, global and per-container).
Touched methods in `base_v5.py` (all currently key on `field_name`):
`_precompute_plugin_thresholds`, `_resolve_numeric_thresholds`,
`_resolve_categorical_mapping`, `_scan_pk_override_fields` — each reads the
schema's `threshold_field` (falling back to `field_name`) for the config-key
prefix. `containers` declares `threshold_field: "cpu"` on `cpu_percent` and
`threshold_field: "mem"` on `memory_usage`. Unit-tested in isolation
(alias-prefixed keys resolve; per-pk `<name>_cpu_warning` resolves; absence of
the key preserves the field-name default for existing plugins).
Delivered as the **second base task of the `containers` plan** (before the
model), independently reviewable from the `stop()` hook.
## 6. Per-plugin design
### 6.1 containers (collection)
- **Engines**: reuse v4 `docker`/`podman`/`lxd` extension modules verbatim.
`self.watchers` built in `__init__` gated on `disable_plugin_<engine>`
(docker → `DockerExtension()`, podman → `PodmanExtension(podman_sock=…)`,
lxd → `LxdExtension(poll_interval=…)`). Missing SDK → engine skipped, plugin
still valid (empty if all skipped).
- **primary_key** = `name` (mirror v4 `get_key()`).
- **fields_description** (from v4): `name`(pk), `id`, `image`, `status`,
`created`, `command`, `cpu_percent`, `cpu_limit`, `memory_usage`,
`memory_usage_no_cache`, `memory_percent`, `memory_inactive_file`,
`memory_limit`, `io_rx`, `io_wx`, `network_rx`, `network_tx`, `ports`,
`uptime`, `engine`, `pod_name`, `pod_id`. The engines' internal working
dicts (`cpu`/`memory`/`io`/`network`, `key`, `memory_percent`) are kept for
computation and excluded from export (v4 `export_exclude_list`); the flat v5
field filter keeps only declared top-level keys.
- **Watched fields / levels** (`EMITS_ALERTS = True`, mirror v4 `update_views`):
- `cpu_percent`: `watched`, `watch_direction: "high"`,
**`threshold_field: "cpu"`** (§5.2). Thresholds from `[containers]
cpu_careful/cpu_warning/cpu_critical`; **per-container override**
`<name>_cpu_careful/…` resolved by the base's per-primary-key mechanism via
the `cpu` alias prefix.
- **Memory — three distinct surfaces** (do NOT collapse; preserves v4 export
parity). The base's `normalize_by` was rejected: it yields a fraction
(`usage/limit ∈ [0,1]`) while the shipped `mem_*` thresholds are percents
and v4 alerts on the percent → would misfire ×100.
- `memory_usage` (**export**): the exact value v4 stores in its flat
`memory_usage` field — unchanged export semantics, no dashboard
regression.
- `memory_usage_no_cache` (**display**): `usage inactive_file` (v4
`memory_usage_no_cache`), shown in the MEM column, `/MAX = memory_limit`.
- `memory_percent` (**alert**): `watched`, `watch_direction: "high"`,
**`threshold_field: "mem"`** (§5.2), computed
`= memory_usage_no_cache / memory_limit * 100`. Thresholds from
`[containers] mem_careful/mem_warning/mem_critical` + per-container
`<name>_mem_careful/…` via the `mem` alias prefix (exact v4
`get_alert(…, maximum=memory.limit)` parity). `memory_inactive_file` is
kept for v4 API parity.
- Value used for memory is `usage - inactive_file` (v4
`memory_usage_no_cache`), computed at grab time.
- **Sort — processlist-aligned** (maintainer decision 2026-07-14): the model
pre-sorts `data` via `glances.processes.glances_processes.sort_key` (v4
`sort_docker_stats` → `sort_stats_processes`), and the renderer reads the
global `view["sort_key"]` and underlines the matching column via a
header→sort-key map (mirrors `processlist`'s `_HEADER_SORT_KEY`). The process
sort key is **dynamic by default** (`auto` → cpu/mem per load, resolved
before the getter returns) — do NOT hardcode a static default that overrides
it. The active sort column is NOT passed via payload metadata (only
`disable_stats`/`max_name_size` are). `vms` uses the identical mechanism.
- **Renderer** (mirror v4 `msg_curse`, MAIN column, responsive full-width):
columns, each gated by `[containers] disable_stats`
(`name,status,uptime,cpu,mem,diskio,networkio,ports,command`): **Engine**
(only if >1 engine present), **Pod** (only if any container carries a
`pod_name`), **Name** (width `max_name_size`, default 20), **Status**,
**Uptime**, **CPU%**, **MEM/MAX**, **IOR/s IOW/s**, **Rx/s Tx/s** (bits
unless `--byte`), **Ports**, **Command**. cpu/mem cell colour from
`_levels`; **status** colour via a `container_alert(status)` mapping
(running/healthy→OK, dead/unhealthy→CRITICAL/ERROR, created/exited→WARNING,
paused/restarting→CAREFUL, else INFO) → `ColorRole`. Active sort column
header underlined.
- **`stop()` override** (see §5).
- **Config** (`[containers]`): `disable`, `show=`/`hide=` (regex filters via
base), `max_name_size` (20), `disable_stats` (CSV), `all` (show stopped),
`podman_sock`, thresholds `cpu_*`/`mem_*` (global + per-container). All
already shipped in v4 conf — no new keys.
### 6.2 vms (collection)
- **Engines**: reuse v4 `multipass` (`/snap/bin/multipass`) and `virsh`
(`/usr/bin/virsh`) CLI extensions verbatim, via `asyncio.to_thread`.
Availability = binary exists + executable (`import_<engine>_error_tag`).
virsh CVE-2026-46606 hardening inherited.
- **primary_key** = `name` (mirror v4 `get_key()`).
- **fields_description** (from v4): `name`(pk), `id`, `release`, `status`,
`cpu_count`, `cpu_time`, `memory_usage`, `memory_total`, `load_1min`,
`load_5min`, `load_15min`, `ipv4`, `engine`, `engine_version`. v4 derives
`cpu_time_rate_per_sec` via `@_manage_rate`; the v5 model declares
`cpu_time` with `rate: True` so the base derives the per-second rate.
- **Levels**: none. **`EMITS_ALERTS = False`** — mirrors v4's *actual*
behaviour: v4 `msg_curse` reads cpu/mem/load decorations but `update_views`
never populates them, so VM alerts never fire. No threshold config exists in
`[vms]`; adding one would be a speculative feature (YAGNI). Status colour is
a rendering concern and stays.
- **Sort**: model pre-sorts via the process sort key (v4 `sort_vm_stats`),
same `view["sort_key"]` underline pattern as `containers`/`processlist`.
- **Renderer** (mirror v4 `msg_curse`, MAIN column, responsive full-width):
**Engine** (only if >1 engine), **Name** (`max_name_size`), **Status**,
**Core** (`cpu_count`), **CPU%** (`cpu_time` rate), **MEM/MAX**, **LOAD
1/5/15min** (only if `load_1min is not None`), **Release**. Status colour via
a `vm_alert(status)` mapping (running→OK, starting/restarting/delayed
shutdown→WARNING, else INFO) → `ColorRole`. Active sort column underlined.
- **`stop()`**: base no-op (no threads).
- **Config** (`[vms]`): `disable` (default `True` — plugin OFF by default,
preserved), `max_name_size` (20), `all`. No threshold keys. No new keys.
## 7. Testing strategy
Per plugin: identity/fields tests; grab-merge + partial-failure (one engine
raises → others still returned, plugin non-empty); renderer layout (header +
rows) with column-visibility toggles; sort-column underline.
- **base `stop()` hook** (containers plan, Task 1): base default is a no-op;
`scheduler.stop()` calls `plugin.stop()` for every plugin; a plugin whose
`stop()` raises does not prevent the others' teardown.
- **containers**: engine flatten/merge + engine injection; `all` tag; show/hide
filter; `memory_usage_no_cache` computation; cpu/mem level mapping incl.
per-container threshold override; `disable_stats` column hiding; Engine/Pod
conditional columns (1 vs >1 engine, pod vs no-pod); status→colour mapping;
sort underline; `stop()` override joins/stops each watcher; empty when no
engine available.
- **vms**: multipass + virsh parse/merge; `cpu_time` rate derivation
(`rate: True`); load columns present only when `load_1min` non-None;
status→colour mapping; `EMITS_ALERTS is False`; disabled-by-default honoured;
empty when no binary present.
## 8. Deliberate divergences from v4 (documented)
1. **`stop()` teardown hook** added to `GlancesPluginBase` + wired into
`GlancesScheduler.stop()`. New base extension point required by the async
port of `containers`' streaming threads; harmless no-op for every other
plugin. (§5.1)
1b. **`threshold_field` schema alias** added to `GlancesPluginBase` threshold
resolution — decouples the config-key prefix from the value-field name so
the shipped `[containers] cpu_*`/`mem_*` keys keep working with
`cpu_percent`/`memory_usage` fields. Defaults to the field name → no change
for any other plugin. (§5.2)
2. **`containers` fetch runs under `asyncio.to_thread`** (the streaming
threads and thread-pool themselves are unchanged v4 code); this is an
integration wrapper, not a behavioural change.
3. **`vms` `EMITS_ALERTS = False`** makes explicit what v4 already did in
practice (dead alert decorations) — no functional change, removes the
dead read path.
## 9. Accepted limitations / out of scope
- **Cross-engine `name` collision**: `primary_key = name`; if two engines
expose the same container/VM name, their `_levels` entries collide (last
wins). This is **v4 parity** (v4 keys `self.views` by `name` too) — accepted,
documented, not fixed in G6A.
- **No shared base** between `containers` and `vms`: v4 shares patterns but no
code; two consumers do not justify a speculative common base (YAGNI). Each
plugin is self-contained.
- SNMP input method (never implemented in v4 for either plugin).
- LXD/podman pod nuances beyond v4 behaviour; no new engine support.
- Any `NEWS.rst` entry (maintainer, release-time).
## 10. Plan decomposition
- **Plan 1 — `containers`** (`docs/superpowers/plans/2026-07-14-glances-v5-g6a-containers.md`):
Task 1 = base `stop()` hook + scheduler wiring + tests (§5.1); Task 2 = base
`threshold_field` alias + tests (§5.2); both prerequisites, independently
reviewable. Then model (engine reuse, fields with `threshold_field` aliases,
levels via `normalize_by`, memory computation, sort), `stop()` override,
renderer (full column set + `disable_stats` + Engine/Pod conditionals +
status colour + sort underline), `docs/aoa/containers.rst`.
- **Plan 2 — `vms`** (`docs/superpowers/plans/2026-07-14-glances-v5-g6a-vms.md`):
model (CLI engine reuse, fields, `cpu_time` rate, `EMITS_ALERTS=False`,
sort), renderer (columns incl. conditional LOAD + status colour + sort
underline), `docs/aoa/vms.rst`.
Each plan updates the plugin's `docs/aoa/*.rst` doc.

View File

@@ -177,6 +177,14 @@ def build_parser() -> argparse.ArgumentParser:
default=False,
help="Mask the last two octets of the public IP address in the TUI (a.b.c.d -> a.b.*.*).",
)
parser.add_argument(
"-b",
"--byte",
dest="byte",
action="store_true",
default=False,
help="Display network rate in bytes per second (default: bits per second).",
)
parser.add_argument(
"--set-password",
action="store_true",
@@ -427,6 +435,7 @@ def assemble(
meangpu=getattr(args, "meangpu", False),
fahrenheit=getattr(args, "fahrenheit", False),
hide_public_info=getattr(args, "hide_public_info", False),
byte=getattr(args, "byte", False),
)
return app, scheduler, host, int(port), tui

View File

@@ -177,6 +177,7 @@ class TuiV5(threading.Thread):
meangpu: bool = False,
fahrenheit: bool = False,
hide_public_info: bool = False,
byte: bool = False,
) -> None:
super().__init__(name="glances-tui-v5", daemon=True)
self.store = store
@@ -213,6 +214,9 @@ class TuiV5(threading.Thread):
self._meangpu = bool(meangpu)
self._fahrenheit = bool(fahrenheit)
self._hide_public_info = bool(hide_public_info)
# Network I/O unit for the containers renderer, seeded from --byte.
# False (default) = bits, matching the v4 default.
self._byte = bool(byte)
# Vertical scroll offset of the help overlay (rows). Reset to 0 each
# time the overlay is opened; clamped to the content in ``_paint_help``
# (which is the only place that knows the terminal height).
@@ -637,6 +641,7 @@ class TuiV5(threading.Thread):
view["meangpu"] = self._meangpu
view["fahrenheit"] = self._fahrenheit
view["hide_public_info"] = self._hide_public_info
view["byte"] = self._byte
# Full mode: bars span (almost) the whole width; compact: a column.
view["quicklook_width"] = max(20, max_x - 8) if self._full_quicklook else self._QUICKLOOK_COMPACT_WIDTH
return view

View File

@@ -230,7 +230,7 @@ class LxdExtension:
except Exception:
self.local_node = None
except Exception as e:
logger.error(f"{self.ext_name} plugin - Can't connect to LXD ({e})")
logger.debug(f"{self.ext_name} plugin - Can't connect to LXD ({e})")
self.client = None
self.disable = True

View File

@@ -0,0 +1,228 @@
#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Glances v5 — Containers plugin (collection, per-container).
Port of ``glances/plugins/containers/__init__.py`` (v4). Reuses the v4
Docker/Podman/LXD engine machinery VERBATIM (streaming threads +
ThreadPoolExecutor); ``_grab_stats`` only wraps the v4 flatten/merge/sort
pipeline in ``asyncio.to_thread``. CPU/IO/net rates stay computed inside the
engines' ``StatsFetcher`` classes. See design doc
docs/superpowers/specs/2026-07-14-glances-v5-g6a-design.md.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, ClassVar
from glances.plugins.containers.engines import ContainersExtension
from glances.plugins.containers.engines.docker import DockerExtension, disable_plugin_docker
from glances.plugins.containers.engines.lxd import LxdExtension, disable_plugin_lxd
from glances.plugins.containers.engines.podman import PodmanExtension, disable_plugin_podman
from glances.plugins.plugin.base_v5 import GlancesPluginBase
from glances.processes import glances_processes
from glances.processes import sort_stats as sort_stats_processes
logger = logging.getLogger(__name__)
_DEFAULT_PODMAN_SOCK = "unix:///run/user/1000/podman/podman.sock"
class PluginModel(GlancesPluginBase[list]):
"""Per-container plugin (collection)."""
plugin_name: ClassVar[str] = "containers"
IS_COLLECTION: ClassVar[bool] = True
EMITS_ALERTS: ClassVar[bool] = True
fields_description: ClassVar[dict[str, dict[str, Any]]] = {
"name": {"description": "Container name.", "unit": "string", "primary_key": True},
"id": {"description": "Container ID.", "unit": "string"},
"image": {"description": "Container image.", "unit": "string"},
"status": {"description": "Container status.", "unit": "string"},
"created": {"description": "Container creation date.", "unit": "string"},
"command": {"description": "Container command.", "unit": "string"},
"cpu_percent": {
"description": "Container CPU consumption.",
"unit": "percent",
"watched": True,
"watch_direction": "high",
"prominent": False,
"threshold_field": "cpu",
},
"cpu_limit": {"description": "Container CPU limit.", "unit": "number"},
"memory_usage": {
"description": "Container memory usage (v4 export value: usage cache when present). Feeds export/REST.",
"unit": "byte",
},
"memory_usage_no_cache": {
"description": "Container memory usage minus inactive_file. TUI display value.",
"unit": "byte",
},
"memory_inactive_file": {"description": "Container memory inactive file.", "unit": "byte"},
"memory_limit": {"description": "Container memory limit.", "unit": "byte"},
"memory_percent": {
"description": "Container memory usage as a percentage of its limit.",
"unit": "percent",
"watched": True,
"watch_direction": "high",
"prominent": False,
"threshold_field": "mem",
},
"io_rx": {"description": "Container IO bytes read rate.", "unit": "bytepersecond"},
"io_wx": {"description": "Container IO bytes write rate.", "unit": "bytepersecond"},
"network_rx": {"description": "Container network RX bitrate.", "unit": "bitpersecond"},
"network_tx": {"description": "Container network TX bitrate.", "unit": "bitpersecond"},
"ports": {"description": "Container ports.", "unit": "string"},
"uptime": {"description": "Container uptime.", "unit": "string"},
"engine": {"description": "Container engine (Docker, Podman, LXD).", "unit": "string"},
"pod_name": {"description": "Pod name (Podman only).", "unit": "string"},
"pod_id": {"description": "Pod ID (Podman only).", "unit": "string"},
}
def __init__(self, store, config) -> None:
super().__init__(store, config)
# Reuse the v4 engines verbatim (Option A). Each construction is
# guarded so a broken engine leaves the others (and an empty plugin)
# valid.
self.watchers: dict[str, ContainersExtension] = {}
if not disable_plugin_docker:
self._try_add_watcher("docker", lambda: DockerExtension())
if not disable_plugin_podman:
self._try_add_watcher("podman", lambda: PodmanExtension(podman_sock=self._podman_sock()))
if not disable_plugin_lxd:
self._try_add_watcher("lxd", lambda: LxdExtension(poll_interval=self._poll_interval()))
# Static config surfaced to the renderer via metadata each cycle.
raw_disable = self.config.get(self.plugin_name, "disable_stats", "")
self._disable_stats: list[str] = (
[s.strip() for s in raw_disable.split(",") if s.strip()]
if isinstance(raw_disable, str)
else list(raw_disable or [])
)
try:
self._max_name_size = int(self.config.get(self.plugin_name, "max_name_size", 20))
except (TypeError, ValueError):
self._max_name_size = 20
def _try_add_watcher(self, engine: str, factory) -> None:
try:
self.watchers[engine] = factory()
except Exception as e:
logger.warning("containers: engine %s unavailable (%s) — skipped", engine, e)
def _podman_sock(self) -> str:
sock = self.config.get(self.plugin_name, "podman_sock", "")
if isinstance(sock, (list, tuple)):
sock = sock[0] if sock else ""
return str(sock) if sock else _DEFAULT_PODMAN_SOCK
def _poll_interval(self) -> float:
val = self.config.get(self.plugin_name, "refresh", -1.0)
try:
val = float(val)
except (TypeError, ValueError):
val = -1.0
return val if val > 0 else 2.0
def _all_tag(self) -> bool:
val = self.config.get(self.plugin_name, "all", False)
return str(val).lower() == "true"
def _update_watchers(self) -> list:
"""v4 flatten/merge/inject-engine/reconcile-memory/sort pipeline.
Blocking (reads engine snapshots); always called via to_thread.
show/hide filtering is left to the base ``_filter_collection`` on the
``name`` primary key — not re-implemented here.
"""
all_tag = self._all_tag()
items: list[dict[str, Any]] = []
for engine, watcher in self.watchers.items():
try:
_version, containers = watcher.update(all_tag=all_tag)
except Exception as e:
logger.warning("containers: engine %s update failed: %s", engine, e)
continue
for c in containers:
c["engine"] = engine
self._reconcile_memory(c)
items.append(c)
return self._sort(items)
@staticmethod
def _reconcile_memory(container: dict[str, Any]) -> None:
"""Compute the three memory surfaces from the engine's nested
``memory`` dict (design §6.1, three-surface decision):
- ``memory_usage`` — LEFT UNTOUCHED (v4 export value set by
the engine's ``generate_stats``). Feeds export / REST.
- ``memory_usage_no_cache`` — ``usage inactive_file``. Feeds the
TUI MEM column (display).
- ``memory_percent`` — ``memory_usage_no_cache / limit * 100``.
Feeds alerting (thresholds, ``threshold_field="mem"``).
"""
mem = container.get("memory") or {}
if "usage" not in mem:
return
usage_no_cache = mem["usage"] - mem.get("inactive_file", 0)
container["memory_usage_no_cache"] = usage_no_cache
container["memory_inactive_file"] = mem.get("inactive_file")
limit = mem.get("limit")
container["memory_percent"] = (usage_no_cache / limit * 100.0) if limit else None
@staticmethod
def _sort(stats: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Faithful reimplementation of v4 ``sort_docker_stats`` — sort by the
process engine's active sort key so containers track the process sort.
``glances_processes.sort_key`` is read fresh every cycle (dynamic
default preserved: ``auto`` resolves to cpu/mem per load before the
getter returns — never hardcode a static key). The map resolves the
dynamically-selected key to a container column; the fallback tuple is
only the column mapping for genuinely unmapped keys, not a static
sort key."""
sort_by, sort_by_secondary = {
"memory_percent": ("memory_usage", "cpu_percent"),
"name": ("name", "cpu_percent"),
}.get(glances_processes.sort_key, ("cpu_percent", "memory_usage"))
try:
return sort_stats_processes(
stats,
sorted_by=sort_by,
sorted_by_secondary=sort_by_secondary,
reverse=glances_processes.sort_key != "name",
)
except Exception as e:
logger.debug("containers: sort failed: %s", e)
return stats
async def _grab_stats(self) -> list:
if not self.watchers:
return []
try:
return await asyncio.to_thread(self._update_watchers)
except Exception as e:
logger.warning("containers: grab failed: %s", e)
return []
def _add_metadata(self) -> None:
super()._add_metadata()
# Static [containers] config the renderer needs (it has no config access).
self._metadata["disable_stats"] = self._disable_stats
self._metadata["max_name_size"] = self._max_name_size
def stop(self) -> None:
for engine, watcher in self.watchers.items():
try:
watcher.stop()
except Exception as e:
logger.warning("containers: stop(%s) failed: %s", engine, e)

View File

@@ -0,0 +1,225 @@
#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Glances v5 — TUI curses renderer for the containers plugin.
Mirror of v4 ``containers.msg_curse``. Header row + one row per container,
MAIN-column full width. Columns are gated by ``[containers] disable_stats``
(surfaced via payload metadata) and by the data (Engine only with >1 engine,
Pod only when a pod is present).
"""
from __future__ import annotations
from typing import Any
from glances.globals import auto_unit
from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row, title_role
# Header → the GLOBAL process sort key (view["sort_key"], dynamic/auto-resolved),
# processlist-aligned. MEM maps to memory_percent because the process sort-key
# space uses memory_percent (the model's data sort maps that onto the
# memory_usage column — same column underlined as is actually sorted).
_HEADER_SORT_KEY: dict[str, str] = {"CONTAINER": "name", "CPU%": "cpu_percent", "MEM": "memory_percent"}
# v4 container_alert(status) → ColorRole. No ERROR/INFO roles in v5 →
# dead/unhealthy fold to CRITICAL, everything unclassified to DEFAULT.
_STATUS_ROLE: dict[str, ColorRole] = {
"running": ColorRole.OK,
"healthy": ColorRole.OK,
"dead": ColorRole.CRITICAL,
"unhealthy": ColorRole.CRITICAL,
"created": ColorRole.WARNING,
"exited": ColorRole.WARNING,
"paused": ColorRole.CAREFUL,
"restarting": ColorRole.CAREFUL,
}
def _status_role(status: str) -> ColorRole:
return _STATUS_ROLE.get(status, ColorRole.DEFAULT)
def _level_role(level_entry: Any) -> tuple[ColorRole, bool]:
if isinstance(level_entry, dict):
return (_LEVEL_TO_ROLE.get(level_entry.get("level"), ColorRole.DEFAULT), bool(level_entry.get("prominent")))
return (ColorRole.DEFAULT, False)
def _header_cell(
label: str, width: int, *, ljust: bool = False, color: ColorRole = ColorRole.HEADER, sort_key: str | None = None
) -> Cell:
text = f"{label:<{width}}" if ljust else f"{label:>{width}}"
return Cell(text=text, color=color, bold=True, underline=bool(sort_key) and _HEADER_SORT_KEY.get(label) == sort_key)
def _build_header_row(
disable: set[str], *, show_engine: bool, show_pod: bool, name_w: int, sort_key: str | None, title_color: ColorRole
) -> Row:
def hdr(label: str, width: int, *, ljust: bool = False, color: ColorRole = ColorRole.HEADER) -> Cell:
return _header_cell(label, width, ljust=ljust, color=color, sort_key=sort_key)
h: list[Cell] = []
if show_engine:
h.append(hdr("Engine", 6, ljust=True, color=title_color))
if show_pod:
h.append(hdr("Pod", 12, ljust=True))
if "name" not in disable:
h.append(hdr("CONTAINER", name_w, ljust=True, color=title_color if not show_engine else ColorRole.HEADER))
if "status" not in disable:
h.append(hdr("Status", 10))
if "uptime" not in disable:
h.append(hdr("Uptime", 10))
if "cpu" not in disable:
h.append(hdr("CPU%", 6))
if "mem" not in disable:
h.append(hdr("MEM", 7))
h.append(Cell(text=f"/{'MAX':<7}", color=ColorRole.HEADER, bold=True))
if "diskio" not in disable:
h.append(hdr("IOR/s", 7))
h.append(hdr("IOW/s", 7, ljust=True))
if "networkio" not in disable:
h.append(hdr("Rx/s", 7))
h.append(hdr("Tx/s", 7, ljust=True))
if "ports" not in disable:
h.append(hdr("Ports", 16, ljust=True))
if "command" not in disable:
h.append(hdr("Command", 8, ljust=True))
return Row(cells=h)
def _name_status_uptime_cells(c: dict[str, Any], disable: set[str], name_w: int) -> list[Cell]:
cells: list[Cell] = []
if "name" not in disable:
cells.append(Cell(text=f"{str(c.get('name', ''))[:name_w]:<{name_w}}"))
if "status" not in disable:
status = str(c.get("status", ""))
cells.append(Cell(text=f"{status[:10]:>10}", color=_status_role(status)))
if "uptime" not in disable:
cells.append(Cell(text=f"{(c.get('uptime') or '_'):>10}"))
return cells
def _cpu_mem_cells(c: dict[str, Any], disable: set[str], item_levels: dict[str, Any]) -> list[Cell]:
cells: list[Cell] = []
if "cpu" not in disable:
cpu = c.get("cpu_percent")
role, prom = _level_role(item_levels.get("cpu_percent"))
text = f"{cpu:>6.1f}" if isinstance(cpu, (int, float)) else f"{'_':>6}"
cells.append(Cell(text=text, color=role, prominent=prom))
if "mem" not in disable:
# Display the no-cache value (v4 MEM column); /MAX = limit. Colour
# from the memory_percent level. memory_usage (export) is NOT shown.
usage, limit = c.get("memory_usage_no_cache"), c.get("memory_limit")
role, prom = _level_role(item_levels.get("memory_percent"))
mtext = f"{auto_unit(usage):>7}" if isinstance(usage, (int, float)) else f"{'_':>7}"
cells.append(Cell(text=mtext, color=role, prominent=prom))
ltext = f"/{auto_unit(limit):<7}" if isinstance(limit, (int, float)) else f"/{'_':<7}"
cells.append(Cell(text=ltext))
return cells
def _io_net_ports_command_cells(c: dict[str, Any], disable: set[str], to_bit: int, net_unit: str) -> list[Cell]:
cells: list[Cell] = []
if "diskio" not in disable:
cells.append(Cell(text=_io_cell(c.get("io_rx"), 7, ljust=False)))
cells.append(Cell(text=_io_cell(c.get("io_wx"), 7, ljust=True)))
if "networkio" not in disable:
cells.append(Cell(text=_net_cell(c.get("network_rx"), to_bit, net_unit, 7, ljust=False)))
cells.append(Cell(text=_net_cell(c.get("network_tx"), to_bit, net_unit, 7, ljust=True)))
if "ports" not in disable:
ports = c.get("ports") or ""
cells.append(Cell(text=f"{(ports if ports != '' else '_'):16}"))
if "command" not in disable:
cells.append(Cell(text=f" {c.get('command') or '_'}"))
return cells
def _build_data_row(
c: dict[str, Any],
disable: set[str],
levels: dict[str, Any],
*,
show_engine: bool,
show_pod: bool,
name_w: int,
to_bit: int,
net_unit: str,
) -> Row:
item_levels = levels.get(c.get("name"), {}) if isinstance(levels, dict) else {}
cells: list[Cell] = []
if show_engine:
cells.append(Cell(text=f"{str(c.get('engine', '')):<6}"))
if show_pod:
cells.append(Cell(text=f"{str(c.get('pod_id') or '-'):<12}"))
cells.extend(_name_status_uptime_cells(c, disable, name_w))
cells.extend(_cpu_mem_cells(c, disable, item_levels))
cells.extend(_io_net_ports_command_cells(c, disable, to_bit, net_unit))
return Row(cells=cells)
def render(
payload: dict[str, Any],
fields_desc: dict[str, dict[str, Any]] | None = None,
view: dict[str, Any] | None = None,
) -> list[Row]:
items: list[dict[str, Any]] = []
if isinstance(payload, dict):
raw = payload.get("data")
if isinstance(raw, list):
items = [i for i in raw if isinstance(i, dict)]
if not items:
return []
view = view or {}
sort_key = view.get("sort_key")
to_bit, net_unit = (1, "") if view.get("byte") else (8, "b")
disable = set(payload.get("disable_stats") or [])
levels = payload.get("_levels") if isinstance(payload.get("_levels"), dict) else {}
conf_max = payload.get("max_name_size") or 20
name_w = min(int(conf_max), max((len(str(i.get("name", ""))) for i in items), default=1))
show_engine = len({i.get("engine") for i in items}) > 1
show_pod = any(i.get("pod_name") for i in items)
title_color = title_role(payload)
header = _build_header_row(
disable, show_engine=show_engine, show_pod=show_pod, name_w=name_w, sort_key=sort_key, title_color=title_color
)
rows: list[Row] = [header]
rows.extend(
_build_data_row(
c,
disable,
levels,
show_engine=show_engine,
show_pod=show_pod,
name_w=name_w,
to_bit=to_bit,
net_unit=net_unit,
)
for c in items
)
return rows
def _io_cell(value: Any, width: int, *, ljust: bool) -> str:
try:
text = auto_unit(int(value)) + "B"
except (TypeError, ValueError):
text = "_"
return f"{text:<{width}}" if ljust else f"{text:>{width}}"
def _net_cell(value: Any, to_bit: int, unit: str, width: int, *, ljust: bool) -> str:
try:
text = auto_unit(int(value * to_bit)) + unit
except (TypeError, ValueError):
text = "_"
return f"{text:<{width}}" if ljust else f"{text:>{width}}"

View File

@@ -206,6 +206,15 @@ class GlancesPluginBase(Generic[T], ABC):
except Exception as e:
logger.warning("Plugin %s update failed: %s", self.plugin_name, e)
def stop(self) -> None:
"""Release resources held by the plugin (background threads, sockets…).
Default no-op. Overridden by plugins that own long-lived resources
(e.g. ``containers`` engine streaming threads). Called once by the
scheduler on shutdown, after the plugin's update loop is cancelled.
Must be safe to call even if the plugin never produced stats.
"""
def _filter_collection(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Drop items whose primary-key value does not pass show/hide filters.
@@ -425,6 +434,17 @@ class GlancesPluginBase(Generic[T], ABC):
# --------------------------------------------------- threshold precompute
@staticmethod
def _threshold_key(field_name: str, schema: dict[str, Any]) -> str:
"""Config-key prefix for a watched field's thresholds.
Defaults to the field name; a field may declare ``threshold_field``
to decouple its config-key prefix from its value key (e.g.
``containers`` stores CPU under ``cpu_percent`` but reads thresholds
from ``[containers] cpu_*``). See design §5.2.
"""
return schema.get("threshold_field", field_name)
def _precompute_plugin_thresholds(self) -> dict[str, dict[str, Any]]:
"""Build plugin-level (pk-agnostic) thresholds for each watched field.
@@ -439,15 +459,16 @@ class GlancesPluginBase(Generic[T], ABC):
"""
out: dict[str, dict[str, Any]] = {}
for field_name, schema in self._watched_fields:
key = self._threshold_key(field_name, schema)
if schema.get("threshold_type") == "categorical":
mapping = read_thresholds_categorical(self.config, self.plugin_name, field=field_name)
mapping = read_thresholds_categorical(self.config, self.plugin_name, field=key)
if mapping:
out[field_name] = {"mapping": mapping}
else:
thresholds = read_thresholds(
self.config,
self.plugin_name,
field=field_name,
field=key,
defaults=schema.get("default_thresholds"),
strict=bool(schema.get("strict_thresholds", False)),
)
@@ -475,14 +496,15 @@ class GlancesPluginBase(Generic[T], ABC):
except AttributeError:
return out # config object without introspection API → skip optim
for key in section_keys:
for field_name, _schema in self._watched_fields:
# Pattern: `<pk>_<field>_<level>` — `<pk>` must be non-empty
# and must NOT be ``<field>_`` (that's the plain
# `<field>_<level>` key, already handled by precompute).
for field_name, schema in self._watched_fields:
tkey = self._threshold_key(field_name, schema)
# Pattern: `<pk>_<tkey>_<level>` — `<pk>` must be non-empty
# and must NOT be ``<tkey>_`` (that's the plain
# `<tkey>_<level>` key, already handled by precompute).
for level in levels:
suffix = f"_{field_name}_{level}"
if key.endswith(suffix) and not key.startswith(f"{field_name}_"):
# `<pk>` is whatever precedes `_<field>_<level>` —
suffix = f"_{tkey}_{level}"
if key.endswith(suffix) and not key.startswith(f"{tkey}_"):
# `<pk>` is whatever precedes `_<tkey>_<level>` —
# must be non-empty.
prefix_len = len(key) - len(suffix)
if prefix_len > 0:
@@ -587,7 +609,10 @@ class GlancesPluginBase(Generic[T], ABC):
):
entry = plugin_thresholds.get(field_name)
return entry.get("mapping", {}) if entry else {}
return read_thresholds_categorical(self.config, self.plugin_name, field=field_name, pk_value=pk_value)
schema = self._fields.get(field_name, {})
return read_thresholds_categorical(
self.config, self.plugin_name, field=self._threshold_key(field_name, schema), pk_value=pk_value
)
def _resolve_numeric_thresholds(
self,
@@ -606,7 +631,7 @@ class GlancesPluginBase(Generic[T], ABC):
return read_thresholds(
self.config,
self.plugin_name,
field=field_name,
field=self._threshold_key(field_name, schema),
pk_value=pk_value,
defaults=schema.get("default_thresholds"),
strict=bool(schema.get("strict_thresholds", False)),

View File

@@ -0,0 +1,143 @@
#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Glances v5 — VMs plugin (collection, per-VM).
Migrated from `glances/plugins/vms/__init__.py`. Reuses the v4 engines
(`glances/plugins/vms/engines/{multipass,virsh}.py`) VERBATIM as
synchronous CLI collectors; ``_grab_stats`` wraps the blocking CLI work
in ``asyncio.to_thread``. Both engines are constructed unconditionally
(v4 parity) — each self-guards on its ``import_*_error_tag`` inside
``update()``, returning ``('', [])`` when its binary is absent.
**Default-disabled**: v4 ships `[vms] disable=True`. This plugin mirrors
that — with no explicit `[vms] disable=False` in the user config it
collects and publishes nothing. The plugin is still discovered so it can
be enabled without code changes.
No alerts: ``EMITS_ALERTS = False`` — no field is declared ``watched``.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, ClassVar
from glances.plugins.plugin.base_v5 import GlancesPluginBase
from glances.plugins.vms.engines import VmsExtension
from glances.plugins.vms.engines.multipass import VmExtension as MultipassVmExtension
from glances.plugins.vms.engines.virsh import VmExtension as VirshVmExtension
from glances.processes import glances_processes
from glances.processes import sort_stats as sort_stats_processes
logger = logging.getLogger(__name__)
_DEFAULT_MAX_NAME_SIZE = 20
def sort_vm_stats(stats: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
"""Port of v4 `glances/plugins/vms/__init__.py::sort_vm_stats`.
Makes VM sort follow the process sort (processlist-aligned). ``glances_processes
.sort_key`` is read fresh every call — the dynamic ``auto`` resolution
(cpu/mem depending on load) is preserved, never hardcoded.
DELIBERATE v4 divergence: v4's `sort_vm_stats` calls `sort_stats_processes`
WITHOUT capturing its return value. Since `sort_stats` returns a NEW sorted
list (it does not mutate in place — see glances/processes.py:816), v4's VM
sort is a silent no-op. We capture the return so the sort actually applies,
matching `containers` (`sort_docker_stats`, which does capture) and the
maintainer's processlist-alignment requirement.
"""
if glances_processes.sort_key == "memory_percent":
sort_by, sort_by_secondary = "memory_usage", "cpu_time"
elif glances_processes.sort_key == "name":
sort_by, sort_by_secondary = "name", "cpu_time"
else:
sort_by, sort_by_secondary = "cpu_time", "memory_usage"
stats = sort_stats_processes(
stats,
sorted_by=sort_by,
sorted_by_secondary=sort_by_secondary,
reverse=glances_processes.sort_key != "name",
)
return sort_by, stats
class PluginModel(GlancesPluginBase[list]):
"""Per-VM plugin (collection)."""
plugin_name: ClassVar[str] = "vms"
IS_COLLECTION: ClassVar[bool] = True
EMITS_ALERTS: ClassVar[bool] = False
fields_description: ClassVar[dict[str, dict[str, Any]]] = {
"name": {"description": "VM name.", "unit": "string", "primary_key": True},
"id": {"description": "VM ID.", "unit": "string"},
"release": {"description": "VM release.", "unit": "string"},
"status": {"description": "VM status.", "unit": "string"},
"cpu_count": {"description": "VM CPU count.", "unit": "number"},
"cpu_time": {"description": "VM CPU time (per-second rate).", "unit": "percent", "rate": True},
"memory_usage": {"description": "VM memory usage.", "unit": "byte"},
"memory_total": {"description": "VM memory total.", "unit": "byte"},
"load_1min": {"description": "VM load, last 1 min (None if unsupported by the engine).", "unit": "float"},
"load_5min": {"description": "VM load, last 5 min (None if unsupported by the engine).", "unit": "float"},
"load_15min": {"description": "VM load, last 15 min (None if unsupported by the engine).", "unit": "float"},
"ipv4": {"description": "VM IPv4 address.", "unit": "string"},
"engine": {"description": "VM engine name.", "unit": "string"},
"engine_version": {"description": "VM engine version.", "unit": "string"},
}
def __init__(self, store: Any, config: Any) -> None:
super().__init__(store, config)
# Mirror v4: build both engines unconditionally; each self-guards on
# its import_*_error_tag inside update() (returns ('', []) when the
# binary is absent). No construction gating — matches v4 __init__.
self.watchers: dict[str, VmsExtension] = {
"multipass": MultipassVmExtension(),
"virsh": VirshVmExtension(),
}
self._max_name_size = int(self.config.get("vms", "max_name_size", _DEFAULT_MAX_NAME_SIZE))
def _is_enabled(self) -> bool:
# Mirror v4 [vms] disable=True default (see npu/model_v5.py).
raw = self.config.get("vms", "disable", "True")
return str(raw).strip().lower() in ("false", "0", "no")
def _all_tag(self) -> bool:
raw = self.config.get("vms", "all", "False")
return str(raw).strip().lower() in ("true", "1", "yes")
def _collect(self) -> list:
stats: list[dict[str, Any]] = []
all_tag = self._all_tag()
for engine, watcher in self.watchers.items():
try:
version, vms = watcher.update(all_tag=all_tag)
except Exception as exc: # noqa: BLE001 — one bad engine must not kill the others
logger.debug("vms: engine %s update failed: %s", engine, exc)
continue
for vm in vms:
vm["engine"] = engine
vm["engine_version"] = version
stats.extend(vms)
# Pre-sort the list to follow the dynamic process sort key (v4
# parity). The returned key is not exposed — the renderer underlines
# from the global view["sort_key"], processlist-aligned.
_, stats = sort_vm_stats(stats)
return stats
async def _grab_stats(self) -> list:
if not self._is_enabled():
return []
return await asyncio.to_thread(self._collect)
def _add_metadata(self) -> None:
super()._add_metadata()
self._metadata["max_name_size"] = self._max_name_size

View File

@@ -0,0 +1,143 @@
#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Glances v5 — TUI curses renderer for the vms plugin.
Mirror of v4 ``vms.msg_curse`` (``glances/plugins/vms/__init__.py``,
``msg_curse``/``vm_alert``/``sort_vm_stats``). Title row + column-header
row + one row per VM, MAIN (RIGHT) column, full width. No alerts
(``EMITS_ALERTS = False`` — the payload carries no ``_levels``).
Columns: Engine (only with >1 distinct engine), Name (``max_name_size``),
Status, Core, CPU% (``cpu_time`` — the per-second rate the base already
computed; absent on the first cycle → placeholder), MEM/MAX (glued into
one cell — v4 underlines only the ``MEM`` sub-cell, here MEM and MAX share
a single ``Cell`` so the underline spans both; cosmetic, intentional
simplification), LOAD 1/5/15min (only when ``load_1min`` is not None on
the first VM — v4 parity), Release.
Sort-column underline follows ``view["sort_key"]`` — the GLOBAL process
sort key (processlist-aligned, dynamic/auto-resolved), NOT a key read
from the payload (the collection payload carries no ``sort_key``).
"""
from __future__ import annotations
from typing import Any
from glances.globals import auto_unit
from glances.outputs.curses_renderer_v5 import Cell, ColorRole, Row
_DEFAULT_MAX_NAME_SIZE = 20
_STATUS_WIDTH = 10
_CORE_WIDTH = 6
_CPU_WIDTH = 6
_MEM_WIDTH = 7
_LOAD_HEADER_WIDTH = 17
_ENGINE_MIN_WIDTH = 8
# Header label -> the GLOBAL process sort key (view["sort_key"]),
# processlist-aligned. Mirrors how sort_vm_stats maps
# glances_processes.sort_key onto a vms field: name -> Name, memory_percent
# -> MEM/MAX, everything else -> CPU%. Core and LOAD have no process-sort
# equivalent and are never underlined.
_HEADER_SORT_FIELD: dict[str, str] = {"Name": "name", "CPU%": "cpu_percent", "MEM/MAX": "memory_percent"}
# v4 vm_alert(status) -> ColorRole. No INFO role in v5 -> DEFAULT (neutral).
_STATUS_ROLE: dict[str, ColorRole] = {
"running": ColorRole.OK,
"starting": ColorRole.WARNING,
"restarting": ColorRole.WARNING,
"delayed shutdown": ColorRole.WARNING,
}
def _status_role(status: Any) -> ColorRole:
return _STATUS_ROLE.get(str(status or "").lower(), ColorRole.DEFAULT)
def _fmt(value: Any) -> str:
return str(value) if value is not None else "-"
def _header(label: str, width: int, *, ljust: bool = False, sort_key: str | None = None) -> Cell:
text = label.ljust(width) if ljust else label.rjust(width)
underline = bool(sort_key) and _HEADER_SORT_FIELD.get(label) == sort_key
return Cell(text=text, color=ColorRole.HEADER, bold=True, underline=underline)
def _build_header_row(*, show_engine: bool, engine_w: int, name_w: int, show_load: bool, sort_key: str | None) -> Row:
cells: list[Cell] = []
if show_engine:
cells.append(_header("Engine", engine_w, ljust=True, sort_key=sort_key))
cells.append(_header("Name", name_w, ljust=True, sort_key=sort_key))
cells.append(_header("Status", _STATUS_WIDTH, sort_key=sort_key))
cells.append(_header("Core", _CORE_WIDTH, sort_key=sort_key))
cells.append(_header("CPU%", _CPU_WIDTH, sort_key=sort_key))
mem_text = f"{'MEM':>{_MEM_WIDTH}}/{'MAX':<{_MEM_WIDTH}}"
mem_underline = bool(sort_key) and _HEADER_SORT_FIELD.get("MEM/MAX") == sort_key
cells.append(Cell(text=mem_text, color=ColorRole.HEADER, bold=True, underline=mem_underline))
if show_load:
cells.append(_header("LOAD 1/5/15min", _LOAD_HEADER_WIDTH, sort_key=sort_key))
cells.append(_header("Release", len("Release")))
return Row(cells=cells)
def _build_data_row(vm: dict[str, Any], *, show_engine: bool, engine_w: int, name_w: int, show_load: bool) -> Row:
cells: list[Cell] = []
if show_engine:
cells.append(Cell(text=str(vm.get("engine", "")).ljust(engine_w)))
cells.append(Cell(text=str(vm.get("name", ""))[:name_w].ljust(name_w)))
status = vm.get("status")
cells.append(Cell(text=str(status or "")[:_STATUS_WIDTH].rjust(_STATUS_WIDTH), color=_status_role(status)))
cells.append(Cell(text=_fmt(vm.get("cpu_count")).rjust(_CORE_WIDTH)))
cells.append(Cell(text=_fmt(vm.get("cpu_time")).rjust(_CPU_WIDTH)))
mem_text = f"{auto_unit(vm.get('memory_usage')):>{_MEM_WIDTH}}/{auto_unit(vm.get('memory_total')):<{_MEM_WIDTH}}"
cells.append(Cell(text=mem_text))
if show_load:
try:
cells.append(Cell(text=f"{vm['load_1min']:>5.1f}/{vm['load_5min']:>5.1f}/{vm['load_15min']:>5.1f}"))
except (KeyError, TypeError):
pass
cells.append(Cell(text=str(vm["release"]) if vm.get("release") is not None else "-"))
return Row(cells=cells)
def render(
payload: dict[str, Any],
fields_desc: dict[str, dict[str, Any]] | None = None,
view: dict[str, Any] | None = None,
) -> list[Row]:
"""Render the vms plugin's column-header + per-VM rows (no title row, for
consistency with the containers plugin)."""
items: list[dict[str, Any]] = []
if isinstance(payload, dict):
raw = payload.get("data")
if isinstance(raw, list):
items = [i for i in raw if isinstance(i, dict)]
if not items:
return []
sort_key = (view or {}).get("sort_key")
max_name_size = payload.get("max_name_size", _DEFAULT_MAX_NAME_SIZE)
show_engine = len({str(i.get("engine", "")) for i in items}) > 1
name_w = min(int(max_name_size), max((len(str(i.get("name", ""))) for i in items), default=max_name_size))
engine_w = 0
if show_engine:
engine_w = max(_ENGINE_MIN_WIDTH, max((len(str(i.get("engine", ""))) for i in items), default=0))
show_load = items[0].get("load_1min") is not None
header_row = _build_header_row(
show_engine=show_engine, engine_w=engine_w, name_w=name_w, show_load=show_load, sort_key=sort_key
)
data_rows = [
_build_data_row(vm, show_engine=show_engine, engine_w=engine_w, name_w=name_w, show_load=show_load)
for vm in items
]
return [header_row, *data_rows]

View File

@@ -155,13 +155,22 @@ class AsyncScheduler:
self._tasks = []
async def stop(self) -> None:
"""Cancel every plugin loop and wait for clean termination."""
"""Cancel every plugin loop, then let each plugin release resources."""
for task in self._tasks:
task.cancel()
# Drain cancellations. `return_exceptions=True` swallows the
# `CancelledError` we just raised on each task.
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
# Teardown hook: let each plugin release long-lived resources
# (e.g. containers' engine streaming threads). Run in a thread so a
# blocking join cannot stall the event loop, and guard each so one
# failing teardown cannot block the others.
for entry in self._entries:
try:
await asyncio.to_thread(entry.plugin.stop)
except Exception as e:
logger.warning("Scheduler: stop() of %s failed: %s", entry.plugin.plugin_name, e)
# ------------------------------------------------------------ internals

View File

@@ -21,12 +21,15 @@ import shlex
import subprocess
import sys
import time
from pathlib import Path
from unittest.mock import patch
import pytest
from glances.config_v5 import GlancesConfigV5
from glances.main import GlancesMain
from glances.stats import GlancesStats
from glances.stats_store_v5 import StatsStoreV5
# Optional imports for WebUI testing
try:
@@ -105,3 +108,41 @@ def web_browser():
# Close the WebDriver instance
driver.quit()
@pytest.fixture
def store_with():
"""Factory fixture — returns a callable producing a fresh StatsStoreV5.
Shared across v5 plugin model tests (see
tests/test_plugin_base_v5_stop_and_threshold_field.py, the origin of
this fixture).
"""
def _factory() -> StatsStoreV5:
return StatsStoreV5()
return _factory
@pytest.fixture
def config_with(tmp_path: Path, monkeypatch):
"""Factory fixture — returns a callable that builds a real GlancesConfigV5
from a ``{section: {key: value}}`` dict, backed by a temp glances.conf.
"""
monkeypatch.setattr(GlancesConfigV5, "SYSTEM_CONFIG_PATH", tmp_path / "etc" / "glances.conf")
xdg = tmp_path / "xdg"
cfg_dir = xdg / "glances"
cfg_dir.mkdir(parents=True)
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg))
def _factory(sections: dict[str, dict[str, str]]) -> GlancesConfigV5:
body = ""
for section, options in sections.items():
body += f"[{section}]\n"
for key, value in options.items():
body += f"{key}={value}\n"
(cfg_dir / "glances.conf").write_text(body)
return GlancesConfigV5()
return _factory

View File

@@ -578,6 +578,34 @@ def test_build_view_meangpu_fahrenheit_default_false(fake_store, fake_alerts, fa
assert view["fahrenheit"] is False
def test_build_view_carries_byte_flag(fake_store, fake_alerts, fake_config):
"""`--byte` reaches the view dict via the constructor param (wired in
main_v5.assemble), consumed by the containers renderer."""
from glances.outputs import glances_curses_v5 as tui_mod
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=[("mem", False)],
fields_by_plugin={"mem": {}},
refresh_interval=0.01,
byte=True,
)
assert tui._byte is True
view = tui._build_view(max_x=200)
assert view["byte"] is True
def test_build_view_byte_defaults_false(fake_store, fake_alerts, fake_config):
from glances.outputs import glances_curses_v5 as tui_mod
tui = _make_tui(tui_mod, fake_store, fake_alerts, fake_config)
assert tui._byte is False
view = tui._build_view(max_x=200)
assert view["byte"] is False
def test_tui_v5_full_quicklook_hides_siblings_end_to_end(fake_store, fake_alerts, fake_config):
"""End-to-end: with `_full_quicklook` on, `_build_frame(max_x)` drives the
real chain (_build_view → build_frame) and the hidden TOP siblings

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python
#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Base-class extension points added for the G6A containers port:
- ``GlancesPluginBase.stop()`` teardown hook (default no-op).
- ``threshold_field`` schema alias in threshold resolution.
"""
from __future__ import annotations
from pathlib import Path
from typing import ClassVar
import pytest
from glances.config_v5 import GlancesConfigV5
from glances.plugins.plugin.base_v5 import GlancesPluginBase
from glances.stats_store_v5 import StatsStoreV5
class _NoopCollection(GlancesPluginBase[list]):
plugin_name: ClassVar[str] = "noop_collection"
IS_COLLECTION: ClassVar[bool] = True
fields_description: ClassVar[dict] = {"name": {"primary_key": True}}
async def _grab_stats(self) -> list:
return []
# ---------------------------------------------------------- fixtures
@pytest.fixture
def store() -> StatsStoreV5:
return StatsStoreV5()
@pytest.fixture
def config(tmp_path: Path, monkeypatch) -> GlancesConfigV5:
monkeypatch.setattr(GlancesConfigV5, "SYSTEM_CONFIG_PATH", tmp_path / "etc" / "glances.conf")
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
return GlancesConfigV5()
def _mk(store, config, cls=_NoopCollection):
return cls(store, config)
def test_stop_default_is_noop(store, config):
plugin = _mk(store, config)
# Default stop() must exist and do nothing (no raise, no return value).
assert plugin.stop() is None
# ---------------------------------------------------- threshold_field alias
@pytest.fixture
def store_with():
"""Factory fixture — returns a callable producing a fresh StatsStoreV5."""
def _factory() -> StatsStoreV5:
return StatsStoreV5()
return _factory
@pytest.fixture
def config_with(tmp_path: Path, monkeypatch):
"""Factory fixture — returns a callable that builds a real GlancesConfigV5
from a ``{section: {key: value}}`` dict, backed by a temp glances.conf.
"""
monkeypatch.setattr(GlancesConfigV5, "SYSTEM_CONFIG_PATH", tmp_path / "etc" / "glances.conf")
xdg = tmp_path / "xdg"
cfg_dir = xdg / "glances"
cfg_dir.mkdir(parents=True)
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg))
def _factory(sections: dict[str, dict[str, str]]) -> GlancesConfigV5:
body = ""
for section, options in sections.items():
body += f"[{section}]\n"
for key, value in options.items():
body += f"{key}={value}\n"
(cfg_dir / "glances.conf").write_text(body)
return GlancesConfigV5()
return _factory
class _AliasCollection(GlancesPluginBase[list]):
plugin_name: ClassVar[str] = "alias_collection"
IS_COLLECTION: ClassVar[bool] = True
fields_description: ClassVar[dict] = {
"name": {"primary_key": True},
# Value under `cpu_percent`, thresholds under the `cpu` prefix.
"cpu_percent": {
"watched": True,
"watch_direction": "high",
"threshold_field": "cpu",
},
}
async def _grab_stats(self) -> list:
return []
def test_threshold_field_alias_resolves_prefixed_keys(store_with, config_with):
# Config uses the v4-style `cpu_*` prefix, NOT `cpu_percent_*`.
config = config_with({"alias_collection": {"cpu_warning": "70", "cpu_critical": "90"}})
plugin = _AliasCollection(store_with(), config)
plugin._stats = [{"name": "web", "cpu_percent": 75.0}]
plugin._derived_parameters()
assert plugin._levels["web"]["cpu_percent"]["level"] == "warning"
def test_threshold_field_alias_resolves_per_pk_override(store_with, config_with):
config = config_with({"alias_collection": {"cpu_warning": "70", "web_cpu_warning": "10"}})
plugin = _AliasCollection(store_with(), config)
plugin._stats = [{"name": "web", "cpu_percent": 15.0}]
plugin._derived_parameters()
# Per-container override `web_cpu_warning=10` wins → 15 ≥ 10 → warning.
assert plugin._levels["web"]["cpu_percent"]["level"] == "warning"
def test_absent_threshold_field_preserves_field_name_default(store_with, config_with):
# A field WITHOUT threshold_field keeps the field-name prefix.
class _Plain(GlancesPluginBase[list]):
plugin_name = "plain_collection"
IS_COLLECTION = True
fields_description = {
"name": {"primary_key": True},
"cpu_percent": {"watched": True, "watch_direction": "high"},
}
async def _grab_stats(self):
return []
config = config_with({"plain_collection": {"cpu_percent_warning": "70"}})
plugin = _Plain(store_with(), config)
plugin._stats = [{"name": "web", "cpu_percent": 75.0}]
plugin._derived_parameters()
assert plugin._levels["web"]["cpu_percent"]["level"] == "warning"

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python
#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Tests for the Glances v5 containers TUI renderer."""
from __future__ import annotations
from glances.outputs.curses_renderer_v5 import ColorRole
from glances.plugins.containers.render_curses_v5 import render
def _payload(data, levels=None, disable_stats=None, max_name_size=20):
return {
"data": data,
"_levels": levels or {},
"disable_stats": disable_stats or [],
"max_name_size": max_name_size,
}
def _texts(row):
return "".join(c.text for c in row.cells)
def test_empty_data_returns_empty():
assert render(_payload([])) == []
def test_header_and_one_row():
data = [
{
"name": "web",
"engine": "docker",
"status": "running",
"uptime": "1h",
"cpu_percent": 12.0,
"memory_usage_no_cache": 200,
"memory_limit": 1000,
"ports": "",
}
]
rows = render(_payload(data), None, {"sort_key": None, "byte": False})
assert len(rows) == 2 # header + 1
header = _texts(rows[0])
assert "CONTAINER" in header and "CPU%" in header and "Status" in header
def test_status_colour_running_is_ok():
data = [{"name": "web", "engine": "docker", "status": "running"}]
rows = render(_payload(data), None, {})
# find the status cell (text startswith "running" padded)
status_cells = [c for c in rows[1].cells if "running" in c.text]
assert status_cells and status_cells[0].color == ColorRole.OK
def test_status_colour_exited_is_warning():
data = [{"name": "web", "engine": "docker", "status": "exited"}]
rows = render(_payload(data), None, {})
cells = [c for c in rows[1].cells if "exited" in c.text]
assert cells and cells[0].color == ColorRole.WARNING
def test_status_colour_dead_is_critical():
data = [{"name": "web", "engine": "docker", "status": "dead"}]
rows = render(_payload(data), None, {})
cells = [c for c in rows[1].cells if "dead" in c.text]
assert cells and cells[0].color == ColorRole.CRITICAL
def test_cpu_cell_coloured_by_level():
data = [{"name": "web", "engine": "docker", "status": "running", "cpu_percent": 95.0}]
levels = {"web": {"cpu_percent": {"level": "critical", "prominent": False}}}
rows = render(_payload(data, levels), None, {})
cpu_cells = [c for c in rows[1].cells if c.text.strip() == "95.0"]
assert cpu_cells and cpu_cells[0].color == ColorRole.CRITICAL
def test_disable_stats_hides_column():
data = [{"name": "web", "engine": "docker", "status": "running", "cpu_percent": 12.0}]
rows = render(_payload(data, disable_stats=["cpu"]), None, {})
assert "CPU%" not in _texts(rows[0])
def test_engine_column_only_when_multiple_engines():
one = [{"name": "a", "engine": "docker", "status": "running"}]
two = [
{"name": "a", "engine": "docker", "status": "running"},
{"name": "b", "engine": "podman", "status": "running"},
]
assert "Engine" not in _texts(render(_payload(one), None, {})[0])
assert "Engine" in _texts(render(_payload(two), None, {})[0])
def test_pod_column_only_when_pod_present():
no_pod = [{"name": "a", "engine": "docker", "status": "running"}]
with_pod = [{"name": "a", "engine": "podman", "status": "running", "pod_name": "p1", "pod_id": "abc"}]
assert "Pod" not in _texts(render(_payload(no_pod), None, {})[0])
assert "Pod" in _texts(render(_payload(with_pod), None, {})[0])
def test_sort_underline_on_cpu():
data = [{"name": "web", "engine": "docker", "status": "running", "cpu_percent": 12.0}]
rows = render(_payload(data), None, {"sort_key": "cpu_percent"})
cpu_hdr = [c for c in rows[0].cells if "CPU%" in c.text]
assert cpu_hdr and cpu_hdr[0].underline is True
def test_sort_underline_on_name():
# Process sort key `name` underlines the CONTAINER (name) header. Guards the
# header-label <-> _HEADER_SORT_KEY consistency (a label rename must update the map).
data = [{"name": "web", "engine": "docker", "status": "running"}]
rows = render(_payload(data), None, {"sort_key": "name"})
name_hdr = [c for c in rows[0].cells if c.text.strip() == "CONTAINER"]
assert name_hdr and name_hdr[0].underline is True
def test_sort_underline_on_mem_maps_to_memory_percent():
# Process sort key `memory_percent` underlines the MEM header (processlist-aligned).
data = [
{"name": "web", "engine": "docker", "status": "running", "memory_usage_no_cache": 100, "memory_limit": 1000}
]
rows = render(_payload(data), None, {"sort_key": "memory_percent"})
mem_hdr = [c for c in rows[0].cells if c.text.strip() == "MEM"]
assert mem_hdr and mem_hdr[0].underline is True
def test_net_bits_vs_bytes():
data = [{"name": "web", "engine": "docker", "status": "running", "network_rx": 100, "network_tx": 0}]
bits = render(_payload(data), None, {"byte": False})
byts = render(_payload(data), None, {"byte": True})
# bits multiply by 8 → different rendered Rx text
assert _texts(bits[1]) != _texts(byts[1])

View File

@@ -0,0 +1,224 @@
#!/usr/bin/env python
#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Tests for the Glances v5 containers plugin model."""
from __future__ import annotations
import pytest
from glances.plugins.containers.model_v5 import PluginModel
def _mk(store_with, config_with, section=None):
return PluginModel(store_with(), config_with({"containers": section or {}}))
def test_identity(store_with, config_with):
p = _mk(store_with, config_with)
assert p.plugin_name == "containers"
assert p.IS_COLLECTION is True
assert p.EMITS_ALERTS is True
assert p._primary_key == "name"
def test_fields_present(store_with, config_with):
p = _mk(store_with, config_with)
fd = p.fields_description
for key in (
"name",
"id",
"image",
"status",
"created",
"command",
"cpu_percent",
"cpu_limit",
"memory_usage",
"memory_usage_no_cache",
"memory_limit",
"memory_percent",
"io_rx",
"io_wx",
"network_rx",
"network_tx",
"ports",
"uptime",
"engine",
"pod_name",
"pod_id",
):
assert key in fd, key
assert fd["name"].get("primary_key") is True
# Threshold aliases (design §5.2).
assert fd["cpu_percent"]["threshold_field"] == "cpu"
assert fd["memory_percent"]["threshold_field"] == "mem"
def test_cpu_level_uses_cpu_prefix_thresholds(store_with, config_with):
p = _mk(store_with, config_with, {"cpu_warning": "70", "cpu_critical": "90"})
p._stats = [{"name": "web", "cpu_percent": 95.0, "memory_percent": None}]
p._derived_parameters()
assert p._levels["web"]["cpu_percent"]["level"] == "critical"
def test_mem_level_uses_mem_prefix_thresholds(store_with, config_with):
p = _mk(store_with, config_with, {"mem_careful": "20", "mem_warning": "50"})
p._stats = [{"name": "web", "cpu_percent": None, "memory_percent": 60.0}]
p._derived_parameters()
assert p._levels["web"]["memory_percent"]["level"] == "warning"
def test_per_container_cpu_override(store_with, config_with):
p = _mk(store_with, config_with, {"cpu_warning": "70", "web_cpu_warning": "10"})
p._stats = [{"name": "web", "cpu_percent": 15.0}]
p._derived_parameters()
assert p._levels["web"]["cpu_percent"]["level"] == "warning"
class _FakeWatcher:
def __init__(self, containers, raises=False):
self._containers = containers
self._raises = raises
self.stopped = False
def update(self, all_tag):
if self._raises:
raise RuntimeError("engine down")
return {}, [dict(c) for c in self._containers]
def stop(self):
self.stopped = True
def _model_with_watchers(store_with, config_with, watchers, section=None):
p = PluginModel(store_with(), config_with({"containers": section or {}}))
p.watchers = watchers
return p
@pytest.mark.asyncio
async def test_grab_merges_engines_and_injects_engine_field(store_with, config_with):
# memory_usage=250 simulates the engine's v4 export value (usagecache);
# the nested memory dict drives the no-cache + percent surfaces.
d = {
"name": "web",
"key": "name",
"memory_usage": 250,
"memory": {"usage": 300, "inactive_file": 100, "limit": 1000},
}
p = _model_with_watchers(store_with, config_with, {"docker": _FakeWatcher([d])})
out = await p._grab_stats()
assert len(out) == 1
assert out[0]["engine"] == "docker"
# Three memory surfaces:
assert out[0]["memory_usage"] == 250 # export (v4 value, untouched)
assert out[0]["memory_usage_no_cache"] == 200 # display (usage inactive_file)
assert out[0]["memory_percent"] == 20.0 # alert (200 / 1000 * 100)
@pytest.mark.asyncio
async def test_grab_partial_failure_keeps_other_engine(store_with, config_with):
ok = {"name": "web", "memory": {}}
p = _model_with_watchers(
store_with,
config_with,
{"bad": _FakeWatcher([], raises=True), "docker": _FakeWatcher([ok])},
)
out = await p._grab_stats()
assert [c["name"] for c in out] == ["web"]
@pytest.mark.asyncio
async def test_grab_empty_when_no_watcher(store_with, config_with):
p = _model_with_watchers(store_with, config_with, {})
assert await p._grab_stats() == []
@pytest.mark.asyncio
async def test_grab_memory_percent_none_when_limit_zero(store_with, config_with):
# limit=0 → no meaningful percent, no divide-by-zero: memory_percent is None.
d = {"name": "web", "memory": {"usage": 300, "inactive_file": 100, "limit": 0}}
p = _model_with_watchers(store_with, config_with, {"docker": _FakeWatcher([d])})
out = await p._grab_stats()
assert out[0]["memory_usage_no_cache"] == 200
assert out[0]["memory_percent"] is None
@pytest.mark.asyncio
async def test_grab_memory_percent_none_when_limit_missing(store_with, config_with):
# No limit key → memory_percent is None (guarded), no exception.
d = {"name": "web", "memory": {"usage": 300, "inactive_file": 100}}
p = _model_with_watchers(store_with, config_with, {"docker": _FakeWatcher([d])})
out = await p._grab_stats()
assert out[0]["memory_usage_no_cache"] == 200
assert out[0]["memory_percent"] is None
@pytest.mark.asyncio
async def test_grab_all_tag_forwarded_to_watcher(store_with, config_with):
seen = {}
class _Capturing(_FakeWatcher):
def update(self, all_tag):
seen["all_tag"] = all_tag
return {}, []
p = _model_with_watchers(store_with, config_with, {"docker": _Capturing([])}, section={"all": "True"})
await p._grab_stats()
assert seen["all_tag"] is True
@pytest.mark.asyncio
async def test_grab_sort_follows_glances_processes_sort_key(store_with, config_with):
from glances.processes import glances_processes
cs = [
{"name": "a", "cpu_percent": 1.0, "memory": {}},
{"name": "b", "cpu_percent": 9.0, "memory": {}},
]
p = _model_with_watchers(store_with, config_with, {"docker": _FakeWatcher(cs)})
saved = glances_processes.sort_key
try:
glances_processes.set_sort_key("cpu_percent", auto=False)
out = await p._grab_stats()
# cpu_percent sort is reverse (highest first).
assert [c["name"] for c in out] == ["b", "a"]
glances_processes.set_sort_key("name", auto=False)
out = await p._grab_stats()
# name sort is ascending.
assert [c["name"] for c in out] == ["a", "b"]
finally:
glances_processes.set_sort_key(saved, auto=False)
def test_stop_calls_each_watcher(store_with, config_with):
w1, w2 = _FakeWatcher([]), _FakeWatcher([])
p = _model_with_watchers(store_with, config_with, {"docker": w1, "podman": w2})
p.stop()
assert w1.stopped and w2.stopped
def test_stop_one_raising_watcher_does_not_block_others(store_with, config_with):
class _Boom(_FakeWatcher):
def stop(self):
raise RuntimeError("boom")
good = _FakeWatcher([])
p = _model_with_watchers(store_with, config_with, {"bad": _Boom([]), "docker": good})
p.stop() # must not raise
assert good.stopped
def test_metadata_carries_disable_stats_and_max_name_size(store_with, config_with):
p = PluginModel(store_with(), config_with({"containers": {"disable_stats": "command", "max_name_size": "12"}}))
p._add_metadata()
assert "command" in p._metadata["disable_stats"]
assert p._metadata["max_name_size"] == 12

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python
#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Tests for the Glances v5 vms TUI renderer."""
from __future__ import annotations
from glances.outputs.curses_renderer_v5 import ColorRole
from glances.plugins.vms.render_curses_v5 import render
def _payload(items, max_name_size=20):
return {"data": items, "max_name_size": max_name_size}
def _flat(rows):
return "".join(c.text for row in rows for c in row.cells)
def _vm(**over):
base = {
"name": "vm-a",
"status": "running",
"cpu_count": 2,
"cpu_time": None,
"memory_usage": 1024,
"memory_total": 4096,
"load_1min": None,
"engine": "multipass",
"release": "24.04",
}
base.update(over)
return base
def test_empty_returns_nothing():
assert render(_payload([])) == []
def test_header_and_row_no_title():
# No title row (consistency with containers): rows[0] is the column header.
rows = render(_payload([_vm()]))
assert "VMs" not in _flat([rows[0]]) # first row is the header, not a "VMs …" title
header = _flat([rows[0]])
assert "Name" in header
assert "Status" in header
assert "Core" in header
assert "CPU%" in header
assert "MEM" in header
assert "MAX" in header
assert "Release" in header
data = _flat([rows[1]])
assert "vm-a" in data
assert "running" in data
def test_engine_column_hidden_single_engine():
items = [_vm(name="vm-a", engine="virsh"), _vm(name="vm-b", engine="virsh")]
rows = render(_payload(items))
assert "Engine" not in _flat([rows[0]])
def test_engine_column_shown_multi_engine():
items = [_vm(name="vm-a", engine="multipass"), _vm(name="vm-b", engine="virsh")]
rows = render(_payload(items))
assert "Engine" in _flat([rows[0]])
def test_load_columns_hidden_when_load_none():
rows = render(_payload([_vm(load_1min=None)]))
assert "LOAD 1/5/15min" not in _flat([rows[0]])
def test_load_columns_shown_when_load_present():
rows = render(_payload([_vm(load_1min=0.5, load_5min=0.4, load_15min=0.3)]))
assert "LOAD 1/5/15min" in _flat([rows[0]])
assert "0.5" in _flat([rows[1]])
def test_status_colour_running_ok():
rows = render(_payload([_vm(status="running")]))
status_cell = [c for c in rows[1].cells if "running" in c.text][0]
assert status_cell.color == ColorRole.OK
def test_status_colour_starting_warning():
rows = render(_payload([_vm(status="starting")]))
status_cell = [c for c in rows[1].cells if "starting" in c.text][0]
assert status_cell.color == ColorRole.WARNING
def test_status_colour_other_default():
rows = render(_payload([_vm(status="stopped")]))
status_cell = [c for c in rows[1].cells if "stopped" in c.text][0]
assert status_cell.color == ColorRole.DEFAULT
def test_cpu_time_absent_shows_dash():
vm = _vm()
del vm["cpu_time"]
rows = render(_payload([vm]))
# Core cell has cpu_count=2; CPU% cell should be the dash placeholder.
dash_cells = [c for c in rows[1].cells if c.text.strip() == "-"]
assert dash_cells
def test_name_truncated_to_max_name_size():
rows = render(_payload([_vm(name="a-very-long-vm-name")], max_name_size=5))
name_cell = rows[1].cells[0] if "Engine" not in _flat([rows[0]]) else rows[1].cells[1]
assert len(name_cell.text.strip()) <= 5
def test_sort_underline_name():
rows = render(_payload([_vm()]), view={"sort_key": "name"})
header = rows[0].cells
name_cell = next(c for c in header if c.text.strip() == "Name")
cpu_cell = next(c for c in header if c.text.strip() == "CPU%")
assert name_cell.underline is True
assert cpu_cell.underline is False
def test_sort_underline_cpu():
rows = render(_payload([_vm()]), view={"sort_key": "cpu_percent"})
header = rows[0].cells
cpu_cell = next(c for c in header if c.text.strip() == "CPU%")
name_cell = next(c for c in header if c.text.strip() == "Name")
assert cpu_cell.underline is True
assert name_cell.underline is False
def test_sort_underline_mem():
rows = render(_payload([_vm()]), view={"sort_key": "memory_percent"})
header = rows[0].cells
mem_cell = next(c for c in header if "MEM" in c.text and "MAX" in c.text)
assert mem_cell.underline is True
def test_no_view_no_underline():
rows = render(_payload([_vm()]))
header = rows[0].cells
assert all(c.underline is False for c in header)

248
tests/test_plugin_vms_v5.py Normal file
View File

@@ -0,0 +1,248 @@
#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Glances v5 — unit tests for the `vms` plugin (collection, disabled by default)."""
from __future__ import annotations
import pytest
from glances.config_v5 import GlancesConfigV5
from glances.plugins.vms.model_v5 import PluginModel
from glances.stats_store_v5 import StatsStoreV5
@pytest.fixture
def store() -> StatsStoreV5:
return StatsStoreV5()
@pytest.fixture
def config(tmp_path, monkeypatch) -> GlancesConfigV5:
monkeypatch.setattr(GlancesConfigV5, "SYSTEM_CONFIG_PATH", tmp_path / "etc" / "glances.conf")
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
return GlancesConfigV5()
def _cfg_with(tmp_path, monkeypatch, body: str) -> GlancesConfigV5:
monkeypatch.setattr(GlancesConfigV5, "SYSTEM_CONFIG_PATH", tmp_path / "etc" / "glances.conf")
xdg = tmp_path / "xdg"
cfg_dir = xdg / "glances"
cfg_dir.mkdir(parents=True)
(cfg_dir / "glances.conf").write_text(body)
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg))
return GlancesConfigV5()
_FIELD_NAMES = (
"name",
"id",
"release",
"status",
"cpu_count",
"cpu_time",
"memory_usage",
"memory_total",
"load_1min",
"load_5min",
"load_15min",
"ipv4",
"engine",
"engine_version",
)
class _FakeEngine:
"""Fake VM engine mirroring `watcher.update(all_tag) -> (version, list[dict])`."""
def __init__(self, version, rows, raises=False):
self._version = version
self._rows = rows
self._raises = raises
def update(self, all_tag):
if self._raises:
raise RuntimeError("engine down")
return self._version, [dict(r) for r in self._rows]
# ---------------------------------------------------------- identity / fields
def test_plugin_identity(store, config):
p = PluginModel(store, config)
assert p.plugin_name == "vms"
assert p.IS_COLLECTION is True
assert p.EMITS_ALERTS is False
assert p._primary_key == "name"
def test_fields_description(store, config):
fd = PluginModel(store, config).fields_description
assert set(fd.keys()) == set(_FIELD_NAMES)
assert fd["name"]["primary_key"] is True
assert fd["cpu_time"]["rate"] is True
for name, schema in fd.items():
assert schema.get("watched") is not True, name
# ---------------------------------------------------------- disabled by default
@pytest.mark.asyncio
async def test_disabled_by_default_returns_empty(store, config):
p = PluginModel(store, config)
assert await p._grab_stats() == []
# ---------------------------------------------------------- engine merge
@pytest.mark.asyncio
async def test_enabled_merges_engines(store, tmp_path, monkeypatch):
config = _cfg_with(tmp_path, monkeypatch, "[vms]\ndisable=False\n")
p = PluginModel(store, config)
p.watchers = {
"multipass": _FakeEngine(
"1.13",
[
{
"name": "vm-a",
"status": "running",
"cpu_count": 2,
"cpu_time": None,
"memory_usage": 1024,
"memory_total": 4096,
"load_1min": None,
}
],
),
"virsh": _FakeEngine(
"9.0",
[
{
"name": "vm-b",
"status": "running",
"cpu_count": 4,
"cpu_time": 1000,
"memory_usage": 2048,
"memory_total": 8192,
"load_1min": None,
}
],
),
}
out = await p._grab_stats()
assert len(out) == 2
by_name = {v["name"]: v for v in out}
assert by_name["vm-a"]["engine"] == "multipass"
assert by_name["vm-a"]["engine_version"] == "1.13"
assert by_name["vm-b"]["engine"] == "virsh"
assert by_name["vm-b"]["engine_version"] == "9.0"
@pytest.mark.asyncio
async def test_one_engine_unavailable_still_returns_other(store, tmp_path, monkeypatch):
config = _cfg_with(tmp_path, monkeypatch, "[vms]\ndisable=False\n")
p = PluginModel(store, config)
p.watchers = {
"multipass": _FakeEngine("", [], raises=True),
"virsh": _FakeEngine("9.0", [{"name": "vm-b", "status": "running", "cpu_count": 1}]),
}
out = await p._grab_stats()
assert [v["name"] for v in out] == ["vm-b"]
@pytest.mark.asyncio
async def test_no_engine_returns_empty(store, tmp_path, monkeypatch):
config = _cfg_with(tmp_path, monkeypatch, "[vms]\ndisable=False\n")
p = PluginModel(store, config)
p.watchers = {
"multipass": _FakeEngine("", []),
"virsh": _FakeEngine("", []),
}
out = await p._grab_stats()
assert out == []
# ---------------------------------------------------------- metadata
@pytest.mark.asyncio
async def test_max_name_size_exposed_in_metadata(store, tmp_path, monkeypatch):
config = _cfg_with(tmp_path, monkeypatch, "[vms]\ndisable=False\n")
p = PluginModel(store, config)
p.watchers = {
"multipass": _FakeEngine("1.13", [{"name": "vm-a", "status": "running", "cpu_count": 2}]),
}
await p.update()
payload = p.get_stats()
assert payload["max_name_size"] == 20
assert "sort_key" not in payload
# ---------------------------------------------------------- cpu_time rate
@pytest.mark.asyncio
async def test_cpu_time_rate_derived(store, tmp_path, monkeypatch):
config = _cfg_with(tmp_path, monkeypatch, "[vms]\ndisable=False\n")
p = PluginModel(store, config)
fake_now = [100.0]
import glances.plugins.plugin.base_v5 as base_module
monkeypatch.setattr(base_module.time, "monotonic", lambda: fake_now[0])
p.watchers = {"virsh": _FakeEngine("9.0", [{"name": "vm-b", "status": "running", "cpu_time": 1000}])}
await p.update()
item = next(i for i in p.get_stats()["data"] if i["name"] == "vm-b")
# First cycle: rate field stripped (no baseline yet).
assert "cpu_time" not in item
fake_now[0] = 102.0
p.watchers = {"virsh": _FakeEngine("9.0", [{"name": "vm-b", "status": "running", "cpu_time": 3000}])}
await p.update()
item = next(i for i in p.get_stats()["data"] if i["name"] == "vm-b")
assert item["cpu_time"] == 1000.0 # (3000 - 1000) / 2.0
@pytest.mark.asyncio
async def test_multipass_cpu_time_none_absent(store, tmp_path, monkeypatch):
config = _cfg_with(tmp_path, monkeypatch, "[vms]\ndisable=False\n")
p = PluginModel(store, config)
p.watchers = {"multipass": _FakeEngine("1.13", [{"name": "vm-a", "status": "running", "cpu_time": None}])}
await p.update()
item = next(i for i in p.get_stats()["data"] if i["name"] == "vm-a")
assert "cpu_time" not in item
def test_sort_vm_stats_actually_orders():
"""Guard against the v4 no-op: sort_vm_stats must capture the sorted list.
`sort_stats_processes` returns a NEW list (does not mutate in place), so a
verbatim v4 port that drops the return value would leave `stats` unsorted.
Here `sort_key` defaults to the cpu_time branch (reverse=True), so the
highest cpu_time must come first regardless of input order.
"""
from glances.plugins.vms import model_v5
from glances.processes import glances_processes
unsorted = [
{"name": "low", "cpu_time": 10, "memory_usage": 1},
{"name": "high", "cpu_time": 90, "memory_usage": 1},
{"name": "mid", "cpu_time": 50, "memory_usage": 1},
]
saved = glances_processes._sort_key # `sort_key` is a read-only property
try:
glances_processes._sort_key = "cpu_percent" # -> default cpu_time branch, reverse
sort_by, ordered = model_v5.sort_vm_stats(list(unsorted))
finally:
glances_processes._sort_key = saved
assert sort_by == "cpu_time"
assert [i["name"] for i in ordered] == ["high", "mid", "low"]

View File

@@ -270,6 +270,37 @@ async def test_stop_cancels_loops_cleanly(store, config):
assert scheduler._tasks == []
async def test_stop_calls_plugin_stop_on_every_plugin(store, config):
calls = []
class _P:
plugin_name = "p_ok"
def __init__(self, name):
self.plugin_name = name
async def update(self):
return None
def stop(self):
calls.append(self.plugin_name)
class _PRaises(_P):
def stop(self):
calls.append(self.plugin_name)
raise RuntimeError("boom")
scheduler = AsyncScheduler(store, config)
scheduler.register(_PRaises("p_bad"), refresh_time=1.0)
scheduler.register(_P("p_ok"), refresh_time=1.0)
# stop() before run: no tasks, but must still call each plugin.stop().
await scheduler.stop()
# Both plugins torn down; the raising one did not block the other.
assert set(calls) == {"p_bad", "p_ok"}
async def test_one_plugin_crash_does_not_kill_others(store, config, caplog):
scheduler = AsyncScheduler(store, config)
raiser = RaisingPlugin(store, config)