mirror of
https://github.com/nicolargo/glances.git
synced 2026-06-02 19:05:00 -04:00
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:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
97
glances/plugins/memswap/model_v5.py
Normal file
97
glances/plugins/memswap/model_v5.py
Normal 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()
|
||||
94
glances/plugins/memswap/render_curses_v5.py
Normal file
94
glances/plugins/memswap/render_curses_v5.py
Normal 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)
|
||||
141
tests/test_plugin_memswap_render_curses_v5.py
Normal file
141
tests/test_plugin_memswap_render_curses_v5.py
Normal 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
|
||||
163
tests/test_plugin_memswap_v5.py
Normal file
163
tests/test_plugin_memswap_v5.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user