diff --git a/docs/architecture/tui-v4-rendering-patterns.md b/docs/architecture/tui-v4-rendering-patterns.md index 14706e3d..67b5f3e1 100644 --- a/docs/architecture/tui-v4-rendering-patterns.md +++ b/docs/architecture/tui-v4-rendering-patterns.md @@ -137,6 +137,46 @@ All byte fields are `DEFAULT` (no threshold configured for them). --- +## memswap + +**Source:** `glances/plugins/memswap/__init__.py::msg_curse` + +**Guard:** returns empty if `not self.stats` or plugin is disabled. + +**Expected v4-equivalent output:** + +``` +SWAP 25.0% +total 16.0G +used 4.0G +free 12.0G +``` + +**Header field table:** + +| field | label | format | width | color rule | +|---|---|---|---|---| +| (title) | `SWAP` | `'{:4}'.format('SWAP')` | 4 | `TITLE` | +| trend arrow | `↑/↓/` | `'{:2}'.format(trend_msg(...))` | 2 | `DEFAULT` | +| percent | `25.0%` | `'{:>6.1%}'.format(percent / 100)` | 6 | `get_views(key='percent', option='decoration')` | + +**Body rows** (lines 2-4): each row is `curse_add_stat(, width=15)` — single label/value pair, label left-aligned, value right-aligned, total row width 15 chars. + +| Line | Field | Notes | +|---|---|---| +| 2 | `total` | Total swap memory | +| 3 | `used` | Used swap memory | +| 4 | `free` | Free swap memory | + +**Color logic:** `percent` decoration comes from `get_alert_log(used, maximum=total)` — standard CAREFUL/WARNING/CRITICAL ladder. + +**Conditional behaviour:** the plugin is hidden when the system has no swap configured (psutil raises on `swap_memory()` — see Illumos/OpenBSD issues #1767, #2719). + +✅ **v5 renderer:** `glances/plugins/memswap/render_curses_v5.py` +(Added in G4-memswap. Trend arrow not yet ported — same status as `mem`/`load`.) + +--- + ## load **Source:** `glances/plugins/load/__init__.py::msg_curse` diff --git a/glances/outputs/mcp_adapter_v5.py b/glances/outputs/mcp_adapter_v5.py index 926a6add..00714688 100644 --- a/glances/outputs/mcp_adapter_v5.py +++ b/glances/outputs/mcp_adapter_v5.py @@ -58,7 +58,6 @@ KNOWN_V5_MISSING_PLUGINS: tuple[str, ...] = ( "processlist", "fs", "diskio", - "memswap", ) # Throttle the "history not supported" WARN so polling MCP clients don't diff --git a/glances/plugins/memswap/model_v5.py b/glances/plugins/memswap/model_v5.py new file mode 100644 index 00000000..974b0920 --- /dev/null +++ b/glances/plugins/memswap/model_v5.py @@ -0,0 +1,97 @@ +# +# Glances - An eye on your system +# +# SPDX-FileCopyrightText: 2026 Nicolas Hennion +# +# SPDX-License-Identifier: LGPL-3.0-only +# + +"""Glances v5 — memswap plugin (scalar, system-wide swap usage). + +Migrated from `glances/plugins/memswap/__init__.py`. Sister of the v5 +`mem` plugin. + +V4-aligned watched fields: +- ``percent`` — prominent True; thresholds 50/70/90 (mirrors `mem`). +- ``sin``/``sout`` — cumulative counters in v4; v5 exposes them as + per-second rates via ``rate: True``. + +The plugin tolerates platforms without a swap file (Illumos, OpenBSD — +cf. issues #1767, #2719): psutil raises on ``swap_memory()`` and v5 +returns an empty payload rather than crashing the scheduler tick. + +SNMP support is **not ported to v5** (architecture §10). +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, ClassVar + +import psutil + +from glances.plugins.plugin.base_v5 import GlancesPluginBase + +logger = logging.getLogger(__name__) + +# Same default ladder as `mem` so the swap and memory panels share a +# UX baseline. Override in glances.conf under [memswap] if needed. +_DEFAULT_PERCENT_THRESHOLDS = {"careful": 50.0, "warning": 70.0, "critical": 90.0} + + +class PluginModel(GlancesPluginBase[dict]): + """System-wide swap memory plugin (scalar).""" + + plugin_name: ClassVar[str] = "memswap" + IS_COLLECTION: ClassVar[bool] = False + + fields_description: ClassVar[dict[str, dict[str, Any]]] = { + "total": { + "description": "Total swap memory.", + "unit": "bytes", + }, + "used": { + "description": "Used swap memory.", + "unit": "bytes", + }, + "free": { + "description": "Free swap memory.", + "unit": "bytes", + }, + "percent": { + "description": "Used swap memory as a percentage of total.", + "unit": "percent", + "watched": True, + "watch_direction": "high", + "prominent": True, + "default_thresholds": _DEFAULT_PERCENT_THRESHOLDS, + }, + "sin": { + "description": ( + "Bytes the system has swapped in from disk (per second — v4 reports " + "the cumulative counter; v5 converts it to a rate)." + ), + "unit": "bytespers", + "rate": True, + }, + "sout": { + "description": ( + "Bytes the system has swapped out to disk (per second — v4 reports " + "the cumulative counter; v5 converts it to a rate)." + ), + "unit": "bytespers", + "rate": True, + }, + } + + async def _grab_stats(self) -> dict: + try: + sm = await asyncio.to_thread(psutil.swap_memory) + except (OSError, RuntimeError) as exc: + # Illumos / OpenBSD without a swap file (issues #1767, #2719). + # Returning an empty dict lets the scheduler keep ticking without + # ever populating the store for this plugin. + logger.debug("memswap: psutil.swap_memory() unavailable on this host: %s", exc) + return {} + return sm._asdict() diff --git a/glances/plugins/memswap/render_curses_v5.py b/glances/plugins/memswap/render_curses_v5.py new file mode 100644 index 00000000..1458f5a7 --- /dev/null +++ b/glances/plugins/memswap/render_curses_v5.py @@ -0,0 +1,94 @@ +# +# 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 memswap plugin. + +Replicates v4 ``memswap.msg_curse()`` +(`glances/plugins/memswap/__init__.py::msg_curse`): a 4-line block +laid out as a single-column label/value grid, with line 1 carrying the +``SWAP`` title + percent usage. + +Reference layout: + + SWAP 25.0% + total 16.0G + used 4.0G + free 12.0G + +- Line 1: ``SWAP`` (HEADER) + percent value coloured by ``_levels.percent``. +- Lines 2-4: ``total`` / ``used`` / ``free`` (label-left, value-right). + +Compared to the v5 ``mem`` renderer, this is a slimmer single-column +grid — there are only three body rows to show, and v4 splits them on +separate lines rather than a 2-column grid. +""" + +from __future__ import annotations + +from typing import Any + +from glances.outputs.curses_renderer_v5 import Cell, ColorRole, Row, _cell_for_field, field_label, title_role + +# Stable floor for the value column. ``mem`` and ``memswap`` both deal +# with bytes / percent — same minimum width keeps the SWAP and MEM +# blocks visually aligned in the top row. +_SWAP_VALUE_WIDTH = 6 + + +def _stat_pair(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]], key: str) -> list[Cell]: + """Return [label, value] cells for one swap stat.""" + schema = fields_desc.get(key, {}) + label_cell = Cell(text=field_label(schema, key, prefer_short=True)) + if key not in payload or payload.get(key) is None: + return [label_cell, Cell(text="-")] + return [label_cell, _cell_for_field(key, payload[key], schema, payload)] + + +def _align_grid(rows: list[list[Cell]]) -> list[Row]: + """Per-column alignment for a 2-cell grid (label, value). + + Value cells are floored at ``_SWAP_VALUE_WIDTH`` so the column does + not jiggle when stats shrink between cycles. + """ + if not rows: + return [] + ncols = max(len(r) for r in rows) + widths = [0] * ncols + for r in rows: + for i, c in enumerate(r): + widths[i] = max(widths[i], len(c.text)) + if ncols >= 2: + widths[1] = max(widths[1], _SWAP_VALUE_WIDTH) + + aligned: list[Row] = [] + for r in rows: + new_cells: list[Cell] = [] + for i, c in enumerate(r): + text = c.text.ljust(widths[i]) if i == 0 else c.text.rjust(widths[i]) + new_cells.append(Cell(text=text, color=c.color, prominent=c.prominent, bold=c.bold)) + aligned.append(Row(cells=new_cells)) + return aligned + + +def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]]) -> list[Row]: + """Render the memswap plugin's TUI block — mirrors v4 ``memswap.msg_curse``.""" + if not payload: + return [Row(cells=[Cell(text="SWAP", color=ColorRole.HEADER, bold=True)])] + + # Line 1: title + percent. + line1: list[Cell] = [ + Cell(text="SWAP", color=title_role(payload), bold=True), + _cell_for_field("percent", payload.get("percent"), fields_desc.get("percent", {}), payload), + ] + + # Lines 2-4: total / used / free. + grid: list[list[Cell]] = [line1] + for key in ("total", "used", "free"): + grid.append(_stat_pair(payload, fields_desc, key)) + + return _align_grid(grid) diff --git a/tests/test_plugin_memswap_render_curses_v5.py b/tests/test_plugin_memswap_render_curses_v5.py new file mode 100644 index 00000000..8054613b --- /dev/null +++ b/tests/test_plugin_memswap_render_curses_v5.py @@ -0,0 +1,141 @@ +"""Glances v5 — tests for the memswap plugin's curses renderer.""" + +from __future__ import annotations + +import pytest + +from glances.outputs.curses_renderer_v5 import ColorRole +from glances.plugins.memswap.render_curses_v5 import render + + +@pytest.fixture +def memswap_fields(): + return { + "total": {"unit": "bytes"}, + "used": {"unit": "bytes"}, + "free": {"unit": "bytes"}, + "percent": { + "unit": "percent", + "watched": True, + "prominent": True, + }, + "sin": {"unit": "bytespers", "rate": True}, + "sout": {"unit": "bytespers", "rate": True}, + } + + +@pytest.fixture +def memswap_payload(): + return { + "total": 16 * 1024**3, + "used": 4 * 1024**3, + "free": 12 * 1024**3, + "percent": 25.0, + "_levels": {"percent": {"level": "ok", "prominent": True}}, + } + + +# ---------------------------------------------------------------- structure + + +def test_render_produces_four_rows(memswap_payload, memswap_fields): + rows = render(memswap_payload, memswap_fields) + assert len(rows) == 4 + + +def test_render_first_row_carries_swap_title(memswap_payload, memswap_fields): + rows = render(memswap_payload, memswap_fields) + text = " ".join(c.text for c in rows[0].cells) + assert "SWAP" in text + + +def test_render_first_row_includes_percent(memswap_payload, memswap_fields): + rows = render(memswap_payload, memswap_fields) + text = " ".join(c.text for c in rows[0].cells) + assert "25.0%" in text + + +def test_render_body_rows_have_total_used_free(memswap_payload, memswap_fields): + rows = render(memswap_payload, memswap_fields) + flat = " ".join(c.text for row in rows[1:] for c in row.cells) + assert "total" in flat + assert "used" in flat + assert "free" in flat + + +def test_render_each_body_row_has_two_cells(memswap_payload, memswap_fields): + rows = render(memswap_payload, memswap_fields) + for r in rows[1:]: + assert len(r.cells) == 2 + + +def test_render_columns_align_across_rows(memswap_payload, memswap_fields): + rows = render(memswap_payload, memswap_fields) + ncols = max(len(r.cells) for r in rows) + for col in range(ncols): + widths = {len(r.cells[col].text) for r in rows if col < len(r.cells)} + assert len(widths) == 1, f"col {col} widths differ: {widths}" + + +def test_render_value_columns_have_stable_width_across_cycles(memswap_fields): + """Value cells must keep a stable width when stats vary cycle-to-cycle.""" + low = { + "total": 1024.0, + "used": 100.0, + "free": 924.0, + "percent": 5.0, + "_levels": {}, + } + high = { + "total": 999_999_999_999.0, + "used": 800_000_000_000.0, + "free": 199_999_999_999.0, + "percent": 100.0, + "_levels": {}, + } + rows_low = render(low, memswap_fields) + rows_high = render(high, memswap_fields) + for col in (1,): + w_low = {len(r.cells[col].text) for r in rows_low if col < len(r.cells)} + w_high = {len(r.cells[col].text) for r in rows_high if col < len(r.cells)} + assert w_low == w_high, f"col {col} jiggles: {w_low} vs {w_high}" + + +def test_render_handles_empty_payload(memswap_fields): + rows = render({}, memswap_fields) + assert len(rows) == 1 + text = " ".join(c.text for c in rows[0].cells) + assert "SWAP" in text + + +# ---------------------------------------------------------------- colour + + +def test_render_title_role_default_when_ok(memswap_payload, memswap_fields): + """When percent _level is ok or careful, the SWAP title stays HEADER (white).""" + rows = render(memswap_payload, memswap_fields) + assert rows[0].cells[0].color == ColorRole.HEADER + assert rows[0].cells[0].bold is True + + +def test_render_title_role_warning_escalates(memswap_fields): + payload = { + "total": 1024, + "used": 800, + "free": 224, + "percent": 80.0, + "_levels": {"percent": {"level": "warning", "prominent": True}}, + } + rows = render(payload, memswap_fields) + assert rows[0].cells[0].color == ColorRole.WARNING + assert rows[0].cells[0].bold is True + + +def test_render_percent_cell_carries_level_and_prominent(memswap_payload, memswap_fields): + """The percent cell on row 1 inherits ColorRole + prominent flag from _levels.""" + payload = {**memswap_payload, "_levels": {"percent": {"level": "warning", "prominent": True}}} + rows = render(payload, memswap_fields) + percent_cell = rows[0].cells[1] + assert "25.0" in percent_cell.text + assert percent_cell.color == ColorRole.WARNING + assert percent_cell.prominent is True diff --git a/tests/test_plugin_memswap_v5.py b/tests/test_plugin_memswap_v5.py new file mode 100644 index 00000000..39530fb6 --- /dev/null +++ b/tests/test_plugin_memswap_v5.py @@ -0,0 +1,163 @@ +# +# Glances - An eye on your system +# +# SPDX-FileCopyrightText: 2026 Nicolas Hennion +# +# SPDX-License-Identifier: LGPL-3.0-only +# + +"""Glances v5 — unit tests for the `memswap` plugin (scalar).""" + +from __future__ import annotations + +from collections import namedtuple +from unittest.mock import patch + +import pytest + +from glances.config_v5 import GlancesConfigV5 +from glances.plugins.memswap.model_v5 import PluginModel +from glances.stats_store_v5 import StatsStoreV5 + +# psutil result stub ------------------------------------------------------ + +SwapMemory = namedtuple("sswap", ["total", "used", "free", "percent", "sin", "sout"]) + + +def _swap(percent: float = 25.0, sin: int = 0, sout: int = 0) -> SwapMemory: + total = 16 * 1024**3 # 16 GiB + used = int(total * percent / 100.0) + return SwapMemory(total=total, used=used, free=total - used, percent=percent, sin=sin, sout=sout) + + +# ----------------------------------------------------------- fixtures + + +@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() + + +# ---------------------------------------------------------- contract + + +def test_plugin_identity(store, config): + plugin = PluginModel(store, config) + assert plugin.plugin_name == "memswap" + assert plugin.IS_COLLECTION is False + + +def test_percent_is_watched_prominent(store, config): + schema = PluginModel(store, config)._fields["percent"] + assert schema["watched"] is True + assert schema["prominent"] is True + assert schema["unit"] == "percent" + + +def test_total_used_free_are_bytes_not_watched(store, config): + fields = PluginModel(store, config)._fields + for name in ("total", "used", "free"): + assert fields[name]["unit"] == "bytes", name + assert fields[name].get("watched", False) is False, name + + +def test_sin_sout_are_rate_counters(store, config): + """sin/sout are cumulative on psutil — v5 converts them to bytes/s.""" + fields = PluginModel(store, config)._fields + for name in ("sin", "sout"): + assert fields[name].get("rate") is True, name + + +# ---------------------------------------------------------- update pipeline + + +async def test_update_writes_swap_fields(store, config): + plugin = PluginModel(store, config) + with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", return_value=_swap(percent=30.0)): + await plugin.update() + payload = store.get("memswap") + assert payload["percent"] == 30.0 + assert payload["total"] == 16 * 1024**3 + assert payload["used"] > 0 + assert payload["free"] > 0 + + +async def test_update_handles_no_swap_configured(store, config): + """Illumos / OpenBSD without a swap file: psutil raises — store must not crash.""" + plugin = PluginModel(store, config) + with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", side_effect=OSError("no swap")): + await plugin.update() + # An empty payload is acceptable — the store key may be absent or empty. + payload = store.get("memswap") + assert payload is None or payload == {} or payload.get("total") is None + + +async def test_first_cycle_strips_rate_fields(store, config): + """sin/sout absent on cycle 1 — no previous sample to diff against.""" + plugin = PluginModel(store, config) + with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", return_value=_swap()): + await plugin.update() + payload = store.get("memswap") + assert "sin" not in payload + assert "sout" not in payload + + +async def test_second_cycle_computes_swap_rates(store, config, monkeypatch): + """Second cycle: sin/sout become per-second rates.""" + plugin = 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]) + + with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", return_value=_swap(sin=1_000_000, sout=500_000)): + await plugin.update() + + fake_now[0] = 101.0 # +1 s elapsed + with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", return_value=_swap(sin=2_000_000, sout=600_000)): + await plugin.update() + + payload = store.get("memswap") + # delta = 1_000_000 / 1s = 1_000_000 bytes/s + assert payload["sin"] == 1_000_000.0 + # delta = 100_000 / 1s = 100_000 bytes/s + assert payload["sout"] == 100_000.0 + + +# ---------------------------------------------------------- _levels + + +async def test_percent_level_uses_default_thresholds(store, config): + plugin = PluginModel(store, config) + with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", return_value=_swap(percent=75.0)): + await plugin.update() + # 75 between warning (70) and critical (90) → warning, prominent + assert store.get("memswap")["_levels"]["percent"] == {"level": "warning", "prominent": True} + + +async def test_percent_level_ok_when_low(store, config): + plugin = PluginModel(store, config) + with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", return_value=_swap(percent=5.0)): + await plugin.update() + assert store.get("memswap")["_levels"]["percent"]["level"] == "ok" + + +# ---------------------------------------------------------- export + + +async def test_get_export_strips_internals_and_levels(store, config): + plugin = PluginModel(store, config) + with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", return_value=_swap()): + await plugin.update() + exported = plugin.get_export() + assert "_levels" not in exported + assert "time_since_update" not in exported + assert exported["percent"] == 25.0 diff --git a/tests/test_webserver_v5.py b/tests/test_webserver_v5.py index 46d5eb3b..82fcc033 100755 --- a/tests/test_webserver_v5.py +++ b/tests/test_webserver_v5.py @@ -377,9 +377,12 @@ def test_attach_mcp_logs_known_v5_gaps(config_factory, store, caplog): msgs = " ".join(r.message for r in caplog.records if r.levelno == logging.INFO) assert "not yet ported" in msgs # The canonical missing v4 plugins must be named so operators can - # match the message against MCP client errors. - for missing in ("processlist", "fs", "diskio", "memswap"): + # match the message against MCP client errors. Updated as plugins + # land in v5: memswap was ported in G4-memswap. + for missing in ("processlist", "fs", "diskio"): assert missing in msgs + # memswap is now ported — must NOT appear as missing. + assert "memswap" not in msgs def test_attach_mcp_logs_history_limitation(config_factory, store, caplog):