mirror of
https://github.com/nicolargo/glances.git
synced 2026-07-30 14:56:19 -04:00
Implement containers and vms plugins
This commit is contained in:
@@ -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::
|
||||
|
||||
@@ -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
|
||||
|
||||
1298
docs/superpowers/plans/2026-07-14-glances-v5-g6a-containers.md
Normal file
1298
docs/superpowers/plans/2026-07-14-glances-v5-g6a-containers.md
Normal file
File diff suppressed because it is too large
Load Diff
273
docs/superpowers/plans/2026-07-14-glances-v5-g6a-vms.md
Normal file
273
docs/superpowers/plans/2026-07-14-glances-v5-g6a-vms.md
Normal 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 235–281) 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 97–104): `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 328–350):
|
||||
```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 16–18, `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 204–314; 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 231–234; 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 214–225): `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 300–306).
|
||||
- `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 1–2 |
|
||||
323
docs/superpowers/specs/2026-07-14-glances-v5-g6a-design.md
Normal file
323
docs/superpowers/specs/2026-07-14-glances-v5-g6a-design.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
228
glances/plugins/containers/model_v5.py
Normal file
228
glances/plugins/containers/model_v5.py
Normal 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)
|
||||
225
glances/plugins/containers/render_curses_v5.py
Normal file
225
glances/plugins/containers/render_curses_v5.py
Normal 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}}"
|
||||
@@ -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)),
|
||||
|
||||
143
glances/plugins/vms/model_v5.py
Normal file
143
glances/plugins/vms/model_v5.py
Normal 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
|
||||
143
glances/plugins/vms/render_curses_v5.py
Normal file
143
glances/plugins/vms/render_curses_v5.py
Normal 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]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
149
tests/test_plugin_base_v5_stop_and_threshold_field.py
Executable file
149
tests/test_plugin_base_v5_stop_and_threshold_field.py
Executable 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"
|
||||
138
tests/test_plugin_containers_render_curses_v5.py
Executable file
138
tests/test_plugin_containers_render_curses_v5.py
Executable 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])
|
||||
224
tests/test_plugin_containers_v5.py
Executable file
224
tests/test_plugin_containers_v5.py
Executable 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 (usage−cache);
|
||||
# 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
|
||||
147
tests/test_plugin_vms_render_curses_v5.py
Executable file
147
tests/test_plugin_vms_render_curses_v5.py
Executable 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
248
tests/test_plugin_vms_v5.py
Normal 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"]
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user