style: clear whitespace, unused-import and blank-line lint

Strip trailing whitespace, drop unused imports/variables/global statements,
split multi-imports, fix E3xx/E1xx spacing and E712 comparisons. No behaviour
change; only E501 long lines remain in flake8.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 04:59:34 -04:00
co-authored by Claude Sonnet 5.5
parent 85c6a4eebe
commit 748349a7b9
35 changed files with 705 additions and 719 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""HeartBeat Client (hbc) - System monitoring client.""" """HeartBeat Client (hbc) - System monitoring client."""
from hbd import __version__ from hbd import __version__ # noqa: F401
-1
View File
@@ -2,7 +2,6 @@
import logging import logging
import os import os
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+2 -3
View File
@@ -16,7 +16,7 @@ import sys
import time import time
from logging.handlers import SysLogHandler from logging.handlers import SysLogHandler
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import List, Optional
# Import protocol and config # Import protocol and config
from .config import load_config from .config import load_config
@@ -572,7 +572,7 @@ async def cleanup(connections: List[AsyncConnection]):
async def async_main(args, config): async def async_main(args, config):
"""Async main function.""" """Async main function."""
global running, shutdown_event, active_tasks, send_shutdown, allow_remote_command global shutdown_event, active_tasks, send_shutdown, allow_remote_command
# Create shutdown event # Create shutdown event
shutdown_event = asyncio.Event() shutdown_event = asyncio.Event()
@@ -838,7 +838,6 @@ def build_parser():
def main(argv=None): def main(argv=None):
"""Main entry point.""" """Main entry point."""
global running, dorestart
parser = build_parser() parser = build_parser()
args = parser.parse_args(argv) args = parser.parse_args(argv)
-2
View File
@@ -5,8 +5,6 @@ load average, and process counts.
""" """
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import sys
from pathlib import Path
# Import from parent package # Import from parent package
from hbd.client.plugin import MonitorPlugin from hbd.client.plugin import MonitorPlugin
+1 -1
View File
@@ -5,7 +5,7 @@ Collects disk usage and I/O statistics using psutil.
""" """
import logging import logging
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional
try: try:
import psutil import psutil
+1
View File
@@ -32,6 +32,7 @@ def _zfs_arc_bytes() -> int:
pass pass
return 0 return 0
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+2 -2
View File
@@ -5,7 +5,7 @@ Collects network interface statistics and connection information using psutil.
""" """
import logging import logging
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional
try: try:
import psutil import psutil
@@ -72,7 +72,7 @@ class NetworkMonitorPlugin(MonitorPlugin):
return False return False
logger.info(f"Network monitor initialized (interval: {self.interval}s, " logger.info(f"Network monitor initialized (interval: {self.interval}s, "
f"connections: {self.include_connections})") f"connections: {self.include_connections})")
# Initialize I/O counters # Initialize I/O counters
try: try:
-1
View File
@@ -5,7 +5,6 @@ kernel, architecture, and distribution details.
""" """
import platform import platform
import sys
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
+1 -1
View File
@@ -1,3 +1,3 @@
"""Common utilities shared between hbc and hbd.""" """Common utilities shared between hbc and hbd."""
from hbd import __version__ from hbd import __version__ # noqa: F401
+1 -2
View File
@@ -8,7 +8,7 @@ Message Types:
PLG: Plugin data message (client -> server) PLG: Plugin data message (client -> server)
""" """
from typing import Dict, Any, Union from typing import Dict, Any
import json import json
import zlib import zlib
@@ -159,4 +159,3 @@ def decode_plugin_data(msg: bytes) -> Dict[str, Any]:
Dictionary with 'ID', 'plugin', and plugin data fields Dictionary with 'ID', 'plugin', and plugin data fields
""" """
return stodict(msg) return stodict(msg)
+1 -1
View File
@@ -1,3 +1,3 @@
"""HeartBeat Daemon (hbd) - Server/daemon component.""" """HeartBeat Daemon (hbd) - Server/daemon component."""
from hbd import __version__ from hbd import __version__ # noqa: F401
+10 -4
View File
@@ -120,7 +120,7 @@ def cmd_passwd(args):
def cmd_notify(args): def cmd_notify(args):
"""Send a test message via a single notification channel.""" """Send a test message via a single notification channel."""
from .config import load_config from .config import load_config
from .notify import Notification, _dispatch_to_channel, setup from .notify import Notification, setup
config = load_config(args.configfile) config = load_config(args.configfile)
setup(config) setup(config)
@@ -193,7 +193,9 @@ def _read_pid(configfile) -> int | None:
def cmd_stop(args): def cmd_stop(args):
import os, signal as _signal, time import os
import signal as _signal
import time
pid = _read_pid(args.configfile) pid = _read_pid(args.configfile)
if pid is None: if pid is None:
sys.exit(1) sys.exit(1)
@@ -212,7 +214,8 @@ def cmd_stop(args):
def cmd_reload(args): def cmd_reload(args):
import os, signal as _signal import os
import signal as _signal
pid = _read_pid(args.configfile) pid = _read_pid(args.configfile)
if pid is None: if pid is None:
sys.exit(1) sys.exit(1)
@@ -222,7 +225,10 @@ def cmd_reload(args):
def cmd_restart(args): def cmd_restart(args):
import os, signal as _signal, time, subprocess import os
import signal as _signal
import time
import subprocess
pid = _read_pid(args.configfile) pid = _read_pid(args.configfile)
if pid is not None: if pid is not None:
print(f"Stopping hbd (pid {pid})...") print(f"Stopping hbd (pid {pid})...")
+8 -11
View File
@@ -43,10 +43,10 @@ SERVER_DEFAULTS = {
# Host management # Host management
"hosts": {}, # Unified host definitions "hosts": {}, # Unified host definitions
"dyndomains": ["example.org"], # Domains to update via nsupdate when a host with dyndns: true is updated "dyndomains": ["example.org"], # Domains to update via nsupdate when a host with dyndns: true is updated
# DNS updates # DNS updates
"nsupdate_bin": "/usr/bin/nsupdate", # Path to nsupdate binary "nsupdate_bin": "/usr/bin/nsupdate", # Path to nsupdate binary
# WebSocket settings # WebSocket settings
"ws_port": 50005, "ws_port": 50005,
@@ -80,7 +80,7 @@ THRESHOLD_DEFAULTS = {
'critical': 90.0 'critical': 90.0
} }
}, },
'memory_monitor': { 'memory_monitor': {
'memory_percent': { 'memory_percent': {
'warning': 85.0, 'warning': 85.0,
'critical': 95.0 'critical': 95.0
@@ -90,7 +90,7 @@ THRESHOLD_DEFAULTS = {
'critical': 75.0 'critical': 75.0
} }
}, },
'disk_monitor': { 'disk_monitor': {
'partitions': { 'partitions': {
'/': { '/': {
'percent': { 'percent': {
@@ -100,18 +100,18 @@ THRESHOLD_DEFAULTS = {
} }
} }
}, },
'rtt': { 'rtt': {
'warning': 200, 'warning': 200,
'critical': 250.0, 'critical': 250.0,
'count': 3 # Optional: number of consecutive breaches before alerting 'count': 3 # Optional: number of consecutive breaches before alerting
}, },
'nagios_runner': { 'nagios_runner': {
'status_code': { 'status_code': {
'display': '{check_name} {output}', 'display': '{check_name} {output}',
'operator': "nagios" 'operator': "nagios"
} }
}, },
'zfs_monitor': { 'zfs_monitor': {
'pools': { 'pools': {
'*': { '*': {
'status': { 'status': {
@@ -205,9 +205,6 @@ class ReloadableConfig:
# Load new config # Load new config
new_config = load_config(path) new_config = load_config(path)
# Store old config for rollback if needed
old_config = self._config
# Update config # Update config
self._config = new_config self._config = new_config
self._logger.info(f"Configuration reloaded from {path}") self._logger.info(f"Configuration reloaded from {path}")
@@ -319,7 +316,7 @@ def get_host_access(config, hostname) -> dict:
""" """
host_cfg = get_host_config(config, hostname) host_cfg = get_host_config(config, hostname)
owner = host_cfg.get("owner") # or get_default_owner(config) owner = host_cfg.get("owner") # or get_default_owner(config)
managers = host_cfg.get("managers", []) managers = host_cfg.get("managers", [])
if isinstance(managers, str): if isinstance(managers, str):
+1
View File
@@ -15,6 +15,7 @@ def _make_yaml() -> YAML:
y.preserve_quotes = True y.preserve_quotes = True
return y return y
# Top-level keys managed by the 'server' logical section # Top-level keys managed by the 'server' logical section
_SERVER_KEYS = [ _SERVER_KEYS = [
"hbd_port", "hbd_host", "ws_port", "wss_port", "hb_port", "hbd_port", "hbd_host", "ws_port", "wss_port", "hb_port",
+2
View File
@@ -1,4 +1,6 @@
msgs = [] # in-memory list of recent messages for new websocket clients; also logged to file via notify.eventlog msgs = [] # in-memory list of recent messages for new websocket clients; also logged to file via notify.eventlog
class Data: class Data:
def __init__(self, config): def __init__(self, config):
+1 -1
View File
@@ -224,7 +224,7 @@ class Connection:
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
self.overdue_timer = loop.call_later(timeout_seconds, self.overdue_timer = loop.call_later(timeout_seconds,
lambda: asyncio.create_task(timer_expired())) lambda: asyncio.create_task(timer_expired()))
except RuntimeError: except RuntimeError:
# No event loop running yet # No event loop running yet
pass pass
+1 -1
View File
@@ -109,6 +109,7 @@ def _insert_threshold_metric(thresholds: dict, metric_path: str, values: dict) -
else: else:
thresholds[plugin].setdefault(intermediate, {})[leaf] = cfg thresholds[plugin].setdefault(intermediate, {})[leaf] = cfg
def _render_template(html_str: str, **context) -> str: def _render_template(html_str: str, **context) -> str:
tmpl = jinja2.Template(html_str) tmpl = jinja2.Template(html_str)
return tmpl.render(**context) return tmpl.render(**context)
@@ -1132,7 +1133,6 @@ async def start(
elif is_mon: elif is_mon:
monitored.append(hostname) monitored.append(hostname)
# Resolve notification channel configs for display # Resolve notification channel configs for display
notif_channels = [] notif_channels = []
if current_user: if current_user:
+3 -3
View File
@@ -88,8 +88,8 @@ class MessageJournal:
self._initialized = True self._initialized = True
logger.info(f"Message journal initialized: {self.journal_path} " logger.info(f"Message journal initialized: {self.journal_path} "
f"(current size: {self._current_size:,} bytes, " f"(current size: {self._current_size:,} bytes, "
f"max: {self.max_size:,} bytes)") f"max: {self.max_size:,} bytes)")
return True return True
except Exception as e: except Exception as e:
@@ -200,7 +200,7 @@ class MessageJournal:
if self.journal_path.exists(): if self.journal_path.exists():
self.journal_path.rename(backup_path) self.journal_path.rename(backup_path)
logger.info(f"Rotated journal: {backup_path} " logger.info(f"Rotated journal: {backup_path} "
f"(size: {self._current_size:,} bytes)") f"(size: {self._current_size:,} bytes)")
# Open new journal file # Open new journal file
self._file_handle = open(self.journal_path, 'a', encoding='utf-8') self._file_handle = open(self.journal_path, 'a', encoding='utf-8')
+1 -2
View File
@@ -5,8 +5,6 @@ import logging
import socket import socket
import time import time
import signal import signal
import sys
import ssl
from . import __version__ from . import __version__
from . import udp from . import udp
@@ -23,6 +21,7 @@ eventlog = notify_mod.eventlog
# shared runtime collections and helpers # shared runtime collections and helpers
def save_state(config, hbdclass): def save_state(config, hbdclass):
"""Save current state to pickle file. Safe to call at any time.""" """Save current state to pickle file. Safe to call at any time."""
import pickle import pickle
-3
View File
@@ -8,8 +8,6 @@ This module can be extended for additional monitoring tasks.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import time
from . import notify as notify_mod from . import notify as notify_mod
DROPOVERDUE = 7 * 24 * 3600 DROPOVERDUE = 7 * 24 * 3600
@@ -25,4 +23,3 @@ async def cleanup_connections(hbdclass):
for conn_type, conn in host.connections.items(): for conn_type, conn in host.connections.items():
if hasattr(conn, 'cancel_overdue_timer'): if hasattr(conn, 'cancel_overdue_timer'):
conn.cancel_overdue_timer() conn.cancel_overdue_timer()
+2 -6
View File
@@ -21,7 +21,7 @@ import smtplib
import subprocess import subprocess
import time import time
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Optional from typing import Optional
from . import data from . import data
@@ -49,6 +49,7 @@ logf = None
_LEVEL_ORDER = {"RECOVER": 0, "INFO": 0, "WARNING": 1, "CRITICAL": 2} _LEVEL_ORDER = {"RECOVER": 0, "INFO": 0, "WARNING": 1, "CRITICAL": 2}
def _level_value(level: str) -> int: def _level_value(level: str) -> int:
return _LEVEL_ORDER.get(level.upper(), 0) return _LEVEL_ORDER.get(level.upper(), 0)
@@ -103,7 +104,6 @@ def initlog(logfile):
def closelog(): def closelog():
global logf
if logf and logf != sys.stderr: if logf and logf != sys.stderr:
try: try:
logf.close() logf.close()
@@ -331,8 +331,6 @@ async def _send_sms_voipms_async(channel_cfg: dict, notif: Notification) -> bool
return False return False
async def _send_matrix_async(channel_cfg: dict, notif: Notification) -> bool: async def _send_matrix_async(channel_cfg: dict, notif: Notification) -> bool:
"""Send a Matrix message using matrix-nio.""" """Send a Matrix message using matrix-nio."""
try: try:
@@ -341,7 +339,6 @@ async def _send_matrix_async(channel_cfg: dict, notif: Notification) -> bool:
logger.error("matrix-nio not installed; pip install matrix-nio") logger.error("matrix-nio not installed; pip install matrix-nio")
return False return False
from nio import AsyncClient
homeserver = channel_cfg.get("homeserver", "") homeserver = channel_cfg.get("homeserver", "")
access_token = channel_cfg.get("access_token", "") access_token = channel_cfg.get("access_token", "")
room_id = channel_cfg.get("room_id", "") room_id = channel_cfg.get("room_id", "")
@@ -359,7 +356,6 @@ async def _send_matrix_async(channel_cfg: dict, notif: Notification) -> bool:
client = AsyncClient(homeserver) client = AsyncClient(homeserver)
client.access_token = access_token client.access_token = access_token
try: try:
from nio import RoomSendResponse
content = { content = {
"msgtype": "m.text", "msgtype": "m.text",
"body": text, "body": text,
+1 -1
View File
@@ -420,7 +420,7 @@ def get_settings_sections(config: dict, threshold_checker=None, user=None) -> li
"fields": [ "fields": [
field("journal_enabled", "Enabled", "boolean", field("journal_enabled", "Enabled", "boolean",
"Turn journalling on or off.", editable=True), "Turn journalling on or off.", editable=True),
field("journal_dir", "Journal directory","path", field("journal_dir", "Journal directory", "path",
"Directory where journal files are written.", editable=True), "Directory where journal files are written.", editable=True),
field("journal_file", "Journal filename", "text", field("journal_file", "Journal filename", "text",
"Base filename for the journal (rotated copies get a numeric suffix)."), "Base filename for the journal (rotated copies get a numeric suffix)."),
+4 -1
View File
@@ -13,13 +13,14 @@ import asyncio
import logging import logging
import time import time
from enum import Enum from enum import Enum
from typing import Dict, List, Any, Optional, Tuple, Callable from typing import Dict, List, Any, Optional, Tuple
from . import notify as notify_mod from . import notify as notify_mod
from .config import THRESHOLD_DEFAULTS from .config import THRESHOLD_DEFAULTS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
eventlog = notify_mod.eventlog eventlog = notify_mod.eventlog
class AlertLevel(Enum): class AlertLevel(Enum):
"""Alert severity levels.""" """Alert severity levels."""
OK = 0 OK = 0
@@ -182,6 +183,7 @@ class AlertState:
def __str__(self): def __str__(self):
return self.to_dict().__str__() return self.to_dict().__str__()
class ThresholdConfig: class ThresholdConfig:
"""Configuration for a single threshold check.""" """Configuration for a single threshold check."""
@@ -946,6 +948,7 @@ class ThresholdChecker:
self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, None) self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, None)
return None return None
def _find_threshold( def _find_threshold(
self, thresholds: Dict[str, "ThresholdConfig"], metric_path: str self, thresholds: Dict[str, "ThresholdConfig"], metric_path: str
) -> Tuple[Optional["ThresholdConfig"], Optional[str]]: ) -> Tuple[Optional["ThresholdConfig"], Optional[str]]:
+3 -3
View File
@@ -30,7 +30,7 @@ if platform == "Darwin":
elif platform == "Linux": elif platform == "Linux":
_SO_TIMESTAMP = 29 # Linux value (not in older Python versions) _SO_TIMESTAMP = 29 # Linux value (not in older Python versions)
elif platform == "FreeBSD": elif platform == "FreeBSD":
_SO_TIMESTAMP = 32 # FreeBSD value (not in older Python versions) _SO_TIMESTAMP = 32 # FreeBSD value (not in older Python versions)
else: else:
logger.warning("SO_TIMESTAMP may not be supported on this platform (%s)", platform) logger.warning("SO_TIMESTAMP may not be supported on this platform (%s)", platform)
_SO_TIMESTAMP = None _SO_TIMESTAMP = None
@@ -71,6 +71,7 @@ class RecvmsgTransport:
Exposes the same sendto() / close() interface as asyncio's DatagramTransport Exposes the same sendto() / close() interface as asyncio's DatagramTransport
so the rest of the code does not need to know which path is in use. so the rest of the code does not need to know which path is in use.
""" """
def __init__(self, loop, sock): def __init__(self, loop, sock):
self._loop = loop self._loop = loop
self._sock = sock self._sock = sock
@@ -432,7 +433,6 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
hbdcls = ctx.get("hbdclass") hbdcls = ctx.get("hbdclass")
msg_to_websockets = ctx.get("msg_to_websockets") msg_to_websockets = ctx.get("msg_to_websockets")
DEBUG = ctx.get("DEBUG", 0) DEBUG = ctx.get("DEBUG", 0)
verbose = ctx.get("verbose", False)
# normalize addr (ip, port) # normalize addr (ip, port)
ip = addr[0] if isinstance(addr, (list, tuple)) else addr ip = addr[0] if isinstance(addr, (list, tuple)) else addr
@@ -523,7 +523,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
# If os_info reports an owner and none is configured server-side, apply it # If os_info reports an owner and none is configured server-side, apply it
if plugin_name == "os_info": if plugin_name == "os_info":
config_owner = config_mod.get_host_access(cfg, uname).get("owner") config_owner = config_mod.get_host_access(cfg, uname).get("owner")
default_owner = config_mod.get_default_owner(cfg) default_owner = config_mod.get_default_owner(cfg)
inferred_owner = config_owner or plugin_data.get("owner") or default_owner inferred_owner = config_owner or plugin_data.get("owner") or default_owner
host.owner = inferred_owner host.owner = inferred_owner
-2
View File
@@ -1,5 +1,3 @@
import glob
import os
import pytest import pytest
from hbd.server import configio from hbd.server import configio
-1
View File
@@ -1,5 +1,4 @@
"""Tests for the config read/write API helpers in http.py.""" """Tests for the config read/write API helpers in http.py."""
import pytest
from hbd.server import http from hbd.server import http
+2 -4
View File
@@ -1,5 +1,4 @@
"""Tests for PUT /api/0/users/me logic.""" """Tests for PUT /api/0/users/me logic."""
import pytest
from hbd.server import users as users_mod from hbd.server import users as users_mod
@@ -87,7 +86,6 @@ def test_put_users_me_notification_channels(tmp_path):
def test_visible_channels_excludes_private_from_others(): def test_visible_channels_excludes_private_from_others():
"""Private channels owned by another user must not appear in the visible set.""" """Private channels owned by another user must not appear in the visible set."""
from hbd.server import settings as settings_mod
config = { config = {
"notification_channels": { "notification_channels": {
@@ -105,7 +103,7 @@ def test_visible_channels_excludes_private_from_others():
self.admin = admin self.admin = admin
alice = FakeUser("alice") alice = FakeUser("alice")
bob = FakeUser("bob") bob = FakeUser("bob")
admin = FakeUser("admin", admin=True) admin = FakeUser("admin", admin=True)
# Simulate _visible_channels_for_user logic (mirrors http.py implementation) # Simulate _visible_channels_for_user logic (mirrors http.py implementation)
@@ -119,5 +117,5 @@ def test_visible_channels_excludes_private_from_others():
} }
assert visible(alice) == {"public_ch", "alice_priv"} assert visible(alice) == {"public_ch", "alice_priv"}
assert visible(bob) == {"public_ch", "bob_priv"} assert visible(bob) == {"public_ch", "bob_priv"}
assert visible(admin) == {"public_ch", "alice_priv", "bob_priv"} assert visible(admin) == {"public_ch", "alice_priv", "bob_priv"}
-1
View File
@@ -1,5 +1,4 @@
"""Tests for notification channel CRUD via configio helpers and visibility logic.""" """Tests for notification channel CRUD via configio helpers and visibility logic."""
import pytest
from hbd.server import configio, settings as settings_mod from hbd.server import configio, settings as settings_mod
-2
View File
@@ -37,7 +37,6 @@ def reset_users_dict():
users_mod.users = original users_mod.users = original
def test_make_state_returns_unique_tokens(): def test_make_state_returns_unique_tokens():
s1 = oauth.make_state() s1 = oauth.make_state()
s2 = oauth.make_state() s2 = oauth.make_state()
@@ -124,7 +123,6 @@ def test_provision_oauth_user_survives_config_reload():
assert "oauthonly" in users_mod.users assert "oauthonly" in users_mod.users
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Integration-style tests: callback logic chain # Integration-style tests: callback logic chain
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
-1
View File
@@ -1,4 +1,3 @@
import pytest
from hbd.server import settings as settings_mod from hbd.server import settings as settings_mod
CFG = { CFG = {
+5 -6
View File
@@ -13,13 +13,12 @@ Tests cover:
""" """
import sys import sys
import time
from pathlib import Path from pathlib import Path
# Add parent directory to path for imports # Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from hbd.server.threshold import ( from hbd.server.threshold import ( # noqa: E402
ThresholdChecker, ThresholdChecker,
ThresholdConfig, ThresholdConfig,
AlertLevel, AlertLevel,
@@ -170,23 +169,23 @@ def test_alert_state():
# Update to WARNING - should trigger notification # Update to WARNING - should trigger notification
changed = alert.update(AlertLevel.WARNING, 85.0) changed = alert.update(AlertLevel.WARNING, 85.0)
assert changed == True, "State change should return True" assert changed is True, "State change should return True"
assert alert.level == AlertLevel.WARNING assert alert.level == AlertLevel.WARNING
assert alert.last_value == 85.0 assert alert.last_value == 85.0
# Update with same level - no notification # Update with same level - no notification
changed = alert.update(AlertLevel.WARNING, 86.0) changed = alert.update(AlertLevel.WARNING, 86.0)
assert changed == False, "No state change should return False" assert changed is False, "No state change should return False"
assert alert.last_value == 86.0 assert alert.last_value == 86.0
# Escalate to CRITICAL # Escalate to CRITICAL
changed = alert.update(AlertLevel.CRITICAL, 95.0) changed = alert.update(AlertLevel.CRITICAL, 95.0)
assert changed == True, "Escalation should trigger notification" assert changed is True, "Escalation should trigger notification"
assert alert.level == AlertLevel.CRITICAL assert alert.level == AlertLevel.CRITICAL
# Recover to OK # Recover to OK
changed = alert.update(AlertLevel.OK, 50.0) changed = alert.update(AlertLevel.OK, 50.0)
assert changed == True, "Recovery should trigger notification" assert changed is True, "Recovery should trigger notification"
assert alert.level == AlertLevel.OK assert alert.level == AlertLevel.OK
print(" ✓ Alert state tracking works correctly") print(" ✓ Alert state tracking works correctly")