From a991427785200956100d5f42f030d65902cd3f4e Mon Sep 17 00:00:00 2001 From: nicolargo Date: Tue, 14 Jul 2026 22:46:46 +0200 Subject: [PATCH] Implement containers and vms plugins --- docs/aoa/containers.rst | 12 + docs/aoa/vms.rst | 9 + .../2026-07-14-glances-v5-g6a-containers.md | 1298 +++++++++++++++++ .../plans/2026-07-14-glances-v5-g6a-vms.md | 273 ++++ .../specs/2026-07-14-glances-v5-g6a-design.md | 323 ++++ glances/main_v5.py | 9 + glances/outputs/glances_curses_v5.py | 5 + glances/plugins/containers/engines/lxd.py | 2 +- glances/plugins/containers/model_v5.py | 228 +++ .../plugins/containers/render_curses_v5.py | 225 +++ glances/plugins/plugin/base_v5.py | 47 +- glances/plugins/vms/model_v5.py | 143 ++ glances/plugins/vms/render_curses_v5.py | 143 ++ glances/scheduler_v5.py | 11 +- tests/conftest.py | 41 + tests/test_curses_v5.py | 28 + ...plugin_base_v5_stop_and_threshold_field.py | 149 ++ ...test_plugin_containers_render_curses_v5.py | 138 ++ tests/test_plugin_containers_v5.py | 224 +++ tests/test_plugin_vms_render_curses_v5.py | 147 ++ tests/test_plugin_vms_v5.py | 248 ++++ tests/test_scheduler_v5.py | 31 + 22 files changed, 3721 insertions(+), 13 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-14-glances-v5-g6a-containers.md create mode 100644 docs/superpowers/plans/2026-07-14-glances-v5-g6a-vms.md create mode 100644 docs/superpowers/specs/2026-07-14-glances-v5-g6a-design.md create mode 100644 glances/plugins/containers/model_v5.py create mode 100644 glances/plugins/containers/render_curses_v5.py create mode 100644 glances/plugins/vms/model_v5.py create mode 100644 glances/plugins/vms/render_curses_v5.py create mode 100755 tests/test_plugin_base_v5_stop_and_threshold_field.py create mode 100755 tests/test_plugin_containers_render_curses_v5.py create mode 100755 tests/test_plugin_containers_v5.py create mode 100755 tests/test_plugin_vms_render_curses_v5.py create mode 100644 tests/test_plugin_vms_v5.py diff --git a/docs/aoa/containers.rst b/docs/aoa/containers.rst index 7bd14688..d18e9f33 100644 --- a/docs/aoa/containers.rst +++ b/docs/aoa/containers.rst @@ -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 + ``_cpu_*`` / ``_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:: diff --git a/docs/aoa/vms.rst b/docs/aoa/vms.rst index f3d77d3f..373aac6b 100644 --- a/docs/aoa/vms.rst +++ b/docs/aoa/vms.rst @@ -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 diff --git a/docs/superpowers/plans/2026-07-14-glances-v5-g6a-containers.md b/docs/superpowers/plans/2026-07-14-glances-v5-g6a-containers.md new file mode 100644 index 00000000..e6ca6ba1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-glances-v5-g6a-containers.md @@ -0,0 +1,1298 @@ +# Glances v5 — containers 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 `containers` plugin to the Glances v5 asyncio collection architecture (MAIN/RIGHT column), reusing the v4 Docker/Podman/LXD engine machinery (streaming threads + `ThreadPoolExecutor`) **unchanged** for performance parity, and raising CPU/MEM alerts per container via the base threshold engine. + +**Architecture:** `PluginModel(GlancesPluginBase[list])`, `IS_COLLECTION=True`, `EMITS_ALERTS=True`, `primary_key="name"`. The model builds `self.watchers` (docker/podman/lxd) in `__init__` exactly as v4, and `_grab_stats` wraps the v4 flatten/merge/inject-engine/sort pipeline in `asyncio.to_thread` (`_update_watchers`). CPU/IO/net rates stay computed inside the engines' `StatsFetcher` classes. A `stop()` override tears the engine threads down on shutdown, wired through a new `GlancesPluginBase.stop()` extension point + `GlancesScheduler.stop()`. Per-container CPU/MEM thresholds keep their shipped config keys (`cpu_*`/`mem_*`) via a new `threshold_field` schema alias. A dedicated `render_curses_v5.render` mirrors v4 `msg_curse()`. + +**Tech Stack:** Python, docker-py / podman / pylxd (all optional, import-guarded), asyncio (to_thread), curses renderer v5, pytest + +## Global Constraints + +- mirror-v4: read the v4 `msg_curse()` + `update_views()` + engines before writing the renderer/model; divergent "clean generic" layouts are regressions. +- **Reuse the v4 engines VERBATIM** (Option A): streaming threads, `ThreadedIterableStreamer`, `ThreadPoolExecutor(6)` inspect (issue #3559), and the in-`StatsFetcher` rate math are **not** rewritten. `_grab_stats` only wraps the top-level pipeline in `asyncio.to_thread`. No per-cycle blocking fan-out. Perf non-regression is non-negotiable. +- `containers` renders in the MAIN (RIGHT) column, full-width and responsive like `processlist` — **NOT** the 34-char LEFT-sidebar budget. `containers` is already in `RIGHT_SLOT` in `curses_renderer_v5.py`; no layout/orchestrator change. +- empty registry/stats must stay valid (no engine available → empty collection, never a crash). +- alerts fire on warning+ only; `careful` is colour-only (v5 engine collapses sub-warning levels). +- no dead code; no speculative config keys; surgical edits. +- do NOT touch `NEWS.rst` (release-time only). +- no commits/push/PR — stage only (each task ends at `git add`; NEVER `git commit`; never add a `Co-Authored-By` trailer). +- tests via `.venv/bin/python -m pytest`; lint `.venv/bin/python -m ruff check` / `.venv/bin/python -m ruff format`. + +--- + +## File Structure + +- **Modify** `glances/plugins/plugin/base_v5.py` — add the `stop()` no-op extension point (Task 1) and the `threshold_field` schema alias in the four threshold-resolution methods (Task 2). +- **Modify** `glances/scheduler_v5.py` — `GlancesScheduler.stop()` calls each plugin's `stop()` (Task 1). +- **Modify** `glances/outputs/glances_curses_v5.py` — thread the `--byte` flag into the per-cycle `view` (Task 3). +- **Create** `glances/plugins/containers/model_v5.py` — the collection plugin (Tasks 4–5). +- **Create** `glances/plugins/containers/render_curses_v5.py` — the TUI renderer (Task 6). +- **Create** `tests/test_plugin_base_v5_stop_and_threshold_field.py` — base extension-point tests (Tasks 1–2). +- **Modify** `tests/test_scheduler_v5.py` — scheduler `stop()` calls `plugin.stop()` (Task 1). +- **Create** `tests/test_plugin_containers_v5.py` — model tests (Tasks 4–5). +- **Create** `tests/test_plugin_containers_render_curses_v5.py` — renderer tests (Task 6). +- **Modify** `docs/aoa/containers.rst` — v5 note (Task 7). `containers` is already in `docs/aoa/index.rst` — do NOT re-add. + +**Reconciliation notes (v4 source vs. spec — baked into this plan):** + +1. **Memory alerting via `memory_percent`.** The spec (§6.1) said "watch `memory_usage` with `normalize_by: memory_limit`". But the base's `normalize_by` yields a **fraction** (usage/limit ∈ [0,1]), while the shipped `[containers] mem_careful=20` thresholds are **percents**. So this plan **computes a `memory_percent` field** (`memory_usage_no_cache / limit * 100`) at grab and declares **`memory_percent`** (with `threshold_field:"mem"`) as the watched field. Mirrors v4's `get_alert(value=usage_no_cache, maximum=limit)` exactly and matches `processlist`. +2. **Three distinct memory surfaces (maintainer decision — preserve v4 export parity; do NOT collapse):** + - **`memory_usage`** (field) = the **exact value v4's engine stores/exports** (raw `usage`, minus `cache` when present). The reconciliation step **leaves it untouched** → no export/dashboard regression. **Feeds: export / REST.** + - **`memory_usage_no_cache`** (field, `usage − inactive_file`) = computed at grab. **Feeds: the TUI MEM column (display).** + - **`memory_percent`** (watched field, `memory_usage_no_cache / limit * 100`) = computed at grab. **Feeds: alerting (thresholds).** + `memory_inactive_file` + `memory_limit` are kept for API parity; `/MAX` in the MEM column shows `memory_limit`. +3. **Sort is deliberately processlist-aligned (dynamic default).** The model pre-sorts each cycle by the **global** `glances_processes.sort_key` (read fresh — `auto` resolves to cpu/mem per load before the getter returns; never hardcode a static key). The renderer underlines the sorted column by comparing `view["sort_key"]` against a header→key map in the **same raw process-sort-key space** as `processlist` (`MEM → memory_percent`). No sort key is passed via payload metadata. +4. **No `__init__` prime.** v4 forces a first `update()` in `__init__` (streaming needs two samples for rates). This plan does **not** block startup with a synchronous docker call at discovery; CPU/IO/net rates simply warm over the first 1–2 scheduler cycles (shown as `_` until then). Deliberate, cleaner in the async model. +5. **`show_engine_name`/`show_pod_name`** are derived **in the renderer** from the payload data (`len({engine})>1`, `any(pod_name)`) — no need to pass them through metadata. +6. Filtering (`show`/`hide`) is done by the **base** `_filter_collection` on the `name` primary key — the grabber does **not** re-implement v4 `is_hide`. + +--- + +### Task 1: base `stop()` teardown hook + scheduler wiring + +**Files:** +- Modify: `glances/plugins/plugin/base_v5.py` +- Modify: `glances/scheduler_v5.py` +- Create: `tests/test_plugin_base_v5_stop_and_threshold_field.py` +- Modify: `tests/test_scheduler_v5.py` + +**Interfaces:** +- Produces: `GlancesPluginBase.stop(self) -> None` (default no-op). `GlancesScheduler.stop()` calls `plugin.stop()` for every registered plugin (guarded, via `asyncio.to_thread`). + +- [ ] **Step 1: Write the failing base-default test** + +Create `tests/test_plugin_base_v5_stop_and_threshold_field.py`: + +```python +#!/usr/bin/env python +# +# Glances - An eye on your system +# +# SPDX-FileCopyrightText: 2026 Nicolas Hennion +# +# 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 typing import ClassVar + +import pytest + +from glances.plugins.plugin.base_v5 import GlancesPluginBase + + +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 [] + + +def _mk(store, config, cls=_NoopCollection): + return cls(store, config) + + +def test_stop_default_is_noop(fake_store, fake_config): + plugin = _mk(fake_store, fake_config) + # Default stop() must exist and do nothing (no raise, no return value). + assert plugin.stop() is None +``` + +Reuse whatever `fake_store` / `fake_config` fixtures the existing base tests use (see `tests/test_plugin_base_v5.py` / `conftest.py`); if the names differ, match them. + +- [ ] **Step 2: Run it — expect FAIL** + +Run: `.venv/bin/python -m pytest tests/test_plugin_base_v5_stop_and_threshold_field.py::test_stop_default_is_noop -v` +Expected: FAIL — `AttributeError: 'NoopCollection' object has no attribute 'stop'`. + +- [ ] **Step 3: Add the `stop()` no-op to `GlancesPluginBase`** + +In `glances/plugins/plugin/base_v5.py`, add (near the `update()` pipeline, after `update`): + +```python + 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. + """ +``` + +- [ ] **Step 4: Run it — expect PASS** + +Run: `.venv/bin/python -m pytest tests/test_plugin_base_v5_stop_and_threshold_field.py::test_stop_default_is_noop -v` +Expected: PASS. + +- [ ] **Step 5: Write the failing scheduler test** + +In `tests/test_scheduler_v5.py`, add (match the file's existing fixture/async style — it already builds a `GlancesScheduler` with fake plugins): + +```python +@pytest.mark.asyncio +async def test_stop_calls_plugin_stop_on_every_plugin(scheduler_factory): + 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") + + sched = scheduler_factory([_PRaises("p_bad"), _P("p_ok")]) + # stop() before run: no tasks, but must still call each plugin.stop(). + await sched.stop() + # Both plugins torn down; the raising one did not block the other. + assert set(calls) == {"p_bad", "p_ok"} +``` + +If `test_scheduler_v5.py` has no `scheduler_factory`, construct the scheduler the same way the existing tests do (build `GlancesScheduler`, `register()` each plugin with a refresh_time), then call `await sched.stop()`. + +- [ ] **Step 6: Run it — expect FAIL** + +Run: `.venv/bin/python -m pytest tests/test_scheduler_v5.py::test_stop_calls_plugin_stop_on_every_plugin -v` +Expected: FAIL — `stop()` currently only cancels tasks; `calls` stays empty. + +- [ ] **Step 7: Wire `stop()` into `GlancesScheduler.stop()`** + +In `glances/scheduler_v5.py`, replace the body of `stop()`: + +```python + async def stop(self) -> None: + """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) +``` + +- [ ] **Step 8: Run both tests — expect PASS** + +Run: `.venv/bin/python -m pytest tests/test_scheduler_v5.py::test_stop_calls_plugin_stop_on_every_plugin tests/test_plugin_base_v5_stop_and_threshold_field.py -v` +Expected: PASS. + +- [ ] **Step 9: Regression + lint + stage** + +Run: `.venv/bin/python -m pytest tests/test_scheduler_v5.py -q` +Expected: all pass (existing `stop()` tests unaffected — cancellation behaviour unchanged). +Run: `.venv/bin/python -m ruff check glances/plugins/plugin/base_v5.py glances/scheduler_v5.py tests/test_plugin_base_v5_stop_and_threshold_field.py tests/test_scheduler_v5.py && .venv/bin/python -m ruff format glances/plugins/plugin/base_v5.py glances/scheduler_v5.py tests/test_plugin_base_v5_stop_and_threshold_field.py tests/test_scheduler_v5.py` +Then: `git add glances/plugins/plugin/base_v5.py glances/scheduler_v5.py tests/test_plugin_base_v5_stop_and_threshold_field.py tests/test_scheduler_v5.py` + +--- + +### Task 2: base `threshold_field` schema alias + +**Files:** +- Modify: `glances/plugins/plugin/base_v5.py` +- Modify: `tests/test_plugin_base_v5_stop_and_threshold_field.py` + +**Interfaces:** +- Produces: an optional `threshold_field` field-schema key. When present, the base uses it (instead of the field name) as the config-key prefix in `read_thresholds` / `read_thresholds_categorical` / `_scan_pk_override_fields`. Value lookup still uses the real field name. Default = field name (every existing plugin unaffected). + +- [ ] **Step 1: Write the failing alias tests** + +Append to `tests/test_plugin_base_v5_stop_and_threshold_field.py`: + +```python +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" +``` + +`store_with` / `config_with` are thin helpers over the existing test fixtures — a store factory and a config whose `.get(section, key, default)` / `.section_keys(section)` reflect the given dict. If the suite already exposes such helpers (see `tests/test_plugin_base_v5.py`), reuse them; otherwise add minimal local fakes in this test file (a dict-backed config exposing `get()` and `section_keys()`). + +- [ ] **Step 2: Run — expect FAIL** + +Run: `.venv/bin/python -m pytest tests/test_plugin_base_v5_stop_and_threshold_field.py -k threshold_field -v` (plus `test_absent_threshold_field...`) +Expected: the two alias tests FAIL (base looks up `cpu_percent_warning`, not `cpu_warning`); the `absent` test already PASSES. + +- [ ] **Step 3: Add the alias helper + thread it through the four methods** + +In `glances/plugins/plugin/base_v5.py`: + +Add a small static helper: + +```python + @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) +``` + +`_precompute_plugin_thresholds` — pass the alias as `field=`: + +```python + 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=key) + if mapping: + out[field_name] = {"mapping": mapping} + else: + thresholds = read_thresholds( + self.config, + self.plugin_name, + field=key, + defaults=schema.get("default_thresholds"), + strict=bool(schema.get("strict_thresholds", False)), + ) + if thresholds: + out[field_name] = {"thresholds": thresholds} +``` + +(Note: the result stays keyed by the real `field_name` so item-side resolution is unchanged.) + +`_resolve_numeric_thresholds` — slow path uses the alias: + +```python + return read_thresholds( + self.config, + self.plugin_name, + field=self._threshold_key(field_name, schema), + pk_value=pk_value, + defaults=schema.get("default_thresholds"), + strict=bool(schema.get("strict_thresholds", False)), + ) +``` + +`_resolve_categorical_mapping` — it lacks a `schema` param; look it up, then use the alias: + +```python + 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 + ) +``` + +`_scan_pk_override_fields` — build the suffix/prefix from the alias, still record the real `field_name`: + +```python + for key in section_keys: + for field_name, schema in self._watched_fields: + tkey = self._threshold_key(field_name, schema) + for level in levels: + suffix = f"_{tkey}_{level}" + if key.endswith(suffix) and not key.startswith(f"{tkey}_"): + prefix_len = len(key) - len(suffix) + if prefix_len > 0: + out.add(field_name) + break +``` + +- [ ] **Step 4: Run alias tests — expect PASS** + +Run: `.venv/bin/python -m pytest tests/test_plugin_base_v5_stop_and_threshold_field.py -v` +Expected: all PASS. + +- [ ] **Step 5: Full base + plugin regression (no other plugin affected)** + +Run: `.venv/bin/python -m pytest tests/test_plugin_base_v5.py tests/test_plugin_processlist_v5.py tests/test_plugin_network_v5.py -q` +Expected: all pass (fields without `threshold_field` behave exactly as before — the helper returns the field name). + +- [ ] **Step 6: Lint + stage** + +Run: `.venv/bin/python -m ruff check glances/plugins/plugin/base_v5.py tests/test_plugin_base_v5_stop_and_threshold_field.py && .venv/bin/python -m ruff format glances/plugins/plugin/base_v5.py tests/test_plugin_base_v5_stop_and_threshold_field.py` +Then: `git add glances/plugins/plugin/base_v5.py tests/test_plugin_base_v5_stop_and_threshold_field.py` + +--- + +### Task 3: thread `--byte` into the TUI `view` + +**Files:** +- Modify: `glances/outputs/glances_curses_v5.py` +- Modify: `tests/test_curses_v5.py` + +**Interfaces:** +- Produces: `view["byte"]` (bool) in the per-cycle view. Mirrors the `--fahrenheit` / `--hide-public-info` wiring (G4B). Default `False` (bits — v4 default). The containers renderer reads `view.get("byte", False)`. + +**Context:** `TuiV5.__init__` already accepts flag params (`fahrenheit`, and via G4B `hide_public_info`) stored as `self._fahrenheit` etc., surfaced in `_build_view`. Add `byte` the same way. The constructor call site (`main_v5` / `_TuiV5(`) passes `fahrenheit=args.fahrenheit`; add `byte=getattr(args, "byte", False)` there too. (Find the exact call site with `grep -n "fahrenheit=" glances/outputs/glances_curses_v5.py glances/main_v5.py`.) + +- [ ] **Step 1: Write the failing view test** + +In `tests/test_curses_v5.py`, add (match the existing `_build_view` / TuiV5 test pattern used for `fahrenheit`): + +```python +def test_build_view_carries_byte_flag(tui_factory): + tui = tui_factory(byte=True) + view = tui._build_view(max_x=200) + assert view["byte"] is True + + +def test_build_view_byte_defaults_false(tui_factory): + tui = tui_factory() + view = tui._build_view(max_x=200) + assert view["byte"] is False +``` + +Use the same `tui_factory` / construction helper the `fahrenheit` view tests use; if there is none, construct `TuiV5` the way those tests do and pass `byte=...`. + +- [ ] **Step 2: Run — expect FAIL** + +Run: `.venv/bin/python -m pytest tests/test_curses_v5.py -k byte -v` +Expected: FAIL — `TuiV5.__init__` has no `byte` param / `view` has no `byte` key. + +- [ ] **Step 3: Add the `byte` param + view key** + +In `glances/outputs/glances_curses_v5.py`, `TuiV5.__init__` signature — add `byte: bool = False` next to `fahrenheit`, and store `self._byte = bool(byte)` next to `self._fahrenheit`. In `_build_view`, add: + +```python + view["byte"] = self._byte +``` + +At the constructor call site (`_TuiV5(` in `main_v5` — or wherever `fahrenheit=` is passed), add `byte=getattr(args, "byte", False)`. + +- [ ] **Step 4: Run — expect PASS** + +Run: `.venv/bin/python -m pytest tests/test_curses_v5.py -k byte -v` +Expected: PASS. + +- [ ] **Step 5: Regression + lint + stage** + +Run: `.venv/bin/python -m pytest tests/test_curses_v5.py -q` +Expected: all pass (additive change; existing view keys unchanged). +Run: `.venv/bin/python -m ruff check glances/outputs/glances_curses_v5.py tests/test_curses_v5.py && .venv/bin/python -m ruff format glances/outputs/glances_curses_v5.py tests/test_curses_v5.py` +Then: `git add glances/outputs/glances_curses_v5.py tests/test_curses_v5.py` + +--- + +### Task 4: containers model — identity, fields, EMITS_ALERTS, levels + +**Files:** +- Create: `glances/plugins/containers/model_v5.py` +- Create: `tests/test_plugin_containers_v5.py` + +**Interfaces:** +- Consumes: `GlancesPluginBase` from `glances.plugins.plugin.base_v5`. +- Produces: `PluginModel` (collection). `plugin_name="containers"`, `IS_COLLECTION=True`, `EMITS_ALERTS=True`, `_primary_key="name"`. `fields_description` per below. Task 5 adds `__init__` (engines), `_grab_stats`, `_add_metadata`, `_sort`, `stop()` to this same class. + +- [ ] **Step 1: Write the failing identity/fields/levels tests** + +Create `tests/test_plugin_containers_v5.py`: + +```python +#!/usr/bin/env python +# +# Glances - An eye on your system +# +# SPDX-FileCopyrightText: 2026 Nicolas Hennion +# +# 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" +``` + +Reuse the same `store_with` / `config_with` helpers as Task 2 (a dict-backed config supporting `get()` + `section_keys()`), placing shared fakes in `tests/conftest.py` if not already there. + +- [ ] **Step 2: Run — expect FAIL** + +Run: `.venv/bin/python -m pytest tests/test_plugin_containers_v5.py -v` +Expected: FAIL — `ModuleNotFoundError: glances.plugins.containers.model_v5`. + +- [ ] **Step 3: Create the model scaffold + fields (no engines yet)** + +Create `glances/plugins/containers/model_v5.py`: + +```python +# +# Glances - An eye on your system +# +# SPDX-FileCopyrightText: 2026 Nicolas Hennion +# +# 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 logging +from typing import Any, ClassVar + +from glances.plugins.plugin.base_v5 import GlancesPluginBase + +logger = logging.getLogger(__name__) + + +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"}, + } + + async def _grab_stats(self) -> list: + # Implemented in Task 5. + return [] +``` + +- [ ] **Step 4: Run — expect PASS** + +Run: `.venv/bin/python -m pytest tests/test_plugin_containers_v5.py -v` +Expected: PASS (levels come from the base engine + the `threshold_field` alias from Task 2). + +- [ ] **Step 5: Lint + stage** + +Run: `.venv/bin/python -m ruff check glances/plugins/containers/model_v5.py tests/test_plugin_containers_v5.py && .venv/bin/python -m ruff format glances/plugins/containers/model_v5.py tests/test_plugin_containers_v5.py` +Then: `git add glances/plugins/containers/model_v5.py tests/test_plugin_containers_v5.py` + +--- + +### Task 5: containers model — engines, `_grab_stats`, memory reconciliation, sort, `stop()` + +**Files:** +- Modify: `glances/plugins/containers/model_v5.py` +- Modify: `tests/test_plugin_containers_v5.py` + +**Interfaces:** +- Consumes: `DockerExtension`/`disable_plugin_docker`, `PodmanExtension`/`disable_plugin_podman`, `LxdExtension`/`disable_plugin_lxd` from `glances.plugins.containers.engines.*`; `glances_processes` + `sort_stats` from `glances.processes`. +- Produces on `PluginModel`: `__init__(store, config)` builds `self.watchers`; `_update_watchers()` (flatten/merge/inject-engine/reconcile-memory/sort); async `_grab_stats` wrapping it in `to_thread`; `_add_metadata` surfacing `disable_stats` + `max_name_size`; `stop()` override. + +- [ ] **Step 1: Write the failing engine/grab/sort/stop tests** + +Append to `tests/test_plugin_containers_v5.py`: + +```python +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() == [] + + +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 +``` + +- [ ] **Step 2: Run — expect FAIL** + +Run: `.venv/bin/python -m pytest tests/test_plugin_containers_v5.py -k "grab or stop or metadata" -v` +Expected: FAIL — `_grab_stats` returns `[]`, `stop()` is the base no-op, `_add_metadata` lacks the extra keys. + +- [ ] **Step 3: Implement engines, grab, reconcile, sort, metadata, stop** + +Replace the imports + body of `glances/plugins/containers/model_v5.py` (keep the `fields_description` from Task 4): + +```python +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" +``` + +Add to the class (after `fields_description`): + +```python + 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) +``` + +Remove the placeholder `_grab_stats` from Task 4. Keep `logger` defined once. + +- [ ] **Step 4: Run — expect PASS** + +Run: `.venv/bin/python -m pytest tests/test_plugin_containers_v5.py -v` +Expected: all PASS. + +- [ ] **Step 5: Discovery smoke — the model imports and instantiates** + +Run: `.venv/bin/python -c "from glances.plugins.containers.model_v5 import PluginModel; print(PluginModel.plugin_name)"` +Expected: prints `containers` with no import error (engines are import-guarded — missing docker/podman/pylxd only logs a warning). + +- [ ] **Step 6: Lint + stage** + +Run: `.venv/bin/python -m ruff check glances/plugins/containers/model_v5.py tests/test_plugin_containers_v5.py && .venv/bin/python -m ruff format glances/plugins/containers/model_v5.py tests/test_plugin_containers_v5.py` +Then: `git add glances/plugins/containers/model_v5.py tests/test_plugin_containers_v5.py` + +--- + +### Task 6: containers renderer (`render_curses_v5.py`) + +**Files:** +- Create: `glances/plugins/containers/render_curses_v5.py` +- Create: `tests/test_plugin_containers_render_curses_v5.py` + +**Interfaces:** +- Consumes: `Cell`, `Row`, `ColorRole`, `_LEVEL_TO_ROLE`, `title_role` from `glances.outputs.curses_renderer_v5`; `payload` (`{"data": [...], "disable_stats": [...], "max_name_size": int, "_levels": {...}}`), `view` (`{"sort_key", "byte"}`). +- Produces: `render(payload, fields_desc=None, view=None) -> list[Row]` — header row + one row per container. Declares `view` → auto-registered as view-aware (`_accepts_view`). Empty `data` → the renderer is not even called (build_frame drops empty collections), but the function must still return `[]` defensively. + +**Column model (mirror v4 `msg_curse`/`build_header`), each gated by `disable_stats`:** Engine (only if >1 engine in data), Pod (only if any `pod_name`), Name (`min(max_name_size, longest name)`, SORT-underline if `sort_key=="name"`), Status (`{:>10}`, coloured by status→role), Uptime (`{:>10}`), CPU% (`{:>6.1f}`, SORT if `cpu_percent`, coloured by level), MEM (`{:>7}` auto_unit(`memory_usage_no_cache`), SORT if `memory_percent`, coloured by `memory_percent` level) + `/MAX` (`{:<7}` auto_unit(`memory_limit`)), IOR/s + IOW/s (`{:>7}`/`{:<7}` auto_unit(io)+"B"), Rx/s + Tx/s (bits×8 unless `view["byte"]`, +"b"/""), Ports (`{:16}`), Command. + +- [ ] **Step 1: Write failing renderer tests** + +Create `tests/test_plugin_containers_render_curses_v5.py`: + +```python +#!/usr/bin/env python +# +# Glances - An eye on your system +# +# SPDX-FileCopyrightText: 2026 Nicolas Hennion +# +# 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 "Name" 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_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]) +``` + +- [ ] **Step 2: Run — expect FAIL** + +Run: `.venv/bin/python -m pytest tests/test_plugin_containers_render_curses_v5.py -v` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement the renderer** + +Create `glances/plugins/containers/render_curses_v5.py`: + +```python +# +# Glances - An eye on your system +# +# SPDX-FileCopyrightText: 2026 Nicolas Hennion +# +# 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] = {"Name": "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 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) + + def hdr(label: str, width: int, *, ljust: bool = False, color: ColorRole = ColorRole.HEADER) -> 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) + + # ---- header row + 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("Name", 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)) + rows: list[Row] = [Row(cells=h)] + + # ---- data rows + for c in items: + 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}")) + 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}")) + 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)) + 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 '_'}")) + rows.append(Row(cells=cells)) + + 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}}" +``` + +- [ ] **Step 4: Run — expect PASS** + +Run: `.venv/bin/python -m pytest tests/test_plugin_containers_render_curses_v5.py -v` +Expected: all PASS. + +- [ ] **Step 5: Renderer auto-registers as view-aware + integration smoke** + +Run: `.venv/bin/python -c "import inspect; from glances.plugins.containers.render_curses_v5 import render; assert 'view' in inspect.signature(render).parameters; print('view-aware OK')"` +Expected: prints `view-aware OK` (so `build_frame` passes `view=`). + +- [ ] **Step 6: Lint + stage** + +Run: `.venv/bin/python -m ruff check glances/plugins/containers/render_curses_v5.py tests/test_plugin_containers_render_curses_v5.py && .venv/bin/python -m ruff format glances/plugins/containers/render_curses_v5.py tests/test_plugin_containers_render_curses_v5.py` +Then: `git add glances/plugins/containers/render_curses_v5.py tests/test_plugin_containers_render_curses_v5.py` + +--- + +### Task 7: docs — `docs/aoa/containers.rst` + +**Files:** +- Modify: `docs/aoa/containers.rst` + +- [ ] **Step 1: Read the current doc + confirm toctree** + +Run: `.venv/bin/python -c "print(open('docs/aoa/containers.rst').read())"` and `grep -n containers docs/aoa/index.rst` +Expected: `containers` is already listed in `docs/aoa/index.rst` — do NOT re-add. + +- [ ] **Step 2: Add a v5 note (config keys + alerts)** + +Append (or fold into the existing config section) an admonition documenting: +- CPU/MEM alerts per container fire on `warning`+ (`careful` is colour-only). +- Threshold keys unchanged: `[containers] cpu_careful/cpu_warning/cpu_critical`, `mem_careful/mem_warning/mem_critical`, and per-container `_cpu_*` / `_mem_*` (MEM thresholds are a percentage of each container's memory **limit**). +- `disable_stats` accepts `name,status,uptime,cpu,mem,diskio,networkio,ports,command`. + +Keep RST underline lengths matching their titles. + +- [ ] **Step 3: Docs build sanity (if the project builds docs) + stage** + +Run (optional, if sphinx is available): `.venv/bin/python -m sphinx -b html -q docs /tmp/g6a-docs-build 2>&1 | tail -5` +Expected: no error referencing `containers.rst`. +Then: `git add docs/aoa/containers.rst` + +--- + +## Final verification (whole plan) + +- [ ] **All containers + base + wiring tests pass** + +Run: `.venv/bin/python -m pytest tests/test_plugin_containers_v5.py tests/test_plugin_containers_render_curses_v5.py tests/test_plugin_base_v5_stop_and_threshold_field.py tests/test_scheduler_v5.py tests/test_curses_v5.py -q` +Expected: all pass. + +- [ ] **Full suite — no regression** + +Run: `.venv/bin/python -m pytest -q` +Expected: same baseline as before the branch (only the pre-existing, unrelated `tests/test_actions_sanitize.py::TestSecurePopen::test_pipe` may fail in isolation — flag to maintainer if seen; it references none of the G6A modules). + +- [ ] **Lint/format clean across all touched files** + +Run: `.venv/bin/python -m ruff check glances/ tests/ && .venv/bin/python -m ruff format --check glances/plugins/containers glances/plugins/plugin/base_v5.py glances/scheduler_v5.py glances/outputs/glances_curses_v5.py` +Expected: clean. + +- [ ] **Everything staged (never committed)** + +Run: `git status --short` +Expected: all G6A-containers files staged (`A`/`M`), nothing committed. diff --git a/docs/superpowers/plans/2026-07-14-glances-v5-g6a-vms.md b/docs/superpowers/plans/2026-07-14-glances-v5-g6a-vms.md new file mode 100644 index 00000000..d7b2518e --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-glances-v5-g6a-vms.md @@ -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 `[] 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 | diff --git a/docs/superpowers/specs/2026-07-14-glances-v5-g6a-design.md b/docs/superpowers/specs/2026-07-14-glances-v5-g6a-design.md new file mode 100644 index 00000000..883369d7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-glances-v5-g6a-design.md @@ -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//`: + +- `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__v5.py` and + `tests/test_plugin__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_` 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 `_f_` and, non-strict, bare ``). + +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` / `_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 `_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_` + (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** + `_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 + `_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__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. diff --git a/glances/main_v5.py b/glances/main_v5.py index d55a0848..b9bc4c1d 100755 --- a/glances/main_v5.py +++ b/glances/main_v5.py @@ -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 diff --git a/glances/outputs/glances_curses_v5.py b/glances/outputs/glances_curses_v5.py index a03125c8..bb97ab93 100644 --- a/glances/outputs/glances_curses_v5.py +++ b/glances/outputs/glances_curses_v5.py @@ -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 diff --git a/glances/plugins/containers/engines/lxd.py b/glances/plugins/containers/engines/lxd.py index f1e34ef7..bca11a72 100644 --- a/glances/plugins/containers/engines/lxd.py +++ b/glances/plugins/containers/engines/lxd.py @@ -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 diff --git a/glances/plugins/containers/model_v5.py b/glances/plugins/containers/model_v5.py new file mode 100644 index 00000000..71d4b981 --- /dev/null +++ b/glances/plugins/containers/model_v5.py @@ -0,0 +1,228 @@ +# +# Glances - An eye on your system +# +# SPDX-FileCopyrightText: 2026 Nicolas Hennion +# +# 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) diff --git a/glances/plugins/containers/render_curses_v5.py b/glances/plugins/containers/render_curses_v5.py new file mode 100644 index 00000000..e2d999de --- /dev/null +++ b/glances/plugins/containers/render_curses_v5.py @@ -0,0 +1,225 @@ +# +# Glances - An eye on your system +# +# SPDX-FileCopyrightText: 2026 Nicolas Hennion +# +# 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}}" diff --git a/glances/plugins/plugin/base_v5.py b/glances/plugins/plugin/base_v5.py index 60ece178..1ca1972d 100755 --- a/glances/plugins/plugin/base_v5.py +++ b/glances/plugins/plugin/base_v5.py @@ -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: `__` — `` must be non-empty - # and must NOT be ``_`` (that's the plain - # `_` key, already handled by precompute). + for field_name, schema in self._watched_fields: + tkey = self._threshold_key(field_name, schema) + # Pattern: `__` — `` must be non-empty + # and must NOT be ``_`` (that's the plain + # `_` 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}_"): - # `` is whatever precedes `__` — + suffix = f"_{tkey}_{level}" + if key.endswith(suffix) and not key.startswith(f"{tkey}_"): + # `` is whatever precedes `__` — # 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)), diff --git a/glances/plugins/vms/model_v5.py b/glances/plugins/vms/model_v5.py new file mode 100644 index 00000000..2501525f --- /dev/null +++ b/glances/plugins/vms/model_v5.py @@ -0,0 +1,143 @@ +# +# Glances - An eye on your system +# +# SPDX-FileCopyrightText: 2026 Nicolas Hennion +# +# 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 diff --git a/glances/plugins/vms/render_curses_v5.py b/glances/plugins/vms/render_curses_v5.py new file mode 100644 index 00000000..f2b204a9 --- /dev/null +++ b/glances/plugins/vms/render_curses_v5.py @@ -0,0 +1,143 @@ +# +# Glances - An eye on your system +# +# SPDX-FileCopyrightText: 2026 Nicolas Hennion +# +# 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] diff --git a/glances/scheduler_v5.py b/glances/scheduler_v5.py index 010ee0c5..64486ba3 100755 --- a/glances/scheduler_v5.py +++ b/glances/scheduler_v5.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index fd8bcc9a..7510ebde 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_curses_v5.py b/tests/test_curses_v5.py index 93a4808b..910a1312 100644 --- a/tests/test_curses_v5.py +++ b/tests/test_curses_v5.py @@ -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 diff --git a/tests/test_plugin_base_v5_stop_and_threshold_field.py b/tests/test_plugin_base_v5_stop_and_threshold_field.py new file mode 100755 index 00000000..c5b41219 --- /dev/null +++ b/tests/test_plugin_base_v5_stop_and_threshold_field.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python +# +# Glances - An eye on your system +# +# SPDX-FileCopyrightText: 2026 Nicolas Hennion +# +# 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" diff --git a/tests/test_plugin_containers_render_curses_v5.py b/tests/test_plugin_containers_render_curses_v5.py new file mode 100755 index 00000000..95fcd954 --- /dev/null +++ b/tests/test_plugin_containers_render_curses_v5.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python +# +# Glances - An eye on your system +# +# SPDX-FileCopyrightText: 2026 Nicolas Hennion +# +# 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]) diff --git a/tests/test_plugin_containers_v5.py b/tests/test_plugin_containers_v5.py new file mode 100755 index 00000000..c3f72022 --- /dev/null +++ b/tests/test_plugin_containers_v5.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python +# +# Glances - An eye on your system +# +# SPDX-FileCopyrightText: 2026 Nicolas Hennion +# +# 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 diff --git a/tests/test_plugin_vms_render_curses_v5.py b/tests/test_plugin_vms_render_curses_v5.py new file mode 100755 index 00000000..5199e25c --- /dev/null +++ b/tests/test_plugin_vms_render_curses_v5.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python +# +# Glances - An eye on your system +# +# SPDX-FileCopyrightText: 2026 Nicolas Hennion +# +# 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) diff --git a/tests/test_plugin_vms_v5.py b/tests/test_plugin_vms_v5.py new file mode 100644 index 00000000..fa1a2e85 --- /dev/null +++ b/tests/test_plugin_vms_v5.py @@ -0,0 +1,248 @@ +# +# Glances - An eye on your system +# +# SPDX-FileCopyrightText: 2026 Nicolas Hennion +# +# 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"] diff --git a/tests/test_scheduler_v5.py b/tests/test_scheduler_v5.py index ebdef5d6..8a8bf328 100755 --- a/tests/test_scheduler_v5.py +++ b/tests/test_scheduler_v5.py @@ -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)