mirror of
https://github.com/nicolargo/glances.git
synced 2026-06-02 19:05:00 -04:00
993 lines
41 KiB
Python
Executable File
993 lines
41 KiB
Python
Executable File
#!/usr/bin/env python
|
|
#
|
|
# Glances - An eye on your system
|
|
#
|
|
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
|
|
#
|
|
# SPDX-License-Identifier: LGPL-3.0-only
|
|
#
|
|
|
|
"""Glances v5 — unit tests for `GlancesAlerts`.
|
|
|
|
Covers: state machine (hysteresis), event shape, history bounded buffer,
|
|
3-level action precedence, fire-and-forget dispatch, scalar vs collection
|
|
plugins, min_duration_seconds per-plugin override.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from glances.actions_v5.action_base import GlancesActionBase
|
|
from glances.alerts_v5 import GlancesAlerts
|
|
from glances.config_v5 import GlancesConfigV5
|
|
from glances.plugins.plugin.base_v5 import GlancesPluginBase
|
|
from glances.stats_store_v5 import StatsStoreV5
|
|
|
|
# ---------------------------------------------------------- fakes
|
|
|
|
|
|
class _RecordingAction(GlancesActionBase):
|
|
"""Test double that records every execute() call."""
|
|
|
|
action_name = "action"
|
|
requires = []
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
async def execute(self, plugin_name, level, context, action_value, repeat=False):
|
|
self.calls.append(
|
|
{
|
|
"plugin_name": plugin_name,
|
|
"level": level,
|
|
"context": dict(context),
|
|
"action_value": action_value,
|
|
"repeat": repeat,
|
|
}
|
|
)
|
|
|
|
|
|
class _BoomAction(GlancesActionBase):
|
|
"""Test double that always raises."""
|
|
|
|
action_name = "boom"
|
|
requires = []
|
|
|
|
async def execute(self, plugin_name, level, context, action_value, repeat=False):
|
|
raise RuntimeError("boom")
|
|
|
|
|
|
class _FakeScalarPlugin(GlancesPluginBase[dict]):
|
|
"""Scalar plugin whose `_levels` is set externally for the test."""
|
|
|
|
plugin_name = "fakescalar"
|
|
IS_COLLECTION = False
|
|
fields_description = {
|
|
"percent": {"description": "p", "unit": "percent"},
|
|
"total": {"description": "t", "unit": "bytes"},
|
|
}
|
|
|
|
def __init__(self, store, config, *, payload=None, levels=None):
|
|
super().__init__(store, config)
|
|
self._payload = payload if payload is not None else {"percent": 50.0, "total": 1024}
|
|
self._fixed_levels = levels if levels is not None else {}
|
|
|
|
async def _grab_stats(self) -> dict:
|
|
return dict(self._payload)
|
|
|
|
def _derived_parameters(self) -> None:
|
|
self._levels = dict(self._fixed_levels)
|
|
|
|
|
|
class _FakeCollectionPlugin(GlancesPluginBase[list]):
|
|
"""Collection plugin whose `_levels` is set externally for the test."""
|
|
|
|
plugin_name = "fakecollection"
|
|
IS_COLLECTION = True
|
|
fields_description = {
|
|
"name": {"description": "n", "unit": "string", "primary_key": True},
|
|
"rx": {"description": "rx", "unit": "bytespers"},
|
|
}
|
|
|
|
def __init__(self, store, config, *, payload=None, levels=None):
|
|
super().__init__(store, config)
|
|
self._payload = payload if payload is not None else [{"name": "eth0", "rx": 1000}, {"name": "lo", "rx": 0}]
|
|
self._fixed_levels = levels if levels is not None else {}
|
|
|
|
async def _grab_stats(self) -> list:
|
|
return [dict(item) for item in self._payload]
|
|
|
|
def _derived_parameters(self) -> None:
|
|
self._levels = {pk: dict(entries) for pk, entries in self._fixed_levels.items()}
|
|
|
|
|
|
# ---------------------------------------------------------- fixtures
|
|
|
|
|
|
@pytest.fixture
|
|
def store() -> StatsStoreV5:
|
|
return StatsStoreV5()
|
|
|
|
|
|
@pytest.fixture
|
|
def config(tmp_path, monkeypatch) -> GlancesConfigV5:
|
|
"""Default test config — disables the alert warmup so each test can
|
|
observe a single ingestion. The warmup itself is tested separately
|
|
via `_config_with(... warmup_cycles=N ...)`."""
|
|
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("[alerts]\nwarmup_cycles=0\n")
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg))
|
|
return GlancesConfigV5()
|
|
|
|
|
|
def _config_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)
|
|
# Default to no warmup so individual alert tests can ingest once and
|
|
# observe the outcome. Tests that need the warmup pass an explicit
|
|
# `warmup_cycles=N` in their body.
|
|
if "warmup_cycles" not in body:
|
|
if "[alerts]" in body:
|
|
body = body.replace("[alerts]", "[alerts]\nwarmup_cycles=0", 1)
|
|
else:
|
|
body = "[alerts]\nwarmup_cycles=0\n" + body
|
|
(cfg_dir / "glances.conf").write_text(body)
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg))
|
|
return GlancesConfigV5()
|
|
|
|
|
|
def _clock():
|
|
"""A mutable monotonic clock substitute. Use `clock.tick(seconds)` to advance."""
|
|
slot = [0.0]
|
|
|
|
class _Clock:
|
|
def __call__(self) -> float:
|
|
return slot[0]
|
|
|
|
def tick(self, seconds: float) -> None:
|
|
slot[0] += seconds
|
|
|
|
return _Clock()
|
|
|
|
|
|
async def _run_with_levels(plugin, alerts, levels):
|
|
"""Force a plugin's `_levels` to `levels`, refresh the store, then ingest."""
|
|
plugin._fixed_levels = levels
|
|
await plugin.update()
|
|
await alerts.ingest_plugin(plugin)
|
|
await alerts.drain()
|
|
|
|
|
|
# ---------------------------------------------------------- state machine
|
|
|
|
|
|
async def test_no_transition_when_level_stays_ok(store, config):
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "ok", "prominent": True}})
|
|
assert alerts.get_history() == []
|
|
|
|
|
|
async def test_immediate_commit_when_min_duration_is_zero(tmp_path, monkeypatch, store):
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n")
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
history = alerts.get_history()
|
|
assert len(history) == 1
|
|
assert history[0]["level"] == "warning"
|
|
assert history[0]["previous_level"] == "ok"
|
|
|
|
|
|
async def test_hysteresis_holds_back_first_observation(tmp_path, monkeypatch, store):
|
|
"""With min_duration > 0, first cycle of a new level does not fire."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=5\n")
|
|
clock = _clock()
|
|
alerts = GlancesAlerts(config, now=clock)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
assert alerts.get_history() == []
|
|
|
|
# 4 s later — still pending.
|
|
clock.tick(4.0)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
assert alerts.get_history() == []
|
|
|
|
# 2 more seconds — total 6 s ≥ 5 → commit.
|
|
clock.tick(2.0)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
history = alerts.get_history()
|
|
assert len(history) == 1
|
|
assert history[0]["level"] == "warning"
|
|
|
|
|
|
async def test_hysteresis_resets_when_observed_level_oscillates(tmp_path, monkeypatch, store):
|
|
"""If the observed level changes during pending, the timer restarts."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=5\n")
|
|
clock = _clock()
|
|
alerts = GlancesAlerts(config, now=clock)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
clock.tick(3.0)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "critical", "prominent": True}})
|
|
clock.tick(3.0)
|
|
# Total 6 s — would have committed warning, but oscillated to critical at t=3.
|
|
# The critical window only started at t=3, so 6-3=3 s < 5 → still no fire.
|
|
assert alerts.get_history() == []
|
|
|
|
|
|
async def test_pending_clears_when_observation_returns_to_committed(tmp_path, monkeypatch, store):
|
|
"""An observation matching the committed level cancels any pending transition."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=5\n")
|
|
clock = _clock()
|
|
alerts = GlancesAlerts(config, now=clock)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
clock.tick(2.0)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "ok", "prominent": True}})
|
|
# Now pending should be cleared. Even after 10 s of warning, the
|
|
# timer starts fresh.
|
|
clock.tick(10.0)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
# First cycle of fresh warning → no fire yet.
|
|
assert alerts.get_history() == []
|
|
clock.tick(6.0)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
history = alerts.get_history()
|
|
assert len(history) == 1
|
|
|
|
|
|
async def test_resolution_event_recorded_but_no_action_fired(tmp_path, monkeypatch, store):
|
|
"""Transition non-ok → ok is recorded in history; non-repeat actions do not fire."""
|
|
config = _config_with(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"[alerts]\nmin_duration_seconds=0\n[fakescalar]\nwarning_action=true\n",
|
|
)
|
|
action = _RecordingAction()
|
|
alerts = GlancesAlerts(config, actions={"action": action})
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
assert any(call["repeat"] is False for call in action.calls)
|
|
action.calls.clear()
|
|
|
|
# Resolution: warning → ok.
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "ok", "prominent": True}})
|
|
history = alerts.get_history()
|
|
assert history[-1]["level"] == "ok"
|
|
assert history[-1]["previous_level"] == "warning"
|
|
# No non-repeat action on resolution.
|
|
assert not any(call["repeat"] is False for call in action.calls)
|
|
|
|
|
|
# ---------------------------------------------------------- event shape
|
|
|
|
|
|
async def test_event_shape_includes_required_fields(tmp_path, monkeypatch, store):
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n")
|
|
alerts = GlancesAlerts(config, hostname="myhost")
|
|
plugin = _FakeScalarPlugin(store, config, payload={"percent": 75.0, "total": 1024})
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
event = alerts.get_history()[0]
|
|
assert event["plugin"] == "fakescalar"
|
|
assert event["field"] == "percent"
|
|
assert event["key"] is None # scalar
|
|
assert event["level"] == "warning"
|
|
assert event["previous_level"] == "ok"
|
|
assert event["value"] == 75.0
|
|
assert event["prominent"] is True
|
|
assert event["hostname"] == "myhost"
|
|
assert "ts" in event
|
|
|
|
|
|
async def test_is_initializing_true_before_any_ingest(config):
|
|
"""At construction time no plugin has been ingested → initializing."""
|
|
alerts = GlancesAlerts(config)
|
|
assert alerts.is_initializing() is True
|
|
|
|
|
|
async def test_is_initializing_true_during_warmup(tmp_path, monkeypatch, store):
|
|
"""While at least one plugin is still inside the warmup window → initializing."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nwarmup_cycles=3\n")
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
# Cycles 1..3 are warmup — no events can fire yet.
|
|
for _ in range(3):
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "ok", "prominent": True}})
|
|
assert alerts.is_initializing() is True
|
|
|
|
|
|
async def test_is_initializing_false_after_warmup_completes(tmp_path, monkeypatch, store):
|
|
"""Once every ingested plugin is past its warmup → no longer initializing."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nwarmup_cycles=3\n")
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
# 4 cycles: 3 warmup + 1 real ingest.
|
|
for _ in range(4):
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "ok", "prominent": True}})
|
|
assert alerts.is_initializing() is False
|
|
|
|
|
|
async def test_is_initializing_true_during_pending_transition(tmp_path, monkeypatch, store):
|
|
"""Post-warmup, while a non-ok observation is still inside its
|
|
min_duration window, the alert subsystem is "settling" — keep the
|
|
initializing flag True so the UI does not flash "(no events)" between
|
|
the warmup phase and the first emitted event."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nwarmup_cycles=1\nmin_duration_seconds=5\n")
|
|
clock = _clock()
|
|
alerts = GlancesAlerts(config, now=clock)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
|
|
# First cycle is warmup → state untouched.
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "careful", "prominent": True}})
|
|
assert alerts.is_initializing() is True
|
|
|
|
# Second cycle: post-warmup. observed=careful, committed=ok → pending.
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "careful", "prominent": True}})
|
|
# History still empty (min_duration not elapsed) but a pending transition exists.
|
|
assert alerts.get_history() == []
|
|
assert alerts.is_initializing() is True
|
|
|
|
# After min_duration elapses, the transition fires — pending cleared.
|
|
clock.tick(6.0)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "careful", "prominent": True}})
|
|
assert alerts.get_history() != []
|
|
assert alerts.is_initializing() is False
|
|
|
|
|
|
async def test_is_initializing_true_if_any_plugin_still_warming(tmp_path, monkeypatch, store):
|
|
"""Mixed state — one plugin done, another still warming up → still initializing."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nwarmup_cycles=3\n")
|
|
alerts = GlancesAlerts(config)
|
|
p_done = _FakeScalarPlugin(store, config)
|
|
p_done.plugin_name = "fast"
|
|
p_slow = _FakeScalarPlugin(store, config)
|
|
p_slow.plugin_name = "slow"
|
|
# `fast` gets 4 cycles, `slow` only 2 — `slow` is still warming up.
|
|
for _ in range(4):
|
|
await _run_with_levels(p_done, alerts, {"percent": {"level": "ok", "prominent": True}})
|
|
for _ in range(2):
|
|
await _run_with_levels(p_slow, alerts, {"percent": {"level": "ok", "prominent": True}})
|
|
assert alerts.is_initializing() is True
|
|
|
|
|
|
async def test_first_event_after_warmup_is_flagged_initial(tmp_path, monkeypatch, store):
|
|
"""When the first post-warmup observation is already non-ok, the emitted
|
|
event is flagged ``is_initial=True`` — Glances was started while the
|
|
system was already in that state, so the renderer must show the level
|
|
as a steady state instead of a misleading "ok → <level>" transition."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n")
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeScalarPlugin(store, config, payload={"percent": 60.0})
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "careful", "prominent": True}})
|
|
|
|
history = alerts.get_history()
|
|
assert len(history) == 1
|
|
event = history[0]
|
|
assert event["level"] == "careful"
|
|
assert event["previous_level"] == "ok" # default committed_level
|
|
assert event["is_initial"] is True
|
|
|
|
|
|
async def test_subsequent_transitions_are_not_initial(tmp_path, monkeypatch, store):
|
|
"""After the first observed level has been committed, every following
|
|
transition is a real change — ``is_initial`` must be ``False``."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n")
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeScalarPlugin(store, config, payload={"percent": 80.0})
|
|
|
|
# First post-warmup observation: warning. Flagged initial.
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
# Then back to ok — real transition.
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "ok", "prominent": True}})
|
|
# Then up again to warning — real transition.
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
|
|
history = alerts.get_history()
|
|
assert len(history) == 3
|
|
assert history[0]["is_initial"] is True
|
|
assert history[1]["is_initial"] is False
|
|
assert history[2]["is_initial"] is False
|
|
|
|
|
|
async def test_initial_flag_set_when_first_observed_is_ok_then_non_ok(tmp_path, monkeypatch, store):
|
|
"""If the first post-warmup observation IS ok, no event is emitted (no
|
|
transition). A later transition out of ok is then a real change, not an
|
|
initial state."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n")
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeScalarPlugin(store, config, payload={"percent": 30.0})
|
|
|
|
# First observation: ok → no event but state is now confirmed at ok.
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "ok", "prominent": True}})
|
|
assert alerts.get_history() == []
|
|
|
|
# Later, system enters careful — real transition, NOT initial.
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "careful", "prominent": True}})
|
|
history = alerts.get_history()
|
|
assert len(history) == 1
|
|
assert history[0]["is_initial"] is False
|
|
|
|
|
|
async def test_event_key_field_is_pk_value_for_collection(tmp_path, monkeypatch, store):
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n")
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeCollectionPlugin(
|
|
store,
|
|
config,
|
|
payload=[{"name": "eth0", "rx": 1000}, {"name": "wlan0", "rx": 500}],
|
|
)
|
|
await _run_with_levels(
|
|
plugin,
|
|
alerts,
|
|
{
|
|
"eth0": {"rx": {"level": "warning", "prominent": True}},
|
|
"wlan0": {"rx": {"level": "critical", "prominent": True}},
|
|
},
|
|
)
|
|
keys = sorted(event["key"] for event in alerts.get_history())
|
|
assert keys == ["eth0", "wlan0"]
|
|
|
|
|
|
# ---------------------------------------------------------- history bounded
|
|
|
|
|
|
async def test_history_size_capped_by_deque(tmp_path, monkeypatch, store):
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\nhistory_size=3\n")
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
# Drive 5 alternating transitions — old events should be evicted.
|
|
for level in ["warning", "ok", "critical", "ok", "warning"]:
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": level, "prominent": True}})
|
|
history = alerts.get_history()
|
|
assert len(history) == 3
|
|
# Last three transitions (critical, ok, warning).
|
|
assert [event["level"] for event in history] == ["critical", "ok", "warning"]
|
|
|
|
|
|
# ---------------------------------------------------------- min_duration per-plugin
|
|
|
|
|
|
async def test_per_plugin_min_duration_overrides_global(tmp_path, monkeypatch, store):
|
|
config = _config_with(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"[alerts]\nmin_duration_seconds=10\n[fakescalar]\nmin_duration_seconds=0\n",
|
|
)
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
# Plugin override is 0 → immediate commit.
|
|
assert len(alerts.get_history()) == 1
|
|
|
|
|
|
# ----------------------- min_duration precedence (Phase 1.2 fix) -----------
|
|
|
|
|
|
async def test_field_min_duration_overrides_plugin(tmp_path, monkeypatch, store):
|
|
"""`<field>_min_duration_seconds` beats the plugin-section default."""
|
|
config = _config_with(
|
|
tmp_path,
|
|
monkeypatch,
|
|
# Plugin says 10 s for everything; the field-specific key says 0 → commits now.
|
|
"[alerts]\nmin_duration_seconds=10\n[fakescalar]\nmin_duration_seconds=10\npercent_min_duration_seconds=0\n",
|
|
)
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
assert len(alerts.get_history()) == 1
|
|
|
|
|
|
async def test_field_level_min_duration_overrides_field(tmp_path, monkeypatch, store):
|
|
"""`<field>_<level>_min_duration_seconds` beats `<field>_min_duration_seconds`.
|
|
|
|
This is the contract the user asked for:
|
|
``ctx_switches_critical_min_duration_seconds=300`` raises critical
|
|
only after 300 s sustained, while other levels of the same field stay
|
|
fast. Here we encode the same shape with `warning_min_duration_seconds=10`
|
|
overridden to `0` only for the critical level — entering critical must
|
|
commit instantly while entering warning would still hold.
|
|
"""
|
|
config = _config_with(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"[alerts]\nmin_duration_seconds=10\n"
|
|
"[fakescalar]\npercent_min_duration_seconds=10\n"
|
|
"percent_critical_min_duration_seconds=0\n",
|
|
)
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
# Observing `critical` directly → uses the 0 s override → immediate commit.
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "critical", "prominent": True}})
|
|
assert len(alerts.get_history()) == 1
|
|
assert alerts.get_history()[0]["level"] == "critical"
|
|
|
|
|
|
async def test_warning_level_uses_field_default_when_only_critical_overridden(tmp_path, monkeypatch, store):
|
|
"""Per-level override on `critical` must NOT bleed into `warning`."""
|
|
config = _config_with(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"[alerts]\nmin_duration_seconds=10\n"
|
|
"[fakescalar]\npercent_min_duration_seconds=10\n"
|
|
"percent_critical_min_duration_seconds=0\n",
|
|
)
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
# warning observation → field default (10 s) applies → no immediate commit.
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
assert alerts.get_history() == []
|
|
|
|
|
|
async def test_collection_pk_field_level_min_duration_overrides_field_level(tmp_path, monkeypatch, store):
|
|
"""`<pk>_<field>_<level>_min_duration_seconds` beats `<field>_<level>_min_duration_seconds`."""
|
|
config = _config_with(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"[alerts]\nmin_duration_seconds=10\n"
|
|
"[fakecollection]\nrx_warning_min_duration_seconds=10\n"
|
|
"eth0_rx_warning_min_duration_seconds=0\n",
|
|
)
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeCollectionPlugin(store, config)
|
|
# eth0 hits the pk-specific 0 s → immediate commit. lo stays under the 10 s field default.
|
|
await _run_with_levels(
|
|
plugin,
|
|
alerts,
|
|
{
|
|
"eth0": {"rx": {"level": "warning", "prominent": True}},
|
|
"lo": {"rx": {"level": "warning", "prominent": True}},
|
|
},
|
|
)
|
|
history = alerts.get_history()
|
|
keys = {(e["key"], e["level"]) for e in history}
|
|
assert ("eth0", "warning") in keys
|
|
assert ("lo", "warning") not in keys # held back by 10 s field default
|
|
|
|
|
|
async def test_collection_pk_field_min_duration_overrides_field(tmp_path, monkeypatch, store):
|
|
"""`<pk>_<field>_min_duration_seconds` beats `<field>_min_duration_seconds`."""
|
|
config = _config_with(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"[alerts]\nmin_duration_seconds=10\n"
|
|
"[fakecollection]\nrx_min_duration_seconds=10\n"
|
|
"eth0_rx_min_duration_seconds=0\n",
|
|
)
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeCollectionPlugin(store, config)
|
|
await _run_with_levels(
|
|
plugin,
|
|
alerts,
|
|
{
|
|
"eth0": {"rx": {"level": "warning", "prominent": True}},
|
|
"lo": {"rx": {"level": "warning", "prominent": True}},
|
|
},
|
|
)
|
|
keys = {(e["key"], e["level"]) for e in alerts.get_history()}
|
|
assert ("eth0", "warning") in keys
|
|
assert ("lo", "warning") not in keys
|
|
|
|
|
|
async def test_ctx_switches_critical_300s_end_to_end(tmp_path, monkeypatch, store):
|
|
"""End-to-end scenario validating the user-requested contract:
|
|
|
|
``ctx_switches_critical_min_duration_seconds=300`` raises CRITICAL only
|
|
after the value has been at critical for at least 300 s. Earlier cycles
|
|
stay pending; warning observations during the window reset the timer.
|
|
"""
|
|
config = _config_with(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"[alerts]\nmin_duration_seconds=5\n[fakescalar]\npercent_critical_min_duration_seconds=300\n",
|
|
)
|
|
clock = _clock()
|
|
alerts = GlancesAlerts(config, now=clock)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "critical", "prominent": True}})
|
|
assert alerts.get_history() == [] # 0 s — pending
|
|
|
|
clock.tick(150.0)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "critical", "prominent": True}})
|
|
assert alerts.get_history() == [] # 150 s — still pending
|
|
|
|
clock.tick(149.0)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "critical", "prominent": True}})
|
|
assert alerts.get_history() == [] # 299 s — still pending
|
|
|
|
clock.tick(2.0)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "critical", "prominent": True}})
|
|
# 301 s ≥ 300 → commit.
|
|
history = alerts.get_history()
|
|
assert len(history) == 1
|
|
assert history[0]["level"] == "critical"
|
|
|
|
|
|
# ---------------------------------------------------------- action dispatch
|
|
|
|
|
|
async def test_non_repeat_action_fires_on_entry(tmp_path, monkeypatch, store):
|
|
config = _config_with(
|
|
tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n[fakescalar]\nwarning_action=echo hi\n"
|
|
)
|
|
action = _RecordingAction()
|
|
alerts = GlancesAlerts(config, actions={"action": action})
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
# One non-repeat call (entry) — repeat key isn't set so no repeat call.
|
|
assert [c["repeat"] for c in action.calls] == [False]
|
|
assert action.calls[0]["action_value"] == "echo hi"
|
|
|
|
|
|
async def test_repeat_action_fires_every_cycle_while_committed_non_ok(tmp_path, monkeypatch, store):
|
|
config = _config_with(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"[alerts]\nmin_duration_seconds=0\n[fakescalar]\nwarning_action_repeat=repeat-cmd\n",
|
|
)
|
|
action = _RecordingAction()
|
|
alerts = GlancesAlerts(config, actions={"action": action})
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
|
|
# First cycle: entry — repeat fires (one call).
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
assert [c["repeat"] for c in action.calls] == [True]
|
|
# Second cycle, same level — repeat fires again.
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
assert [c["repeat"] for c in action.calls] == [True, True]
|
|
# Third cycle, back to ok — no repeat fire.
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "ok", "prominent": True}})
|
|
assert [c["repeat"] for c in action.calls] == [True, True]
|
|
|
|
|
|
async def test_both_repeat_and_non_repeat_fire_on_entry(tmp_path, monkeypatch, store):
|
|
config = _config_with(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"[alerts]\nmin_duration_seconds=0\n[fakescalar]\nwarning_action=once\nwarning_action_repeat=every\n",
|
|
)
|
|
action = _RecordingAction()
|
|
alerts = GlancesAlerts(config, actions={"action": action})
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
repeat_flags = sorted(c["repeat"] for c in action.calls)
|
|
assert repeat_flags == [False, True] # both fire on entry
|
|
|
|
|
|
async def test_action_key_precedence_pk_then_field_then_bare(tmp_path, monkeypatch, store):
|
|
"""For collections, `<pk>_<field>_<level>_<action>` beats `<field>_..` beats `<level>_..`."""
|
|
config = _config_with(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"[alerts]\nmin_duration_seconds=0\n"
|
|
"[fakecollection]\n"
|
|
"critical_action=any-field\n"
|
|
"rx_critical_action=any-iface\n"
|
|
"wlan0_rx_critical_action=wlan-specific\n",
|
|
)
|
|
action = _RecordingAction()
|
|
alerts = GlancesAlerts(config, actions={"action": action})
|
|
plugin = _FakeCollectionPlugin(
|
|
store,
|
|
config,
|
|
payload=[{"name": "eth0", "rx": 100}, {"name": "wlan0", "rx": 200}],
|
|
)
|
|
await _run_with_levels(
|
|
plugin,
|
|
alerts,
|
|
{
|
|
"eth0": {"rx": {"level": "critical", "prominent": True}},
|
|
"wlan0": {"rx": {"level": "critical", "prominent": True}},
|
|
},
|
|
)
|
|
# Filter non-repeat calls (entries).
|
|
entries = [c for c in action.calls if not c["repeat"]]
|
|
payloads = {c["context"]["name"]: c["action_value"] for c in entries}
|
|
assert payloads["eth0"] == "any-iface" # field-specific (no pk override for eth0)
|
|
assert payloads["wlan0"] == "wlan-specific"
|
|
|
|
|
|
async def test_action_template_context_includes_builtins(tmp_path, monkeypatch, store):
|
|
config = _config_with(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"[alerts]\nmin_duration_seconds=0\n[fakescalar]\nwarning_action=t\n",
|
|
)
|
|
action = _RecordingAction()
|
|
alerts = GlancesAlerts(config, actions={"action": action}, hostname="myhost")
|
|
plugin = _FakeScalarPlugin(store, config, payload={"percent": 75.0, "total": 1024})
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
context = action.calls[0]["context"]
|
|
assert context["_glances_hostname"] == "myhost"
|
|
assert context["_glances_plugin"] == "fakescalar"
|
|
assert context["_glances_level"] == "warning"
|
|
assert "_glances_timestamp" in context
|
|
# Plugin export values flow into the context.
|
|
assert context["percent"] == 75.0
|
|
assert context["total"] == 1024
|
|
|
|
|
|
async def test_failing_action_is_logged_and_does_not_raise(tmp_path, monkeypatch, store, caplog):
|
|
config = _config_with(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"[alerts]\nmin_duration_seconds=0\n[fakescalar]\nwarning_boom=t\n",
|
|
)
|
|
alerts = GlancesAlerts(config, actions={"boom": _BoomAction()})
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
with caplog.at_level(logging.WARNING):
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
assert "Action 'boom' failed" in caplog.text
|
|
|
|
|
|
# ---------------------------------------------------------- collection plugin
|
|
|
|
|
|
async def test_collection_state_is_per_pk_value(tmp_path, monkeypatch, store):
|
|
"""Each interface tracks its own committed level independently."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n")
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeCollectionPlugin(
|
|
store,
|
|
config,
|
|
payload=[{"name": "eth0", "rx": 1000}, {"name": "wlan0", "rx": 500}],
|
|
)
|
|
# First cycle: eth0 warning, wlan0 ok.
|
|
await _run_with_levels(
|
|
plugin,
|
|
alerts,
|
|
{
|
|
"eth0": {"rx": {"level": "warning", "prominent": True}},
|
|
"wlan0": {"rx": {"level": "ok", "prominent": True}},
|
|
},
|
|
)
|
|
assert len(alerts.get_history()) == 1
|
|
assert alerts.get_history()[0]["key"] == "eth0"
|
|
|
|
# Second cycle: eth0 stays warning, wlan0 → critical.
|
|
await _run_with_levels(
|
|
plugin,
|
|
alerts,
|
|
{
|
|
"eth0": {"rx": {"level": "warning", "prominent": True}},
|
|
"wlan0": {"rx": {"level": "critical", "prominent": True}},
|
|
},
|
|
)
|
|
# Two transitions total now: eth0 warning entry + wlan0 critical entry.
|
|
assert len(alerts.get_history()) == 2
|
|
last = alerts.get_history()[-1]
|
|
assert last["key"] == "wlan0"
|
|
assert last["level"] == "critical"
|
|
|
|
|
|
# ---------------------------------------------------------- minor
|
|
|
|
|
|
async def test_get_history_returns_a_copy(tmp_path, monkeypatch, store):
|
|
"""Mutating the returned list must not corrupt the internal deque."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n")
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
snapshot = alerts.get_history()
|
|
snapshot.clear()
|
|
assert len(alerts.get_history()) == 1
|
|
|
|
|
|
async def test_ingest_plugin_with_no_actions_is_safe(tmp_path, monkeypatch, store):
|
|
"""A `GlancesAlerts` with no actions registry still ingests transitions cleanly."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n")
|
|
alerts = GlancesAlerts(config) # actions=None default
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
assert len(alerts.get_history()) == 1
|
|
|
|
|
|
# ---------------------------------------------------------- warmup
|
|
|
|
|
|
async def test_warmup_skips_first_n_cycles(tmp_path, monkeypatch, store):
|
|
"""For the first `warmup_cycles` ingestions per plugin, no event is emitted."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\nwarmup_cycles=3\n")
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
# First 3 ingestions: warming up, ignored even with a warning level.
|
|
for _ in range(3):
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
assert alerts.get_history() == []
|
|
# Cycle 4: warmup elapsed, first real ingestion fires the transition.
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
history = alerts.get_history()
|
|
assert len(history) == 1
|
|
assert history[0]["level"] == "warning"
|
|
assert history[0]["previous_level"] == "ok"
|
|
|
|
|
|
async def test_warmup_is_per_plugin(tmp_path, monkeypatch, store):
|
|
"""Two plugins ingesting interleaved each have their own warmup window."""
|
|
|
|
class _PluginP1(_FakeScalarPlugin):
|
|
plugin_name = "p1"
|
|
|
|
class _PluginP2(_FakeScalarPlugin):
|
|
plugin_name = "p2"
|
|
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\nwarmup_cycles=2\n")
|
|
alerts = GlancesAlerts(config)
|
|
p1 = _PluginP1(store, config)
|
|
p2 = _PluginP2(store, config)
|
|
|
|
await _run_with_levels(p1, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
await _run_with_levels(p2, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
# Each plugin: 1 warmup tick consumed, still in warmup.
|
|
assert alerts.get_history() == []
|
|
|
|
await _run_with_levels(p1, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
await _run_with_levels(p2, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
# Each plugin: 2 warmup ticks consumed (== warmup_cycles), still no event.
|
|
assert alerts.get_history() == []
|
|
|
|
await _run_with_levels(p1, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
# p1 cycle 3 — warmup over → emits event.
|
|
assert len(alerts.get_history()) == 1
|
|
assert alerts.get_history()[0]["plugin"] == "p1"
|
|
|
|
|
|
async def test_warmup_zero_means_immediate_ingestion(tmp_path, monkeypatch, store):
|
|
"""`warmup_cycles=0` disables the warmup (used by most existing tests)."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\nwarmup_cycles=0\n")
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "warning", "prominent": True}})
|
|
assert len(alerts.get_history()) == 1
|
|
|
|
|
|
# ---------------------------------------------------------- EMITS_ALERTS opt-out
|
|
|
|
|
|
async def test_plugin_with_emits_alerts_false_is_skipped(tmp_path, monkeypatch, store):
|
|
"""Plugins flagged ``EMITS_ALERTS=False`` produce ``_levels`` for the
|
|
renderer but contribute nothing to the alerts history.
|
|
|
|
Mirrors the processlist case: per-process CPU/MEM thresholds drive cell
|
|
colouring without paging the operator on individual pids."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\nwarmup_cycles=0\n")
|
|
alerts = GlancesAlerts(config)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
plugin.EMITS_ALERTS = False # opt-out (subclass-level in real plugins)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "critical", "prominent": True}})
|
|
assert alerts.get_history() == []
|
|
|
|
|
|
async def test_plugin_with_emits_alerts_false_does_not_fire_actions(tmp_path, monkeypatch, store):
|
|
"""Opt-out also prevents action dispatch (entry + repeat)."""
|
|
config = _config_with(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"[alerts]\nmin_duration_seconds=0\nwarmup_cycles=0\n[fakescalar]\npercent_critical_action=run_recording\n",
|
|
)
|
|
recording = _RecordingAction()
|
|
registry = {"run_recording": recording}
|
|
alerts = GlancesAlerts(config, actions=registry)
|
|
plugin = _FakeScalarPlugin(store, config)
|
|
plugin.EMITS_ALERTS = False
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "critical", "prominent": True}})
|
|
assert recording.calls == []
|
|
|
|
|
|
# ---------------------------------------------------------- dynamic process auto-sort
|
|
|
|
|
|
class _MemPlugin(_FakeScalarPlugin):
|
|
"""Scalar plugin named ``mem`` — drives auto-sort to memory_percent."""
|
|
|
|
plugin_name = "mem"
|
|
|
|
|
|
class _CpuPlugin(_FakeScalarPlugin):
|
|
"""Scalar plugin named ``cpu`` — its ``iowait`` field drives io_counters."""
|
|
|
|
plugin_name = "cpu"
|
|
|
|
|
|
class _FakeProcessEngine:
|
|
"""Duck-typed ``glances_processes`` stand-in for auto-sort tests."""
|
|
|
|
def __init__(self, auto_sort: bool = True) -> None:
|
|
self.auto_sort = auto_sort
|
|
self.sort_key = "cpu_percent"
|
|
self.calls: list[tuple[str, bool]] = []
|
|
|
|
def set_sort_key(self, key, auto=True) -> None:
|
|
self.calls.append((key, auto))
|
|
self.sort_key = "cpu_percent" if key == "auto" else key
|
|
self.auto_sort = True if key == "auto" else auto
|
|
|
|
|
|
async def test_auto_sort_defaults_to_cpu_percent(tmp_path, monkeypatch, store):
|
|
"""No active alert → auto-sort keeps cpu_percent (and stays enabled)."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n")
|
|
engine = _FakeProcessEngine()
|
|
alerts = GlancesAlerts(config, process_engine=engine)
|
|
plugin = _CpuPlugin(store, config)
|
|
await _run_with_levels(plugin, alerts, {"total": {"level": "ok", "prominent": True}})
|
|
assert engine.sort_key == "cpu_percent"
|
|
assert engine.calls[-1] == ("cpu_percent", True)
|
|
|
|
|
|
async def test_auto_sort_switches_to_memory_on_mem_alert(tmp_path, monkeypatch, store):
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n")
|
|
engine = _FakeProcessEngine()
|
|
alerts = GlancesAlerts(config, process_engine=engine)
|
|
plugin = _MemPlugin(store, config)
|
|
await _run_with_levels(plugin, alerts, {"percent": {"level": "critical", "prominent": True}})
|
|
assert engine.sort_key == "memory_percent"
|
|
|
|
|
|
async def test_auto_sort_switches_to_io_on_cpu_iowait_alert(tmp_path, monkeypatch, store):
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n")
|
|
engine = _FakeProcessEngine()
|
|
alerts = GlancesAlerts(config, process_engine=engine)
|
|
plugin = _CpuPlugin(store, config)
|
|
await _run_with_levels(plugin, alerts, {"iowait": {"level": "warning", "prominent": True}})
|
|
assert engine.sort_key == "io_counters"
|
|
|
|
|
|
async def test_auto_sort_mem_takes_precedence_over_iowait(tmp_path, monkeypatch, store):
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n")
|
|
engine = _FakeProcessEngine()
|
|
alerts = GlancesAlerts(config, process_engine=engine)
|
|
cpu = _CpuPlugin(store, config)
|
|
mem = _MemPlugin(store, config)
|
|
await _run_with_levels(cpu, alerts, {"iowait": {"level": "warning", "prominent": True}})
|
|
assert engine.sort_key == "io_counters"
|
|
await _run_with_levels(mem, alerts, {"percent": {"level": "critical", "prominent": True}})
|
|
# MEM now active alongside IOWAIT → memory_percent wins.
|
|
assert engine.sort_key == "memory_percent"
|
|
|
|
|
|
async def test_auto_sort_resets_after_recovery(tmp_path, monkeypatch, store):
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n")
|
|
engine = _FakeProcessEngine()
|
|
alerts = GlancesAlerts(config, process_engine=engine)
|
|
mem = _MemPlugin(store, config)
|
|
await _run_with_levels(mem, alerts, {"percent": {"level": "critical", "prominent": True}})
|
|
assert engine.sort_key == "memory_percent"
|
|
# Memory pressure clears → back to the cpu_percent default.
|
|
await _run_with_levels(mem, alerts, {"percent": {"level": "ok", "prominent": True}})
|
|
assert engine.sort_key == "cpu_percent"
|
|
|
|
|
|
async def test_auto_sort_noop_when_manual_sort_selected(tmp_path, monkeypatch, store):
|
|
"""`auto_sort=False` (user picked a manual key) → engine untouched."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n")
|
|
engine = _FakeProcessEngine(auto_sort=False)
|
|
alerts = GlancesAlerts(config, process_engine=engine)
|
|
mem = _MemPlugin(store, config)
|
|
await _run_with_levels(mem, alerts, {"percent": {"level": "critical", "prominent": True}})
|
|
assert engine.calls == []
|
|
|
|
|
|
async def test_auto_sort_noop_without_engine(tmp_path, monkeypatch, store):
|
|
"""No engine wired (default) → ingest still works, no crash."""
|
|
config = _config_with(tmp_path, monkeypatch, "[alerts]\nmin_duration_seconds=0\n")
|
|
alerts = GlancesAlerts(config) # process_engine=None
|
|
mem = _MemPlugin(store, config)
|
|
await _run_with_levels(mem, alerts, {"percent": {"level": "critical", "prominent": True}})
|
|
assert len(alerts.get_history()) == 1
|