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:
+1
-1
@@ -4,7 +4,7 @@ This package contains both the heartbeat client (hbc) and server (hbd) component
|
|||||||
organized into separate subpackages:
|
organized into separate subpackages:
|
||||||
|
|
||||||
- hbd.client: Client component with system monitoring plugins
|
- hbd.client: Client component with system monitoring plugins
|
||||||
- hbd.server: Server/daemon component with web UI and notifications
|
- hbd.server: Server/daemon component with web UI and notifications
|
||||||
- hbd.common: Shared utilities and protocol definitions
|
- hbd.common: Shared utilities and protocol definitions
|
||||||
|
|
||||||
Install options:
|
Install options:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -37,10 +36,10 @@ def load_config(path=None):
|
|||||||
"""Load configuration from a YAML file and merge with client defaults.
|
"""Load configuration from a YAML file and merge with client defaults.
|
||||||
|
|
||||||
If YAML is not available or the file does not exist, defaults are returned.
|
If YAML is not available or the file does not exist, defaults are returned.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Path to YAML config file (default: ~/.hbc.yaml)
|
path: Path to YAML config file (default: ~/.hbc.yaml)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with configuration
|
Dictionary with configuration
|
||||||
"""
|
"""
|
||||||
|
|||||||
+59
-60
@@ -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
|
||||||
@@ -46,20 +46,20 @@ allow_remote_command = False
|
|||||||
|
|
||||||
class AsyncConnection:
|
class AsyncConnection:
|
||||||
"""Async UDP connection to a heartbeat server."""
|
"""Async UDP connection to a heartbeat server."""
|
||||||
|
|
||||||
def __init__(self, conn_id: int, addr: str, port: int, af: int, name: str):
|
def __init__(self, conn_id: int, addr: str, port: int, af: int, name: str):
|
||||||
self.conn_id = conn_id
|
self.conn_id = conn_id
|
||||||
self.addr = addr
|
self.addr = addr
|
||||||
self.port = port
|
self.port = port
|
||||||
self.af = af
|
self.af = af
|
||||||
self.name = name
|
self.name = name
|
||||||
|
|
||||||
self.ackcount = 0
|
self.ackcount = 0
|
||||||
self.lastack = 0.0
|
self.lastack = 0.0
|
||||||
self.send_count = 0
|
self.send_count = 0
|
||||||
self.lastsend = 0.0
|
self.lastsend = 0.0
|
||||||
self.rtts = [0.0]
|
self.rtts = [0.0]
|
||||||
|
|
||||||
self.transport: Optional[asyncio.DatagramTransport] = None
|
self.transport: Optional[asyncio.DatagramTransport] = None
|
||||||
self.protocol: Optional[asyncio.DatagramProtocol] = None
|
self.protocol: Optional[asyncio.DatagramProtocol] = None
|
||||||
self._dead = False
|
self._dead = False
|
||||||
@@ -89,7 +89,7 @@ class AsyncConnection:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Failed to open connection: {e}")
|
self.logger.error(f"Failed to open connection: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Close the connection."""
|
"""Close the connection."""
|
||||||
if self.transport:
|
if self.transport:
|
||||||
@@ -104,10 +104,10 @@ class AsyncConnection:
|
|||||||
self.af = af
|
self.af = af
|
||||||
self.addr = addr
|
self.addr = addr
|
||||||
self.logger = logging.getLogger(f"hbc.conn.{addr}")
|
self.logger = logging.getLogger(f"hbc.conn.{addr}")
|
||||||
|
|
||||||
async def sendto(self, msg: dict, msg_id: str = "HTB"):
|
async def sendto(self, msg: dict, msg_id: str = "HTB"):
|
||||||
"""Send a message to the server.
|
"""Send a message to the server.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
msg: Message dictionary
|
msg: Message dictionary
|
||||||
msg_id: Message ID (HTB, PLG, etc.)
|
msg_id: Message ID (HTB, PLG, etc.)
|
||||||
@@ -121,36 +121,36 @@ class AsyncConnection:
|
|||||||
if not self.transport:
|
if not self.transport:
|
||||||
self.logger.error("Cannot send - no transport")
|
self.logger.error("Cannot send - no transport")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Add standard fields
|
# Add standard fields
|
||||||
msg["name"] = shortname(self.name)
|
msg["name"] = shortname(self.name)
|
||||||
msg["id"] = self.conn_id
|
msg["id"] = self.conn_id
|
||||||
msg["time"] = time.time()
|
msg["time"] = time.time()
|
||||||
|
|
||||||
# Encode message
|
# Encode message
|
||||||
data = dicttos(msg_id, msg)
|
data = dicttos(msg_id, msg)
|
||||||
|
|
||||||
# Send
|
# Send
|
||||||
self.transport.sendto(data, (self.addr, self.port))
|
self.transport.sendto(data, (self.addr, self.port))
|
||||||
self.send_count += 1
|
self.send_count += 1
|
||||||
self.lastsend = time.time()
|
self.lastsend = time.time()
|
||||||
|
|
||||||
self.logger.debug(f"Sent {msg_id} message ({len(data)} bytes)")
|
self.logger.debug(f"Sent {msg_id} message ({len(data)} bytes)")
|
||||||
|
|
||||||
def handle_ack(self, msg: dict, now: float):
|
def handle_ack(self, msg: dict, now: float):
|
||||||
"""Handle ACK message from server.
|
"""Handle ACK message from server.
|
||||||
|
|
||||||
RTT is calculated as: (time ACK received) - (time HTB sent)
|
RTT is calculated as: (time ACK received) - (time HTB sent)
|
||||||
"""
|
"""
|
||||||
self.lastack = now
|
self.lastack = now
|
||||||
|
|
||||||
# Calculate RTT: time ACK received minus time HTB sent
|
# Calculate RTT: time ACK received minus time HTB sent
|
||||||
rtt = (now - self.lastsend) * 1000.0 # Convert to ms
|
rtt = (now - self.lastsend) * 1000.0 # Convert to ms
|
||||||
|
|
||||||
self.rtts.append(rtt)
|
self.rtts.append(rtt)
|
||||||
if len(self.rtts) > 10:
|
if len(self.rtts) > 10:
|
||||||
self.rtts.pop(0)
|
self.rtts.pop(0)
|
||||||
|
|
||||||
self.ackcount += 1
|
self.ackcount += 1
|
||||||
self.logger.debug(f"ACK received, RTT: {rtt:.1f}ms")
|
self.logger.debug(f"ACK received, RTT: {rtt:.1f}ms")
|
||||||
if msg.get("request_update"):
|
if msg.get("request_update"):
|
||||||
@@ -160,11 +160,11 @@ class AsyncConnection:
|
|||||||
|
|
||||||
class HeartbeatProtocol(asyncio.DatagramProtocol):
|
class HeartbeatProtocol(asyncio.DatagramProtocol):
|
||||||
"""Protocol handler for incoming UDP messages."""
|
"""Protocol handler for incoming UDP messages."""
|
||||||
|
|
||||||
def __init__(self, connection: AsyncConnection):
|
def __init__(self, connection: AsyncConnection):
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.logger = logging.getLogger("hbc.protocol")
|
self.logger = logging.getLogger("hbc.protocol")
|
||||||
|
|
||||||
def datagram_received(self, data: bytes, addr):
|
def datagram_received(self, data: bytes, addr):
|
||||||
"""Handle incoming datagram."""
|
"""Handle incoming datagram."""
|
||||||
try:
|
try:
|
||||||
@@ -172,10 +172,10 @@ class HeartbeatProtocol(asyncio.DatagramProtocol):
|
|||||||
if not msg:
|
if not msg:
|
||||||
self.logger.warning(f"Failed to parse message from {addr}")
|
self.logger.warning(f"Failed to parse message from {addr}")
|
||||||
return
|
return
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
msg_id = msg.get("ID")
|
msg_id = msg.get("ID")
|
||||||
|
|
||||||
if msg_id == "ACK":
|
if msg_id == "ACK":
|
||||||
self.connection.handle_ack(msg, now)
|
self.connection.handle_ack(msg, now)
|
||||||
elif msg_id == "CMD":
|
elif msg_id == "CMD":
|
||||||
@@ -186,10 +186,10 @@ class HeartbeatProtocol(asyncio.DatagramProtocol):
|
|||||||
asyncio.create_task(handle_update(self.connection, msg))
|
asyncio.create_task(handle_update(self.connection, msg))
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Unknown message type: {msg_id}")
|
self.logger.warning(f"Unknown message type: {msg_id}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error processing datagram: {e}", exc_info=True)
|
self.logger.error(f"Error processing datagram: {e}", exc_info=True)
|
||||||
|
|
||||||
def error_received(self, exc):
|
def error_received(self, exc):
|
||||||
"""Handle protocol errors — close transport so the heartbeat sender retries."""
|
"""Handle protocol errors — close transport so the heartbeat sender retries."""
|
||||||
self.logger.warning(f"Protocol error on {self.connection.addr}: {exc} — will retry")
|
self.logger.warning(f"Protocol error on {self.connection.addr}: {exc} — will retry")
|
||||||
@@ -230,7 +230,7 @@ async def handle_command(conn: AsyncConnection, msg: dict):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
result = str(e)
|
result = str(e)
|
||||||
status = "Error"
|
status = "Error"
|
||||||
|
|
||||||
# Send response
|
# Send response
|
||||||
response = {
|
response = {
|
||||||
"service": "command",
|
"service": "command",
|
||||||
@@ -446,14 +446,14 @@ async def plugin_collector_interval(
|
|||||||
interval: int
|
interval: int
|
||||||
):
|
):
|
||||||
"""Collect plugins on a specific interval.
|
"""Collect plugins on a specific interval.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
conn: Connection to send on
|
conn: Connection to send on
|
||||||
plugins: List of plugins to collect
|
plugins: List of plugins to collect
|
||||||
interval: Collection interval in seconds
|
interval: Collection interval in seconds
|
||||||
"""
|
"""
|
||||||
logger = logging.getLogger(f"hbc.plugins.{interval}s")
|
logger = logging.getLogger(f"hbc.plugins.{interval}s")
|
||||||
|
|
||||||
while running:
|
while running:
|
||||||
for plugin in plugins:
|
for plugin in plugins:
|
||||||
try:
|
try:
|
||||||
@@ -471,7 +471,7 @@ async def plugin_collector_interval(
|
|||||||
f"Error collecting {plugin.name}: {e}",
|
f"Error collecting {plugin.name}: {e}",
|
||||||
exc_info=True
|
exc_info=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wait for next interval or shutdown event
|
# Wait for next interval or shutdown event
|
||||||
try:
|
try:
|
||||||
if shutdown_event:
|
if shutdown_event:
|
||||||
@@ -498,11 +498,11 @@ def stop():
|
|||||||
"""Stop the event loop."""
|
"""Stop the event loop."""
|
||||||
global running
|
global running
|
||||||
running = False
|
running = False
|
||||||
|
|
||||||
# Set shutdown event to wake up sleeping tasks
|
# Set shutdown event to wake up sleeping tasks
|
||||||
if shutdown_event:
|
if shutdown_event:
|
||||||
shutdown_event.set()
|
shutdown_event.set()
|
||||||
|
|
||||||
# Cancel all active tasks
|
# Cancel all active tasks
|
||||||
for task in active_tasks:
|
for task in active_tasks:
|
||||||
if not task.done():
|
if not task.done():
|
||||||
@@ -555,7 +555,7 @@ async def cleanup(connections: List[AsyncConnection]):
|
|||||||
"""Cleanup connections on shutdown."""
|
"""Cleanup connections on shutdown."""
|
||||||
logger = logging.getLogger("hbc.cleanup")
|
logger = logging.getLogger("hbc.cleanup")
|
||||||
logger.info("Cleaning up connections")
|
logger.info("Cleaning up connections")
|
||||||
|
|
||||||
target = next((c for c in connections if c.transport), connections[0] if connections else None)
|
target = next((c for c in connections if c.transport), connections[0] if connections else None)
|
||||||
# A SIGHUP restart is not a host shutdown, so don't announce one.
|
# A SIGHUP restart is not a host shutdown, so don't announce one.
|
||||||
if target and send_shutdown and not dorestart:
|
if target and send_shutdown and not dorestart:
|
||||||
@@ -565,35 +565,35 @@ async def cleanup(connections: List[AsyncConnection]):
|
|||||||
logger.error(f"Error sending shutdown: {e}")
|
logger.error(f"Error sending shutdown: {e}")
|
||||||
for conn in connections:
|
for conn in connections:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# Give messages time to send
|
# Give messages time to send
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
active_tasks = []
|
active_tasks = []
|
||||||
allow_remote_command = bool(config.get("allow_remote_command", False))
|
allow_remote_command = bool(config.get("allow_remote_command", False))
|
||||||
|
|
||||||
logger = logging.getLogger("hbc.main")
|
logger = logging.getLogger("hbc.main")
|
||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
iam = socket.gethostname()
|
iam = socket.gethostname()
|
||||||
if args.name:
|
if args.name:
|
||||||
iam = args.name
|
iam = args.name
|
||||||
|
|
||||||
hb_hosts = args.hosts
|
hb_hosts = args.hosts
|
||||||
hb_port = config.get("hb_port", PORT)
|
hb_port = config.get("hb_port", PORT)
|
||||||
interval = config.get("interval", INTERVAL)
|
interval = config.get("interval", INTERVAL)
|
||||||
|
|
||||||
logger.info(f"hbc {__version__} on {iam} -> {hb_hosts} port={hb_port}, interval={interval}s")
|
logger.info(f"hbc {__version__} on {iam} -> {hb_hosts} port={hb_port}, interval={interval}s")
|
||||||
if allow_remote_command:
|
if allow_remote_command:
|
||||||
logger.warning("allow_remote_command is true — CMD packets from the server will be executed")
|
logger.warning("allow_remote_command is true — CMD packets from the server will be executed")
|
||||||
|
|
||||||
af_filter = (socket.AF_INET if getattr(args, "ipv4_only", False)
|
af_filter = (socket.AF_INET if getattr(args, "ipv4_only", False)
|
||||||
else socket.AF_INET6 if getattr(args, "ipv6_only", False)
|
else socket.AF_INET6 if getattr(args, "ipv6_only", False)
|
||||||
else 0)
|
else 0)
|
||||||
@@ -632,7 +632,7 @@ async def async_main(args, config):
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
logger.info(f"Created {len(connections)} connections")
|
logger.info(f"Created {len(connections)} connections")
|
||||||
|
|
||||||
# Send boot/message if requested
|
# Send boot/message if requested
|
||||||
send_shutdown = False
|
send_shutdown = False
|
||||||
if args.boot or args.message:
|
if args.boot or args.message:
|
||||||
@@ -643,27 +643,27 @@ async def async_main(args, config):
|
|||||||
if args.message:
|
if args.message:
|
||||||
boot_msg["service"] = "service"
|
boot_msg["service"] = "service"
|
||||||
boot_msg["msg"] = args.message
|
boot_msg["msg"] = args.message
|
||||||
|
|
||||||
boot_msg["acks"] = 0
|
boot_msg["acks"] = 0
|
||||||
target = next((c for c in connections if c.transport), connections[0])
|
target = next((c for c in connections if c.transport), connections[0])
|
||||||
await target.sendto(boot_msg)
|
await target.sendto(boot_msg)
|
||||||
|
|
||||||
if args.message and not args.daemon:
|
if args.message and not args.daemon:
|
||||||
# Message-only mode
|
# Message-only mode
|
||||||
await cleanup(connections)
|
await cleanup(connections)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Load plugins
|
# Load plugins
|
||||||
registry = PluginRegistry()
|
registry = PluginRegistry()
|
||||||
loader = PluginLoader(registry)
|
loader = PluginLoader(registry)
|
||||||
|
|
||||||
plugin_dir = Path(__file__).parent / "plugins"
|
plugin_dir = Path(__file__).parent / "plugins"
|
||||||
if plugin_dir.exists():
|
if plugin_dir.exists():
|
||||||
count = await loader.load_from_directory(plugin_dir, config)
|
count = await loader.load_from_directory(plugin_dir, config)
|
||||||
logger.info(f"Loaded {count} plugins")
|
logger.info(f"Loaded {count} plugins")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Plugin directory not found: {plugin_dir}")
|
logger.warning(f"Plugin directory not found: {plugin_dir}")
|
||||||
|
|
||||||
# Setup signal handlers
|
# Setup signal handlers
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
@@ -675,7 +675,7 @@ async def async_main(args, config):
|
|||||||
stop()
|
stop()
|
||||||
|
|
||||||
loop.add_signal_handler(signal.SIGHUP, _sighup)
|
loop.add_signal_handler(signal.SIGHUP, _sighup)
|
||||||
|
|
||||||
# Start async tasks
|
# Start async tasks
|
||||||
# Per-protocol internet presence probe (shared with the network_monitor plugin)
|
# Per-protocol internet presence probe (shared with the network_monitor plugin)
|
||||||
net_cfg = dict((config.get("plugins") or {}).get("network_monitor")
|
net_cfg = dict((config.get("plugins") or {}).get("network_monitor")
|
||||||
@@ -690,7 +690,7 @@ async def async_main(args, config):
|
|||||||
for conn in connections:
|
for conn in connections:
|
||||||
task = asyncio.create_task(heartbeat_sender(conn, interval, connectivity))
|
task = asyncio.create_task(heartbeat_sender(conn, interval, connectivity))
|
||||||
active_tasks.append(task)
|
active_tasks.append(task)
|
||||||
|
|
||||||
active_tasks.append(asyncio.create_task(
|
active_tasks.append(asyncio.create_task(
|
||||||
dns_refresher(connections, hb_hosts, hb_port, af_filter, iam, interval, connectivity, conn_id)))
|
dns_refresher(connections, hb_hosts, hb_port, af_filter, iam, interval, connectivity, conn_id)))
|
||||||
|
|
||||||
@@ -698,18 +698,18 @@ async def async_main(args, config):
|
|||||||
if connections and registry.get_enabled():
|
if connections and registry.get_enabled():
|
||||||
task = asyncio.create_task(plugin_collector(connections[0], registry))
|
task = asyncio.create_task(plugin_collector(connections[0], registry))
|
||||||
active_tasks.append(task)
|
active_tasks.append(task)
|
||||||
|
|
||||||
# Wait for stop or tasks to complete
|
# Wait for stop or tasks to complete
|
||||||
try:
|
try:
|
||||||
await asyncio.gather(*active_tasks, return_exceptions=True)
|
await asyncio.gather(*active_tasks, return_exceptions=True)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("Tasks cancelled")
|
logger.info("Tasks cancelled")
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
logger.info("Shutting down...")
|
logger.info("Shutting down...")
|
||||||
await cleanup(connections)
|
await cleanup(connections)
|
||||||
await loader.unload_all()
|
await loader.unload_all()
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -727,11 +727,11 @@ def daemonize(
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
sys.stderr.write(f"fork #1 failed: {e}\n")
|
sys.stderr.write(f"fork #1 failed: {e}\n")
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
|
|
||||||
os.chdir(working_dir)
|
os.chdir(working_dir)
|
||||||
os.setsid()
|
os.setsid()
|
||||||
os.umask(0)
|
os.umask(0)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pid = os.fork()
|
pid = os.fork()
|
||||||
if pid > 0:
|
if pid > 0:
|
||||||
@@ -739,14 +739,14 @@ def daemonize(
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
sys.stderr.write(f"fork #2 failed: {e}\n")
|
sys.stderr.write(f"fork #2 failed: {e}\n")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
|
|
||||||
si = open(stdin, "r")
|
si = open(stdin, "r")
|
||||||
so = open(stdout, "a+")
|
so = open(stdout, "a+")
|
||||||
se = open(stderr, "a+")
|
se = open(stderr, "a+")
|
||||||
|
|
||||||
os.dup2(si.fileno(), sys.stdin.fileno())
|
os.dup2(si.fileno(), sys.stdin.fileno())
|
||||||
os.dup2(so.fileno(), sys.stdout.fileno())
|
os.dup2(so.fileno(), sys.stdout.fileno())
|
||||||
os.dup2(se.fileno(), sys.stderr.fileno())
|
os.dup2(se.fileno(), sys.stderr.fileno())
|
||||||
@@ -838,18 +838,17 @@ 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)
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
log_level = logging.WARNING
|
log_level = logging.WARNING
|
||||||
if args.verbose:
|
if args.verbose:
|
||||||
log_level = logging.INFO
|
log_level = logging.INFO
|
||||||
if args.debug:
|
if args.debug:
|
||||||
log_level = logging.DEBUG
|
log_level = logging.DEBUG
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=log_level,
|
level=log_level,
|
||||||
format="%(asctime)s %(name)s %(levelname)s: %(message)s",
|
format="%(asctime)s %(name)s %(levelname)s: %(message)s",
|
||||||
@@ -858,14 +857,14 @@ def main(argv=None):
|
|||||||
|
|
||||||
# Load config
|
# Load config
|
||||||
config = load_config(args.configfile)
|
config = load_config(args.configfile)
|
||||||
|
|
||||||
# Daemonize if requested
|
# Daemonize if requested
|
||||||
if args.daemon:
|
if args.daemon:
|
||||||
logging.info("Daemonizing...")
|
logging.info("Daemonizing...")
|
||||||
daemonize()
|
daemonize()
|
||||||
_reconfigure_logging_for_daemon(log_level)
|
_reconfigure_logging_for_daemon(log_level)
|
||||||
logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}")
|
logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}")
|
||||||
|
|
||||||
# Run async main
|
# Run async main
|
||||||
try:
|
try:
|
||||||
exit_code = asyncio.run(async_main(args, config))
|
exit_code = asyncio.run(async_main(args, config))
|
||||||
@@ -875,7 +874,7 @@ def main(argv=None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Fatal error: {e}", exc_info=True)
|
logging.error(f"Fatal error: {e}", exc_info=True)
|
||||||
exit_code = 1
|
exit_code = 1
|
||||||
|
|
||||||
# Handle restart
|
# Handle restart
|
||||||
if dorestart:
|
if dorestart:
|
||||||
logging.info("Restarting...")
|
logging.info("Restarting...")
|
||||||
@@ -883,7 +882,7 @@ def main(argv=None):
|
|||||||
# a SIGHUP restart is not a host reboot.
|
# a SIGHUP restart is not a host reboot.
|
||||||
restart_argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a not in ("-b", "--boot")]
|
restart_argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a not in ("-b", "--boot")]
|
||||||
os.execv(restart_argv[0], restart_argv)
|
os.execv(restart_argv[0], restart_argv)
|
||||||
|
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+77
-77
@@ -31,13 +31,13 @@ class Plugin(ABC):
|
|||||||
enabled: Whether plugin is active (can be disabled via config)
|
enabled: Whether plugin is active (can be disabled via config)
|
||||||
skip_reason: Set by plugin before returning False from initialize(); causes loader to log INFO instead of WARNING.
|
skip_reason: Set by plugin before returning False from initialize(); causes loader to log INFO instead of WARNING.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str = ""
|
name: str = ""
|
||||||
version: str = "1.0.0"
|
version: str = "1.0.0"
|
||||||
description: str = ""
|
description: str = ""
|
||||||
interval: int = 0
|
interval: int = 0
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
"""Initialize plugin with optional configuration.
|
"""Initialize plugin with optional configuration.
|
||||||
|
|
||||||
@@ -48,51 +48,51 @@ class Plugin(ABC):
|
|||||||
self.logger = logging.getLogger(f"plugin.{self.name}")
|
self.logger = logging.getLogger(f"plugin.{self.name}")
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
self.skip_reason: Optional[str] = None
|
self.skip_reason: Optional[str] = None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def initialize(self) -> bool:
|
async def initialize(self) -> bool:
|
||||||
"""Initialize plugin (load resources, check dependencies).
|
"""Initialize plugin (load resources, check dependencies).
|
||||||
|
|
||||||
Called once when plugin is loaded. Plugins should validate dependencies
|
Called once when plugin is loaded. Plugins should validate dependencies
|
||||||
(e.g., check if psutil is available) and prepare any resources.
|
(e.g., check if psutil is available) and prepare any resources.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if initialization succeeded, False otherwise
|
True if initialization succeeded, False otherwise
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def collect(self) -> Dict[str, Any]:
|
async def collect(self) -> Dict[str, Any]:
|
||||||
"""Collect data from the system.
|
"""Collect data from the system.
|
||||||
|
|
||||||
This is the main method called on each collection interval. Should return
|
This is the main method called on each collection interval. Should return
|
||||||
a dictionary of key-value pairs representing the collected data.
|
a dictionary of key-value pairs representing the collected data.
|
||||||
|
|
||||||
Keys should be strings (metric names). Values can be:
|
Keys should be strings (metric names). Values can be:
|
||||||
- Scalars: int, float, str, bool
|
- Scalars: int, float, str, bool
|
||||||
- Lists/dicts (will be serialized appropriately)
|
- Lists/dicts (will be serialized appropriately)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary of collected metrics, or empty dict on error
|
Dictionary of collected metrics, or empty dict on error
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def cleanup(self) -> None:
|
async def cleanup(self) -> None:
|
||||||
"""Cleanup plugin resources before shutdown.
|
"""Cleanup plugin resources before shutdown.
|
||||||
|
|
||||||
Called when plugin is being unloaded or on system shutdown.
|
Called when plugin is being unloaded or on system shutdown.
|
||||||
Override to release resources, close connections, etc.
|
Override to release resources, close connections, etc.
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def validate_data(self, data: Dict[str, Any]) -> bool:
|
def validate_data(self, data: Dict[str, Any]) -> bool:
|
||||||
"""Validate collected data before sending to server.
|
"""Validate collected data before sending to server.
|
||||||
|
|
||||||
Override to implement custom validation logic.
|
Override to implement custom validation logic.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: Data returned from collect()
|
data: Data returned from collect()
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if data is valid, False otherwise
|
True if data is valid, False otherwise
|
||||||
"""
|
"""
|
||||||
@@ -101,46 +101,46 @@ class Plugin(ABC):
|
|||||||
|
|
||||||
class InfoPlugin(Plugin):
|
class InfoPlugin(Plugin):
|
||||||
"""Plugin for collecting static or rarely-changing information.
|
"""Plugin for collecting static or rarely-changing information.
|
||||||
|
|
||||||
InfoPlugins collect data that doesn't change frequently:
|
InfoPlugins collect data that doesn't change frequently:
|
||||||
- OS name and version
|
- OS name and version
|
||||||
- Hardware specifications (CPU model, RAM size)
|
- Hardware specifications (CPU model, RAM size)
|
||||||
- Network interface MAC addresses
|
- Network interface MAC addresses
|
||||||
|
|
||||||
Characteristics:
|
Characteristics:
|
||||||
- interval = 0 (collected once at startup by default)
|
- interval = 0 (collected once at startup by default)
|
||||||
- Can specify interval > 0 for periodic refresh (e.g., check for hardware changes)
|
- Can specify interval > 0 for periodic refresh (e.g., check for hardware changes)
|
||||||
- Data is cached and reused until next collection
|
- Data is cached and reused until next collection
|
||||||
"""
|
"""
|
||||||
|
|
||||||
interval: int = 0 # Collect once at startup
|
interval: int = 0 # Collect once at startup
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self._cached_data: Optional[Dict[str, Any]] = None
|
self._cached_data: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
async def get_cached_data(self) -> Optional[Dict[str, Any]]:
|
async def get_cached_data(self) -> Optional[Dict[str, Any]]:
|
||||||
"""Get cached data if available (avoids re-collection).
|
"""Get cached data if available (avoids re-collection).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Cached data dict, or None if not yet collected
|
Cached data dict, or None if not yet collected
|
||||||
"""
|
"""
|
||||||
return self._cached_data
|
return self._cached_data
|
||||||
|
|
||||||
async def collect(self) -> Dict[str, Any]:
|
async def collect(self) -> Dict[str, Any]:
|
||||||
"""Collect and cache static information."""
|
"""Collect and cache static information."""
|
||||||
if self._cached_data is None:
|
if self._cached_data is None:
|
||||||
self._cached_data = await self._collect_info()
|
self._cached_data = await self._collect_info()
|
||||||
return self._cached_data
|
return self._cached_data
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def _collect_info(self) -> Dict[str, Any]:
|
async def _collect_info(self) -> Dict[str, Any]:
|
||||||
"""Internal method to perform actual data collection.
|
"""Internal method to perform actual data collection.
|
||||||
|
|
||||||
Override this method instead of collect() for InfoPlugins.
|
Override this method instead of collect() for InfoPlugins.
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def invalidate_cache(self) -> None:
|
def invalidate_cache(self) -> None:
|
||||||
"""Force re-collection on next collect() call."""
|
"""Force re-collection on next collect() call."""
|
||||||
self._cached_data = None
|
self._cached_data = None
|
||||||
@@ -148,33 +148,33 @@ class InfoPlugin(Plugin):
|
|||||||
|
|
||||||
class MonitorPlugin(Plugin):
|
class MonitorPlugin(Plugin):
|
||||||
"""Plugin for collecting periodic monitoring data.
|
"""Plugin for collecting periodic monitoring data.
|
||||||
|
|
||||||
MonitorPlugins collect time-series metrics that change frequently:
|
MonitorPlugins collect time-series metrics that change frequently:
|
||||||
- CPU usage percentage
|
- CPU usage percentage
|
||||||
- Memory consumption
|
- Memory consumption
|
||||||
- Disk I/O statistics
|
- Disk I/O statistics
|
||||||
- Network traffic
|
- Network traffic
|
||||||
|
|
||||||
Characteristics:
|
Characteristics:
|
||||||
- interval > 0 (e.g., 30 seconds for CPU, 60 for disk)
|
- interval > 0 (e.g., 30 seconds for CPU, 60 for disk)
|
||||||
- Collected continuously on schedule
|
- Collected continuously on schedule
|
||||||
- Data includes timestamps for time-series tracking
|
- Data includes timestamps for time-series tracking
|
||||||
"""
|
"""
|
||||||
|
|
||||||
interval: int = 30 # Default: collect every 30 seconds
|
interval: int = 30 # Default: collect every 30 seconds
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self._last_reading: Optional[Dict[str, Any]] = None
|
self._last_reading: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
def get_last_reading(self) -> Optional[Dict[str, Any]]:
|
def get_last_reading(self) -> Optional[Dict[str, Any]]:
|
||||||
"""Get the last collected reading.
|
"""Get the last collected reading.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Last reading dict with timestamp, or None if not yet collected
|
Last reading dict with timestamp, or None if not yet collected
|
||||||
"""
|
"""
|
||||||
return self._last_reading
|
return self._last_reading
|
||||||
|
|
||||||
async def collect(self) -> Dict[str, Any]:
|
async def collect(self) -> Dict[str, Any]:
|
||||||
"""Collect monitoring data and store as last reading."""
|
"""Collect monitoring data and store as last reading."""
|
||||||
data = await self._collect_metrics()
|
data = await self._collect_metrics()
|
||||||
@@ -184,11 +184,11 @@ class MonitorPlugin(Plugin):
|
|||||||
data['_timestamp'] = time.time()
|
data['_timestamp'] = time.time()
|
||||||
self._last_reading = data
|
self._last_reading = data
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def _collect_metrics(self) -> Dict[str, Any]:
|
async def _collect_metrics(self) -> Dict[str, Any]:
|
||||||
"""Internal method to perform actual metric collection.
|
"""Internal method to perform actual metric collection.
|
||||||
|
|
||||||
Override this method instead of collect() for MonitorPlugins.
|
Override this method instead of collect() for MonitorPlugins.
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
@@ -196,38 +196,38 @@ class MonitorPlugin(Plugin):
|
|||||||
|
|
||||||
class PluginRegistry:
|
class PluginRegistry:
|
||||||
"""Registry for managing loaded plugins.
|
"""Registry for managing loaded plugins.
|
||||||
|
|
||||||
Maintains a collection of loaded plugins and provides methods to
|
Maintains a collection of loaded plugins and provides methods to
|
||||||
query plugins by name, type, or interval.
|
query plugins by name, type, or interval.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._plugins: Dict[str, Plugin] = {}
|
self._plugins: Dict[str, Plugin] = {}
|
||||||
self.logger = logging.getLogger("plugin.registry")
|
self.logger = logging.getLogger("plugin.registry")
|
||||||
|
|
||||||
def register(self, plugin: Plugin) -> bool:
|
def register(self, plugin: Plugin) -> bool:
|
||||||
"""Register a plugin instance.
|
"""Register a plugin instance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin: Plugin instance to register
|
plugin: Plugin instance to register
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if registered successfully, False if name conflict
|
True if registered successfully, False if name conflict
|
||||||
"""
|
"""
|
||||||
if plugin.name in self._plugins:
|
if plugin.name in self._plugins:
|
||||||
self.logger.error(f"Plugin '{plugin.name}' already registered")
|
self.logger.error(f"Plugin '{plugin.name}' already registered")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self._plugins[plugin.name] = plugin
|
self._plugins[plugin.name] = plugin
|
||||||
self.logger.info(f"Registered plugin: {plugin.name} v{plugin.version}")
|
self.logger.info(f"Registered plugin: {plugin.name} v{plugin.version}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def unregister(self, name: str) -> bool:
|
def unregister(self, name: str) -> bool:
|
||||||
"""Unregister a plugin by name.
|
"""Unregister a plugin by name.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Plugin name to unregister
|
name: Plugin name to unregister
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if unregistered, False if not found
|
True if unregistered, False if not found
|
||||||
"""
|
"""
|
||||||
@@ -236,43 +236,43 @@ class PluginRegistry:
|
|||||||
self.logger.info(f"Unregistered plugin: {name}")
|
self.logger.info(f"Unregistered plugin: {name}")
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get(self, name: str) -> Optional[Plugin]:
|
def get(self, name: str) -> Optional[Plugin]:
|
||||||
"""Get plugin by name.
|
"""Get plugin by name.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Plugin name
|
name: Plugin name
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Plugin instance or None if not found
|
Plugin instance or None if not found
|
||||||
"""
|
"""
|
||||||
return self._plugins.get(name)
|
return self._plugins.get(name)
|
||||||
|
|
||||||
def get_all(self) -> List[Plugin]:
|
def get_all(self) -> List[Plugin]:
|
||||||
"""Get all registered plugins."""
|
"""Get all registered plugins."""
|
||||||
return list(self._plugins.values())
|
return list(self._plugins.values())
|
||||||
|
|
||||||
def get_enabled(self) -> List[Plugin]:
|
def get_enabled(self) -> List[Plugin]:
|
||||||
"""Get all enabled plugins."""
|
"""Get all enabled plugins."""
|
||||||
return [p for p in self._plugins.values() if p.enabled]
|
return [p for p in self._plugins.values() if p.enabled]
|
||||||
|
|
||||||
def get_by_type(self, plugin_type: Type[Plugin]) -> List[Plugin]:
|
def get_by_type(self, plugin_type: Type[Plugin]) -> List[Plugin]:
|
||||||
"""Get all plugins of a specific type.
|
"""Get all plugins of a specific type.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_type: Plugin class (InfoPlugin or MonitorPlugin)
|
plugin_type: Plugin class (InfoPlugin or MonitorPlugin)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of plugins matching the type
|
List of plugins matching the type
|
||||||
"""
|
"""
|
||||||
return [p for p in self._plugins.values() if isinstance(p, plugin_type)]
|
return [p for p in self._plugins.values() if isinstance(p, plugin_type)]
|
||||||
|
|
||||||
def get_by_interval(self, interval: int) -> List[Plugin]:
|
def get_by_interval(self, interval: int) -> List[Plugin]:
|
||||||
"""Get all plugins with a specific collection interval.
|
"""Get all plugins with a specific collection interval.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
interval: Interval in seconds (0 for one-time collection)
|
interval: Interval in seconds (0 for one-time collection)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of plugins with matching interval
|
List of plugins with matching interval
|
||||||
"""
|
"""
|
||||||
@@ -281,51 +281,51 @@ class PluginRegistry:
|
|||||||
|
|
||||||
class PluginLoader:
|
class PluginLoader:
|
||||||
"""Load plugins from filesystem and instantiate them.
|
"""Load plugins from filesystem and instantiate them.
|
||||||
|
|
||||||
Scans plugin directories for Python modules containing Plugin subclasses,
|
Scans plugin directories for Python modules containing Plugin subclasses,
|
||||||
loads them dynamically, and registers them with the PluginRegistry.
|
loads them dynamically, and registers them with the PluginRegistry.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, registry: PluginRegistry):
|
def __init__(self, registry: PluginRegistry):
|
||||||
self.registry = registry
|
self.registry = registry
|
||||||
self.logger = logging.getLogger("plugin.loader")
|
self.logger = logging.getLogger("plugin.loader")
|
||||||
self._loaded_modules: Dict[str, Any] = {}
|
self._loaded_modules: Dict[str, Any] = {}
|
||||||
|
|
||||||
async def load_from_directory(
|
async def load_from_directory(
|
||||||
self,
|
self,
|
||||||
directory: Path,
|
directory: Path,
|
||||||
config: Optional[Dict[str, Any]] = None
|
config: Optional[Dict[str, Any]] = None
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Load all plugins from a directory.
|
"""Load all plugins from a directory.
|
||||||
|
|
||||||
Scans for .py files, imports them, finds Plugin subclasses,
|
Scans for .py files, imports them, finds Plugin subclasses,
|
||||||
instantiates them with config, initializes, and registers.
|
instantiates them with config, initializes, and registers.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
directory: Path to plugin directory
|
directory: Path to plugin directory
|
||||||
config: Configuration dict (may contain per-plugin config)
|
config: Configuration dict (may contain per-plugin config)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of plugins successfully loaded
|
Number of plugins successfully loaded
|
||||||
"""
|
"""
|
||||||
if not directory.exists() or not directory.is_dir():
|
if not directory.exists() or not directory.is_dir():
|
||||||
self.logger.warning(f"Plugin directory not found: {directory}")
|
self.logger.warning(f"Plugin directory not found: {directory}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
loaded_count = 0
|
loaded_count = 0
|
||||||
raw_config = config or {}
|
raw_config = config or {}
|
||||||
# Per-plugin config lives under the 'plugins' key or at top-level.
|
# Per-plugin config lives under the 'plugins' key or at top-level.
|
||||||
# CLIENT_DEFAULTS seeds "plugins": {} so the key always exists; check
|
# CLIENT_DEFAULTS seeds "plugins": {} so the key always exists; check
|
||||||
# both the subdict and top-level so that either layout in .hbc.yaml works.
|
# both the subdict and top-level so that either layout in .hbc.yaml works.
|
||||||
plugins_subconfig = raw_config.get("plugins", {})
|
plugins_subconfig = raw_config.get("plugins", {})
|
||||||
|
|
||||||
# Scan for Python files
|
# Scan for Python files
|
||||||
for plugin_file in directory.glob("*.py"):
|
for plugin_file in directory.glob("*.py"):
|
||||||
if plugin_file.name.startswith("_"):
|
if plugin_file.name.startswith("_"):
|
||||||
continue # Skip __init__.py and private modules
|
continue # Skip __init__.py and private modules
|
||||||
|
|
||||||
self.logger.debug(f"Processing plugin file: {plugin_file.name}")
|
self.logger.debug(f"Processing plugin file: {plugin_file.name}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Load module dynamically
|
# Load module dynamically
|
||||||
module_name = f"plugins.{plugin_file.stem}"
|
module_name = f"plugins.{plugin_file.stem}"
|
||||||
@@ -333,17 +333,17 @@ class PluginLoader:
|
|||||||
if not spec or not spec.loader:
|
if not spec or not spec.loader:
|
||||||
self.logger.warning(f"Could not create spec for {plugin_file}")
|
self.logger.warning(f"Could not create spec for {plugin_file}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
sys.modules[module_name] = module
|
sys.modules[module_name] = module
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
self._loaded_modules[module_name] = module
|
self._loaded_modules[module_name] = module
|
||||||
|
|
||||||
self.logger.debug(f"Loaded module: {module_name}")
|
self.logger.debug(f"Loaded module: {module_name}")
|
||||||
|
|
||||||
# Track which plugin classes we've already processed to avoid duplicates
|
# Track which plugin classes we've already processed to avoid duplicates
|
||||||
processed_classes = set()
|
processed_classes = set()
|
||||||
|
|
||||||
# Find Plugin subclasses in module
|
# Find Plugin subclasses in module
|
||||||
for name, obj in inspect.getmembers(module, inspect.isclass):
|
for name, obj in inspect.getmembers(module, inspect.isclass):
|
||||||
# Skip base classes and non-Plugin classes
|
# Skip base classes and non-Plugin classes
|
||||||
@@ -353,15 +353,15 @@ class PluginLoader:
|
|||||||
if not issubclass(obj, Plugin):
|
if not issubclass(obj, Plugin):
|
||||||
self.logger.debug(f"Skipping non-Plugin class: {name}")
|
self.logger.debug(f"Skipping non-Plugin class: {name}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip if we've already processed this class (handles module-level aliases)
|
# Skip if we've already processed this class (handles module-level aliases)
|
||||||
if id(obj) in processed_classes:
|
if id(obj) in processed_classes:
|
||||||
self.logger.debug(f"Skipping duplicate reference to: {obj.__name__}")
|
self.logger.debug(f"Skipping duplicate reference to: {obj.__name__}")
|
||||||
continue
|
continue
|
||||||
processed_classes.add(id(obj))
|
processed_classes.add(id(obj))
|
||||||
|
|
||||||
self.logger.debug(f"Found plugin class: {name}")
|
self.logger.debug(f"Found plugin class: {name}")
|
||||||
|
|
||||||
# Instantiate plugin with config — check plugins subdict first,
|
# Instantiate plugin with config — check plugins subdict first,
|
||||||
# then top-level keys (e.g. nagios_runner: ... at root of config).
|
# then top-level keys (e.g. nagios_runner: ... at root of config).
|
||||||
plugin_instance_config = dict(plugins_subconfig.get(obj.name) or raw_config.get(obj.name) or {})
|
plugin_instance_config = dict(plugins_subconfig.get(obj.name) or raw_config.get(obj.name) or {})
|
||||||
@@ -369,7 +369,7 @@ class PluginLoader:
|
|||||||
if "owner" in raw_config and "owner" not in plugin_instance_config:
|
if "owner" in raw_config and "owner" not in plugin_instance_config:
|
||||||
plugin_instance_config["owner"] = raw_config["owner"]
|
plugin_instance_config["owner"] = raw_config["owner"]
|
||||||
plugin = obj(config=plugin_instance_config)
|
plugin = obj(config=plugin_instance_config)
|
||||||
|
|
||||||
# Initialize plugin
|
# Initialize plugin
|
||||||
try:
|
try:
|
||||||
initialized = await plugin.initialize()
|
initialized = await plugin.initialize()
|
||||||
@@ -389,7 +389,7 @@ class PluginLoader:
|
|||||||
exc_info=True
|
exc_info=True
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Register with registry
|
# Register with registry
|
||||||
if self.registry.register(plugin):
|
if self.registry.register(plugin):
|
||||||
loaded_count += 1
|
loaded_count += 1
|
||||||
@@ -397,15 +397,15 @@ class PluginLoader:
|
|||||||
f"Loaded plugin: {plugin.name} v{plugin.version} "
|
f"Loaded plugin: {plugin.name} v{plugin.version} "
|
||||||
f"(interval: {plugin.interval}s)"
|
f"(interval: {plugin.interval}s)"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
f"Error loading plugin from {plugin_file}: {e}",
|
f"Error loading plugin from {plugin_file}: {e}",
|
||||||
exc_info=True
|
exc_info=True
|
||||||
)
|
)
|
||||||
|
|
||||||
return loaded_count
|
return loaded_count
|
||||||
|
|
||||||
async def unload_all(self) -> None:
|
async def unload_all(self) -> None:
|
||||||
"""Unload all plugins and cleanup resources."""
|
"""Unload all plugins and cleanup resources."""
|
||||||
for plugin in self.registry.get_all():
|
for plugin in self.registry.get_all():
|
||||||
@@ -417,7 +417,7 @@ class PluginLoader:
|
|||||||
exc_info=True
|
exc_info=True
|
||||||
)
|
)
|
||||||
self.registry.unregister(plugin.name)
|
self.registry.unregister(plugin.name)
|
||||||
|
|
||||||
# Remove loaded modules
|
# Remove loaded modules
|
||||||
for module_name in self._loaded_modules:
|
for module_name in self._loaded_modules:
|
||||||
if module_name in sys.modules:
|
if module_name in sys.modules:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -14,7 +12,7 @@ from hbd.client.plugin import MonitorPlugin
|
|||||||
|
|
||||||
class CPUMonitorPlugin(MonitorPlugin):
|
class CPUMonitorPlugin(MonitorPlugin):
|
||||||
"""Monitor CPU usage and load.
|
"""Monitor CPU usage and load.
|
||||||
|
|
||||||
Collects:
|
Collects:
|
||||||
- Overall CPU usage percentage
|
- Overall CPU usage percentage
|
||||||
- Per-core CPU usage (if enabled in config)
|
- Per-core CPU usage (if enabled in config)
|
||||||
@@ -22,28 +20,28 @@ class CPUMonitorPlugin(MonitorPlugin):
|
|||||||
- Process count
|
- Process count
|
||||||
- CPU frequency (if available)
|
- CPU frequency (if available)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "cpu_monitor"
|
name = "cpu_monitor"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
description = "CPU usage and load monitoring"
|
description = "CPU usage and load monitoring"
|
||||||
interval = 300 # MonitorPlugin: collect every 5 minutes by default
|
interval = 300 # MonitorPlugin: collect every 5 minutes by default
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self.psutil = None
|
self.psutil = None
|
||||||
self.per_core = config.get("per_core", False) if config else False
|
self.per_core = config.get("per_core", False) if config else False
|
||||||
self.interval = config.get("interval", 300) if config else 300
|
self.interval = config.get("interval", 300) if config else 300
|
||||||
|
|
||||||
async def initialize(self) -> bool:
|
async def initialize(self) -> bool:
|
||||||
"""Initialize the CPU monitor plugin.
|
"""Initialize the CPU monitor plugin.
|
||||||
|
|
||||||
Checks if psutil is available.
|
Checks if psutil is available.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if psutil is available, False otherwise
|
True if psutil is available, False otherwise
|
||||||
"""
|
"""
|
||||||
self.logger.info(f"Initializing {self.name} plugin")
|
self.logger.info(f"Initializing {self.name} plugin")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import psutil
|
import psutil
|
||||||
self.psutil = psutil
|
self.psutil = psutil
|
||||||
@@ -54,23 +52,23 @@ class CPUMonitorPlugin(MonitorPlugin):
|
|||||||
"psutil module not available. Install with: pip install psutil"
|
"psutil module not available. Install with: pip install psutil"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _collect_metrics(self) -> Dict[str, Any]:
|
async def _collect_metrics(self) -> Dict[str, Any]:
|
||||||
"""Collect CPU metrics.
|
"""Collect CPU metrics.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with CPU metrics
|
Dictionary with CPU metrics
|
||||||
"""
|
"""
|
||||||
if not self.psutil:
|
if not self.psutil:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = {}
|
data = {}
|
||||||
|
|
||||||
# Overall CPU usage percentage (non-blocking, interval=1 for accuracy)
|
# Overall CPU usage percentage (non-blocking, interval=1 for accuracy)
|
||||||
# Note: first call to cpu_percent() returns 0.0, subsequent calls work correctly
|
# Note: first call to cpu_percent() returns 0.0, subsequent calls work correctly
|
||||||
data["cpu_percent"] = self.psutil.cpu_percent(interval=1)
|
data["cpu_percent"] = self.psutil.cpu_percent(interval=1)
|
||||||
|
|
||||||
# Per-core CPU usage (if enabled)
|
# Per-core CPU usage (if enabled)
|
||||||
if self.per_core:
|
if self.per_core:
|
||||||
per_core_percents = self.psutil.cpu_percent(interval=0, percpu=True)
|
per_core_percents = self.psutil.cpu_percent(interval=0, percpu=True)
|
||||||
@@ -79,7 +77,7 @@ class CPUMonitorPlugin(MonitorPlugin):
|
|||||||
else:
|
else:
|
||||||
# Just report core count
|
# Just report core count
|
||||||
data["cpu_core_count"] = self.psutil.cpu_count()
|
data["cpu_core_count"] = self.psutil.cpu_count()
|
||||||
|
|
||||||
# Load average (Unix-like systems only)
|
# Load average (Unix-like systems only)
|
||||||
try:
|
try:
|
||||||
load_avg = self.psutil.getloadavg()
|
load_avg = self.psutil.getloadavg()
|
||||||
@@ -89,13 +87,13 @@ class CPUMonitorPlugin(MonitorPlugin):
|
|||||||
except (AttributeError, OSError):
|
except (AttributeError, OSError):
|
||||||
# Not available on Windows
|
# Not available on Windows
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Process count
|
# Process count
|
||||||
try:
|
try:
|
||||||
data["process_count"] = len(self.psutil.pids())
|
data["process_count"] = len(self.psutil.pids())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Could not get process count: {e}")
|
self.logger.warning(f"Could not get process count: {e}")
|
||||||
|
|
||||||
# CPU frequency (if available)
|
# CPU frequency (if available)
|
||||||
try:
|
try:
|
||||||
freq = self.psutil.cpu_freq()
|
freq = self.psutil.cpu_freq()
|
||||||
@@ -107,7 +105,7 @@ class CPUMonitorPlugin(MonitorPlugin):
|
|||||||
# Not available on all systems, or may fail on FreeBSD with sysctl issues
|
# Not available on all systems, or may fail on FreeBSD with sysctl issues
|
||||||
self.logger.debug(f"CPU frequency not available: {e}")
|
self.logger.debug(f"CPU frequency not available: {e}")
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# CPU times (user, system, idle, etc.)
|
# CPU times (user, system, idle, etc.)
|
||||||
try:
|
try:
|
||||||
cpu_times = self.psutil.cpu_times_percent(interval=0)
|
cpu_times = self.psutil.cpu_times_percent(interval=0)
|
||||||
@@ -125,12 +123,12 @@ class CPUMonitorPlugin(MonitorPlugin):
|
|||||||
data["uptime_seconds"] = int(time.time() - self.psutil.boot_time())
|
data["uptime_seconds"] = int(time.time() - self.psutil.boot_time())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug(f"Could not get uptime: {e}")
|
self.logger.debug(f"Could not get uptime: {e}")
|
||||||
|
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
f"Collected CPU metrics: {data.get('cpu_percent', 'N/A')}% usage"
|
f"Collected CPU metrics: {data.get('cpu_percent', 'N/A')}% usage"
|
||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error collecting CPU metrics: {e}", exc_info=True)
|
self.logger.error(f"Error collecting CPU metrics: {e}", exc_info=True)
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -20,27 +20,27 @@ logger = logging.getLogger(__name__)
|
|||||||
class DiskMonitorPlugin(MonitorPlugin):
|
class DiskMonitorPlugin(MonitorPlugin):
|
||||||
"""
|
"""
|
||||||
Monitor disk usage and I/O statistics.
|
Monitor disk usage and I/O statistics.
|
||||||
|
|
||||||
Collects:
|
Collects:
|
||||||
- Disk partition information
|
- Disk partition information
|
||||||
- Disk usage per partition (total, used, free, percent)
|
- Disk usage per partition (total, used, free, percent)
|
||||||
- Disk I/O counters (read/write bytes, read/write count)
|
- Disk I/O counters (read/write bytes, read/write count)
|
||||||
- Disk I/O time statistics
|
- Disk I/O time statistics
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
interval: Collection interval in seconds (default: 300)
|
interval: Collection interval in seconds (default: 300)
|
||||||
partitions: List of mount points to monitor (default: all)
|
partitions: List of mount points to monitor (default: all)
|
||||||
include_io: Include disk I/O statistics (default: True)
|
include_io: Include disk I/O statistics (default: True)
|
||||||
exclude_types: List of filesystem types to exclude (default: tmpfs, devtmpfs, squashfs)
|
exclude_types: List of filesystem types to exclude (default: tmpfs, devtmpfs, squashfs)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "disk_monitor"
|
name = "disk_monitor"
|
||||||
interval = 300 # Collect every 5 minutes by default
|
interval = 300 # Collect every 5 minutes by default
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
"""
|
"""
|
||||||
Initialize the disk monitor plugin.
|
Initialize the disk monitor plugin.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Optional configuration dict with keys:
|
config: Optional configuration dict with keys:
|
||||||
- interval: Collection interval in seconds (default: 300)
|
- interval: Collection interval in seconds (default: 300)
|
||||||
@@ -53,34 +53,34 @@ class DiskMonitorPlugin(MonitorPlugin):
|
|||||||
self.include_io = self.config.get('include_io', True)
|
self.include_io = self.config.get('include_io', True)
|
||||||
self.exclude_types = set(self.config.get('exclude_types', ['tmpfs', 'devtmpfs', 'squashfs']))
|
self.exclude_types = set(self.config.get('exclude_types', ['tmpfs', 'devtmpfs', 'squashfs']))
|
||||||
self.interval = self.config.get('interval', 300)
|
self.interval = self.config.get('interval', 300)
|
||||||
|
|
||||||
if psutil is None:
|
if psutil is None:
|
||||||
raise ImportError("psutil library is required for disk_monitor plugin")
|
raise ImportError("psutil library is required for disk_monitor plugin")
|
||||||
|
|
||||||
# Store previous I/O counters for delta calculation
|
# Store previous I/O counters for delta calculation
|
||||||
self._prev_io = {}
|
self._prev_io = {}
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
"""Initialize the plugin (check psutil availability)."""
|
"""Initialize the plugin (check psutil availability)."""
|
||||||
if psutil is None:
|
if psutil is None:
|
||||||
logger.error("psutil not available - disk_monitor cannot run")
|
logger.error("psutil not available - disk_monitor cannot run")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
logger.info(f"Disk monitor initialized (interval: {self.interval}s, io: {self.include_io})")
|
logger.info(f"Disk monitor initialized (interval: {self.interval}s, io: {self.include_io})")
|
||||||
|
|
||||||
# Initialize I/O counters if available
|
# Initialize I/O counters if available
|
||||||
if self.include_io:
|
if self.include_io:
|
||||||
try:
|
try:
|
||||||
self._prev_io = psutil.disk_io_counters(perdisk=True)
|
self._prev_io = psutil.disk_io_counters(perdisk=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not initialize disk I/O counters: {e}")
|
logger.warning(f"Could not initialize disk I/O counters: {e}")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def collect(self) -> Dict[str, Any]:
|
async def collect(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Collect current disk statistics.
|
Collect current disk statistics.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with disk metrics organized by partition:
|
Dictionary with disk metrics organized by partition:
|
||||||
- partitions: Dict of partition data, keyed by mount point
|
- partitions: Dict of partition data, keyed by mount point
|
||||||
@@ -103,7 +103,7 @@ class DiskMonitorPlugin(MonitorPlugin):
|
|||||||
if psutil is None:
|
if psutil is None:
|
||||||
logger.error("psutil not available")
|
logger.error("psutil not available")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await self._collect_metrics()
|
data = await self._collect_metrics()
|
||||||
logger.debug(f"Collected disk metrics: {len(data.get('partitions', {}))} partitions")
|
logger.debug(f"Collected disk metrics: {len(data.get('partitions', {}))} partitions")
|
||||||
@@ -111,24 +111,24 @@ class DiskMonitorPlugin(MonitorPlugin):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error collecting disk metrics: {e}")
|
logger.error(f"Error collecting disk metrics: {e}")
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
async def _collect_metrics(self) -> Dict[str, Any]:
|
async def _collect_metrics(self) -> Dict[str, Any]:
|
||||||
"""Collect disk metrics from psutil."""
|
"""Collect disk metrics from psutil."""
|
||||||
metrics = {}
|
metrics = {}
|
||||||
|
|
||||||
# Collect partition usage
|
# Collect partition usage
|
||||||
partitions_data = {}
|
partitions_data = {}
|
||||||
partitions = psutil.disk_partitions(all=False)
|
partitions = psutil.disk_partitions(all=False)
|
||||||
|
|
||||||
for partition in partitions:
|
for partition in partitions:
|
||||||
# Skip unwanted filesystem types
|
# Skip unwanted filesystem types
|
||||||
if partition.fstype in self.exclude_types:
|
if partition.fstype in self.exclude_types:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip if we're only monitoring specific partitions
|
# Skip if we're only monitoring specific partitions
|
||||||
if self.partitions and partition.mountpoint not in self.partitions:
|
if self.partitions and partition.mountpoint not in self.partitions:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
usage = psutil.disk_usage(partition.mountpoint)
|
usage = psutil.disk_usage(partition.mountpoint)
|
||||||
partitions_data[partition.mountpoint] = {
|
partitions_data[partition.mountpoint] = {
|
||||||
@@ -145,15 +145,15 @@ class DiskMonitorPlugin(MonitorPlugin):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error reading {partition.mountpoint}: {e}")
|
logger.warning(f"Error reading {partition.mountpoint}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
metrics['partitions'] = partitions_data
|
metrics['partitions'] = partitions_data
|
||||||
|
|
||||||
# Collect I/O statistics
|
# Collect I/O statistics
|
||||||
if self.include_io:
|
if self.include_io:
|
||||||
try:
|
try:
|
||||||
io_counters = psutil.disk_io_counters(perdisk=True)
|
io_counters = psutil.disk_io_counters(perdisk=True)
|
||||||
io_data = {}
|
io_data = {}
|
||||||
|
|
||||||
for disk_name, counters in io_counters.items():
|
for disk_name, counters in io_counters.items():
|
||||||
disk_stats = {
|
disk_stats = {
|
||||||
'read_count': counters.read_count,
|
'read_count': counters.read_count,
|
||||||
@@ -161,7 +161,7 @@ class DiskMonitorPlugin(MonitorPlugin):
|
|||||||
'read_bytes': counters.read_bytes,
|
'read_bytes': counters.read_bytes,
|
||||||
'write_bytes': counters.write_bytes,
|
'write_bytes': counters.write_bytes,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add time statistics if available
|
# Add time statistics if available
|
||||||
if hasattr(counters, 'read_time'):
|
if hasattr(counters, 'read_time'):
|
||||||
disk_stats['read_time'] = counters.read_time
|
disk_stats['read_time'] = counters.read_time
|
||||||
@@ -169,7 +169,7 @@ class DiskMonitorPlugin(MonitorPlugin):
|
|||||||
disk_stats['write_time'] = counters.write_time
|
disk_stats['write_time'] = counters.write_time
|
||||||
if hasattr(counters, 'busy_time'):
|
if hasattr(counters, 'busy_time'):
|
||||||
disk_stats['busy_time'] = counters.busy_time
|
disk_stats['busy_time'] = counters.busy_time
|
||||||
|
|
||||||
# Calculate deltas from previous collection
|
# Calculate deltas from previous collection
|
||||||
if disk_name in self._prev_io:
|
if disk_name in self._prev_io:
|
||||||
prev = self._prev_io[disk_name]
|
prev = self._prev_io[disk_name]
|
||||||
@@ -177,19 +177,19 @@ class DiskMonitorPlugin(MonitorPlugin):
|
|||||||
disk_stats['write_bytes_delta'] = counters.write_bytes - prev.write_bytes
|
disk_stats['write_bytes_delta'] = counters.write_bytes - prev.write_bytes
|
||||||
disk_stats['read_count_delta'] = counters.read_count - prev.read_count
|
disk_stats['read_count_delta'] = counters.read_count - prev.read_count
|
||||||
disk_stats['write_count_delta'] = counters.write_count - prev.write_count
|
disk_stats['write_count_delta'] = counters.write_count - prev.write_count
|
||||||
|
|
||||||
io_data[disk_name] = disk_stats
|
io_data[disk_name] = disk_stats
|
||||||
|
|
||||||
metrics['io_counters'] = io_data
|
metrics['io_counters'] = io_data
|
||||||
|
|
||||||
# Store current counters for next delta calculation
|
# Store current counters for next delta calculation
|
||||||
self._prev_io = io_counters
|
self._prev_io = io_counters
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not collect disk I/O statistics: {e}")
|
logger.warning(f"Could not collect disk I/O statistics: {e}")
|
||||||
|
|
||||||
return metrics
|
return metrics
|
||||||
|
|
||||||
async def cleanup(self):
|
async def cleanup(self):
|
||||||
"""Cleanup (nothing to do for this plugin)."""
|
"""Cleanup (nothing to do for this plugin)."""
|
||||||
logger.info("Disk monitor cleanup")
|
logger.info("Disk monitor cleanup")
|
||||||
|
|||||||
@@ -20,29 +20,29 @@ logger = logging.getLogger(__name__)
|
|||||||
class FilesystemInfoPlugin(InfoPlugin):
|
class FilesystemInfoPlugin(InfoPlugin):
|
||||||
"""
|
"""
|
||||||
Collect filesystem and partition information.
|
Collect filesystem and partition information.
|
||||||
|
|
||||||
This is an InfoPlugin that collects static information once during startup.
|
This is an InfoPlugin that collects static information once during startup.
|
||||||
|
|
||||||
By default, only reports physical mounted filesystems (e.g., ext4, xfs, btrfs).
|
By default, only reports physical mounted filesystems (e.g., ext4, xfs, btrfs).
|
||||||
Set include_pseudo=True to also include pseudo filesystems (proc, sysfs, tmpfs, etc.).
|
Set include_pseudo=True to also include pseudo filesystems (proc, sysfs, tmpfs, etc.).
|
||||||
|
|
||||||
Collects:
|
Collects:
|
||||||
- List of mounted filesystems
|
- List of mounted filesystems
|
||||||
- Partition details (device, mount point, filesystem type, options)
|
- Partition details (device, mount point, filesystem type, options)
|
||||||
- Filesystem capabilities and features
|
- Filesystem capabilities and features
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
include_pseudo: Include pseudo/virtual filesystems (default: False)
|
include_pseudo: Include pseudo/virtual filesystems (default: False)
|
||||||
exclude_types: List of additional filesystem types to exclude (default: [])
|
exclude_types: List of additional filesystem types to exclude (default: [])
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "filesystem_info"
|
name = "filesystem_info"
|
||||||
interval = 0 # InfoPlugin - collect once
|
interval = 0 # InfoPlugin - collect once
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
"""
|
"""
|
||||||
Initialize the filesystem info plugin.
|
Initialize the filesystem info plugin.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Optional configuration dict with keys:
|
config: Optional configuration dict with keys:
|
||||||
- include_pseudo: Include pseudo/virtual filesystems (default: False)
|
- include_pseudo: Include pseudo/virtual filesystems (default: False)
|
||||||
@@ -53,25 +53,25 @@ class FilesystemInfoPlugin(InfoPlugin):
|
|||||||
# By default, no exclusions since all=False filters most pseudo filesystems
|
# By default, no exclusions since all=False filters most pseudo filesystems
|
||||||
# Users can add specific types to exclude if needed
|
# Users can add specific types to exclude if needed
|
||||||
self.exclude_types = set(self.config.get('exclude_types', []))
|
self.exclude_types = set(self.config.get('exclude_types', []))
|
||||||
|
|
||||||
if psutil is None:
|
if psutil is None:
|
||||||
raise ImportError("psutil library is required for filesystem_info plugin")
|
raise ImportError("psutil library is required for filesystem_info plugin")
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
"""Initialize the plugin (check psutil availability)."""
|
"""Initialize the plugin (check psutil availability)."""
|
||||||
if psutil is None:
|
if psutil is None:
|
||||||
logger.error("psutil not available - filesystem_info cannot run")
|
logger.error("psutil not available - filesystem_info cannot run")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
logger.info(f"Filesystem info initialized (pseudo: {self.include_pseudo})")
|
logger.info(f"Filesystem info initialized (pseudo: {self.include_pseudo})")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def collect(self) -> Dict[str, Any]:
|
async def collect(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Collect filesystem information.
|
Collect filesystem information.
|
||||||
|
|
||||||
Returns only physical mounted filesystems by default.
|
Returns only physical mounted filesystems by default.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with filesystem data:
|
Dictionary with filesystem data:
|
||||||
- filesystems: List of filesystem dictionaries:
|
- filesystems: List of filesystem dictionaries:
|
||||||
@@ -87,7 +87,7 @@ class FilesystemInfoPlugin(InfoPlugin):
|
|||||||
if psutil is None:
|
if psutil is None:
|
||||||
logger.error("psutil not available")
|
logger.error("psutil not available")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await self._collect_info()
|
data = await self._collect_info()
|
||||||
logger.info(f"Collected filesystem info: {len(data.get('filesystems', []))} filesystems")
|
logger.info(f"Collected filesystem info: {len(data.get('filesystems', []))} filesystems")
|
||||||
@@ -95,30 +95,30 @@ class FilesystemInfoPlugin(InfoPlugin):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error collecting filesystem info: {e}")
|
logger.error(f"Error collecting filesystem info: {e}")
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
async def _collect_info(self) -> Dict[str, Any]:
|
async def _collect_info(self) -> Dict[str, Any]:
|
||||||
"""Collect filesystem information from psutil."""
|
"""Collect filesystem information from psutil."""
|
||||||
info = {}
|
info = {}
|
||||||
filesystems = []
|
filesystems = []
|
||||||
filesystem_types = set()
|
filesystem_types = set()
|
||||||
|
|
||||||
# Get mounted disk partitions
|
# Get mounted disk partitions
|
||||||
# all=False returns only physical devices (real mounted filesystems)
|
# all=False returns only physical devices (real mounted filesystems)
|
||||||
# all=True would include pseudo filesystems (proc, sysfs, etc.)
|
# all=True would include pseudo filesystems (proc, sysfs, etc.)
|
||||||
partitions = psutil.disk_partitions(all=self.include_pseudo)
|
partitions = psutil.disk_partitions(all=self.include_pseudo)
|
||||||
|
|
||||||
for partition in partitions:
|
for partition in partitions:
|
||||||
# Additional filtering if exclude_types is specified
|
# Additional filtering if exclude_types is specified
|
||||||
if partition.fstype in self.exclude_types:
|
if partition.fstype in self.exclude_types:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
fs_info = {
|
fs_info = {
|
||||||
'device': partition.device,
|
'device': partition.device,
|
||||||
'mountpoint': partition.mountpoint,
|
'mountpoint': partition.mountpoint,
|
||||||
'fstype': partition.fstype,
|
'fstype': partition.fstype,
|
||||||
'opts': partition.opts,
|
'opts': partition.opts,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Try to get filesystem capabilities
|
# Try to get filesystem capabilities
|
||||||
try:
|
try:
|
||||||
# Get path configuration for this mount point
|
# Get path configuration for this mount point
|
||||||
@@ -141,14 +141,14 @@ class FilesystemInfoPlugin(InfoPlugin):
|
|||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not get pathconf for {partition.mountpoint}: {e}")
|
logger.debug(f"Could not get pathconf for {partition.mountpoint}: {e}")
|
||||||
|
|
||||||
filesystems.append(fs_info)
|
filesystems.append(fs_info)
|
||||||
filesystem_types.add(partition.fstype)
|
filesystem_types.add(partition.fstype)
|
||||||
|
|
||||||
info['filesystems'] = filesystems
|
info['filesystems'] = filesystems
|
||||||
info['filesystem_types'] = sorted(list(filesystem_types))
|
info['filesystem_types'] = sorted(list(filesystem_types))
|
||||||
info['mount_count'] = len(filesystems)
|
info['mount_count'] = len(filesystems)
|
||||||
|
|
||||||
# Add some additional filesystem statistics
|
# Add some additional filesystem statistics
|
||||||
try:
|
try:
|
||||||
# Get boot time (useful for determining filesystem mount times)
|
# Get boot time (useful for determining filesystem mount times)
|
||||||
@@ -156,9 +156,9 @@ class FilesystemInfoPlugin(InfoPlugin):
|
|||||||
info['boot_time'] = boot_time
|
info['boot_time'] = boot_time
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not get boot time: {e}")
|
logger.debug(f"Could not get boot time: {e}")
|
||||||
|
|
||||||
return info
|
return info
|
||||||
|
|
||||||
async def cleanup(self):
|
async def cleanup(self):
|
||||||
"""Cleanup (nothing to do for this plugin)."""
|
"""Cleanup (nothing to do for this plugin)."""
|
||||||
logger.info("Filesystem info cleanup")
|
logger.info("Filesystem info cleanup")
|
||||||
|
|||||||
@@ -32,31 +32,32 @@ def _zfs_arc_bytes() -> int:
|
|||||||
pass
|
pass
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MemoryMonitorPlugin(MonitorPlugin):
|
class MemoryMonitorPlugin(MonitorPlugin):
|
||||||
"""
|
"""
|
||||||
Monitor memory and swap usage.
|
Monitor memory and swap usage.
|
||||||
|
|
||||||
Collects:
|
Collects:
|
||||||
- Physical memory (RAM) usage and statistics
|
- Physical memory (RAM) usage and statistics
|
||||||
- Virtual memory details
|
- Virtual memory details
|
||||||
- Swap memory usage and statistics
|
- Swap memory usage and statistics
|
||||||
- Memory available for applications
|
- Memory available for applications
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
interval: Collection interval in seconds (default: 300)
|
interval: Collection interval in seconds (default: 300)
|
||||||
include_swap: Include swap statistics (default: True)
|
include_swap: Include swap statistics (default: True)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "memory_monitor"
|
name = "memory_monitor"
|
||||||
interval = 300 # Collect every 5 minutes by default
|
interval = 300 # Collect every 5 minutes by default
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
"""
|
"""
|
||||||
Initialize the memory monitor plugin.
|
Initialize the memory monitor plugin.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Optional configuration dict with keys:
|
config: Optional configuration dict with keys:
|
||||||
- interval: Collection interval in seconds (default: 300)
|
- interval: Collection interval in seconds (default: 300)
|
||||||
@@ -65,23 +66,23 @@ class MemoryMonitorPlugin(MonitorPlugin):
|
|||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self.include_swap = self.config.get('include_swap', True)
|
self.include_swap = self.config.get('include_swap', True)
|
||||||
self.interval = self.config.get('interval', 300)
|
self.interval = self.config.get('interval', 300)
|
||||||
|
|
||||||
if psutil is None:
|
if psutil is None:
|
||||||
raise ImportError("psutil library is required for memory_monitor plugin")
|
raise ImportError("psutil library is required for memory_monitor plugin")
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
"""Initialize the plugin (check psutil availability)."""
|
"""Initialize the plugin (check psutil availability)."""
|
||||||
if psutil is None:
|
if psutil is None:
|
||||||
logger.error("psutil not available - memory_monitor cannot run")
|
logger.error("psutil not available - memory_monitor cannot run")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
logger.info(f"Memory monitor initialized (interval: {self.interval}s, swap: {self.include_swap})")
|
logger.info(f"Memory monitor initialized (interval: {self.interval}s, swap: {self.include_swap})")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def collect(self) -> Dict[str, Any]:
|
async def collect(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Collect current memory statistics.
|
Collect current memory statistics.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with memory metrics:
|
Dictionary with memory metrics:
|
||||||
- memory_total: Total physical RAM in bytes
|
- memory_total: Total physical RAM in bytes
|
||||||
@@ -104,7 +105,7 @@ class MemoryMonitorPlugin(MonitorPlugin):
|
|||||||
if psutil is None:
|
if psutil is None:
|
||||||
logger.error("psutil not available")
|
logger.error("psutil not available")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await self._collect_metrics()
|
data = await self._collect_metrics()
|
||||||
logger.debug(f"Collected memory metrics: {len(data)} fields")
|
logger.debug(f"Collected memory metrics: {len(data)} fields")
|
||||||
@@ -112,11 +113,11 @@ class MemoryMonitorPlugin(MonitorPlugin):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error collecting memory metrics: {e}")
|
logger.error(f"Error collecting memory metrics: {e}")
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
async def _collect_metrics(self) -> Dict[str, Any]:
|
async def _collect_metrics(self) -> Dict[str, Any]:
|
||||||
"""Collect memory metrics from psutil."""
|
"""Collect memory metrics from psutil."""
|
||||||
metrics = {}
|
metrics = {}
|
||||||
|
|
||||||
# Virtual (physical) memory statistics
|
# Virtual (physical) memory statistics
|
||||||
vmem = psutil.virtual_memory()
|
vmem = psutil.virtual_memory()
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ class MemoryMonitorPlugin(MonitorPlugin):
|
|||||||
metrics['memory_used'] = used
|
metrics['memory_used'] = used
|
||||||
metrics['memory_free'] = vmem.free
|
metrics['memory_free'] = vmem.free
|
||||||
metrics['memory_percent'] = percent
|
metrics['memory_percent'] = percent
|
||||||
|
|
||||||
# Platform-specific memory details
|
# Platform-specific memory details
|
||||||
if hasattr(vmem, 'active'):
|
if hasattr(vmem, 'active'):
|
||||||
metrics['memory_active'] = vmem.active
|
metrics['memory_active'] = vmem.active
|
||||||
@@ -146,7 +147,7 @@ class MemoryMonitorPlugin(MonitorPlugin):
|
|||||||
metrics['memory_cached'] = vmem.cached
|
metrics['memory_cached'] = vmem.cached
|
||||||
if hasattr(vmem, 'shared'):
|
if hasattr(vmem, 'shared'):
|
||||||
metrics['memory_shared'] = vmem.shared
|
metrics['memory_shared'] = vmem.shared
|
||||||
|
|
||||||
# Swap memory statistics
|
# Swap memory statistics
|
||||||
if self.include_swap:
|
if self.include_swap:
|
||||||
try:
|
try:
|
||||||
@@ -155,7 +156,7 @@ class MemoryMonitorPlugin(MonitorPlugin):
|
|||||||
metrics['swap_used'] = swap.used
|
metrics['swap_used'] = swap.used
|
||||||
metrics['swap_free'] = swap.free
|
metrics['swap_free'] = swap.free
|
||||||
metrics['swap_percent'] = swap.percent
|
metrics['swap_percent'] = swap.percent
|
||||||
|
|
||||||
# Swap in/out counters (may not be available on all platforms)
|
# Swap in/out counters (may not be available on all platforms)
|
||||||
if hasattr(swap, 'sin'):
|
if hasattr(swap, 'sin'):
|
||||||
metrics['swap_sin'] = swap.sin
|
metrics['swap_sin'] = swap.sin
|
||||||
@@ -163,9 +164,9 @@ class MemoryMonitorPlugin(MonitorPlugin):
|
|||||||
metrics['swap_sout'] = swap.sout
|
metrics['swap_sout'] = swap.sout
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not collect swap statistics: {e}")
|
logger.warning(f"Could not collect swap statistics: {e}")
|
||||||
|
|
||||||
return metrics
|
return metrics
|
||||||
|
|
||||||
async def cleanup(self):
|
async def cleanup(self):
|
||||||
"""Cleanup (nothing to do for this plugin)."""
|
"""Cleanup (nothing to do for this plugin)."""
|
||||||
logger.info("Memory monitor cleanup")
|
logger.info("Memory monitor cleanup")
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ STATUS_NAMES = {
|
|||||||
|
|
||||||
class NagiosRunnerPlugin(MonitorPlugin):
|
class NagiosRunnerPlugin(MonitorPlugin):
|
||||||
"""Run Nagios-compatible monitoring plugins.
|
"""Run Nagios-compatible monitoring plugins.
|
||||||
|
|
||||||
This plugin executes external Nagios plugins and collects their output,
|
This plugin executes external Nagios plugins and collects their output,
|
||||||
including status codes, messages, and performance data.
|
including status codes, messages, and performance data.
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
interval: Collection interval in seconds (default: 300)
|
interval: Collection interval in seconds (default: 300)
|
||||||
commands: List of command definitions with 'name' and 'command' keys
|
commands: List of command definitions with 'name' and 'command' keys
|
||||||
@@ -62,20 +62,20 @@ class NagiosRunnerPlugin(MonitorPlugin):
|
|||||||
- name: check_load
|
- name: check_load
|
||||||
command: /usr/lib/nagios/plugins/check_load -w 5,4,3 -c 10,8,6
|
command: /usr/lib/nagios/plugins/check_load -w 5,4,3 -c 10,8,6
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "nagios_runner"
|
name = "nagios_runner"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
description = "Execute Nagios-compatible monitoring plugins"
|
description = "Execute Nagios-compatible monitoring plugins"
|
||||||
interval = 300 # MonitorPlugin: collect every 5 minutes by default
|
interval = 300 # MonitorPlugin: collect every 5 minutes by default
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
|
|
||||||
# Extract configuration
|
# Extract configuration
|
||||||
self.commands: List[Dict[str, str]] = config.get("commands", []) if config else []
|
self.commands: List[Dict[str, str]] = config.get("commands", []) if config else []
|
||||||
self.timeout: int = config.get("timeout", 30) if config else 30
|
self.timeout: int = config.get("timeout", 30) if config else 30
|
||||||
self.interval = config.get("interval", 300) if config else 300
|
self.interval = config.get("interval", 300) if config else 300
|
||||||
|
|
||||||
async def initialize(self) -> bool:
|
async def initialize(self) -> bool:
|
||||||
"""Initialize the Nagios runner plugin.
|
"""Initialize the Nagios runner plugin.
|
||||||
|
|
||||||
@@ -117,10 +117,10 @@ class NagiosRunnerPlugin(MonitorPlugin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _collect_metrics(self) -> Dict[str, Any]:
|
async def _collect_metrics(self) -> Dict[str, Any]:
|
||||||
"""Collect metrics from all configured Nagios plugins.
|
"""Collect metrics from all configured Nagios plugins.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with results from all plugins
|
Dictionary with results from all plugins
|
||||||
"""
|
"""
|
||||||
@@ -159,7 +159,7 @@ class NagiosRunnerPlugin(MonitorPlugin):
|
|||||||
results[f"{name}_output"] = str(e)
|
results[f"{name}_output"] = str(e)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def _run_nagios_plugin(
|
async def _run_nagios_plugin(
|
||||||
self,
|
self,
|
||||||
command: str
|
command: str
|
||||||
@@ -210,33 +210,33 @@ class NagiosRunnerPlugin(MonitorPlugin):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error executing command: {e}")
|
self.logger.error(f"Error executing command: {e}")
|
||||||
return NAGIOS_UNKNOWN, f"Execution error: {str(e)}", {}
|
return NAGIOS_UNKNOWN, f"Execution error: {str(e)}", {}
|
||||||
|
|
||||||
def _parse_perfdata(self, output: str) -> Dict[str, Any]:
|
def _parse_perfdata(self, output: str) -> Dict[str, Any]:
|
||||||
"""Parse Nagios performance data from plugin output.
|
"""Parse Nagios performance data from plugin output.
|
||||||
|
|
||||||
Nagios performance data format:
|
Nagios performance data format:
|
||||||
'label'=value[UOM];[warn];[crit];[min];[max]
|
'label'=value[UOM];[warn];[crit];[min];[max]
|
||||||
|
|
||||||
Multiple metrics separated by spaces.
|
Multiple metrics separated by spaces.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
output: Plugin output string
|
output: Plugin output string
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary of metric_name: value
|
Dictionary of metric_name: value
|
||||||
"""
|
"""
|
||||||
perfdata = {}
|
perfdata = {}
|
||||||
|
|
||||||
# Performance data comes after the pipe character
|
# Performance data comes after the pipe character
|
||||||
if '|' not in output:
|
if '|' not in output:
|
||||||
return perfdata
|
return perfdata
|
||||||
|
|
||||||
perf_section = output.split('|', 1)[1].strip()
|
perf_section = output.split('|', 1)[1].strip()
|
||||||
|
|
||||||
# Regex to match performance data format
|
# Regex to match performance data format
|
||||||
# Matches: 'label'=value or label=value
|
# Matches: 'label'=value or label=value
|
||||||
perf_regex = r"'?([^'=]+)'?=([\d.]+)([a-zA-Z%]*);?([\d.]*);?([\d.]*);?([\d.]*);?([\d.]*)"
|
perf_regex = r"'?([^'=]+)'?=([\d.]+)([a-zA-Z%]*);?([\d.]*);?([\d.]*);?([\d.]*);?([\d.]*)"
|
||||||
|
|
||||||
for match in re.finditer(perf_regex, perf_section):
|
for match in re.finditer(perf_regex, perf_section):
|
||||||
label = match.group(1).strip()
|
label = match.group(1).strip()
|
||||||
value_str = match.group(2)
|
value_str = match.group(2)
|
||||||
@@ -245,43 +245,43 @@ class NagiosRunnerPlugin(MonitorPlugin):
|
|||||||
crit = match.group(5)
|
crit = match.group(5)
|
||||||
min_val = match.group(6)
|
min_val = match.group(6)
|
||||||
max_val = match.group(7)
|
max_val = match.group(7)
|
||||||
|
|
||||||
# Convert value to float
|
# Convert value to float
|
||||||
try:
|
try:
|
||||||
value = float(value_str)
|
value = float(value_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Store the value
|
# Store the value
|
||||||
perfdata[label] = value
|
perfdata[label] = value
|
||||||
|
|
||||||
# Optionally store UOM as separate field
|
# Optionally store UOM as separate field
|
||||||
if uom:
|
if uom:
|
||||||
perfdata[f"{label}_uom"] = uom
|
perfdata[f"{label}_uom"] = uom
|
||||||
|
|
||||||
# Store thresholds if present
|
# Store thresholds if present
|
||||||
if warn:
|
if warn:
|
||||||
try:
|
try:
|
||||||
perfdata[f"{label}_warn"] = float(warn)
|
perfdata[f"{label}_warn"] = float(warn)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if crit:
|
if crit:
|
||||||
try:
|
try:
|
||||||
perfdata[f"{label}_crit"] = float(crit)
|
perfdata[f"{label}_crit"] = float(crit)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if min_val:
|
if min_val:
|
||||||
try:
|
try:
|
||||||
perfdata[f"{label}_min"] = float(min_val)
|
perfdata[f"{label}_min"] = float(min_val)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if max_val:
|
if max_val:
|
||||||
try:
|
try:
|
||||||
perfdata[f"{label}_max"] = float(max_val)
|
perfdata[f"{label}_max"] = float(max_val)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return perfdata
|
return perfdata
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -21,13 +21,13 @@ logger = logging.getLogger(__name__)
|
|||||||
class NetworkMonitorPlugin(MonitorPlugin):
|
class NetworkMonitorPlugin(MonitorPlugin):
|
||||||
"""
|
"""
|
||||||
Monitor network interface statistics and connections.
|
Monitor network interface statistics and connections.
|
||||||
|
|
||||||
Collects:
|
Collects:
|
||||||
- Network interface I/O counters (bytes sent/received, packets, errors, drops)
|
- Network interface I/O counters (bytes sent/received, packets, errors, drops)
|
||||||
- Per-interface statistics
|
- Per-interface statistics
|
||||||
- Network connection counts by state
|
- Network connection counts by state
|
||||||
- Interface addresses and configuration
|
- Interface addresses and configuration
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
interval: Collection interval in seconds (default: 300)
|
interval: Collection interval in seconds (default: 300)
|
||||||
interfaces: List of interfaces to monitor (default: all)
|
interfaces: List of interfaces to monitor (default: all)
|
||||||
@@ -38,14 +38,14 @@ class NetworkMonitorPlugin(MonitorPlugin):
|
|||||||
probe_v6: IPv6 address to ping (default: 2606:4700:4700::1111)
|
probe_v6: IPv6 address to ping (default: 2606:4700:4700::1111)
|
||||||
probe_interval: Seconds between ICMP probes while healthy (default: 60)
|
probe_interval: Seconds between ICMP probes while healthy (default: 60)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "network_monitor"
|
name = "network_monitor"
|
||||||
interval = 300 # Collect every 5 minutes by default
|
interval = 300 # Collect every 5 minutes by default
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
"""
|
"""
|
||||||
Initialize the network monitor plugin.
|
Initialize the network monitor plugin.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Optional configuration dict with keys:
|
config: Optional configuration dict with keys:
|
||||||
- interval: Collection interval in seconds (default: 300)
|
- interval: Collection interval in seconds (default: 300)
|
||||||
@@ -58,34 +58,34 @@ class NetworkMonitorPlugin(MonitorPlugin):
|
|||||||
self.include_connections = self.config.get('include_connections', True)
|
self.include_connections = self.config.get('include_connections', True)
|
||||||
self.include_addresses = self.config.get('include_addresses', False)
|
self.include_addresses = self.config.get('include_addresses', False)
|
||||||
self.interval = self.config.get('interval', 300)
|
self.interval = self.config.get('interval', 300)
|
||||||
|
|
||||||
if psutil is None:
|
if psutil is None:
|
||||||
raise ImportError("psutil library is required for network_monitor plugin")
|
raise ImportError("psutil library is required for network_monitor plugin")
|
||||||
|
|
||||||
# Store previous I/O counters for delta calculation
|
# Store previous I/O counters for delta calculation
|
||||||
self._prev_io = {}
|
self._prev_io = {}
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
"""Initialize the plugin (check psutil availability)."""
|
"""Initialize the plugin (check psutil availability)."""
|
||||||
if psutil is None:
|
if psutil is None:
|
||||||
logger.error("psutil not available - network_monitor cannot run")
|
logger.error("psutil not available - network_monitor cannot run")
|
||||||
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:
|
||||||
self._prev_io = psutil.net_io_counters(pernic=True)
|
self._prev_io = psutil.net_io_counters(pernic=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not initialize network I/O counters: {e}")
|
logger.warning(f"Could not initialize network I/O counters: {e}")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def collect(self) -> Dict[str, Any]:
|
async def collect(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Collect current network statistics.
|
Collect current network statistics.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with network metrics:
|
Dictionary with network metrics:
|
||||||
- interfaces: Dict of interface statistics, keyed by interface name
|
- interfaces: Dict of interface statistics, keyed by interface name
|
||||||
@@ -113,7 +113,7 @@ class NetworkMonitorPlugin(MonitorPlugin):
|
|||||||
if psutil is None:
|
if psutil is None:
|
||||||
logger.error("psutil not available")
|
logger.error("psutil not available")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await self._collect_metrics()
|
data = await self._collect_metrics()
|
||||||
logger.debug(f"Collected network metrics: {len(data.get('interfaces', {}))} interfaces")
|
logger.debug(f"Collected network metrics: {len(data.get('interfaces', {}))} interfaces")
|
||||||
@@ -121,21 +121,21 @@ class NetworkMonitorPlugin(MonitorPlugin):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error collecting network metrics: {e}")
|
logger.error(f"Error collecting network metrics: {e}")
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
async def _collect_metrics(self) -> Dict[str, Any]:
|
async def _collect_metrics(self) -> Dict[str, Any]:
|
||||||
"""Collect network metrics from psutil."""
|
"""Collect network metrics from psutil."""
|
||||||
metrics = {}
|
metrics = {}
|
||||||
|
|
||||||
# Collect per-interface I/O counters
|
# Collect per-interface I/O counters
|
||||||
try:
|
try:
|
||||||
io_counters = psutil.net_io_counters(pernic=True)
|
io_counters = psutil.net_io_counters(pernic=True)
|
||||||
interfaces_data = {}
|
interfaces_data = {}
|
||||||
|
|
||||||
for iface_name, counters in io_counters.items():
|
for iface_name, counters in io_counters.items():
|
||||||
# Skip if we're only monitoring specific interfaces
|
# Skip if we're only monitoring specific interfaces
|
||||||
if self.interfaces and iface_name not in self.interfaces:
|
if self.interfaces and iface_name not in self.interfaces:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
iface_stats = {
|
iface_stats = {
|
||||||
'bytes_sent': counters.bytes_sent,
|
'bytes_sent': counters.bytes_sent,
|
||||||
'bytes_recv': counters.bytes_recv,
|
'bytes_recv': counters.bytes_recv,
|
||||||
@@ -146,7 +146,7 @@ class NetworkMonitorPlugin(MonitorPlugin):
|
|||||||
'dropin': counters.dropin,
|
'dropin': counters.dropin,
|
||||||
'dropout': counters.dropout,
|
'dropout': counters.dropout,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Calculate deltas from previous collection
|
# Calculate deltas from previous collection
|
||||||
if iface_name in self._prev_io:
|
if iface_name in self._prev_io:
|
||||||
prev = self._prev_io[iface_name]
|
prev = self._prev_io[iface_name]
|
||||||
@@ -154,46 +154,46 @@ class NetworkMonitorPlugin(MonitorPlugin):
|
|||||||
iface_stats['bytes_recv_delta'] = counters.bytes_recv - prev.bytes_recv
|
iface_stats['bytes_recv_delta'] = counters.bytes_recv - prev.bytes_recv
|
||||||
iface_stats['packets_sent_delta'] = counters.packets_sent - prev.packets_sent
|
iface_stats['packets_sent_delta'] = counters.packets_sent - prev.packets_sent
|
||||||
iface_stats['packets_recv_delta'] = counters.packets_recv - prev.packets_recv
|
iface_stats['packets_recv_delta'] = counters.packets_recv - prev.packets_recv
|
||||||
|
|
||||||
interfaces_data[iface_name] = iface_stats
|
interfaces_data[iface_name] = iface_stats
|
||||||
|
|
||||||
metrics['interfaces'] = interfaces_data
|
metrics['interfaces'] = interfaces_data
|
||||||
|
|
||||||
# Store current counters for next delta calculation
|
# Store current counters for next delta calculation
|
||||||
self._prev_io = io_counters
|
self._prev_io = io_counters
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not collect network I/O counters: {e}")
|
logger.warning(f"Could not collect network I/O counters: {e}")
|
||||||
|
|
||||||
# Collect connection statistics
|
# Collect connection statistics
|
||||||
if self.include_connections:
|
if self.include_connections:
|
||||||
try:
|
try:
|
||||||
connections = psutil.net_connections(kind='inet')
|
connections = psutil.net_connections(kind='inet')
|
||||||
conn_stats = {}
|
conn_stats = {}
|
||||||
|
|
||||||
# Count connections by state
|
# Count connections by state
|
||||||
for conn in connections:
|
for conn in connections:
|
||||||
state = conn.status
|
state = conn.status
|
||||||
conn_stats[state] = conn_stats.get(state, 0) + 1
|
conn_stats[state] = conn_stats.get(state, 0) + 1
|
||||||
|
|
||||||
metrics['connections'] = conn_stats
|
metrics['connections'] = conn_stats
|
||||||
|
|
||||||
except (PermissionError, psutil.AccessDenied):
|
except (PermissionError, psutil.AccessDenied):
|
||||||
logger.debug("Permission denied for net_connections (requires root/admin)")
|
logger.debug("Permission denied for net_connections (requires root/admin)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not collect connection statistics: {e}")
|
logger.warning(f"Could not collect connection statistics: {e}")
|
||||||
|
|
||||||
# Collect interface addresses
|
# Collect interface addresses
|
||||||
if self.include_addresses:
|
if self.include_addresses:
|
||||||
try:
|
try:
|
||||||
addresses = psutil.net_if_addrs()
|
addresses = psutil.net_if_addrs()
|
||||||
addr_data = {}
|
addr_data = {}
|
||||||
|
|
||||||
for iface_name, addrs in addresses.items():
|
for iface_name, addrs in addresses.items():
|
||||||
# Skip if we're only monitoring specific interfaces
|
# Skip if we're only monitoring specific interfaces
|
||||||
if self.interfaces and iface_name not in self.interfaces:
|
if self.interfaces and iface_name not in self.interfaces:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
iface_addrs = []
|
iface_addrs = []
|
||||||
for addr in addrs:
|
for addr in addrs:
|
||||||
addr_info = {
|
addr_info = {
|
||||||
@@ -205,33 +205,33 @@ class NetworkMonitorPlugin(MonitorPlugin):
|
|||||||
if addr.broadcast:
|
if addr.broadcast:
|
||||||
addr_info['broadcast'] = addr.broadcast
|
addr_info['broadcast'] = addr.broadcast
|
||||||
iface_addrs.append(addr_info)
|
iface_addrs.append(addr_info)
|
||||||
|
|
||||||
addr_data[iface_name] = iface_addrs
|
addr_data[iface_name] = iface_addrs
|
||||||
|
|
||||||
metrics['addresses'] = addr_data
|
metrics['addresses'] = addr_data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not collect interface addresses: {e}")
|
logger.warning(f"Could not collect interface addresses: {e}")
|
||||||
|
|
||||||
# Add interface stats (up/down status, speed, mtu)
|
# Add interface stats (up/down status, speed, mtu)
|
||||||
try:
|
try:
|
||||||
if_stats = psutil.net_if_stats()
|
if_stats = psutil.net_if_stats()
|
||||||
stats_data = {}
|
stats_data = {}
|
||||||
|
|
||||||
for iface_name, stats in if_stats.items():
|
for iface_name, stats in if_stats.items():
|
||||||
# Skip if we're only monitoring specific interfaces
|
# Skip if we're only monitoring specific interfaces
|
||||||
if self.interfaces and iface_name not in self.interfaces:
|
if self.interfaces and iface_name not in self.interfaces:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
stats_data[iface_name] = {
|
stats_data[iface_name] = {
|
||||||
'isup': stats.isup,
|
'isup': stats.isup,
|
||||||
'duplex': str(stats.duplex) if hasattr(stats, 'duplex') else None,
|
'duplex': str(stats.duplex) if hasattr(stats, 'duplex') else None,
|
||||||
'speed': stats.speed,
|
'speed': stats.speed,
|
||||||
'mtu': stats.mtu,
|
'mtu': stats.mtu,
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics['interface_stats'] = stats_data
|
metrics['interface_stats'] = stats_data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not collect interface stats: {e}")
|
logger.warning(f"Could not collect interface stats: {e}")
|
||||||
|
|
||||||
@@ -239,9 +239,9 @@ class NetworkMonitorPlugin(MonitorPlugin):
|
|||||||
shared = get_shared()
|
shared = get_shared()
|
||||||
if shared is not None:
|
if shared is not None:
|
||||||
metrics['connectivity'] = shared.as_metric()
|
metrics['connectivity'] = shared.as_metric()
|
||||||
|
|
||||||
return metrics
|
return metrics
|
||||||
|
|
||||||
async def cleanup(self):
|
async def cleanup(self):
|
||||||
"""Cleanup (nothing to do for this plugin)."""
|
"""Cleanup (nothing to do for this plugin)."""
|
||||||
logger.info("Network monitor cleanup")
|
logger.info("Network monitor cleanup")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ from hbd.client.plugin import InfoPlugin
|
|||||||
|
|
||||||
class OSInfoPlugin(InfoPlugin):
|
class OSInfoPlugin(InfoPlugin):
|
||||||
"""Collect operating system information.
|
"""Collect operating system information.
|
||||||
|
|
||||||
This plugin gathers static OS information that rarely changes:
|
This plugin gathers static OS information that rarely changes:
|
||||||
- OS name and version
|
- OS name and version
|
||||||
- Kernel version
|
- Kernel version
|
||||||
@@ -23,27 +22,27 @@ class OSInfoPlugin(InfoPlugin):
|
|||||||
- Distribution details (for Linux)
|
- Distribution details (for Linux)
|
||||||
- Python version (used by hbc)
|
- Python version (used by hbc)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "os_info"
|
name = "os_info"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
description = "Operating system and platform information"
|
description = "Operating system and platform information"
|
||||||
interval = 0 # InfoPlugin: collect once at startup
|
interval = 0 # InfoPlugin: collect once at startup
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
|
|
||||||
async def initialize(self) -> bool:
|
async def initialize(self) -> bool:
|
||||||
"""Initialize the OS info plugin.
|
"""Initialize the OS info plugin.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True (always succeeds - platform module is stdlib)
|
True (always succeeds - platform module is stdlib)
|
||||||
"""
|
"""
|
||||||
self.logger.info(f"Initializing {self.name} plugin")
|
self.logger.info(f"Initializing {self.name} plugin")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _collect_info(self) -> Dict[str, Any]:
|
async def _collect_info(self) -> Dict[str, Any]:
|
||||||
"""Collect OS information.
|
"""Collect OS information.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with OS details
|
Dictionary with OS details
|
||||||
"""
|
"""
|
||||||
@@ -65,15 +64,15 @@ class OSInfoPlugin(InfoPlugin):
|
|||||||
if self.config.get("owner"):
|
if self.config.get("owner"):
|
||||||
self.logger.debug(f"Adding owner from config: {self.config['owner']}")
|
self.logger.debug(f"Adding owner from config: {self.config['owner']}")
|
||||||
data["owner"] = self.config["owner"]
|
data["owner"] = self.config["owner"]
|
||||||
|
|
||||||
# Add Linux-specific distribution info
|
# Add Linux-specific distribution info
|
||||||
if platform.system() == "Linux":
|
if platform.system() == "Linux":
|
||||||
data.update(self._get_linux_distro())
|
data.update(self._get_linux_distro())
|
||||||
|
|
||||||
# Add macOS-specific info
|
# Add macOS-specific info
|
||||||
elif platform.system() == "Darwin":
|
elif platform.system() == "Darwin":
|
||||||
data["macos_version"] = platform.mac_ver()[0]
|
data["macos_version"] = platform.mac_ver()[0]
|
||||||
|
|
||||||
# Add Windows-specific info
|
# Add Windows-specific info
|
||||||
elif platform.system() == "Windows":
|
elif platform.system() == "Windows":
|
||||||
win_ver = platform.win32_ver()
|
win_ver = platform.win32_ver()
|
||||||
@@ -81,22 +80,22 @@ class OSInfoPlugin(InfoPlugin):
|
|||||||
data["windows_version"] = win_ver[1]
|
data["windows_version"] = win_ver[1]
|
||||||
data["windows_sp"] = win_ver[2]
|
data["windows_sp"] = win_ver[2]
|
||||||
data["windows_type"] = win_ver[3]
|
data["windows_type"] = win_ver[3]
|
||||||
|
|
||||||
self.logger.debug(f"Collected OS info: {data['system']} {data['release']}")
|
self.logger.debug(f"Collected OS info: {data['system']} {data['release']}")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error collecting OS info: {e}", exc_info=True)
|
self.logger.error(f"Error collecting OS info: {e}", exc_info=True)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _get_linux_distro(self) -> Dict[str, str]:
|
def _get_linux_distro(self) -> Dict[str, str]:
|
||||||
"""Get Linux distribution information.
|
"""Get Linux distribution information.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with distribution details
|
Dictionary with distribution details
|
||||||
"""
|
"""
|
||||||
distro_info = {}
|
distro_info = {}
|
||||||
|
|
||||||
# Try reading /etc/os-release (standard on modern Linux)
|
# Try reading /etc/os-release (standard on modern Linux)
|
||||||
os_release = Path("/etc/os-release")
|
os_release = Path("/etc/os-release")
|
||||||
if os_release.exists():
|
if os_release.exists():
|
||||||
@@ -121,7 +120,7 @@ class OSInfoPlugin(InfoPlugin):
|
|||||||
distro_info["distro_pretty_name"] = value
|
distro_info["distro_pretty_name"] = value
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Could not read /etc/os-release: {e}")
|
self.logger.warning(f"Could not read /etc/os-release: {e}")
|
||||||
|
|
||||||
# Fallback: try lsb_release (older systems)
|
# Fallback: try lsb_release (older systems)
|
||||||
elif Path("/etc/lsb-release").exists():
|
elif Path("/etc/lsb-release").exists():
|
||||||
try:
|
try:
|
||||||
@@ -138,5 +137,5 @@ class OSInfoPlugin(InfoPlugin):
|
|||||||
distro_info["distro_name"] = value
|
distro_info["distro_name"] = value
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Could not read /etc/lsb-release: {e}")
|
self.logger.warning(f"Could not read /etc/lsb-release: {e}")
|
||||||
|
|
||||||
return distro_info
|
return distro_info
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+12
-13
@@ -8,17 +8,17 @@ 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
|
||||||
|
|
||||||
|
|
||||||
def encode_value(v: Any) -> str:
|
def encode_value(v: Any) -> str:
|
||||||
"""Encode a value for protocol transmission.
|
"""Encode a value for protocol transmission.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
v: Value to encode (int, float, str, bool, list, dict, etc.)
|
v: Value to encode (int, float, str, bool, list, dict, etc.)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
String representation suitable for protocol
|
String representation suitable for protocol
|
||||||
"""
|
"""
|
||||||
@@ -35,23 +35,23 @@ def encode_value(v: Any) -> str:
|
|||||||
|
|
||||||
def decode_value(val: str) -> Any:
|
def decode_value(val: str) -> Any:
|
||||||
"""Decode a value from protocol format.
|
"""Decode a value from protocol format.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
val: String value from protocol
|
val: String value from protocol
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decoded Python object
|
Decoded Python object
|
||||||
"""
|
"""
|
||||||
if not val:
|
if not val:
|
||||||
return val
|
return val
|
||||||
|
|
||||||
# Check for JSON-encoded complex types
|
# Check for JSON-encoded complex types
|
||||||
if val.startswith("@"):
|
if val.startswith("@"):
|
||||||
try:
|
try:
|
||||||
return json.loads(val[1:])
|
return json.loads(val[1:])
|
||||||
except Exception:
|
except Exception:
|
||||||
return val[1:] # Return as string without @
|
return val[1:] # Return as string without @
|
||||||
|
|
||||||
# Try numeric conversion (avoid eval to prevent SyntaxWarnings on version strings)
|
# Try numeric conversion (avoid eval to prevent SyntaxWarnings on version strings)
|
||||||
if val[0].isdigit() or (val[0] == '-' and len(val) > 1 and val[1].isdigit()):
|
if val[0].isdigit() or (val[0] == '-' and len(val) > 1 and val[1].isdigit()):
|
||||||
try:
|
try:
|
||||||
@@ -63,7 +63,7 @@ def decode_value(val: str) -> Any:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
return val
|
return val
|
||||||
|
|
||||||
return val
|
return val
|
||||||
|
|
||||||
|
|
||||||
@@ -135,12 +135,12 @@ def oldmtodict(msg: bytes):
|
|||||||
|
|
||||||
def encode_plugin_data(plugin_name: str, data: Dict[str, Any]) -> bytes:
|
def encode_plugin_data(plugin_name: str, data: Dict[str, Any]) -> bytes:
|
||||||
"""Encode plugin data into a PLG message.
|
"""Encode plugin data into a PLG message.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_name: Name of the plugin (e.g., "os_info", "cpu_monitor")
|
plugin_name: Name of the plugin (e.g., "os_info", "cpu_monitor")
|
||||||
data: Plugin data dictionary
|
data: Plugin data dictionary
|
||||||
compress: Whether to compress the payload
|
compress: Whether to compress the payload
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Encoded message bytes
|
Encoded message bytes
|
||||||
"""
|
"""
|
||||||
@@ -151,12 +151,11 @@ def encode_plugin_data(plugin_name: str, data: Dict[str, Any]) -> bytes:
|
|||||||
|
|
||||||
def decode_plugin_data(msg: bytes) -> Dict[str, Any]:
|
def decode_plugin_data(msg: bytes) -> Dict[str, Any]:
|
||||||
"""Decode a PLG message into plugin data.
|
"""Decode a PLG message into plugin data.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
msg: Raw message bytes
|
msg: Raw message bytes
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with 'ID', 'plugin', and plugin data fields
|
Dictionary with 'ID', 'plugin', and plugin data fields
|
||||||
"""
|
"""
|
||||||
return stodict(msg)
|
return stodict(msg)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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})...")
|
||||||
|
|||||||
+33
-36
@@ -43,30 +43,30 @@ 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,
|
||||||
"wss_port": None,
|
"wss_port": None,
|
||||||
"cert_path": "/usr/local/etc/ssl/",
|
"cert_path": "/usr/local/etc/ssl/",
|
||||||
"wss_pem": "fullchain.pem",
|
"wss_pem": "fullchain.pem",
|
||||||
"wss_key": "privkey.pem",
|
"wss_key": "privkey.pem",
|
||||||
|
|
||||||
# Message journal configuration
|
# Message journal configuration
|
||||||
"journal_enabled": True,
|
"journal_enabled": True,
|
||||||
"journal_dir": "/var/log/heartbeat",
|
"journal_dir": "/var/log/heartbeat",
|
||||||
"journal_file": "messages.journal",
|
"journal_file": "messages.journal",
|
||||||
"journal_max_size": 100 * 1024 * 1024, # 100MB
|
"journal_max_size": 100 * 1024 * 1024, # 100MB
|
||||||
"journal_max_backups": 10,
|
"journal_max_backups": 10,
|
||||||
|
|
||||||
# Runtime flags
|
# Runtime flags
|
||||||
"foreground": False,
|
"foreground": False,
|
||||||
"verbose": False,
|
"verbose": False,
|
||||||
"debug": 0,
|
"debug": 0,
|
||||||
|
|
||||||
# Plugin/threshold configs (for clients reporting to this server)
|
# Plugin/threshold configs (for clients reporting to this server)
|
||||||
"plugins": {},
|
"plugins": {},
|
||||||
"thresholds": {},
|
"thresholds": {},
|
||||||
@@ -76,11 +76,11 @@ THRESHOLD_DEFAULTS = {
|
|||||||
'thresholds': {
|
'thresholds': {
|
||||||
'cpu_monitor': {
|
'cpu_monitor': {
|
||||||
'cpu_percent': {
|
'cpu_percent': {
|
||||||
'warning': 80.0,
|
'warning': 80.0,
|
||||||
'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': {
|
||||||
@@ -137,10 +137,10 @@ def load_config(path=None):
|
|||||||
"""Load configuration from a YAML file and merge with server defaults.
|
"""Load configuration from a YAML file and merge with server defaults.
|
||||||
|
|
||||||
If YAML is not available or the file does not exist, defaults are returned.
|
If YAML is not available or the file does not exist, defaults are returned.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Path to YAML config file (default: ~/.hb.yaml)
|
path: Path to YAML config file (default: ~/.hb.yaml)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with configuration
|
Dictionary with configuration
|
||||||
"""
|
"""
|
||||||
@@ -165,16 +165,16 @@ def load_config(path=None):
|
|||||||
|
|
||||||
class ReloadableConfig:
|
class ReloadableConfig:
|
||||||
"""Thread-safe/async-safe configuration wrapper that supports runtime reloading.
|
"""Thread-safe/async-safe configuration wrapper that supports runtime reloading.
|
||||||
|
|
||||||
This class wraps the configuration dictionary and provides:
|
This class wraps the configuration dictionary and provides:
|
||||||
- Thread-safe config reloading via SIGHUP
|
- Thread-safe config reloading via SIGHUP
|
||||||
- Backward-compatible dict-like access
|
- Backward-compatible dict-like access
|
||||||
- Async lock to prevent concurrent reloads
|
- Async lock to prevent concurrent reloads
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, initial_config, config_path=None):
|
def __init__(self, initial_config, config_path=None):
|
||||||
"""Initialize with initial configuration.
|
"""Initialize with initial configuration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
initial_config: Initial configuration dictionary
|
initial_config: Initial configuration dictionary
|
||||||
config_path: Path to config file for reloading (optional)
|
config_path: Path to config file for reloading (optional)
|
||||||
@@ -183,65 +183,62 @@ class ReloadableConfig:
|
|||||||
self._config_path = config_path
|
self._config_path = config_path
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._logger = logging.getLogger(__name__)
|
self._logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
async def reload(self, config_path=None):
|
async def reload(self, config_path=None):
|
||||||
"""Reload configuration from file.
|
"""Reload configuration from file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config_path: Path to config file (uses stored path if not provided)
|
config_path: Path to config file (uses stored path if not provided)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
New configuration dictionary
|
New configuration dictionary
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception if reload fails (keeps existing config)
|
Exception if reload fails (keeps existing config)
|
||||||
"""
|
"""
|
||||||
path = config_path or self._config_path
|
path = config_path or self._config_path
|
||||||
if not path:
|
if not path:
|
||||||
raise ValueError("No config path specified for reload")
|
raise ValueError("No config path specified for reload")
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
try:
|
try:
|
||||||
# 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}")
|
||||||
|
|
||||||
return new_config
|
return new_config
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._logger.error(f"Failed to reload config from {path}: {e}", exc_info=True)
|
self._logger.error(f"Failed to reload config from {path}: {e}", exc_info=True)
|
||||||
# Keep existing config on error
|
# Keep existing config on error
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def get(self, key, default=None):
|
def get(self, key, default=None):
|
||||||
"""Get a config value (dict-compatible)."""
|
"""Get a config value (dict-compatible)."""
|
||||||
return self._config.get(key, default)
|
return self._config.get(key, default)
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
"""Get a config value via subscript (dict-compatible)."""
|
"""Get a config value via subscript (dict-compatible)."""
|
||||||
return self._config[key]
|
return self._config[key]
|
||||||
|
|
||||||
def __contains__(self, key):
|
def __contains__(self, key):
|
||||||
"""Check if key exists (dict-compatible)."""
|
"""Check if key exists (dict-compatible)."""
|
||||||
return key in self._config
|
return key in self._config
|
||||||
|
|
||||||
def keys(self):
|
def keys(self):
|
||||||
"""Return config keys (dict-compatible)."""
|
"""Return config keys (dict-compatible)."""
|
||||||
return self._config.keys()
|
return self._config.keys()
|
||||||
|
|
||||||
def items(self):
|
def items(self):
|
||||||
"""Return config items (dict-compatible)."""
|
"""Return config items (dict-compatible)."""
|
||||||
return self._config.items()
|
return self._config.items()
|
||||||
|
|
||||||
def values(self):
|
def values(self):
|
||||||
"""Return config values (dict-compatible)."""
|
"""Return config values (dict-compatible)."""
|
||||||
return self._config.values()
|
return self._config.values()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def config(self):
|
def config(self):
|
||||||
"""Get the underlying config dict (for components that need full dict)."""
|
"""Get the underlying config dict (for components that need full dict)."""
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+5
-3
@@ -1,12 +1,14 @@
|
|||||||
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):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.data = {}
|
self.data = {}
|
||||||
|
|
||||||
def update(self, new_data):
|
def update(self, new_data):
|
||||||
self.data.update(new_data)
|
self.data.update(new_data)
|
||||||
|
|
||||||
def get(self, key, default=None):
|
def get(self, key, default=None):
|
||||||
return self.data.get(key, default)
|
return self.data.get(key, default)
|
||||||
|
|||||||
+34
-34
@@ -43,7 +43,7 @@ class Connection:
|
|||||||
self.statetime = self.lastbeat
|
self.statetime = self.lastbeat
|
||||||
self.deltastatetime = "computed"
|
self.deltastatetime = "computed"
|
||||||
self.state = Connection.UNKNOWN
|
self.state = Connection.UNKNOWN
|
||||||
|
|
||||||
# Timer-based reachability monitoring
|
# Timer-based reachability monitoring
|
||||||
self.overdue_timer = None
|
self.overdue_timer = None
|
||||||
self.overdue_callback = None
|
self.overdue_callback = None
|
||||||
@@ -196,39 +196,39 @@ class Connection:
|
|||||||
if self.host.isDynDns():
|
if self.host.isDynDns():
|
||||||
Host.dnsQ.put((self.host.name, self.addr))
|
Host.dnsQ.put((self.host.name, self.addr))
|
||||||
return r
|
return r
|
||||||
|
|
||||||
def reset_overdue_timer(self, timeout_seconds, callback):
|
def reset_overdue_timer(self, timeout_seconds, callback):
|
||||||
"""Reset the overdue timer for this connection.
|
"""Reset the overdue timer for this connection.
|
||||||
|
|
||||||
Cancels any existing timer and sets a new one that will mark
|
Cancels any existing timer and sets a new one that will mark
|
||||||
the connection as overdue if no heartbeat arrives before timeout.
|
the connection as overdue if no heartbeat arrives before timeout.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
timeout_seconds: Seconds before marking as overdue
|
timeout_seconds: Seconds before marking as overdue
|
||||||
callback: Async function to call when timer expires
|
callback: Async function to call when timer expires
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
# Cancel existing timer if any
|
# Cancel existing timer if any
|
||||||
if self.overdue_timer and not self.overdue_timer.cancelled():
|
if self.overdue_timer and not self.overdue_timer.cancelled():
|
||||||
self.overdue_timer.cancel()
|
self.overdue_timer.cancel()
|
||||||
|
|
||||||
# Store parameters for later reference
|
# Store parameters for later reference
|
||||||
self.timeout_duration = timeout_seconds
|
self.timeout_duration = timeout_seconds
|
||||||
self.overdue_callback = callback
|
self.overdue_callback = callback
|
||||||
|
|
||||||
# Create new timer
|
# Create new timer
|
||||||
async def timer_expired():
|
async def timer_expired():
|
||||||
await callback(self)
|
await callback(self)
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
def cancel_overdue_timer(self):
|
def cancel_overdue_timer(self):
|
||||||
"""Cancel the overdue timer if it exists and clear all timer references."""
|
"""Cancel the overdue timer if it exists and clear all timer references."""
|
||||||
if self.overdue_timer:
|
if self.overdue_timer:
|
||||||
@@ -241,37 +241,37 @@ class Connection:
|
|||||||
self.overdue_timer = None
|
self.overdue_timer = None
|
||||||
self.overdue_callback = None
|
self.overdue_callback = None
|
||||||
self.timeout_duration = None
|
self.timeout_duration = None
|
||||||
|
|
||||||
def get_avg_rtt(self):
|
def get_avg_rtt(self):
|
||||||
"""Get average RTT from recent samples."""
|
"""Get average RTT from recent samples."""
|
||||||
valid_rtts = [r for r in self.rtts if r > 0]
|
valid_rtts = [r for r in self.rtts if r > 0]
|
||||||
if valid_rtts:
|
if valid_rtts:
|
||||||
return sum(valid_rtts) / len(valid_rtts)
|
return sum(valid_rtts) / len(valid_rtts)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def get_current_rtt(self):
|
def get_current_rtt(self):
|
||||||
"""Get most recent RTT value."""
|
"""Get most recent RTT value."""
|
||||||
return self.rtts[-1] if self.rtts else 0
|
return self.rtts[-1] if self.rtts else 0
|
||||||
|
|
||||||
def check_rtt_threshold(self, warning_threshold=None, critical_threshold=None):
|
def check_rtt_threshold(self, warning_threshold=None, critical_threshold=None):
|
||||||
"""Check if RTT exceeds thresholds.
|
"""Check if RTT exceeds thresholds.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
warning_threshold: RTT in ms for warning level
|
warning_threshold: RTT in ms for warning level
|
||||||
critical_threshold: RTT in ms for critical level
|
critical_threshold: RTT in ms for critical level
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (level, rtt_value) where level is None, 'WARNING', or 'CRITICAL'
|
Tuple of (level, rtt_value) where level is None, 'WARNING', or 'CRITICAL'
|
||||||
"""
|
"""
|
||||||
rtt = self.get_current_rtt()
|
rtt = self.get_current_rtt()
|
||||||
if rtt <= 0:
|
if rtt <= 0:
|
||||||
return (None, rtt)
|
return (None, rtt)
|
||||||
|
|
||||||
if critical_threshold and rtt > critical_threshold:
|
if critical_threshold and rtt > critical_threshold:
|
||||||
return ('CRITICAL', rtt)
|
return ('CRITICAL', rtt)
|
||||||
elif warning_threshold and rtt > warning_threshold:
|
elif warning_threshold and rtt > warning_threshold:
|
||||||
return ('WARNING', rtt)
|
return ('WARNING', rtt)
|
||||||
|
|
||||||
return (None, rtt)
|
return (None, rtt)
|
||||||
|
|
||||||
|
|
||||||
@@ -328,7 +328,7 @@ class Host:
|
|||||||
d["dyn"] = str(self.dyn)
|
d["dyn"] = str(self.dyn)
|
||||||
d["num"] = self.num
|
d["num"] = self.num
|
||||||
d["status"] = self.status()
|
d["status"] = self.status()
|
||||||
|
|
||||||
# Add alert counts (split by acknowledged status)
|
# Add alert counts (split by acknowledged status)
|
||||||
warning_unacked = 0
|
warning_unacked = 0
|
||||||
warning_acked = 0
|
warning_acked = 0
|
||||||
@@ -347,12 +347,12 @@ class Host:
|
|||||||
critical_acked += 1
|
critical_acked += 1
|
||||||
else:
|
else:
|
||||||
critical_unacked += 1
|
critical_unacked += 1
|
||||||
|
|
||||||
d["alert_warning_unacked"] = warning_unacked
|
d["alert_warning_unacked"] = warning_unacked
|
||||||
d["alert_warning_acked"] = warning_acked
|
d["alert_warning_acked"] = warning_acked
|
||||||
d["alert_critical_unacked"] = critical_unacked
|
d["alert_critical_unacked"] = critical_unacked
|
||||||
d["alert_critical_acked"] = critical_acked
|
d["alert_critical_acked"] = critical_acked
|
||||||
|
|
||||||
for c in ["IPv4", "IPv6"]:
|
for c in ["IPv4", "IPv6"]:
|
||||||
if c in self.connections:
|
if c in self.connections:
|
||||||
cs = self.connections[c].statedict()
|
cs = self.connections[c].statedict()
|
||||||
@@ -409,7 +409,7 @@ class Host:
|
|||||||
ddict[d] = cl
|
ddict[d] = cl
|
||||||
else:
|
else:
|
||||||
ddict[d] = self.__dict__[d]
|
ddict[d] = self.__dict__[d]
|
||||||
|
|
||||||
# Add alert counts (computed from alert_states)
|
# Add alert counts (computed from alert_states)
|
||||||
warning_unacked = 0
|
warning_unacked = 0
|
||||||
warning_acked = 0
|
warning_acked = 0
|
||||||
@@ -428,7 +428,7 @@ class Host:
|
|||||||
critical_acked += 1
|
critical_acked += 1
|
||||||
else:
|
else:
|
||||||
critical_unacked += 1
|
critical_unacked += 1
|
||||||
|
|
||||||
ddict["alert_warning_unacked"] = warning_unacked
|
ddict["alert_warning_unacked"] = warning_unacked
|
||||||
ddict["alert_warning_acked"] = warning_acked
|
ddict["alert_warning_acked"] = warning_acked
|
||||||
ddict["alert_critical_unacked"] = critical_unacked
|
ddict["alert_critical_unacked"] = critical_unacked
|
||||||
@@ -554,7 +554,7 @@ class Host:
|
|||||||
|
|
||||||
def add_plugin_data(self, plugin_name, data, timestamp=None):
|
def add_plugin_data(self, plugin_name, data, timestamp=None):
|
||||||
"""Store plugin data with timestamp.
|
"""Store plugin data with timestamp.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_name: Name of the plugin (e.g., "cpu_monitor")
|
plugin_name: Name of the plugin (e.g., "cpu_monitor")
|
||||||
data: Dict of plugin data
|
data: Dict of plugin data
|
||||||
@@ -562,24 +562,24 @@ class Host:
|
|||||||
"""
|
"""
|
||||||
if timestamp is None:
|
if timestamp is None:
|
||||||
timestamp = time.time()
|
timestamp = time.time()
|
||||||
|
|
||||||
if plugin_name not in self.plugin_data:
|
if plugin_name not in self.plugin_data:
|
||||||
self.plugin_data[plugin_name] = []
|
self.plugin_data[plugin_name] = []
|
||||||
|
|
||||||
# Add new data
|
# Add new data
|
||||||
self.plugin_data[plugin_name].append((timestamp, data))
|
self.plugin_data[plugin_name].append((timestamp, data))
|
||||||
|
|
||||||
# Enforce retention limit (keep last N samples)
|
# Enforce retention limit (keep last N samples)
|
||||||
if len(self.plugin_data[plugin_name]) > self.plugin_retention:
|
if len(self.plugin_data[plugin_name]) > self.plugin_retention:
|
||||||
self.plugin_data[plugin_name] = self.plugin_data[plugin_name][-self.plugin_retention:]
|
self.plugin_data[plugin_name] = self.plugin_data[plugin_name][-self.plugin_retention:]
|
||||||
|
|
||||||
def get_plugin_data(self, plugin_name, limit=None):
|
def get_plugin_data(self, plugin_name, limit=None):
|
||||||
"""Retrieve plugin data for a specific plugin.
|
"""Retrieve plugin data for a specific plugin.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_name: Name of the plugin
|
plugin_name: Name of the plugin
|
||||||
limit: Optional limit on number of recent samples to return
|
limit: Optional limit on number of recent samples to return
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of (timestamp, data) tuples, most recent last
|
List of (timestamp, data) tuples, most recent last
|
||||||
"""
|
"""
|
||||||
@@ -587,19 +587,19 @@ class Host:
|
|||||||
if limit and len(data) > limit:
|
if limit and len(data) > limit:
|
||||||
return data[-limit:]
|
return data[-limit:]
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def get_latest_plugin_data(self, plugin_name):
|
def get_latest_plugin_data(self, plugin_name):
|
||||||
"""Get the most recent plugin data for a plugin.
|
"""Get the most recent plugin data for a plugin.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_name: Name of the plugin
|
plugin_name: Name of the plugin
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(timestamp, data) tuple or None if no data
|
(timestamp, data) tuple or None if no data
|
||||||
"""
|
"""
|
||||||
data = self.plugin_data.get(plugin_name, [])
|
data = self.plugin_data.get(plugin_name, [])
|
||||||
return data[-1] if data else None
|
return data[-1] if data else None
|
||||||
|
|
||||||
def get_all_plugin_data(self):
|
def get_all_plugin_data(self):
|
||||||
"""Get all plugin data for this host.
|
"""Get all plugin data for this host.
|
||||||
|
|
||||||
|
|||||||
+22
-22
@@ -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)
|
||||||
@@ -563,7 +564,7 @@ async def start(
|
|||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
hostname = request.match_info.get("hostname")
|
hostname = request.match_info.get("hostname")
|
||||||
|
|
||||||
if hostname not in hbdclass.Host.hosts:
|
if hostname not in hbdclass.Host.hosts:
|
||||||
return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
|
return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
|
||||||
|
|
||||||
@@ -582,7 +583,7 @@ async def start(
|
|||||||
"data": data,
|
"data": data,
|
||||||
"sample_count": len(samples),
|
"sample_count": len(samples),
|
||||||
}
|
}
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"hostname": hostname,
|
"hostname": hostname,
|
||||||
"plugins": plugins_summary,
|
"plugins": plugins_summary,
|
||||||
@@ -602,23 +603,23 @@ async def start(
|
|||||||
host = hbdclass.Host.hosts[hostname]
|
host = hbdclass.Host.hosts[hostname]
|
||||||
if not _can_view_host(user, host):
|
if not _can_view_host(user, host):
|
||||||
return web.json_response({"error": "Forbidden"}, status=403)
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
|
|
||||||
# Get limit from query parameter
|
# Get limit from query parameter
|
||||||
limit = request.rel_url.query.get("limit", "10")
|
limit = request.rel_url.query.get("limit", "10")
|
||||||
try:
|
try:
|
||||||
limit = int(limit)
|
limit = int(limit)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
limit = 10
|
limit = 10
|
||||||
|
|
||||||
# Get plugin data
|
# Get plugin data
|
||||||
samples = host.get_plugin_data(plugin_name, limit=limit)
|
samples = host.get_plugin_data(plugin_name, limit=limit)
|
||||||
|
|
||||||
if not samples:
|
if not samples:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": f"No data for plugin '{plugin_name}' on host '{hostname}'"},
|
{"error": f"No data for plugin '{plugin_name}' on host '{hostname}'"},
|
||||||
status=404
|
status=404
|
||||||
)
|
)
|
||||||
|
|
||||||
# Format samples
|
# Format samples
|
||||||
formatted_samples = [
|
formatted_samples = [
|
||||||
{
|
{
|
||||||
@@ -627,7 +628,7 @@ async def start(
|
|||||||
}
|
}
|
||||||
for ts, data in samples
|
for ts, data in samples
|
||||||
]
|
]
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"hostname": hostname,
|
"hostname": hostname,
|
||||||
"plugin": plugin_name,
|
"plugin": plugin_name,
|
||||||
@@ -648,17 +649,17 @@ async def start(
|
|||||||
host = hbdclass.Host.hosts[hostname]
|
host = hbdclass.Host.hosts[hostname]
|
||||||
if not _can_view_host(user, host):
|
if not _can_view_host(user, host):
|
||||||
return web.json_response({"error": "Forbidden"}, status=403)
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
|
|
||||||
# Get alert states
|
# Get alert states
|
||||||
alerts = []
|
alerts = []
|
||||||
for metric_path, alert_state in host.alert_states.items():
|
for metric_path, alert_state in host.alert_states.items():
|
||||||
alerts.append(alert_state.to_dict())
|
alerts.append(alert_state.to_dict())
|
||||||
|
|
||||||
# Get summary if threshold_checker available
|
# Get summary if threshold_checker available
|
||||||
summary = {"ok": 0, "warning": 0, "critical": 0, "unknown": 0}
|
summary = {"ok": 0, "warning": 0, "critical": 0, "unknown": 0}
|
||||||
if threshold_checker:
|
if threshold_checker:
|
||||||
summary = threshold_checker.get_alert_summary(host.alert_states)
|
summary = threshold_checker.get_alert_summary(host.alert_states)
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"hostname": hostname,
|
"hostname": hostname,
|
||||||
"alerts": alerts,
|
"alerts": alerts,
|
||||||
@@ -684,31 +685,31 @@ async def start(
|
|||||||
state for state in host.alert_states.values()
|
state for state in host.alert_states.values()
|
||||||
if state.level != AlertLevel.OK
|
if state.level != AlertLevel.OK
|
||||||
]
|
]
|
||||||
|
|
||||||
for alert in active_alerts:
|
for alert in active_alerts:
|
||||||
alert_dict = alert.to_dict()
|
alert_dict = alert.to_dict()
|
||||||
alert_dict["hostname"] = hostname
|
alert_dict["hostname"] = hostname
|
||||||
all_alerts.append(alert_dict)
|
all_alerts.append(alert_dict)
|
||||||
|
|
||||||
# Sort by level (critical first) then by hostname
|
# Sort by level (critical first) then by hostname
|
||||||
level_order = {"CRITICAL": 0, "WARNING": 1, "UNKNOWN": 2, "OK": 3}
|
level_order = {"CRITICAL": 0, "WARNING": 1, "UNKNOWN": 2, "OK": 3}
|
||||||
all_alerts.sort(
|
all_alerts.sort(
|
||||||
key=lambda a: (level_order.get(a["level"], 99), a["hostname"], a["metric_path"])
|
key=lambda a: (level_order.get(a["level"], 99), a["hostname"], a["metric_path"])
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get summary counts
|
# Get summary counts
|
||||||
summary = {"critical": 0, "warning": 0, "unknown": 0, "total": len(all_alerts)}
|
summary = {"critical": 0, "warning": 0, "unknown": 0, "total": len(all_alerts)}
|
||||||
for alert in all_alerts:
|
for alert in all_alerts:
|
||||||
level = alert["level"].lower()
|
level = alert["level"].lower()
|
||||||
if level in summary:
|
if level in summary:
|
||||||
summary[level] += 1
|
summary[level] += 1
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"alerts": all_alerts,
|
"alerts": all_alerts,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"host_count": len(hbdclass.Host.hosts),
|
"host_count": len(hbdclass.Host.hosts),
|
||||||
})
|
})
|
||||||
|
|
||||||
async def api_acknowledge_alert(request):
|
async def api_acknowledge_alert(request):
|
||||||
"""Acknowledge an alert to stop reminder notifications."""
|
"""Acknowledge an alert to stop reminder notifications."""
|
||||||
user, err = _require_auth(request)
|
user, err = _require_auth(request)
|
||||||
@@ -721,22 +722,22 @@ async def start(
|
|||||||
{"error": "Invalid JSON in request body"},
|
{"error": "Invalid JSON in request body"},
|
||||||
status=400
|
status=400
|
||||||
)
|
)
|
||||||
|
|
||||||
hostname = data.get("hostname")
|
hostname = data.get("hostname")
|
||||||
metric_path = data.get("metric_path")
|
metric_path = data.get("metric_path")
|
||||||
|
|
||||||
if not hostname or not metric_path:
|
if not hostname or not metric_path:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "Missing required fields: hostname and metric_path"},
|
{"error": "Missing required fields: hostname and metric_path"},
|
||||||
status=400
|
status=400
|
||||||
)
|
)
|
||||||
|
|
||||||
if hostname not in hbdclass.Host.hosts:
|
if hostname not in hbdclass.Host.hosts:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": f"Host '{hostname}' not found"},
|
{"error": f"Host '{hostname}' not found"},
|
||||||
status=404
|
status=404
|
||||||
)
|
)
|
||||||
|
|
||||||
host = hbdclass.Host.hosts[hostname]
|
host = hbdclass.Host.hosts[hostname]
|
||||||
if not _can_view_host(user, host):
|
if not _can_view_host(user, host):
|
||||||
return web.json_response({"error": "Forbidden"}, status=403)
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
@@ -746,10 +747,10 @@ async def start(
|
|||||||
{"error": f"Alert '{metric_path}' not found for host '{hostname}'"},
|
{"error": f"Alert '{metric_path}' not found for host '{hostname}'"},
|
||||||
status=404
|
status=404
|
||||||
)
|
)
|
||||||
|
|
||||||
alert_state = host.alert_states[metric_path]
|
alert_state = host.alert_states[metric_path]
|
||||||
alert_state.acknowledge()
|
alert_state.acknowledge()
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"success": True,
|
"success": True,
|
||||||
"hostname": hostname,
|
"hostname": hostname,
|
||||||
@@ -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:
|
||||||
|
|||||||
+52
-52
@@ -19,14 +19,14 @@ logger = logging.getLogger(__name__)
|
|||||||
class MessageJournal:
|
class MessageJournal:
|
||||||
"""
|
"""
|
||||||
Journal logger for heartbeat messages with size-based rotation.
|
Journal logger for heartbeat messages with size-based rotation.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Logs all received messages in JSON format
|
- Logs all received messages in JSON format
|
||||||
- Automatic rotation when file size exceeds threshold
|
- Automatic rotation when file size exceeds threshold
|
||||||
- Keeps configurable number of rotated logs
|
- Keeps configurable number of rotated logs
|
||||||
- Thread-safe and async-safe operation
|
- Thread-safe and async-safe operation
|
||||||
- Configurable log directory and file naming
|
- Configurable log directory and file naming
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
journal_dir: Directory for journal files (default: /var/log/heartbeat/)
|
journal_dir: Directory for journal files (default: /var/log/heartbeat/)
|
||||||
journal_file: Base filename (default: messages.journal)
|
journal_file: Base filename (default: messages.journal)
|
||||||
@@ -34,69 +34,69 @@ class MessageJournal:
|
|||||||
max_backups: Number of backup files to keep (default: 10)
|
max_backups: Number of backup files to keep (default: 10)
|
||||||
enabled: Enable/disable journaling (default: True)
|
enabled: Enable/disable journaling (default: True)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
"""
|
"""
|
||||||
Initialize the message journal.
|
Initialize the message journal.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Configuration dictionary with journal settings
|
config: Configuration dictionary with journal settings
|
||||||
"""
|
"""
|
||||||
self.config = config or {}
|
self.config = config or {}
|
||||||
|
|
||||||
# Configuration options
|
# Configuration options
|
||||||
self.journal_dir = Path(self.config.get('journal_dir', '/var/log/heartbeat'))
|
self.journal_dir = Path(self.config.get('journal_dir', '/var/log/heartbeat'))
|
||||||
self.journal_file = self.config.get('journal_file', 'messages.journal')
|
self.journal_file = self.config.get('journal_file', 'messages.journal')
|
||||||
self.max_size = self.config.get('journal_max_size', 100 * 1024 * 1024) # 100MB default
|
self.max_size = self.config.get('journal_max_size', 100 * 1024 * 1024) # 100MB default
|
||||||
self.max_backups = self.config.get('journal_max_backups', 10)
|
self.max_backups = self.config.get('journal_max_backups', 10)
|
||||||
self.enabled = self.config.get('journal_enabled', True)
|
self.enabled = self.config.get('journal_enabled', True)
|
||||||
|
|
||||||
# Runtime state
|
# Runtime state
|
||||||
self._file_handle = None
|
self._file_handle = None
|
||||||
self._current_size = 0
|
self._current_size = 0
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
|
|
||||||
# Full path to current journal file
|
# Full path to current journal file
|
||||||
self.journal_path = self.journal_dir / self.journal_file
|
self.journal_path = self.journal_dir / self.journal_file
|
||||||
|
|
||||||
async def initialize(self) -> bool:
|
async def initialize(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Initialize the journal.
|
Initialize the journal.
|
||||||
|
|
||||||
Creates journal directory if needed and opens the journal file.
|
Creates journal directory if needed and opens the journal file.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if initialization successful, False otherwise
|
True if initialization successful, False otherwise
|
||||||
"""
|
"""
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
logger.info("Message journal disabled in configuration")
|
logger.info("Message journal disabled in configuration")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create journal directory if it doesn't exist
|
# Create journal directory if it doesn't exist
|
||||||
self.journal_dir.mkdir(parents=True, exist_ok=True)
|
self.journal_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Open journal file in append mode
|
# Open journal file in append mode
|
||||||
self._file_handle = open(self.journal_path, 'a', encoding='utf-8')
|
self._file_handle = open(self.journal_path, 'a', encoding='utf-8')
|
||||||
|
|
||||||
# Get current file size
|
# Get current file size
|
||||||
try:
|
try:
|
||||||
self._current_size = os.path.getsize(self.journal_path)
|
self._current_size = os.path.getsize(self.journal_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
self._current_size = 0
|
self._current_size = 0
|
||||||
|
|
||||||
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:
|
||||||
logger.error(f"Failed to initialize message journal: {e}")
|
logger.error(f"Failed to initialize message journal: {e}")
|
||||||
self.enabled = False
|
self.enabled = False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def log_message(
|
async def log_message(
|
||||||
self,
|
self,
|
||||||
msg: Dict[str, Any],
|
msg: Dict[str, Any],
|
||||||
@@ -105,7 +105,7 @@ class MessageJournal:
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Log a received message to the journal.
|
Log a received message to the journal.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
msg: Parsed message dictionary
|
msg: Parsed message dictionary
|
||||||
addr: Source address (ip, port) tuple
|
addr: Source address (ip, port) tuple
|
||||||
@@ -113,19 +113,19 @@ class MessageJournal:
|
|||||||
"""
|
"""
|
||||||
if not self.enabled or not self._initialized:
|
if not self.enabled or not self._initialized:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Skip HTB (heartbeat) messages - too verbose
|
# Skip HTB (heartbeat) messages - too verbose
|
||||||
msg_id = msg.get('ID', '')
|
msg_id = msg.get('ID', '')
|
||||||
if msg_id == 'HTB':
|
if msg_id == 'HTB':
|
||||||
return
|
return
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
try:
|
try:
|
||||||
# Prepare journal entry
|
# Prepare journal entry
|
||||||
if timestamp is None:
|
if timestamp is None:
|
||||||
import time
|
import time
|
||||||
timestamp = time.time()
|
timestamp = time.time()
|
||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
'timestamp': timestamp,
|
'timestamp': timestamp,
|
||||||
'datetime': datetime.fromtimestamp(timestamp).isoformat(),
|
'datetime': datetime.fromtimestamp(timestamp).isoformat(),
|
||||||
@@ -133,21 +133,21 @@ class MessageJournal:
|
|||||||
'source_port': addr[1] if isinstance(addr, (tuple, list)) and len(addr) > 1 else None,
|
'source_port': addr[1] if isinstance(addr, (tuple, list)) and len(addr) > 1 else None,
|
||||||
'message': msg
|
'message': msg
|
||||||
}
|
}
|
||||||
|
|
||||||
# Serialize to JSON (one line per entry)
|
# Serialize to JSON (one line per entry)
|
||||||
json_line = json.dumps(entry, separators=(',', ':')) + '\n'
|
json_line = json.dumps(entry, separators=(',', ':')) + '\n'
|
||||||
json_bytes = json_line.encode('utf-8')
|
json_bytes = json_line.encode('utf-8')
|
||||||
|
|
||||||
# Check if rotation is needed
|
# Check if rotation is needed
|
||||||
if self._current_size + len(json_bytes) > self.max_size:
|
if self._current_size + len(json_bytes) > self.max_size:
|
||||||
await self._rotate()
|
await self._rotate()
|
||||||
|
|
||||||
# Write to journal
|
# Write to journal
|
||||||
if self._file_handle:
|
if self._file_handle:
|
||||||
self._file_handle.write(json_line)
|
self._file_handle.write(json_line)
|
||||||
self._file_handle.flush() # Ensure data is written
|
self._file_handle.flush() # Ensure data is written
|
||||||
self._current_size += len(json_bytes)
|
self._current_size += len(json_bytes)
|
||||||
|
|
||||||
logger.debug(f"Logged message from {addr[0]}: {msg.get('ID', 'UNKNOWN')}")
|
logger.debug(f"Logged message from {addr[0]}: {msg.get('ID', 'UNKNOWN')}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -181,7 +181,7 @@ class MessageJournal:
|
|||||||
async def _rotate(self):
|
async def _rotate(self):
|
||||||
"""
|
"""
|
||||||
Rotate the journal file.
|
Rotate the journal file.
|
||||||
|
|
||||||
Renames current file with timestamp, opens new file, and removes
|
Renames current file with timestamp, opens new file, and removes
|
||||||
old backups exceeding max_backups limit.
|
old backups exceeding max_backups limit.
|
||||||
"""
|
"""
|
||||||
@@ -190,25 +190,25 @@ class MessageJournal:
|
|||||||
if self._file_handle:
|
if self._file_handle:
|
||||||
self._file_handle.close()
|
self._file_handle.close()
|
||||||
self._file_handle = None
|
self._file_handle = None
|
||||||
|
|
||||||
# Generate backup filename with timestamp
|
# Generate backup filename with timestamp
|
||||||
timestamp_str = datetime.now().strftime('%Y%m%d-%H%M%S')
|
timestamp_str = datetime.now().strftime('%Y%m%d-%H%M%S')
|
||||||
backup_name = f"{self.journal_file}.{timestamp_str}"
|
backup_name = f"{self.journal_file}.{timestamp_str}"
|
||||||
backup_path = self.journal_dir / backup_name
|
backup_path = self.journal_dir / backup_name
|
||||||
|
|
||||||
# Rename current file to backup
|
# Rename current file to backup
|
||||||
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')
|
||||||
self._current_size = 0
|
self._current_size = 0
|
||||||
|
|
||||||
# Clean up old backups
|
# Clean up old backups
|
||||||
await self._cleanup_old_backups()
|
await self._cleanup_old_backups()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error rotating journal: {e}")
|
logger.error(f"Error rotating journal: {e}")
|
||||||
# Try to reopen the file even if rotation failed
|
# Try to reopen the file even if rotation failed
|
||||||
@@ -217,18 +217,18 @@ class MessageJournal:
|
|||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
logger.error(f"Failed to reopen journal after rotation error: {e2}")
|
logger.error(f"Failed to reopen journal after rotation error: {e2}")
|
||||||
self.enabled = False
|
self.enabled = False
|
||||||
|
|
||||||
async def _cleanup_old_backups(self):
|
async def _cleanup_old_backups(self):
|
||||||
"""
|
"""
|
||||||
Remove old backup files exceeding max_backups limit.
|
Remove old backup files exceeding max_backups limit.
|
||||||
|
|
||||||
Keeps only the most recent backups based on filename (which includes timestamp).
|
Keeps only the most recent backups based on filename (which includes timestamp).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Find all backup files
|
# Find all backup files
|
||||||
backup_pattern = f"{self.journal_file}.*"
|
backup_pattern = f"{self.journal_file}.*"
|
||||||
backup_files = sorted(self.journal_dir.glob(backup_pattern))
|
backup_files = sorted(self.journal_dir.glob(backup_pattern))
|
||||||
|
|
||||||
# Remove oldest backups if we have too many
|
# Remove oldest backups if we have too many
|
||||||
if len(backup_files) > self.max_backups:
|
if len(backup_files) > self.max_backups:
|
||||||
files_to_remove = backup_files[:len(backup_files) - self.max_backups]
|
files_to_remove = backup_files[:len(backup_files) - self.max_backups]
|
||||||
@@ -238,10 +238,10 @@ class MessageJournal:
|
|||||||
logger.info(f"Removed old backup: {backup_file.name}")
|
logger.info(f"Removed old backup: {backup_file.name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to remove old backup {backup_file}: {e}")
|
logger.warning(f"Failed to remove old backup {backup_file}: {e}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error cleaning up old backups: {e}")
|
logger.error(f"Error cleaning up old backups: {e}")
|
||||||
|
|
||||||
async def log_threshold_event(
|
async def log_threshold_event(
|
||||||
self,
|
self,
|
||||||
host_name: str,
|
host_name: str,
|
||||||
@@ -253,7 +253,7 @@ class MessageJournal:
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Log a threshold state change event.
|
Log a threshold state change event.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
host_name: Name of the host
|
host_name: Name of the host
|
||||||
metric_path: Full metric path (e.g., "cpu_monitor.cpu_percent")
|
metric_path: Full metric path (e.g., "cpu_monitor.cpu_percent")
|
||||||
@@ -264,11 +264,11 @@ class MessageJournal:
|
|||||||
"""
|
"""
|
||||||
if not self.enabled or not self._initialized:
|
if not self.enabled or not self._initialized:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if timestamp is None:
|
if timestamp is None:
|
||||||
timestamp = __import__('time').time()
|
timestamp = __import__('time').time()
|
||||||
|
|
||||||
event = {
|
event = {
|
||||||
'timestamp': timestamp,
|
'timestamp': timestamp,
|
||||||
'iso_time': datetime.fromtimestamp(timestamp).isoformat(),
|
'iso_time': datetime.fromtimestamp(timestamp).isoformat(),
|
||||||
@@ -279,30 +279,30 @@ class MessageJournal:
|
|||||||
'new_level': new_level,
|
'new_level': new_level,
|
||||||
'value': value,
|
'value': value,
|
||||||
}
|
}
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if not self._file_handle:
|
if not self._file_handle:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if rotation is needed
|
# Check if rotation is needed
|
||||||
if self._current_size >= self.max_size:
|
if self._current_size >= self.max_size:
|
||||||
await self._rotate()
|
await self._rotate()
|
||||||
|
|
||||||
# Write event
|
# Write event
|
||||||
line = json.dumps(event) + '\n'
|
line = json.dumps(event) + '\n'
|
||||||
self._file_handle.write(line)
|
self._file_handle.write(line)
|
||||||
self._file_handle.flush()
|
self._file_handle.flush()
|
||||||
|
|
||||||
# Update size
|
# Update size
|
||||||
self._current_size += len(line.encode('utf-8'))
|
self._current_size += len(line.encode('utf-8'))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error logging threshold event: {e}")
|
logger.error(f"Error logging threshold event: {e}")
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
"""
|
"""
|
||||||
Close the journal and release resources.
|
Close the journal and release resources.
|
||||||
|
|
||||||
Should be called during shutdown.
|
Should be called during shutdown.
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
@@ -315,11 +315,11 @@ class MessageJournal:
|
|||||||
finally:
|
finally:
|
||||||
self._file_handle = None
|
self._file_handle = None
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
|
|
||||||
def get_stats(self) -> Dict[str, Any]:
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get journal statistics.
|
Get journal statistics.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with journal stats
|
Dictionary with journal stats
|
||||||
"""
|
"""
|
||||||
@@ -423,10 +423,10 @@ _journal_instance: Optional[MessageJournal] = None
|
|||||||
def get_journal(config: Optional[Dict[str, Any]] = None) -> MessageJournal:
|
def get_journal(config: Optional[Dict[str, Any]] = None) -> MessageJournal:
|
||||||
"""
|
"""
|
||||||
Get or create the global journal instance.
|
Get or create the global journal instance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Configuration dictionary (only used on first call)
|
config: Configuration dictionary (only used on first call)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
MessageJournal instance
|
MessageJournal instance
|
||||||
"""
|
"""
|
||||||
|
|||||||
+19
-20
@@ -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
|
||||||
@@ -68,21 +67,21 @@ def cleanup_function(config, hbdclass):
|
|||||||
|
|
||||||
async def reload_configuration(config_obj, config_path, components):
|
async def reload_configuration(config_obj, config_path, components):
|
||||||
"""Reload configuration and update all components.
|
"""Reload configuration and update all components.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config_obj: ReloadableConfig instance
|
config_obj: ReloadableConfig instance
|
||||||
config_path: Path to config file
|
config_path: Path to config file
|
||||||
components: Dict with threshold_checker and other components
|
components: Dict with threshold_checker and other components
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if reload succeeded, False otherwise
|
True if reload succeeded, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("Starting configuration reload...")
|
logger.info("Starting configuration reload...")
|
||||||
|
|
||||||
# Reload config file
|
# Reload config file
|
||||||
new_config = await config_obj.reload(config_path)
|
new_config = await config_obj.reload(config_path)
|
||||||
|
|
||||||
# Update notify module
|
# Update notify module
|
||||||
notify_mod.reload_config(new_config)
|
notify_mod.reload_config(new_config)
|
||||||
|
|
||||||
@@ -103,23 +102,23 @@ async def reload_configuration(config_obj, config_path, components):
|
|||||||
if 'threshold_checker' in components:
|
if 'threshold_checker' in components:
|
||||||
components['threshold_checker'].reload(new_config)
|
components['threshold_checker'].reload(new_config)
|
||||||
components['threshold_checker'].purge_stale_alerts(hbdclass)
|
components['threshold_checker'].purge_stale_alerts(hbdclass)
|
||||||
|
|
||||||
# Note: Changes to the following require restart:
|
# Note: Changes to the following require restart:
|
||||||
# - hb_port, hbd_port, ws_port (already bound)
|
# - hb_port, hbd_port, ws_port (already bound)
|
||||||
# - SSL certificates (already loaded)
|
# - SSL certificates (already loaded)
|
||||||
# - pickfile (already opened)
|
# - pickfile (already opened)
|
||||||
# - journal settings (journal already initialized)
|
# - journal settings (journal already initialized)
|
||||||
|
|
||||||
# These are reloadable and effective immediately:
|
# These are reloadable and effective immediately:
|
||||||
# - notification_channels
|
# - notification_channels
|
||||||
# - threshold_configs
|
# - threshold_configs
|
||||||
# - hosts (watchhosts, dyndns, notification_channels)
|
# - hosts (watchhosts, dyndns, notification_channels)
|
||||||
# - grace period (used on next heartbeat)
|
# - grace period (used on next heartbeat)
|
||||||
# - debug/verbose flags (used on next message)
|
# - debug/verbose flags (used on next message)
|
||||||
|
|
||||||
logger.info("Configuration reload completed successfully")
|
logger.info("Configuration reload completed successfully")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("=" * 60)
|
logger.error("=" * 60)
|
||||||
logger.error(f"Failed to reload configuration: {e}", exc_info=True)
|
logger.error(f"Failed to reload configuration: {e}", exc_info=True)
|
||||||
@@ -142,7 +141,7 @@ async def _run_async(config, config_path=None):
|
|||||||
sig_name = signal.Signals(signum).name if hasattr(signal, "Signals") else signum
|
sig_name = signal.Signals(signum).name if hasattr(signal, "Signals") else signum
|
||||||
logger.info(f"Received {sig_name}, initiating shutdown...")
|
logger.info(f"Received {sig_name}, initiating shutdown...")
|
||||||
loop.call_soon_threadsafe(shutdown_event.set)
|
loop.call_soon_threadsafe(shutdown_event.set)
|
||||||
|
|
||||||
def reload_handler(signum, frame):
|
def reload_handler(signum, frame):
|
||||||
sig_name = signal.Signals(signum).name if hasattr(signal, "Signals") else signum
|
sig_name = signal.Signals(signum).name if hasattr(signal, "Signals") else signum
|
||||||
logger.info(f"Received {sig_name}, initiating config reload...")
|
logger.info(f"Received {sig_name}, initiating config reload...")
|
||||||
@@ -181,7 +180,7 @@ async def _run_async(config, config_path=None):
|
|||||||
journal=msg_journal,
|
journal=msg_journal,
|
||||||
)
|
)
|
||||||
logger.info("Threshold checker initialized")
|
logger.info("Threshold checker initialized")
|
||||||
|
|
||||||
# Components dict for reload orchestration
|
# Components dict for reload orchestration
|
||||||
components = {
|
components = {
|
||||||
'threshold_checker': threshold_checker,
|
'threshold_checker': threshold_checker,
|
||||||
@@ -324,7 +323,7 @@ async def _run_async(config, config_path=None):
|
|||||||
],
|
],
|
||||||
return_when=asyncio.FIRST_COMPLETED
|
return_when=asyncio.FIRST_COMPLETED
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check which event was triggered
|
# Check which event was triggered
|
||||||
if shutdown_event.is_set():
|
if shutdown_event.is_set():
|
||||||
logger.info("Shutdown signal received, stopping services...")
|
logger.info("Shutdown signal received, stopping services...")
|
||||||
@@ -332,24 +331,24 @@ async def _run_async(config, config_path=None):
|
|||||||
for task in pending:
|
for task in pending:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
break
|
break
|
||||||
|
|
||||||
if reload_event.is_set():
|
if reload_event.is_set():
|
||||||
# Clear the event for next reload
|
# Clear the event for next reload
|
||||||
reload_event.clear()
|
reload_event.clear()
|
||||||
|
|
||||||
# Cancel pending wait tasks
|
# Cancel pending wait tasks
|
||||||
for task in pending:
|
for task in pending:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
# Perform reload if config_path is available
|
# Perform reload if config_path is available
|
||||||
if config_path:
|
if config_path:
|
||||||
await reload_configuration(config, config_path, components)
|
await reload_configuration(config, config_path, components)
|
||||||
else:
|
else:
|
||||||
logger.warning("Cannot reload: no config path available")
|
logger.warning("Cannot reload: no config path available")
|
||||||
|
|
||||||
# Continue main loop
|
# Continue main loop
|
||||||
continue
|
continue
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error in main loop: %s", e)
|
logger.exception("Error in main loop: %s", e)
|
||||||
finally:
|
finally:
|
||||||
@@ -381,7 +380,7 @@ async def _run_async(config, config_path=None):
|
|||||||
logger.warning("Timeout waiting for tasks to cancel")
|
logger.warning("Timeout waiting for tasks to cancel")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Exception during task cancellation: %s", e)
|
logger.debug("Exception during task cancellation: %s", e)
|
||||||
|
|
||||||
# Close message journal
|
# Close message journal
|
||||||
try:
|
try:
|
||||||
await msg_journal.close()
|
await msg_journal.close()
|
||||||
@@ -487,7 +486,7 @@ def run(config, config_path=None):
|
|||||||
"""Start the hbd service (blocking).
|
"""Start the hbd service (blocking).
|
||||||
|
|
||||||
Manually manages the event loop to ensure clean shutdown.
|
Manually manages the event loop to ensure clean shutdown.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Configuration dictionary
|
config: Configuration dictionary
|
||||||
config_path: Path to config file (for reload support)
|
config_path: Path to config file (for reload support)
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
"""Monitor helper for heartbeat daemon.
|
"""Monitor helper for heartbeat daemon.
|
||||||
|
|
||||||
This module provides monitoring tasks for the heartbeat daemon.
|
This module provides monitoring tasks for the heartbeat daemon.
|
||||||
The primary reachability monitoring is now event-driven (timers set/reset
|
The primary reachability monitoring is now event-driven (timers set/reset
|
||||||
on HTB arrival in udp.py) rather than periodic polling.
|
on HTB arrival in udp.py) rather than periodic polling.
|
||||||
|
|
||||||
This module can be extended for additional monitoring tasks.
|
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
|
||||||
@@ -18,11 +16,10 @@ eventlog = notify_mod.eventlog
|
|||||||
|
|
||||||
async def cleanup_connections(hbdclass):
|
async def cleanup_connections(hbdclass):
|
||||||
"""Clean up connection timers on shutdown.
|
"""Clean up connection timers on shutdown.
|
||||||
|
|
||||||
Cancels all active overdue timers to prevent callbacks after shutdown.
|
Cancels all active overdue timers to prevent callbacks after shutdown.
|
||||||
"""
|
"""
|
||||||
for hostname, host in list(hbdclass.Host.hosts.items()):
|
for hostname, host in list(hbdclass.Host.hosts.items()):
|
||||||
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()
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)."),
|
||||||
|
|||||||
+110
-107
@@ -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
|
||||||
@@ -64,30 +65,30 @@ class AlertState:
|
|||||||
self.acknowledged_at = None # Timestamp when acknowledged
|
self.acknowledged_at = None # Timestamp when acknowledged
|
||||||
self.consecutive_count = 0 # Consecutive exceedances while still OK (for count gating)
|
self.consecutive_count = 0 # Consecutive exceedances while still OK (for count gating)
|
||||||
self.pending_since: Optional[float] = None # non-None while waiting out grace period before notifying
|
self.pending_since: Optional[float] = None # non-None while waiting out grace period before notifying
|
||||||
|
|
||||||
def update(
|
def update(
|
||||||
self,
|
self,
|
||||||
level: AlertLevel,
|
level: AlertLevel,
|
||||||
value: Any,
|
value: Any,
|
||||||
threshold_value: Optional[float] = None,
|
threshold_value: Optional[float] = None,
|
||||||
operator: Optional[str] = None
|
operator: Optional[str] = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Update alert state.
|
Update alert state.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
level: New alert level
|
level: New alert level
|
||||||
value: Current metric value
|
value: Current metric value
|
||||||
threshold_value: The threshold value that was exceeded (if applicable)
|
threshold_value: The threshold value that was exceeded (if applicable)
|
||||||
operator: The comparison operator (>, <, >=, etc.)
|
operator: The comparison operator (>, <, >=, etc.)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if state changed (notification needed), False otherwise
|
True if state changed (notification needed), False otherwise
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
self.last_check = now
|
self.last_check = now
|
||||||
self.last_value = value
|
self.last_value = value
|
||||||
|
|
||||||
# Update threshold info when alert is active
|
# Update threshold info when alert is active
|
||||||
if level != AlertLevel.OK:
|
if level != AlertLevel.OK:
|
||||||
self.threshold_value = threshold_value
|
self.threshold_value = threshold_value
|
||||||
@@ -96,7 +97,7 @@ class AlertState:
|
|||||||
# Clear threshold info when returning to OK
|
# Clear threshold info when returning to OK
|
||||||
self.threshold_value = None
|
self.threshold_value = None
|
||||||
self.operator = None
|
self.operator = None
|
||||||
|
|
||||||
# Check if state changed
|
# Check if state changed
|
||||||
if level != self.level:
|
if level != self.level:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -116,13 +117,13 @@ class AlertState:
|
|||||||
self.acknowledged = False
|
self.acknowledged = False
|
||||||
self.acknowledged_at = None
|
self.acknowledged_at = None
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert alert state to dictionary for serialization."""
|
"""Convert alert state to dictionary for serialization."""
|
||||||
import math
|
import math
|
||||||
|
|
||||||
# Helper to sanitize numeric values for JSON (handle inf/nan)
|
# Helper to sanitize numeric values for JSON (handle inf/nan)
|
||||||
def sanitize_value(val):
|
def sanitize_value(val):
|
||||||
if isinstance(val, float):
|
if isinstance(val, float):
|
||||||
@@ -131,7 +132,7 @@ class AlertState:
|
|||||||
if math.isnan(val):
|
if math.isnan(val):
|
||||||
return None
|
return None
|
||||||
return val
|
return val
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"metric_path": self.metric_path,
|
"metric_path": self.metric_path,
|
||||||
"level": self.level.name,
|
"level": self.level.name,
|
||||||
@@ -141,11 +142,11 @@ class AlertState:
|
|||||||
"notification_count": self.notification_count,
|
"notification_count": self.notification_count,
|
||||||
"acknowledged": self.acknowledged,
|
"acknowledged": self.acknowledged,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Include acknowledgment timestamp if acknowledged
|
# Include acknowledgment timestamp if acknowledged
|
||||||
if self.acknowledged_at is not None:
|
if self.acknowledged_at is not None:
|
||||||
result["acknowledged_at"] = self.acknowledged_at
|
result["acknowledged_at"] = self.acknowledged_at
|
||||||
|
|
||||||
# Include threshold info if available
|
# Include threshold info if available
|
||||||
if self.threshold_value is not None:
|
if self.threshold_value is not None:
|
||||||
result["threshold_value"] = sanitize_value(self.threshold_value)
|
result["threshold_value"] = sanitize_value(self.threshold_value)
|
||||||
@@ -164,7 +165,7 @@ class AlertState:
|
|||||||
result["recovery_threshold"] = round(self.threshold_value + ha, 4)
|
result["recovery_threshold"] = round(self.threshold_value + ha, 4)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def __setstate__(self, state):
|
def __setstate__(self, state):
|
||||||
"""Restore from pickle, backfilling fields added after the pickle was written."""
|
"""Restore from pickle, backfilling fields added after the pickle was written."""
|
||||||
self.__dict__.update(state)
|
self.__dict__.update(state)
|
||||||
@@ -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."""
|
||||||
|
|
||||||
@@ -218,7 +220,7 @@ class ThresholdConfig:
|
|||||||
self.display = display
|
self.display = display
|
||||||
self.count = max(1, int(count))
|
self.count = max(1, int(count))
|
||||||
self.grace = float(grace) if grace is not None else None
|
self.grace = float(grace) if grace is not None else None
|
||||||
|
|
||||||
# Parse operator
|
# Parse operator
|
||||||
try:
|
try:
|
||||||
self.operator = ComparisonOperator(operator)
|
self.operator = ComparisonOperator(operator)
|
||||||
@@ -229,7 +231,7 @@ class ThresholdConfig:
|
|||||||
metric_path
|
metric_path
|
||||||
)
|
)
|
||||||
self.operator = ComparisonOperator.GT
|
self.operator = ComparisonOperator.GT
|
||||||
|
|
||||||
def evaluate(self, value: float) -> AlertLevel:
|
def evaluate(self, value: float) -> AlertLevel:
|
||||||
"""
|
"""
|
||||||
Evaluate a value against this threshold.
|
Evaluate a value against this threshold.
|
||||||
@@ -271,7 +273,7 @@ class ThresholdConfig:
|
|||||||
return AlertLevel.WARNING
|
return AlertLevel.WARNING
|
||||||
|
|
||||||
return AlertLevel.OK
|
return AlertLevel.OK
|
||||||
|
|
||||||
def evaluate_with_hysteresis(
|
def evaluate_with_hysteresis(
|
||||||
self,
|
self,
|
||||||
value: float,
|
value: float,
|
||||||
@@ -279,11 +281,11 @@ class ThresholdConfig:
|
|||||||
) -> AlertLevel:
|
) -> AlertLevel:
|
||||||
"""
|
"""
|
||||||
Evaluate with hysteresis to prevent flapping.
|
Evaluate with hysteresis to prevent flapping.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value: Current metric value
|
value: Current metric value
|
||||||
current_level: Current alert level
|
current_level: Current alert level
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
New alert level considering hysteresis
|
New alert level considering hysteresis
|
||||||
"""
|
"""
|
||||||
@@ -296,7 +298,7 @@ class ThresholdConfig:
|
|||||||
# If no hysteresis, return new level
|
# If no hysteresis, return new level
|
||||||
if self.hysteresis == 0.0:
|
if self.hysteresis == 0.0:
|
||||||
return new_level
|
return new_level
|
||||||
|
|
||||||
# If improving (going to a lower severity), apply hysteresis
|
# If improving (going to a lower severity), apply hysteresis
|
||||||
if new_level.value < current_level.value:
|
if new_level.value < current_level.value:
|
||||||
# For recovery, value must be better by hysteresis amount
|
# For recovery, value must be better by hysteresis amount
|
||||||
@@ -306,10 +308,10 @@ class ThresholdConfig:
|
|||||||
threshold = self.warning
|
threshold = self.warning
|
||||||
else:
|
else:
|
||||||
return new_level
|
return new_level
|
||||||
|
|
||||||
# Calculate hysteresis threshold
|
# Calculate hysteresis threshold
|
||||||
hysteresis_amount = abs(threshold * self.hysteresis)
|
hysteresis_amount = abs(threshold * self.hysteresis)
|
||||||
|
|
||||||
if self.operator in [ComparisonOperator.GT, ComparisonOperator.GTE]:
|
if self.operator in [ComparisonOperator.GT, ComparisonOperator.GTE]:
|
||||||
# For "greater than" thresholds, value must go below by hysteresis
|
# For "greater than" thresholds, value must go below by hysteresis
|
||||||
recovery_threshold = threshold - hysteresis_amount
|
recovery_threshold = threshold - hysteresis_amount
|
||||||
@@ -322,9 +324,9 @@ class ThresholdConfig:
|
|||||||
if value <= recovery_threshold:
|
if value <= recovery_threshold:
|
||||||
# Not enough improvement, keep current level
|
# Not enough improvement, keep current level
|
||||||
return current_level
|
return current_level
|
||||||
|
|
||||||
return new_level
|
return new_level
|
||||||
|
|
||||||
def _compare(self, value: float, threshold: float) -> bool:
|
def _compare(self, value: float, threshold: float) -> bool:
|
||||||
"""Perform comparison based on operator."""
|
"""Perform comparison based on operator."""
|
||||||
if self.operator == ComparisonOperator.GT:
|
if self.operator == ComparisonOperator.GT:
|
||||||
@@ -344,7 +346,7 @@ class ThresholdConfig:
|
|||||||
|
|
||||||
class ThresholdChecker:
|
class ThresholdChecker:
|
||||||
"""Main threshold checking and alerting system."""
|
"""Main threshold checking and alerting system."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: Dict[str, Any],
|
config: Dict[str, Any],
|
||||||
@@ -353,7 +355,7 @@ class ThresholdChecker:
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize threshold checker.
|
Initialize threshold checker.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Threshold configuration dictionary from YAML
|
config: Threshold configuration dictionary from YAML
|
||||||
renotify_interval: Seconds between repeat notifications (default: 1 hour)
|
renotify_interval: Seconds between repeat notifications (default: 1 hour)
|
||||||
@@ -373,14 +375,14 @@ class ThresholdChecker:
|
|||||||
|
|
||||||
# Default config name to use when no mapping exists
|
# Default config name to use when no mapping exists
|
||||||
self.default_config = "default"
|
self.default_config = "default"
|
||||||
|
|
||||||
self.renotify_interval = renotify_interval
|
self.renotify_interval = renotify_interval
|
||||||
self.grace_seconds: float = float(config.get("grace", 2))
|
self.grace_seconds: float = float(config.get("grace", 2))
|
||||||
self.journal = journal
|
self.journal = journal
|
||||||
|
|
||||||
# Parse configuration
|
# Parse configuration
|
||||||
self._parse_config(config)
|
self._parse_config(config)
|
||||||
|
|
||||||
total_thresholds = sum(len(cfg) for cfg in self.threshold_configs.values())
|
total_thresholds = sum(len(cfg) for cfg in self.threshold_configs.values())
|
||||||
if total_thresholds == 0 and len(self.thresholds) > 0:
|
if total_thresholds == 0 and len(self.thresholds) > 0:
|
||||||
# Backward compatibility: using single threshold set
|
# Backward compatibility: using single threshold set
|
||||||
@@ -392,18 +394,18 @@ class ThresholdChecker:
|
|||||||
len(self.threshold_configs),
|
len(self.threshold_configs),
|
||||||
total_thresholds
|
total_thresholds
|
||||||
)
|
)
|
||||||
|
|
||||||
def reload(self, config: Dict[str, Any]):
|
def reload(self, config: Dict[str, Any]):
|
||||||
"""Reload threshold configuration from new config dict.
|
"""Reload threshold configuration from new config dict.
|
||||||
|
|
||||||
This clears all existing thresholds and re-parses from the new configuration.
|
This clears all existing thresholds and re-parses from the new configuration.
|
||||||
Alert states are preserved to maintain hysteresis across reloads.
|
Alert states are preserved to maintain hysteresis across reloads.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: New configuration dictionary
|
config: New configuration dictionary
|
||||||
"""
|
"""
|
||||||
logger.info("Reloading threshold configuration...")
|
logger.info("Reloading threshold configuration...")
|
||||||
|
|
||||||
# Clear old configuration
|
# Clear old configuration
|
||||||
self.threshold_configs.clear()
|
self.threshold_configs.clear()
|
||||||
self.threshold_raw_configs.clear()
|
self.threshold_raw_configs.clear()
|
||||||
@@ -413,13 +415,13 @@ class ThresholdChecker:
|
|||||||
|
|
||||||
# Parse new configuration
|
# Parse new configuration
|
||||||
self._parse_config(config)
|
self._parse_config(config)
|
||||||
|
|
||||||
total_thresholds = sum(len(cfg) for cfg in self.threshold_configs.values())
|
total_thresholds = sum(len(cfg) for cfg in self.threshold_configs.values())
|
||||||
if total_thresholds == 0 and len(self.thresholds) > 0:
|
if total_thresholds == 0 and len(self.thresholds) > 0:
|
||||||
total_thresholds = len(self.thresholds)
|
total_thresholds = len(self.thresholds)
|
||||||
|
|
||||||
logger.info("Threshold configuration reloaded: %d total thresholds", total_thresholds)
|
logger.info("Threshold configuration reloaded: %d total thresholds", total_thresholds)
|
||||||
|
|
||||||
def _parse_config(self, config: Dict[str, Any]):
|
def _parse_config(self, config: Dict[str, Any]):
|
||||||
"""Parse threshold configuration from YAML structure.
|
"""Parse threshold configuration from YAML structure.
|
||||||
|
|
||||||
@@ -449,7 +451,7 @@ class ThresholdChecker:
|
|||||||
self._parse_legacy_config(config)
|
self._parse_legacy_config(config)
|
||||||
else:
|
else:
|
||||||
logger.info("No thresholds configured")
|
logger.info("No thresholds configured")
|
||||||
|
|
||||||
def _parse_multi_config(self, config: Dict[str, Any]):
|
def _parse_multi_config(self, config: Dict[str, Any]):
|
||||||
"""Parse multiple named threshold configurations."""
|
"""Parse multiple named threshold configurations."""
|
||||||
threshold_configs = config.get("threshold_configs", {})
|
threshold_configs = config.get("threshold_configs", {})
|
||||||
@@ -545,34 +547,34 @@ class ThresholdChecker:
|
|||||||
if isinstance(legacy_mapping, dict):
|
if isinstance(legacy_mapping, dict):
|
||||||
for host_name, value in legacy_mapping.items():
|
for host_name, value in legacy_mapping.items():
|
||||||
self.host_config_mapping[host_name] = _normalise(value)
|
self.host_config_mapping[host_name] = _normalise(value)
|
||||||
|
|
||||||
# Set default config (first one alphabetically or explicitly set)
|
# Set default config (first one alphabetically or explicitly set)
|
||||||
self.default_config = config.get("default_threshold_config", "default")
|
self.default_config = config.get("default_threshold_config", "default")
|
||||||
if self.default_config not in self.threshold_configs and self.threshold_configs:
|
if self.default_config not in self.threshold_configs and self.threshold_configs:
|
||||||
# Use first available config as default
|
# Use first available config as default
|
||||||
self.default_config = sorted(self.threshold_configs.keys())[0]
|
self.default_config = sorted(self.threshold_configs.keys())[0]
|
||||||
logger.info("Using '%s' as default threshold config", self.default_config)
|
logger.info("Using '%s' as default threshold config", self.default_config)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Loaded %d threshold configurations with %d host mappings",
|
"Loaded %d threshold configurations with %d host mappings",
|
||||||
len(self.threshold_configs),
|
len(self.threshold_configs),
|
||||||
len(self.host_config_mapping)
|
len(self.host_config_mapping)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _parse_legacy_config(self, config: Dict[str, Any]):
|
def _parse_legacy_config(self, config: Dict[str, Any]):
|
||||||
"""Parse legacy single threshold configuration for backward compatibility."""
|
"""Parse legacy single threshold configuration for backward compatibility."""
|
||||||
if not config or "thresholds" not in config:
|
if not config or "thresholds" not in config:
|
||||||
logger.info("No thresholds configured")
|
logger.info("No thresholds configured")
|
||||||
return
|
return
|
||||||
|
|
||||||
thresholds_config = config["thresholds"]
|
thresholds_config = config["thresholds"]
|
||||||
|
|
||||||
for plugin_name, plugin_thresholds in thresholds_config.items():
|
for plugin_name, plugin_thresholds in thresholds_config.items():
|
||||||
if not isinstance(plugin_thresholds, dict):
|
if not isinstance(plugin_thresholds, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self._parse_plugin_thresholds(plugin_name, plugin_thresholds, target_dict=self.thresholds)
|
self._parse_plugin_thresholds(plugin_name, plugin_thresholds, target_dict=self.thresholds)
|
||||||
|
|
||||||
def _parse_plugin_thresholds(
|
def _parse_plugin_thresholds(
|
||||||
self,
|
self,
|
||||||
plugin_name: str,
|
plugin_name: str,
|
||||||
@@ -580,7 +582,7 @@ class ThresholdChecker:
|
|||||||
target_dict: Optional[Dict[str, ThresholdConfig]] = None
|
target_dict: Optional[Dict[str, ThresholdConfig]] = None
|
||||||
):
|
):
|
||||||
"""Parse thresholds for a specific plugin.
|
"""Parse thresholds for a specific plugin.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_name: Name of the plugin
|
plugin_name: Name of the plugin
|
||||||
thresholds: Threshold configuration dictionary
|
thresholds: Threshold configuration dictionary
|
||||||
@@ -588,7 +590,7 @@ class ThresholdChecker:
|
|||||||
"""
|
"""
|
||||||
if target_dict is None:
|
if target_dict is None:
|
||||||
target_dict = self.thresholds
|
target_dict = self.thresholds
|
||||||
|
|
||||||
# Special handling for RTT thresholds (per-host)
|
# Special handling for RTT thresholds (per-host)
|
||||||
if plugin_name == "rtt":
|
if plugin_name == "rtt":
|
||||||
self._parse_rtt_thresholds(thresholds, target_dict)
|
self._parse_rtt_thresholds(thresholds, target_dict)
|
||||||
@@ -606,7 +608,7 @@ class ThresholdChecker:
|
|||||||
for metric_name, threshold_config in thresholds.items():
|
for metric_name, threshold_config in thresholds.items():
|
||||||
if not isinstance(threshold_config, dict):
|
if not isinstance(threshold_config, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Handle nested metrics (e.g., partitions./.percent or pools.*.status)
|
# Handle nested metrics (e.g., partitions./.percent or pools.*.status)
|
||||||
if metric_name == "partitions":
|
if metric_name == "partitions":
|
||||||
self._parse_partition_thresholds(plugin_name, threshold_config, target_dict)
|
self._parse_partition_thresholds(plugin_name, threshold_config, target_dict)
|
||||||
@@ -614,9 +616,9 @@ class ThresholdChecker:
|
|||||||
if metric_name == "pools":
|
if metric_name == "pools":
|
||||||
self._parse_pool_thresholds(plugin_name, threshold_config, target_dict)
|
self._parse_pool_thresholds(plugin_name, threshold_config, target_dict)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
metric_path = f"{plugin_name}.{metric_name}"
|
metric_path = f"{plugin_name}.{metric_name}"
|
||||||
|
|
||||||
# Extract threshold values
|
# Extract threshold values
|
||||||
warning = threshold_config.get("warning")
|
warning = threshold_config.get("warning")
|
||||||
critical = threshold_config.get("critical")
|
critical = threshold_config.get("critical")
|
||||||
@@ -643,7 +645,7 @@ class ThresholdChecker:
|
|||||||
display=display,
|
display=display,
|
||||||
grace=grace,
|
grace=grace,
|
||||||
)
|
)
|
||||||
|
|
||||||
target_dict[metric_path] = threshold
|
target_dict[metric_path] = threshold
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Registered threshold for %s: warn=%s, crit=%s, op=%s",
|
"Registered threshold for %s: warn=%s, crit=%s, op=%s",
|
||||||
@@ -652,7 +654,7 @@ class ThresholdChecker:
|
|||||||
critical,
|
critical,
|
||||||
operator
|
operator
|
||||||
)
|
)
|
||||||
|
|
||||||
def _parse_partition_thresholds(
|
def _parse_partition_thresholds(
|
||||||
self,
|
self,
|
||||||
plugin_name: str,
|
plugin_name: str,
|
||||||
@@ -660,7 +662,7 @@ class ThresholdChecker:
|
|||||||
target_dict: Optional[Dict[str, ThresholdConfig]] = None
|
target_dict: Optional[Dict[str, ThresholdConfig]] = None
|
||||||
):
|
):
|
||||||
"""Parse partition-specific thresholds for disk monitoring.
|
"""Parse partition-specific thresholds for disk monitoring.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_name: Name of the plugin
|
plugin_name: Name of the plugin
|
||||||
partitions: Partition threshold configuration
|
partitions: Partition threshold configuration
|
||||||
@@ -668,18 +670,18 @@ class ThresholdChecker:
|
|||||||
"""
|
"""
|
||||||
if target_dict is None:
|
if target_dict is None:
|
||||||
target_dict = self.thresholds
|
target_dict = self.thresholds
|
||||||
|
|
||||||
for partition, metrics in partitions.items():
|
for partition, metrics in partitions.items():
|
||||||
if not isinstance(metrics, dict):
|
if not isinstance(metrics, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for metric_name, threshold_config in metrics.items():
|
for metric_name, threshold_config in metrics.items():
|
||||||
if not isinstance(threshold_config, dict):
|
if not isinstance(threshold_config, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Create metric path like "disk_monitor./dev/sda1.percent"
|
# Create metric path like "disk_monitor./dev/sda1.percent"
|
||||||
metric_path = f"{plugin_name}.{partition}.{metric_name}"
|
metric_path = f"{plugin_name}.{partition}.{metric_name}"
|
||||||
|
|
||||||
warning = threshold_config.get("warning")
|
warning = threshold_config.get("warning")
|
||||||
critical = threshold_config.get("critical")
|
critical = threshold_config.get("critical")
|
||||||
operator = threshold_config.get("operator", ">")
|
operator = threshold_config.get("operator", ">")
|
||||||
@@ -700,7 +702,7 @@ class ThresholdChecker:
|
|||||||
display=display,
|
display=display,
|
||||||
grace=grace,
|
grace=grace,
|
||||||
)
|
)
|
||||||
|
|
||||||
target_dict[metric_path] = threshold
|
target_dict[metric_path] = threshold
|
||||||
|
|
||||||
def _parse_pool_thresholds(
|
def _parse_pool_thresholds(
|
||||||
@@ -761,26 +763,26 @@ class ThresholdChecker:
|
|||||||
target_dict: Optional[Dict[str, ThresholdConfig]] = None
|
target_dict: Optional[Dict[str, ThresholdConfig]] = None
|
||||||
):
|
):
|
||||||
"""Parse RTT thresholds (network latency thresholds).
|
"""Parse RTT thresholds (network latency thresholds).
|
||||||
|
|
||||||
RTT thresholds are configured as:
|
RTT thresholds are configured as:
|
||||||
thresholds:
|
thresholds:
|
||||||
rtt:
|
rtt:
|
||||||
warning: 100.0 # ms
|
warning: 100.0 # ms
|
||||||
critical: 500.0 # ms
|
critical: 500.0 # ms
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
rtt_thresholds: RTT threshold configuration
|
rtt_thresholds: RTT threshold configuration
|
||||||
target_dict: Dictionary to store parsed thresholds
|
target_dict: Dictionary to store parsed thresholds
|
||||||
"""
|
"""
|
||||||
if target_dict is None:
|
if target_dict is None:
|
||||||
target_dict = self.thresholds
|
target_dict = self.thresholds
|
||||||
|
|
||||||
if not isinstance(rtt_thresholds, dict):
|
if not isinstance(rtt_thresholds, dict):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Metric path is simply "rtt" (not per-host)
|
# Metric path is simply "rtt" (not per-host)
|
||||||
metric_path = "rtt"
|
metric_path = "rtt"
|
||||||
|
|
||||||
warning = rtt_thresholds.get("warning")
|
warning = rtt_thresholds.get("warning")
|
||||||
critical = rtt_thresholds.get("critical")
|
critical = rtt_thresholds.get("critical")
|
||||||
operator = rtt_thresholds.get("operator", ">")
|
operator = rtt_thresholds.get("operator", ">")
|
||||||
@@ -813,7 +815,7 @@ class ThresholdChecker:
|
|||||||
critical,
|
critical,
|
||||||
count,
|
count,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_thresholds_for_host(self, host_name: str) -> Dict[str, ThresholdConfig]:
|
def get_thresholds_for_host(self, host_name: str) -> Dict[str, ThresholdConfig]:
|
||||||
"""Get the effective threshold configuration for a host.
|
"""Get the effective threshold configuration for a host.
|
||||||
|
|
||||||
@@ -864,7 +866,7 @@ class ThresholdChecker:
|
|||||||
else:
|
else:
|
||||||
result.update(raw)
|
result.update(raw)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def check_value(
|
def check_value(
|
||||||
self,
|
self,
|
||||||
host_name: str,
|
host_name: str,
|
||||||
@@ -874,30 +876,30 @@ class ThresholdChecker:
|
|||||||
) -> Optional[Tuple[AlertLevel, AlertLevel]]:
|
) -> Optional[Tuple[AlertLevel, AlertLevel]]:
|
||||||
"""
|
"""
|
||||||
Check a single value against configured threshold.
|
Check a single value against configured threshold.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
host_name: Name of the host
|
host_name: Name of the host
|
||||||
metric_path: Full metric path (e.g., "rtt.hostname")
|
metric_path: Full metric path (e.g., "rtt.hostname")
|
||||||
value: The metric value to check
|
value: The metric value to check
|
||||||
alert_states: Host's alert_states dictionary
|
alert_states: Host's alert_states dictionary
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (old_level, new_level) if state changed, None otherwise
|
Tuple of (old_level, new_level) if state changed, None otherwise
|
||||||
"""
|
"""
|
||||||
# Get host-specific thresholds
|
# Get host-specific thresholds
|
||||||
thresholds = self.get_thresholds_for_host(host_name)
|
thresholds = self.get_thresholds_for_host(host_name)
|
||||||
|
|
||||||
if metric_path not in thresholds:
|
if metric_path not in thresholds:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
threshold = thresholds[metric_path]
|
threshold = thresholds[metric_path]
|
||||||
|
|
||||||
# Get or create alert state
|
# Get or create alert state
|
||||||
if metric_path not in alert_states:
|
if metric_path not in alert_states:
|
||||||
alert_states[metric_path] = AlertState(metric_path)
|
alert_states[metric_path] = AlertState(metric_path)
|
||||||
|
|
||||||
alert_state = alert_states[metric_path]
|
alert_state = alert_states[metric_path]
|
||||||
|
|
||||||
# Evaluate threshold with hysteresis
|
# Evaluate threshold with hysteresis
|
||||||
new_level = threshold.evaluate_with_hysteresis(
|
new_level = threshold.evaluate_with_hysteresis(
|
||||||
value,
|
value,
|
||||||
@@ -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]]:
|
||||||
@@ -985,21 +988,21 @@ class ThresholdChecker:
|
|||||||
) -> list:
|
) -> list:
|
||||||
"""
|
"""
|
||||||
Check plugin data against configured thresholds.
|
Check plugin data against configured thresholds.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
host_name: Name of the host
|
host_name: Name of the host
|
||||||
plugin_name: Name of the plugin
|
plugin_name: Name of the plugin
|
||||||
data: Plugin data dictionary
|
data: Plugin data dictionary
|
||||||
alert_states: Host's alert_states dictionary
|
alert_states: Host's alert_states dictionary
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of (metric_path, old_level, new_level, value) tuples for state changes
|
List of (metric_path, old_level, new_level, value) tuples for state changes
|
||||||
"""
|
"""
|
||||||
state_changes = []
|
state_changes = []
|
||||||
|
|
||||||
# Get host-specific thresholds
|
# Get host-specific thresholds
|
||||||
thresholds = self.get_thresholds_for_host(host_name)
|
thresholds = self.get_thresholds_for_host(host_name)
|
||||||
|
|
||||||
# Check flat metrics
|
# Check flat metrics
|
||||||
for metric_name, value in data.items():
|
for metric_name, value in data.items():
|
||||||
metric_path = f"{plugin_name}.{metric_name}"
|
metric_path = f"{plugin_name}.{metric_name}"
|
||||||
@@ -1045,9 +1048,9 @@ class ThresholdChecker:
|
|||||||
alert_states,
|
alert_states,
|
||||||
state_changes
|
state_changes
|
||||||
)
|
)
|
||||||
|
|
||||||
return state_changes
|
return state_changes
|
||||||
|
|
||||||
def _check_nested_metrics(
|
def _check_nested_metrics(
|
||||||
self,
|
self,
|
||||||
host_name: str,
|
host_name: str,
|
||||||
@@ -1059,7 +1062,7 @@ class ThresholdChecker:
|
|||||||
"""Check nested metrics like partition-specific thresholds."""
|
"""Check nested metrics like partition-specific thresholds."""
|
||||||
# Get host-specific thresholds
|
# Get host-specific thresholds
|
||||||
thresholds = self.get_thresholds_for_host(host_name)
|
thresholds = self.get_thresholds_for_host(host_name)
|
||||||
|
|
||||||
# ZFS pool health checks
|
# ZFS pool health checks
|
||||||
if plugin_name == "zfs_monitor" and "pools" in data:
|
if plugin_name == "zfs_monitor" and "pools" in data:
|
||||||
pools = data["pools"]
|
pools = data["pools"]
|
||||||
@@ -1103,29 +1106,29 @@ class ThresholdChecker:
|
|||||||
partitions = data["partitions"]
|
partitions = data["partitions"]
|
||||||
if not isinstance(partitions, dict):
|
if not isinstance(partitions, dict):
|
||||||
return
|
return
|
||||||
|
|
||||||
for partition, metrics in partitions.items():
|
for partition, metrics in partitions.items():
|
||||||
if not isinstance(metrics, dict):
|
if not isinstance(metrics, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for metric_name, value in metrics.items():
|
for metric_name, value in metrics.items():
|
||||||
metric_path = f"{plugin_name}.{partition}.{metric_name}"
|
metric_path = f"{plugin_name}.{partition}.{metric_name}"
|
||||||
|
|
||||||
if metric_path not in thresholds:
|
if metric_path not in thresholds:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
threshold = thresholds[metric_path]
|
threshold = thresholds[metric_path]
|
||||||
|
|
||||||
if metric_path not in alert_states:
|
if metric_path not in alert_states:
|
||||||
alert_states[metric_path] = AlertState(metric_path)
|
alert_states[metric_path] = AlertState(metric_path)
|
||||||
|
|
||||||
alert_state = alert_states[metric_path]
|
alert_state = alert_states[metric_path]
|
||||||
|
|
||||||
new_level = threshold.evaluate_with_hysteresis(
|
new_level = threshold.evaluate_with_hysteresis(
|
||||||
value,
|
value,
|
||||||
alert_state.level
|
alert_state.level
|
||||||
)
|
)
|
||||||
|
|
||||||
# Determine which threshold was exceeded
|
# Determine which threshold was exceeded
|
||||||
threshold_value = None
|
threshold_value = None
|
||||||
if new_level == AlertLevel.CRITICAL and threshold.critical is not None:
|
if new_level == AlertLevel.CRITICAL and threshold.critical is not None:
|
||||||
@@ -1141,7 +1144,7 @@ class ThresholdChecker:
|
|||||||
self._apply_grace(host_name, alert_state, metric_path, old_level, new_level, value, threshold, data)
|
self._apply_grace(host_name, alert_state, metric_path, old_level, new_level, value, threshold, data)
|
||||||
elif new_level != AlertLevel.OK:
|
elif new_level != AlertLevel.OK:
|
||||||
self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, data)
|
self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, data)
|
||||||
|
|
||||||
def _trigger_notification(
|
def _trigger_notification(
|
||||||
self,
|
self,
|
||||||
host_name: str,
|
host_name: str,
|
||||||
@@ -1155,7 +1158,7 @@ class ThresholdChecker:
|
|||||||
metric_name: Optional[str] = None,
|
metric_name: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Trigger a notification for an alert state change.
|
"""Trigger a notification for an alert state change.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
host_name: Name of the host
|
host_name: Name of the host
|
||||||
metric_path: Full metric path
|
metric_path: Full metric path
|
||||||
@@ -1171,7 +1174,7 @@ class ThresholdChecker:
|
|||||||
threshold_value = threshold.critical
|
threshold_value = threshold.critical
|
||||||
elif new_level == AlertLevel.WARNING and threshold.warning is not None:
|
elif new_level == AlertLevel.WARNING and threshold.warning is not None:
|
||||||
threshold_value = threshold.warning
|
threshold_value = threshold.warning
|
||||||
|
|
||||||
# Format operator symbol
|
# Format operator symbol
|
||||||
op_symbol = threshold.operator.value
|
op_symbol = threshold.operator.value
|
||||||
|
|
||||||
@@ -1223,7 +1226,7 @@ class ThresholdChecker:
|
|||||||
formatted_threshold_msg = _fmt() if has_display and new_level != AlertLevel.OK else None
|
formatted_threshold_msg = _fmt() if has_display and new_level != AlertLevel.OK else None
|
||||||
|
|
||||||
return lvl, message, formatted_threshold_msg
|
return lvl, message, formatted_threshold_msg
|
||||||
|
|
||||||
def _send_notification(
|
def _send_notification(
|
||||||
self,
|
self,
|
||||||
host_name: str,
|
host_name: str,
|
||||||
@@ -1254,7 +1257,7 @@ class ThresholdChecker:
|
|||||||
service=short_path,
|
service=short_path,
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
|
|
||||||
# Log to journal
|
# Log to journal
|
||||||
if self.journal is not None:
|
if self.journal is not None:
|
||||||
try:
|
try:
|
||||||
@@ -1270,7 +1273,7 @@ class ThresholdChecker:
|
|||||||
logger.debug(f"Failed to log threshold event to journal: {e}")
|
logger.debug(f"Failed to log threshold event to journal: {e}")
|
||||||
# Log to eventlog as well
|
# Log to eventlog as well
|
||||||
eventlog(host_name, lvl, message, service="threshold")
|
eventlog(host_name, lvl, message, service="threshold")
|
||||||
|
|
||||||
def _format_display(
|
def _format_display(
|
||||||
self,
|
self,
|
||||||
display_format: str,
|
display_format: str,
|
||||||
@@ -1330,7 +1333,7 @@ class ThresholdChecker:
|
|||||||
status = plugin_data.get(f"{check_name}_status")
|
status = plugin_data.get(f"{check_name}_status")
|
||||||
if status is not None:
|
if status is not None:
|
||||||
format_context['status'] = status
|
format_context['status'] = status
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Format the display string
|
# Format the display string
|
||||||
return display_format.format(**format_context)
|
return display_format.format(**format_context)
|
||||||
@@ -1349,7 +1352,7 @@ class ThresholdChecker:
|
|||||||
e
|
e
|
||||||
)
|
)
|
||||||
return f"(threshold: {op_symbol} {threshold_value})"
|
return f"(threshold: {op_symbol} {threshold_value})"
|
||||||
|
|
||||||
def _apply_grace(
|
def _apply_grace(
|
||||||
self,
|
self,
|
||||||
host_name: str,
|
host_name: str,
|
||||||
@@ -1472,7 +1475,7 @@ class ThresholdChecker:
|
|||||||
metric_name: Optional[str] = None,
|
metric_name: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Check if we should send a repeat notification.
|
"""Check if we should send a repeat notification.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
host_name: Name of the host
|
host_name: Name of the host
|
||||||
alert_state: Current alert state
|
alert_state: Current alert state
|
||||||
@@ -1487,16 +1490,16 @@ class ThresholdChecker:
|
|||||||
# Skip reminders if alert has been acknowledged
|
# Skip reminders if alert has been acknowledged
|
||||||
if alert_state.acknowledged:
|
if alert_state.acknowledged:
|
||||||
return
|
return
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
# Check if we should re-notify
|
# Check if we should re-notify
|
||||||
if alert_state.last_notification is None:
|
if alert_state.last_notification is None:
|
||||||
# First notification already sent during state change
|
# First notification already sent during state change
|
||||||
alert_state.last_notification = now
|
alert_state.last_notification = now
|
||||||
alert_state.notification_count = 1
|
alert_state.notification_count = 1
|
||||||
return
|
return
|
||||||
|
|
||||||
if (now - alert_state.last_notification) >= self.renotify_interval:
|
if (now - alert_state.last_notification) >= self.renotify_interval:
|
||||||
# Determine which threshold is active
|
# Determine which threshold is active
|
||||||
threshold_value = None
|
threshold_value = None
|
||||||
@@ -1504,7 +1507,7 @@ class ThresholdChecker:
|
|||||||
threshold_value = threshold.critical
|
threshold_value = threshold.critical
|
||||||
elif alert_state.level == AlertLevel.WARNING and threshold.warning is not None:
|
elif alert_state.level == AlertLevel.WARNING and threshold.warning is not None:
|
||||||
threshold_value = threshold.warning
|
threshold_value = threshold.warning
|
||||||
|
|
||||||
# Format operator symbol
|
# Format operator symbol
|
||||||
op_symbol = threshold.operator.value
|
op_symbol = threshold.operator.value
|
||||||
short_path = (metric_path.partition(".")[2] or metric_path).removesuffix("_status_code")
|
short_path = (metric_path.partition(".")[2] or metric_path).removesuffix("_status_code")
|
||||||
@@ -1541,7 +1544,7 @@ class ThresholdChecker:
|
|||||||
logger.info("Re-notification sent: %s", message)
|
logger.info("Re-notification sent: %s", message)
|
||||||
alert_state.last_notification = now
|
alert_state.last_notification = now
|
||||||
alert_state.notification_count += 1
|
alert_state.notification_count += 1
|
||||||
|
|
||||||
def purge_stale_alerts(self, hbdclass) -> None:
|
def purge_stale_alerts(self, hbdclass) -> None:
|
||||||
"""Remove alert states that have no matching threshold configuration.
|
"""Remove alert states that have no matching threshold configuration.
|
||||||
|
|
||||||
@@ -1578,10 +1581,10 @@ class ThresholdChecker:
|
|||||||
def get_active_alerts(self, alert_states: Dict[str, AlertState]) -> list:
|
def get_active_alerts(self, alert_states: Dict[str, AlertState]) -> list:
|
||||||
"""
|
"""
|
||||||
Get all currently active (non-OK) alerts.
|
Get all currently active (non-OK) alerts.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
alert_states: Host's alert_states dictionary
|
alert_states: Host's alert_states dictionary
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of AlertState objects that are not OK
|
List of AlertState objects that are not OK
|
||||||
"""
|
"""
|
||||||
@@ -1589,19 +1592,19 @@ class ThresholdChecker:
|
|||||||
state for state in alert_states.values()
|
state for state in alert_states.values()
|
||||||
if state.level != AlertLevel.OK
|
if state.level != AlertLevel.OK
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_alert_summary(self, alert_states: Dict[str, AlertState]) -> Dict[str, int]:
|
def get_alert_summary(self, alert_states: Dict[str, AlertState]) -> Dict[str, int]:
|
||||||
"""
|
"""
|
||||||
Get summary counts of alert levels.
|
Get summary counts of alert levels.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
alert_states: Host's alert_states dictionary
|
alert_states: Host's alert_states dictionary
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with counts: {"ok": N, "warning": N, "critical": N}
|
Dictionary with counts: {"ok": N, "warning": N, "critical": N}
|
||||||
"""
|
"""
|
||||||
summary = {"ok": 0, "warning": 0, "critical": 0, "unknown": 0}
|
summary = {"ok": 0, "warning": 0, "critical": 0, "unknown": 0}
|
||||||
|
|
||||||
for state in alert_states.values():
|
for state in alert_states.values():
|
||||||
if state.level == AlertLevel.OK:
|
if state.level == AlertLevel.OK:
|
||||||
summary["ok"] += 1
|
summary["ok"] += 1
|
||||||
@@ -1611,5 +1614,5 @@ class ThresholdChecker:
|
|||||||
summary["critical"] += 1
|
summary["critical"] += 1
|
||||||
elif state.level == AlertLevel.UNKNOWN:
|
elif state.level == AlertLevel.UNKNOWN:
|
||||||
summary["unknown"] += 1
|
summary["unknown"] += 1
|
||||||
|
|
||||||
return summary
|
return summary
|
||||||
|
|||||||
+11
-11
@@ -23,14 +23,14 @@ _STALE_INTERVAL_MULTIPLIER = 3
|
|||||||
|
|
||||||
# SO_TIMESTAMP: kernel attaches a struct timeval to each received datagram.
|
# SO_TIMESTAMP: kernel attaches a struct timeval to each received datagram.
|
||||||
# Supported on Linux, FreeBSD, and macOS. The constant is not exposed by
|
# Supported on Linux, FreeBSD, and macOS. The constant is not exposed by
|
||||||
# Python's socket module on all platforms
|
# Python's socket module on all platforms
|
||||||
platform = platform_system()
|
platform = platform_system()
|
||||||
if platform == "Darwin":
|
if platform == "Darwin":
|
||||||
_SO_TIMESTAMP = 1024 # SO_TIMESTAMP on macOS (not in Python's socket module)
|
_SO_TIMESTAMP = 1024 # SO_TIMESTAMP on macOS (not in Python's socket module)
|
||||||
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
|
||||||
@@ -410,7 +411,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
if not msg:
|
if not msg:
|
||||||
return
|
return
|
||||||
now = ctx.get("recv_ts") or time.time()
|
now = ctx.get("recv_ts") or time.time()
|
||||||
|
|
||||||
# Log message to journal
|
# Log message to journal
|
||||||
msg_journal = ctx.get("msg_journal")
|
msg_journal = ctx.get("msg_journal")
|
||||||
if msg_journal:
|
if msg_journal:
|
||||||
@@ -421,7 +422,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
loop.create_task(msg_journal.log_message(msg, addr, now))
|
loop.create_task(msg_journal.log_message(msg, addr, now))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Failed to log message to journal: {e}")
|
logger.debug(f"Failed to log message to journal: {e}")
|
||||||
|
|
||||||
# Only named HTB/PLG datagrams come from clients; anything else (e.g. an internet scanner's
|
# Only named HTB/PLG datagrams come from clients; anything else (e.g. an internet scanner's
|
||||||
# SIP probe) is journaled above but must not sign on a host.
|
# SIP probe) is journaled above but must not sign on a host.
|
||||||
if msg.get("ID") not in ("HTB", "PLG") or not msg.get("name"):
|
if msg.get("ID") not in ("HTB", "PLG") or not msg.get("name"):
|
||||||
@@ -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
|
||||||
@@ -457,7 +457,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
else:
|
else:
|
||||||
host = hbdcls.Host.hosts[uname]
|
host = hbdcls.Host.hosts[uname]
|
||||||
newh = False
|
newh = False
|
||||||
|
|
||||||
cid = msg.get("id", 0)
|
cid = msg.get("id", 0)
|
||||||
try:
|
try:
|
||||||
rtt = float(msg.get("rtt"))
|
rtt = float(msg.get("rtt"))
|
||||||
@@ -523,14 +523,14 @@ 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
|
||||||
logger.info(f"owner for {uname} is {host.owner}")
|
logger.info(f"owner for {uname} is {host.owner}")
|
||||||
if DEBUG > 1:
|
if DEBUG > 1:
|
||||||
print(f"Stored plugin data for {uname}: {plugin_name}")
|
print(f"Stored plugin data for {uname}: {plugin_name}")
|
||||||
|
|
||||||
# Check thresholds if checker is available
|
# Check thresholds if checker is available
|
||||||
threshold_checker = ctx.get("threshold_checker")
|
threshold_checker = ctx.get("threshold_checker")
|
||||||
if threshold_checker:
|
if threshold_checker:
|
||||||
@@ -545,7 +545,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
print(f"Threshold state changes for {uname}: {state_changes}")
|
print(f"Threshold state changes for {uname}: {state_changes}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking thresholds for {uname}.{plugin_name}: {e}")
|
logger.error(f"Error checking thresholds for {uname}.{plugin_name}: {e}")
|
||||||
|
|
||||||
# Notify websockets of plugin update
|
# Notify websockets of plugin update
|
||||||
if msg_to_websockets:
|
if msg_to_websockets:
|
||||||
try:
|
try:
|
||||||
@@ -671,13 +671,13 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
timeout_seconds = interval + grace
|
timeout_seconds = interval + grace
|
||||||
on_overdue, _ = _make_timer_callbacks(uname, host, ctx)
|
on_overdue, _ = _make_timer_callbacks(uname, host, ctx)
|
||||||
conn.reset_overdue_timer(timeout_seconds, on_overdue)
|
conn.reset_overdue_timer(timeout_seconds, on_overdue)
|
||||||
|
|
||||||
# Check RTT thresholds using the threshold checker
|
# Check RTT thresholds using the threshold checker
|
||||||
threshold_checker = ctx.get("threshold_checker")
|
threshold_checker = ctx.get("threshold_checker")
|
||||||
if threshold_checker and rtt and rtt > 0:
|
if threshold_checker and rtt and rtt > 0:
|
||||||
# Metric path for RTT is simply "rtt"
|
# Metric path for RTT is simply "rtt"
|
||||||
metric_path = "rtt"
|
metric_path = "rtt"
|
||||||
|
|
||||||
# Check against configured thresholds (handles alerts, notifications, etc.)
|
# Check against configured thresholds (handles alerts, notifications, etc.)
|
||||||
threshold_checker.check_value(
|
threshold_checker.check_value(
|
||||||
host_name=uname,
|
host_name=uname,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import glob
|
|
||||||
import os
|
|
||||||
import pytest
|
import pytest
|
||||||
from hbd.server import configio
|
from hbd.server import configio
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,4 +1,3 @@
|
|||||||
import pytest
|
|
||||||
from hbd.server import settings as settings_mod
|
from hbd.server import settings as settings_mod
|
||||||
|
|
||||||
CFG = {
|
CFG = {
|
||||||
|
|||||||
+70
-71
@@ -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,
|
||||||
@@ -31,37 +30,37 @@ from hbd.server.threshold import (
|
|||||||
def test_threshold_config_basic():
|
def test_threshold_config_basic():
|
||||||
"""Test basic threshold configuration."""
|
"""Test basic threshold configuration."""
|
||||||
print("Test 1: Basic threshold configuration...")
|
print("Test 1: Basic threshold configuration...")
|
||||||
|
|
||||||
config = ThresholdConfig(
|
config = ThresholdConfig(
|
||||||
metric_path="cpu_monitor.cpu_percent",
|
metric_path="cpu_monitor.cpu_percent",
|
||||||
warning=80.0,
|
warning=80.0,
|
||||||
critical=90.0,
|
critical=90.0,
|
||||||
operator=">",
|
operator=">",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test below warning
|
# Test below warning
|
||||||
result = config.evaluate(50.0)
|
result = config.evaluate(50.0)
|
||||||
assert result == AlertLevel.OK, f"Expected OK, got {result}"
|
assert result == AlertLevel.OK, f"Expected OK, got {result}"
|
||||||
|
|
||||||
# Test at warning
|
# Test at warning
|
||||||
result = config.evaluate(80.0)
|
result = config.evaluate(80.0)
|
||||||
assert result == AlertLevel.OK, f"Expected OK at boundary, got {result}"
|
assert result == AlertLevel.OK, f"Expected OK at boundary, got {result}"
|
||||||
|
|
||||||
# Test above warning but below critical
|
# Test above warning but below critical
|
||||||
result = config.evaluate(85.0)
|
result = config.evaluate(85.0)
|
||||||
assert result == AlertLevel.WARNING, f"Expected WARNING, got {result}"
|
assert result == AlertLevel.WARNING, f"Expected WARNING, got {result}"
|
||||||
|
|
||||||
# Test above critical
|
# Test above critical
|
||||||
result = config.evaluate(95.0)
|
result = config.evaluate(95.0)
|
||||||
assert result == AlertLevel.CRITICAL, f"Expected CRITICAL, got {result}"
|
assert result == AlertLevel.CRITICAL, f"Expected CRITICAL, got {result}"
|
||||||
|
|
||||||
print(" ✓ Basic threshold configuration works")
|
print(" ✓ Basic threshold configuration works")
|
||||||
|
|
||||||
|
|
||||||
def test_threshold_operators():
|
def test_threshold_operators():
|
||||||
"""Test all comparison operators."""
|
"""Test all comparison operators."""
|
||||||
print("\nTest 2: Comparison operators...")
|
print("\nTest 2: Comparison operators...")
|
||||||
|
|
||||||
# Greater than operator
|
# Greater than operator
|
||||||
config_gt = ThresholdConfig(
|
config_gt = ThresholdConfig(
|
||||||
metric_path="test.metric",
|
metric_path="test.metric",
|
||||||
@@ -71,7 +70,7 @@ def test_threshold_operators():
|
|||||||
)
|
)
|
||||||
assert config_gt.evaluate(85.0) == AlertLevel.WARNING
|
assert config_gt.evaluate(85.0) == AlertLevel.WARNING
|
||||||
assert config_gt.evaluate(75.0) == AlertLevel.OK
|
assert config_gt.evaluate(75.0) == AlertLevel.OK
|
||||||
|
|
||||||
# Less than operator (for inverse thresholds like available memory)
|
# Less than operator (for inverse thresholds like available memory)
|
||||||
config_lt = ThresholdConfig(
|
config_lt = ThresholdConfig(
|
||||||
metric_path="memory.available_mb",
|
metric_path="memory.available_mb",
|
||||||
@@ -82,7 +81,7 @@ def test_threshold_operators():
|
|||||||
assert config_lt.evaluate(800) == AlertLevel.WARNING, "Should warn when below 1000"
|
assert config_lt.evaluate(800) == AlertLevel.WARNING, "Should warn when below 1000"
|
||||||
assert config_lt.evaluate(400) == AlertLevel.CRITICAL, "Should be critical when below 500"
|
assert config_lt.evaluate(400) == AlertLevel.CRITICAL, "Should be critical when below 500"
|
||||||
assert config_lt.evaluate(1500) == AlertLevel.OK, "Should be OK when above 1000"
|
assert config_lt.evaluate(1500) == AlertLevel.OK, "Should be OK when above 1000"
|
||||||
|
|
||||||
# Greater than or equal
|
# Greater than or equal
|
||||||
config_gte = ThresholdConfig(
|
config_gte = ThresholdConfig(
|
||||||
metric_path="test.metric",
|
metric_path="test.metric",
|
||||||
@@ -91,7 +90,7 @@ def test_threshold_operators():
|
|||||||
)
|
)
|
||||||
assert config_gte.evaluate(80.0) == AlertLevel.WARNING
|
assert config_gte.evaluate(80.0) == AlertLevel.WARNING
|
||||||
assert config_gte.evaluate(79.9) == AlertLevel.OK
|
assert config_gte.evaluate(79.9) == AlertLevel.OK
|
||||||
|
|
||||||
# Less than or equal
|
# Less than or equal
|
||||||
config_lte = ThresholdConfig(
|
config_lte = ThresholdConfig(
|
||||||
metric_path="test.metric",
|
metric_path="test.metric",
|
||||||
@@ -100,14 +99,14 @@ def test_threshold_operators():
|
|||||||
)
|
)
|
||||||
assert config_lte.evaluate(20.0) == AlertLevel.WARNING
|
assert config_lte.evaluate(20.0) == AlertLevel.WARNING
|
||||||
assert config_lte.evaluate(20.1) == AlertLevel.OK
|
assert config_lte.evaluate(20.1) == AlertLevel.OK
|
||||||
|
|
||||||
print(" ✓ All comparison operators work correctly")
|
print(" ✓ All comparison operators work correctly")
|
||||||
|
|
||||||
|
|
||||||
def test_hysteresis():
|
def test_hysteresis():
|
||||||
"""Test hysteresis to prevent flapping."""
|
"""Test hysteresis to prevent flapping."""
|
||||||
print("\nTest 3: Hysteresis...")
|
print("\nTest 3: Hysteresis...")
|
||||||
|
|
||||||
config = ThresholdConfig(
|
config = ThresholdConfig(
|
||||||
metric_path="cpu_monitor.cpu_percent",
|
metric_path="cpu_monitor.cpu_percent",
|
||||||
warning=80.0,
|
warning=80.0,
|
||||||
@@ -115,87 +114,87 @@ def test_hysteresis():
|
|||||||
operator=">",
|
operator=">",
|
||||||
hysteresis=0.1, # 10% hysteresis
|
hysteresis=0.1, # 10% hysteresis
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start at OK, go to WARNING
|
# Start at OK, go to WARNING
|
||||||
result = config.evaluate_with_hysteresis(85.0, AlertLevel.OK)
|
result = config.evaluate_with_hysteresis(85.0, AlertLevel.OK)
|
||||||
assert result == AlertLevel.WARNING, "Should enter WARNING state"
|
assert result == AlertLevel.WARNING, "Should enter WARNING state"
|
||||||
|
|
||||||
# Try to recover with insufficient improvement (within hysteresis)
|
# Try to recover with insufficient improvement (within hysteresis)
|
||||||
# Warning threshold is 80, hysteresis is 10%, so need to go below 80 - 8 = 72
|
# Warning threshold is 80, hysteresis is 10%, so need to go below 80 - 8 = 72
|
||||||
result = config.evaluate_with_hysteresis(77.0, AlertLevel.WARNING)
|
result = config.evaluate_with_hysteresis(77.0, AlertLevel.WARNING)
|
||||||
assert result == AlertLevel.WARNING, "Should stay in WARNING (hysteresis)"
|
assert result == AlertLevel.WARNING, "Should stay in WARNING (hysteresis)"
|
||||||
|
|
||||||
# Recover with sufficient improvement
|
# Recover with sufficient improvement
|
||||||
result = config.evaluate_with_hysteresis(70.0, AlertLevel.WARNING)
|
result = config.evaluate_with_hysteresis(70.0, AlertLevel.WARNING)
|
||||||
assert result == AlertLevel.OK, "Should recover to OK"
|
assert result == AlertLevel.OK, "Should recover to OK"
|
||||||
|
|
||||||
# Test critical hysteresis
|
# Test critical hysteresis
|
||||||
result = config.evaluate_with_hysteresis(95.0, AlertLevel.WARNING)
|
result = config.evaluate_with_hysteresis(95.0, AlertLevel.WARNING)
|
||||||
assert result == AlertLevel.CRITICAL, "Should escalate to CRITICAL"
|
assert result == AlertLevel.CRITICAL, "Should escalate to CRITICAL"
|
||||||
|
|
||||||
# Try to recover from critical with insufficient improvement
|
# Try to recover from critical with insufficient improvement
|
||||||
# Critical threshold is 90, hysteresis is 10%, so need to go below 90 - 9 = 81
|
# Critical threshold is 90, hysteresis is 10%, so need to go below 90 - 9 = 81
|
||||||
result = config.evaluate_with_hysteresis(85.0, AlertLevel.CRITICAL)
|
result = config.evaluate_with_hysteresis(85.0, AlertLevel.CRITICAL)
|
||||||
assert result == AlertLevel.CRITICAL, "Should stay CRITICAL (hysteresis, still above 81)"
|
assert result == AlertLevel.CRITICAL, "Should stay CRITICAL (hysteresis, still above 81)"
|
||||||
|
|
||||||
# Sufficient improvement to drop from CRITICAL (below 81)
|
# Sufficient improvement to drop from CRITICAL (below 81)
|
||||||
result = config.evaluate_with_hysteresis(75.0, AlertLevel.CRITICAL)
|
result = config.evaluate_with_hysteresis(75.0, AlertLevel.CRITICAL)
|
||||||
assert result == AlertLevel.OK, "Should drop to OK (below warning threshold)"
|
assert result == AlertLevel.OK, "Should drop to OK (below warning threshold)"
|
||||||
|
|
||||||
# Now test dropping from CRITICAL to WARNING
|
# Now test dropping from CRITICAL to WARNING
|
||||||
result = config.evaluate_with_hysteresis(95.0, AlertLevel.OK)
|
result = config.evaluate_with_hysteresis(95.0, AlertLevel.OK)
|
||||||
assert result == AlertLevel.CRITICAL, "Should go to CRITICAL"
|
assert result == AlertLevel.CRITICAL, "Should go to CRITICAL"
|
||||||
|
|
||||||
# Drop to between warning and critical, but still in hysteresis zone
|
# Drop to between warning and critical, but still in hysteresis zone
|
||||||
result = config.evaluate_with_hysteresis(82.0, AlertLevel.CRITICAL)
|
result = config.evaluate_with_hysteresis(82.0, AlertLevel.CRITICAL)
|
||||||
assert result == AlertLevel.CRITICAL, "Should stay CRITICAL (in hysteresis)"
|
assert result == AlertLevel.CRITICAL, "Should stay CRITICAL (in hysteresis)"
|
||||||
|
|
||||||
# Drop below critical hysteresis but still above warning threshold
|
# Drop below critical hysteresis but still above warning threshold
|
||||||
# At 80.1, we're above WARNING (80) so should evaluate to WARNING
|
# At 80.1, we're above WARNING (80) so should evaluate to WARNING
|
||||||
result = config.evaluate_with_hysteresis(80.5, AlertLevel.CRITICAL)
|
result = config.evaluate_with_hysteresis(80.5, AlertLevel.CRITICAL)
|
||||||
assert result == AlertLevel.WARNING, "Should drop to WARNING when below critical hysteresis"
|
assert result == AlertLevel.WARNING, "Should drop to WARNING when below critical hysteresis"
|
||||||
|
|
||||||
print(" ✓ Hysteresis prevents flapping")
|
print(" ✓ Hysteresis prevents flapping")
|
||||||
|
|
||||||
|
|
||||||
def test_alert_state():
|
def test_alert_state():
|
||||||
"""Test alert state tracking."""
|
"""Test alert state tracking."""
|
||||||
print("\nTest 4: Alert state tracking...")
|
print("\nTest 4: Alert state tracking...")
|
||||||
|
|
||||||
alert = AlertState("cpu_monitor.cpu_percent")
|
alert = AlertState("cpu_monitor.cpu_percent")
|
||||||
|
|
||||||
# Initial state
|
# Initial state
|
||||||
assert alert.level == AlertLevel.OK
|
assert alert.level == AlertLevel.OK
|
||||||
assert alert.notification_count == 0
|
assert alert.notification_count == 0
|
||||||
|
|
||||||
# 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")
|
||||||
|
|
||||||
|
|
||||||
def test_threshold_checker_parsing():
|
def test_threshold_checker_parsing():
|
||||||
"""Test parsing threshold configuration from YAML structure."""
|
"""Test parsing threshold configuration from YAML structure."""
|
||||||
print("\nTest 5: Configuration parsing...")
|
print("\nTest 5: Configuration parsing...")
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"cpu_monitor": {
|
"cpu_monitor": {
|
||||||
@@ -239,9 +238,9 @@ def test_threshold_checker_parsing():
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checker = ThresholdChecker(config)
|
checker = ThresholdChecker(config)
|
||||||
|
|
||||||
# Verify thresholds were parsed
|
# Verify thresholds were parsed
|
||||||
assert "cpu_monitor.cpu_percent" in checker.thresholds
|
assert "cpu_monitor.cpu_percent" in checker.thresholds
|
||||||
assert "cpu_monitor.load_1min" in checker.thresholds
|
assert "cpu_monitor.load_1min" in checker.thresholds
|
||||||
@@ -249,11 +248,11 @@ def test_threshold_checker_parsing():
|
|||||||
assert "memory_monitor.available_mb" in checker.thresholds
|
assert "memory_monitor.available_mb" in checker.thresholds
|
||||||
assert "disk_monitor./.percent" in checker.thresholds
|
assert "disk_monitor./.percent" in checker.thresholds
|
||||||
assert "disk_monitor./home.percent" in checker.thresholds
|
assert "disk_monitor./home.percent" in checker.thresholds
|
||||||
|
|
||||||
# Verify operators were parsed correctly
|
# Verify operators were parsed correctly
|
||||||
assert checker.thresholds["cpu_monitor.cpu_percent"].operator == ComparisonOperator.GT
|
assert checker.thresholds["cpu_monitor.cpu_percent"].operator == ComparisonOperator.GT
|
||||||
assert checker.thresholds["memory_monitor.available_mb"].operator == ComparisonOperator.LT
|
assert checker.thresholds["memory_monitor.available_mb"].operator == ComparisonOperator.LT
|
||||||
|
|
||||||
print(f" ✓ Parsed {len(checker.thresholds)} thresholds correctly")
|
print(f" ✓ Parsed {len(checker.thresholds)} thresholds correctly")
|
||||||
|
|
||||||
|
|
||||||
@@ -269,7 +268,7 @@ def _checker_with_notifications(config, notifications, monkeypatch):
|
|||||||
def test_check_plugin_data(monkeypatch):
|
def test_check_plugin_data(monkeypatch):
|
||||||
"""Test checking plugin data against thresholds."""
|
"""Test checking plugin data against thresholds."""
|
||||||
print("\nTest 6: Plugin data checking...")
|
print("\nTest 6: Plugin data checking...")
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"cpu_monitor": {
|
"cpu_monitor": {
|
||||||
@@ -284,53 +283,53 @@ def test_check_plugin_data(monkeypatch):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notifications = []
|
notifications = []
|
||||||
checker = _checker_with_notifications(config, notifications, monkeypatch)
|
checker = _checker_with_notifications(config, notifications, monkeypatch)
|
||||||
alert_states = {}
|
alert_states = {}
|
||||||
|
|
||||||
# First check - OK
|
# First check - OK
|
||||||
plugin_data = {
|
plugin_data = {
|
||||||
"cpu_percent": 50.0,
|
"cpu_percent": 50.0,
|
||||||
"load_1min": 2.0,
|
"load_1min": 2.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
state_changes = checker.check_plugin_data(
|
state_changes = checker.check_plugin_data(
|
||||||
host_name="testhost",
|
host_name="testhost",
|
||||||
plugin_name="cpu_monitor",
|
plugin_name="cpu_monitor",
|
||||||
data=plugin_data,
|
data=plugin_data,
|
||||||
alert_states=alert_states,
|
alert_states=alert_states,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(state_changes) == 0, "No thresholds violated, no state changes"
|
assert len(state_changes) == 0, "No thresholds violated, no state changes"
|
||||||
assert len(notifications) == 0, "No notifications should be sent"
|
assert len(notifications) == 0, "No notifications should be sent"
|
||||||
|
|
||||||
# Second check - WARNING
|
# Second check - WARNING
|
||||||
plugin_data = {
|
plugin_data = {
|
||||||
"cpu_percent": 85.0,
|
"cpu_percent": 85.0,
|
||||||
"load_1min": 2.0,
|
"load_1min": 2.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
state_changes = checker.check_plugin_data(
|
state_changes = checker.check_plugin_data(
|
||||||
host_name="testhost",
|
host_name="testhost",
|
||||||
plugin_name="cpu_monitor",
|
plugin_name="cpu_monitor",
|
||||||
data=plugin_data,
|
data=plugin_data,
|
||||||
alert_states=alert_states,
|
alert_states=alert_states,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(state_changes) == 1, "One metric should change state"
|
assert len(state_changes) == 1, "One metric should change state"
|
||||||
assert state_changes[0][0] == "cpu_monitor.cpu_percent"
|
assert state_changes[0][0] == "cpu_monitor.cpu_percent"
|
||||||
assert state_changes[0][2] == AlertLevel.WARNING
|
assert state_changes[0][2] == AlertLevel.WARNING
|
||||||
assert len(notifications) == 1, "One notification should be sent"
|
assert len(notifications) == 1, "One notification should be sent"
|
||||||
assert "WARNING" in notifications[0]
|
assert "WARNING" in notifications[0]
|
||||||
assert "testhost" in notifications[0]
|
assert "testhost" in notifications[0]
|
||||||
|
|
||||||
# Third check - CRITICAL
|
# Third check - CRITICAL
|
||||||
plugin_data = {
|
plugin_data = {
|
||||||
"cpu_percent": 95.0,
|
"cpu_percent": 95.0,
|
||||||
"load_1min": 9.0,
|
"load_1min": 9.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
notifications.clear()
|
notifications.clear()
|
||||||
state_changes = checker.check_plugin_data(
|
state_changes = checker.check_plugin_data(
|
||||||
host_name="testhost",
|
host_name="testhost",
|
||||||
@@ -338,16 +337,16 @@ def test_check_plugin_data(monkeypatch):
|
|||||||
data=plugin_data,
|
data=plugin_data,
|
||||||
alert_states=alert_states,
|
alert_states=alert_states,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(state_changes) == 2, "Two metrics should change state"
|
assert len(state_changes) == 2, "Two metrics should change state"
|
||||||
assert len(notifications) == 2, "Two notifications should be sent"
|
assert len(notifications) == 2, "Two notifications should be sent"
|
||||||
|
|
||||||
# Fourth check - Recovery
|
# Fourth check - Recovery
|
||||||
plugin_data = {
|
plugin_data = {
|
||||||
"cpu_percent": 50.0,
|
"cpu_percent": 50.0,
|
||||||
"load_1min": 1.0,
|
"load_1min": 1.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
notifications.clear()
|
notifications.clear()
|
||||||
state_changes = checker.check_plugin_data(
|
state_changes = checker.check_plugin_data(
|
||||||
host_name="testhost",
|
host_name="testhost",
|
||||||
@@ -355,18 +354,18 @@ def test_check_plugin_data(monkeypatch):
|
|||||||
data=plugin_data,
|
data=plugin_data,
|
||||||
alert_states=alert_states,
|
alert_states=alert_states,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(state_changes) == 2, "Two metrics should recover"
|
assert len(state_changes) == 2, "Two metrics should recover"
|
||||||
assert len(notifications) == 2, "Two recovery notifications"
|
assert len(notifications) == 2, "Two recovery notifications"
|
||||||
assert any("RECOVER" in n for n in notifications), "Should have recovery notification"
|
assert any("RECOVER" in n for n in notifications), "Should have recovery notification"
|
||||||
|
|
||||||
print(" ✓ Plugin data checking and notifications work")
|
print(" ✓ Plugin data checking and notifications work")
|
||||||
|
|
||||||
|
|
||||||
def test_nested_metrics(monkeypatch):
|
def test_nested_metrics(monkeypatch):
|
||||||
"""Test checking nested metrics like disk partitions."""
|
"""Test checking nested metrics like disk partitions."""
|
||||||
print("\nTest 7: Nested metrics (partitions)...")
|
print("\nTest 7: Nested metrics (partitions)...")
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"disk_monitor": {
|
"disk_monitor": {
|
||||||
@@ -387,11 +386,11 @@ def test_nested_metrics(monkeypatch):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notifications = []
|
notifications = []
|
||||||
checker = _checker_with_notifications(config, notifications, monkeypatch)
|
checker = _checker_with_notifications(config, notifications, monkeypatch)
|
||||||
alert_states = {}
|
alert_states = {}
|
||||||
|
|
||||||
plugin_data = {
|
plugin_data = {
|
||||||
"partitions": {
|
"partitions": {
|
||||||
"/": {
|
"/": {
|
||||||
@@ -404,26 +403,26 @@ def test_nested_metrics(monkeypatch):
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
state_changes = checker.check_plugin_data(
|
state_changes = checker.check_plugin_data(
|
||||||
host_name="testhost",
|
host_name="testhost",
|
||||||
plugin_name="disk_monitor",
|
plugin_name="disk_monitor",
|
||||||
data=plugin_data,
|
data=plugin_data,
|
||||||
alert_states=alert_states,
|
alert_states=alert_states,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(state_changes) == 1, "One partition should trigger alert"
|
assert len(state_changes) == 1, "One partition should trigger alert"
|
||||||
assert "/home" in state_changes[0][0], "Should be /home partition"
|
assert "/home" in state_changes[0][0], "Should be /home partition"
|
||||||
assert state_changes[0][2] == AlertLevel.WARNING
|
assert state_changes[0][2] == AlertLevel.WARNING
|
||||||
assert len(notifications) == 1
|
assert len(notifications) == 1
|
||||||
|
|
||||||
print(" ✓ Nested metric checking works")
|
print(" ✓ Nested metric checking works")
|
||||||
|
|
||||||
|
|
||||||
def test_alert_summary():
|
def test_alert_summary():
|
||||||
"""Test getting alert summaries."""
|
"""Test getting alert summaries."""
|
||||||
print("\nTest 8: Alert summaries...")
|
print("\nTest 8: Alert summaries...")
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"cpu_monitor": {
|
"cpu_monitor": {
|
||||||
@@ -435,31 +434,31 @@ def test_alert_summary():
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checker = ThresholdChecker(config)
|
checker = ThresholdChecker(config)
|
||||||
alert_states = {}
|
alert_states = {}
|
||||||
|
|
||||||
# Create some alert states
|
# Create some alert states
|
||||||
plugin_data = {
|
plugin_data = {
|
||||||
"cpu_percent": 85.0, # WARNING
|
"cpu_percent": 85.0, # WARNING
|
||||||
"load_1min": 9.0, # CRITICAL
|
"load_1min": 9.0, # CRITICAL
|
||||||
}
|
}
|
||||||
checker.check_plugin_data("testhost", "cpu_monitor", plugin_data, alert_states)
|
checker.check_plugin_data("testhost", "cpu_monitor", plugin_data, alert_states)
|
||||||
|
|
||||||
plugin_data = {
|
plugin_data = {
|
||||||
"percent": 96.0, # CRITICAL
|
"percent": 96.0, # CRITICAL
|
||||||
}
|
}
|
||||||
checker.check_plugin_data("testhost", "memory_monitor", plugin_data, alert_states)
|
checker.check_plugin_data("testhost", "memory_monitor", plugin_data, alert_states)
|
||||||
|
|
||||||
# Get summary
|
# Get summary
|
||||||
summary = checker.get_alert_summary(alert_states)
|
summary = checker.get_alert_summary(alert_states)
|
||||||
assert summary["warning"] == 1, "Should have 1 warning"
|
assert summary["warning"] == 1, "Should have 1 warning"
|
||||||
assert summary["critical"] == 2, "Should have 2 critical"
|
assert summary["critical"] == 2, "Should have 2 critical"
|
||||||
|
|
||||||
# Get active alerts
|
# Get active alerts
|
||||||
active = checker.get_active_alerts(alert_states)
|
active = checker.get_active_alerts(alert_states)
|
||||||
assert len(active) == 3, "Should have 3 active alerts"
|
assert len(active) == 3, "Should have 3 active alerts"
|
||||||
|
|
||||||
print(" ✓ Alert summaries work correctly")
|
print(" ✓ Alert summaries work correctly")
|
||||||
|
|
||||||
|
|
||||||
@@ -468,7 +467,7 @@ def run_all_tests():
|
|||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
print("THRESHOLD SYSTEM TEST SUITE")
|
print("THRESHOLD SYSTEM TEST SUITE")
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
test_threshold_config_basic()
|
test_threshold_config_basic()
|
||||||
test_threshold_operators()
|
test_threshold_operators()
|
||||||
@@ -478,12 +477,12 @@ def run_all_tests():
|
|||||||
test_check_plugin_data()
|
test_check_plugin_data()
|
||||||
test_nested_metrics()
|
test_nested_metrics()
|
||||||
test_alert_summary()
|
test_alert_summary()
|
||||||
|
|
||||||
print("\n" + "=" * 70)
|
print("\n" + "=" * 70)
|
||||||
print("✓ ALL TESTS PASSED")
|
print("✓ ALL TESTS PASSED")
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
except AssertionError as e:
|
except AssertionError as e:
|
||||||
print(f"\n✗ TEST FAILED: {e}")
|
print(f"\n✗ TEST FAILED: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|||||||
Reference in New Issue
Block a user