feat(v5): G4-memswap — port the memswap plugin to v5 (scalar)

Sister of the v5 ``mem`` plugin. Same pattern, slimmer layout
(single-column body — v4 ``memswap.msg_curse`` does not 2-col).

Model (``glances/plugins/memswap/model_v5.py``):
- ``total`` / ``used`` / ``free`` — bytes, snapshot.
- ``percent`` — watched + prominent, default thresholds 50/70/90
  (same ladder as ``mem`` for UX consistency).
- ``sin`` / ``sout`` — cumulative in v4; v5 exposes them as
  bytes/sec via ``rate: True``.
- Tolerates platforms without a swap file (Illumos, OpenBSD —
  issues #1767, #2719): psutil raises, model returns ``{}`` so
  the scheduler tick keeps going.

Renderer (``glances/plugins/memswap/render_curses_v5.py``):

    SWAP   25.0%
    total  16.0G
    used    4.0G
    free   12.0G

- Line 1: ``SWAP`` (HEADER) + percent cell coloured by ``_levels.percent``.
  Title escalates to warning/critical when the prominent percent reaches
  those levels.
- Lines 2-4: ``total`` / ``used`` / ``free`` as label/value pairs.
- Value column floored at 6 chars so it does not jiggle between cycles.

Adjacent changes:
- ``KNOWN_V5_MISSING_PLUGINS`` in ``mcp_adapter_v5`` shrinks to
  ``processlist, fs, diskio`` — memswap no longer surfaces in the
  MCP startup gap log.
- v4 catalogue (``docs/architecture/tui-v4-rendering-patterns.md``)
  grows a ``## memswap`` section +  footer pointing to the new
  renderer.

22 new tests (11 model + 11 renderer). Full v5 suite: 669 passed
(+22), lint clean.
This commit is contained in:
nicolargo
2026-05-15 14:17:04 +02:00
parent ebe0382252
commit f080a48937
7 changed files with 540 additions and 3 deletions

View File

@@ -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(<field>, 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`

View File

@@ -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

View File

@@ -0,0 +1,97 @@
#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
#
# 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()

View File

@@ -0,0 +1,94 @@
#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Glances v5 — TUI curses renderer for the 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)

View File

@@ -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

View File

@@ -0,0 +1,163 @@
#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Glances v5 — unit tests for the `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

View File

@@ -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):