From 0b2440e5864eb398c4c64dff314f052b8b8a233f Mon Sep 17 00:00:00 2001 From: Andrey Date: Wed, 27 Aug 2025 01:17:15 +0300 Subject: [PATCH] Add server monitoring functionality and update Makefile and requirements - Introduced a new server monitoring module in `run_helper.py` with graceful shutdown handling. - Updated `.gitignore` to include PID files. - Added `test-monitor` target in `Makefile` for testing the server monitoring module. - Included `psutil` in `requirements.txt` for system monitoring capabilities. --- .gitignore | 5 + Makefile | 9 +- helper_bot/__init__.py | 1 + helper_bot/server_monitor.py | 453 +++++++++++++++++++++++++++++++++++ requirements.txt | 3 + run_helper.py | 79 +++++- tests/test_monitor.py | 81 +++++++ 7 files changed, 629 insertions(+), 2 deletions(-) create mode 100644 helper_bot/server_monitor.py create mode 100644 tests/test_monitor.py diff --git a/.gitignore b/.gitignore index 019b7d1..37bcbc7 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,8 @@ test.db ehthumbs.db Thumbs.db PERFORMANCE_IMPROVEMENTS.md + +# PID files +*.pid +helper_bot.pid +voice_bot.pid diff --git a/Makefile b/Makefile index bf4e8f7..1d96859 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help test test-db test-coverage test-html clean install +.PHONY: help test test-db test-coverage test-html clean install test-monitor # Default target help: @@ -11,6 +11,7 @@ help: @echo " test-errors - Run error handling tests only" @echo " test-utils - Run utility functions tests only" @echo " test-keyboards - Run keyboard and filter tests only" + @echo " test-monitor - Test server monitoring module" @echo " test-coverage - Run tests with coverage report (helper_bot + database)" @echo " test-html - Run tests and generate HTML coverage report" @echo " clean - Clean up generated files" @@ -49,6 +50,10 @@ test-utils: test-keyboards: python3 -m pytest tests/test_keyboards_and_filters.py -v +# Test server monitoring module +test-monitor: + python3 tests/test_monitor.py + # Run tests with coverage test-coverage: python3 -m pytest tests/ --cov=helper_bot --cov=database --cov-report=term @@ -69,5 +74,7 @@ clean: rm -f .coverage rm -f database/test.db rm -f test.db + rm -f helper_bot.pid + rm -f voice_bot.pid find . -type d -name "__pycache__" -exec rm -rf {} + find . -type f -name "*.pyc" -delete diff --git a/helper_bot/__init__.py b/helper_bot/__init__.py index e69de29..3ed7b11 100644 --- a/helper_bot/__init__.py +++ b/helper_bot/__init__.py @@ -0,0 +1 @@ +from . import server_monitor diff --git a/helper_bot/server_monitor.py b/helper_bot/server_monitor.py new file mode 100644 index 0000000..d43eafb --- /dev/null +++ b/helper_bot/server_monitor.py @@ -0,0 +1,453 @@ +import asyncio +import os +import psutil +import time +from datetime import datetime, timedelta +from typing import Dict, Optional, Tuple +import logging + +logger = logging.getLogger(__name__) + + +class ServerMonitor: + def __init__(self, bot, group_for_logs: str, important_logs: str): + self.bot = bot + self.group_for_logs = group_for_logs + self.important_logs = important_logs + + # Пороговые значения для алертов + self.threshold = 80.0 + self.recovery_threshold = 75.0 + + # Состояние алертов для предотвращения спама + self.alert_states = { + 'cpu': False, + 'ram': False, + 'disk': False + } + + # PID файлы для отслеживания процессов + self.pid_files = { + 'voice_bot': 'voice_bot.pid', + 'helper_bot': 'helper_bot.pid' + } + + # Время последней отправки статуса + self.last_status_time = None + + # Для расчета скорости диска + self.last_disk_io = None + self.last_disk_io_time = None + + def get_system_info(self) -> Dict: + """Получение информации о системе""" + try: + # CPU + cpu_percent = psutil.cpu_percent(interval=1) + load_avg = psutil.getloadavg() + cpu_count = psutil.cpu_count() + + # Память + memory = psutil.virtual_memory() + swap = psutil.swap_memory() + + # Диск + disk = psutil.disk_usage('/') + disk_io = psutil.disk_io_counters() + + # Расчет скорости диска + disk_read_speed, disk_write_speed = self._calculate_disk_speed(disk_io) + + # Система + boot_time = psutil.boot_time() + uptime = time.time() - boot_time + + return { + 'cpu_percent': cpu_percent, + 'load_avg_1m': round(load_avg[0], 2), + 'load_avg_5m': round(load_avg[1], 2), + 'load_avg_15m': round(load_avg[2], 2), + 'cpu_count': cpu_count, + 'ram_used': round(memory.used / (1024**3), 2), + 'ram_total': round(memory.total / (1024**3), 2), + 'ram_percent': round(memory.percent, 1), + 'swap_used': round(swap.used / (1024**3), 2), + 'swap_total': round(swap.total / (1024**3), 2), + 'swap_percent': swap.percent, + 'disk_used': round(disk.used / (1024**3), 2), + 'disk_total': round(disk.total / (1024**3), 2), + 'disk_percent': round((disk.used / disk.total) * 100, 2), + 'disk_free': round(disk.free / (1024**3), 2), + 'disk_read_speed': disk_read_speed, + 'disk_write_speed': disk_write_speed, + 'disk_io_percent': self._calculate_disk_io_percent(), + 'system_uptime': self._format_uptime(uptime), + 'server_hostname': psutil.os.uname().nodename, + 'current_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + except Exception as e: + logger.error(f"Ошибка при получении информации о системе: {e}") + return {} + + def _format_bytes(self, bytes_value: int) -> str: + """Форматирование байтов в человекочитаемый вид""" + if bytes_value == 0: + return "0 B" + + size_names = ["B", "KB", "MB", "GB", "TB"] + i = 0 + while bytes_value >= 1024 and i < len(size_names) - 1: + bytes_value /= 1024.0 + i += 1 + + return f"{bytes_value:.1f} {size_names[i]}" + + def _format_uptime(self, seconds: float) -> str: + """Форматирование времени работы системы""" + days = int(seconds // 86400) + hours = int((seconds % 86400) // 3600) + minutes = int((seconds % 3600) // 60) + + if days > 0: + return f"{days}д {hours}ч {minutes}м" + elif hours > 0: + return f"{hours}ч {minutes}м" + else: + return f"{minutes}м" + + def check_process_status(self, process_name: str) -> str: + """Проверка статуса процесса""" + try: + # Сначала проверяем по PID файлу + pid_file = self.pid_files.get(process_name) + if pid_file and os.path.exists(pid_file): + try: + with open(pid_file, 'r') as f: + content = f.read().strip() + if content and content != '# Этот файл будет автоматически обновляться при запуске бота': + pid = int(content) + if psutil.pid_exists(pid): + return "✅" + except (ValueError, FileNotFoundError): + pass + + # Проверяем по имени процесса более точно + for proc in psutil.process_iter(['pid', 'name', 'cmdline']): + try: + proc_name = proc.info['name'].lower() + cmdline = ' '.join(proc.info['cmdline']).lower() if proc.info['cmdline'] else '' + + # Более точная проверка для каждого бота + if process_name == 'voice_bot': + # Проверяем voice_bot + if ('voice_bot' in proc_name or + 'voice_bot' in cmdline or + 'voice_bot_v2.py' in cmdline): + return "✅" + elif process_name == 'helper_bot': + # Проверяем helper_bot + if ('helper_bot' in proc_name or + 'helper_bot' in cmdline or + 'run_helper.py' in cmdline or + 'python' in proc_name and 'helper_bot' in cmdline): + return "✅" + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + return "❌" + except Exception as e: + logger.error(f"Ошибка при проверке процесса {process_name}: {e}") + return "❌" + + def should_send_status(self) -> bool: + """Проверка, нужно ли отправить статус (каждые 30 минут в 00 и 30 минут часа)""" + now = datetime.now() + + # Проверяем, что сейчас 00 или 30 минут часа + if now.minute in [0, 30]: + # Проверяем, не отправляли ли мы уже статус в эту минуту + if (self.last_status_time is None or + self.last_status_time.hour != now.hour or + self.last_status_time.minute != now.minute): + self.last_status_time = now + return True + + return False + + def _calculate_disk_speed(self, current_disk_io) -> Tuple[str, str]: + """Расчет скорости чтения/записи диска""" + current_time = time.time() + + if self.last_disk_io is None or self.last_disk_io_time is None: + self.last_disk_io = current_disk_io + self.last_disk_io_time = current_time + return "0 B/s", "0 B/s" + + time_diff = current_time - self.last_disk_io_time + if time_diff < 1: # Минимальный интервал 1 секунда + return "0 B/s", "0 B/s" + + read_diff = current_disk_io.read_bytes - self.last_disk_io.read_bytes + write_diff = current_disk_io.write_bytes - self.last_disk_io.write_bytes + + read_speed = read_diff / time_diff + write_speed = write_diff / time_diff + + # Обновляем предыдущие значения + self.last_disk_io = current_disk_io + self.last_disk_io_time = current_time + + return self._format_bytes(read_speed) + "/s", self._format_bytes(write_speed) + "/s" + + def _calculate_disk_io_percent(self) -> int: + """Расчет процента загрузки диска на основе IOPS""" + try: + # Получаем статистику диска + disk_io = psutil.disk_io_counters() + if disk_io is None: + return 0 + + # Простая эвристика: считаем общее количество операций + total_ops = disk_io.read_count + disk_io.write_count + + # Нормализуем к проценту (это приблизительная оценка) + # На macOS обычно нормальная нагрузка до 1000-5000 операций в секунду + if total_ops < 1000: + return 10 + elif total_ops < 5000: + return 30 + elif total_ops < 10000: + return 50 + elif total_ops < 20000: + return 70 + else: + return 90 + except: + return 0 + + def should_send_startup_status(self) -> bool: + """Проверка, нужно ли отправить статус при запуске""" + return self.last_status_time is None + + async def send_startup_message(self): + """Отправка сообщения о запуске бота""" + try: + message = f"""🚀 **Бот запущен!** +--------------------------------- +**Время запуска:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +**Сервер:** `{psutil.os.uname().nodename}` +**Система:** {psutil.os.uname().sysname} {psutil.os.uname().release} + +✅ Мониторинг сервера активирован +✅ Статус будет отправляться каждые 30 минут (в 00 и 30 минут часа) +✅ Алерты будут отправляться при превышении пороговых значений +---------------------------------""" + + await self.bot.send_message( + chat_id=self.important_logs, + text=message, + parse_mode='HTML' + ) + logger.info("Сообщение о запуске бота отправлено") + + except Exception as e: + logger.error(f"Ошибка при отправке сообщения о запуске: {e}") + + async def send_shutdown_message(self): + """Отправка сообщения об отключении бота""" + try: + # Получаем финальную информацию о системе + system_info = self.get_system_info() + if not system_info: + system_info = { + 'current_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'server_hostname': psutil.os.uname().nodename + } + + message = f"""🛑 **Бот отключен!** +--------------------------------- +**Время отключения:** {system_info['current_time']} +**Сервер:** `{system_info['server_hostname']}` + +❌ Мониторинг сервера остановлен +❌ Статус больше не будет отправляться +❌ Алерты отключены + +⚠️ **Внимание:** Проверьте состояние сервера! +---------------------------------""" + + await self.bot.send_message( + chat_id=self.important_logs, + text=message, + parse_mode='HTML' + ) + logger.info("Сообщение об отключении бота отправлено") + + except Exception as e: + logger.error(f"Ошибка при отправке сообщения об отключении: {e}") + + def check_alerts(self, system_info: Dict) -> Tuple[bool, Optional[str]]: + """Проверка необходимости отправки алертов""" + alerts = [] + + # Проверка CPU + if system_info['cpu_percent'] > self.threshold and not self.alert_states['cpu']: + self.alert_states['cpu'] = True + alerts.append(('cpu', system_info['cpu_percent'], f"Нагрузка за 1 мин: {system_info['load_avg_1m']}")) + + # Проверка RAM + if system_info['ram_percent'] > self.threshold and not self.alert_states['ram']: + self.alert_states['ram'] = True + alerts.append(('ram', system_info['ram_percent'], f"Используется: {system_info['ram_used']} GB из {system_info['ram_total']} GB")) + + # Проверка диска + if system_info['disk_percent'] > self.threshold and not self.alert_states['disk']: + self.alert_states['disk'] = True + alerts.append(('disk', system_info['disk_percent'], f"Свободно: {system_info['disk_free']} GB на /")) + + # Проверка восстановления + recoveries = [] + if system_info['cpu_percent'] < self.recovery_threshold and self.alert_states['cpu']: + self.alert_states['cpu'] = False + recoveries.append(('cpu', system_info['cpu_percent'])) + + if system_info['ram_percent'] < self.recovery_threshold and self.alert_states['ram']: + self.alert_states['ram'] = False + recoveries.append(('ram', system_info['ram_percent'])) + + if system_info['disk_percent'] < self.recovery_threshold and self.alert_states['disk']: + self.alert_states['disk'] = False + recoveries.append(('disk', system_info['disk_percent'])) + + return alerts, recoveries + + async def send_status_message(self, system_info: Dict): + """Отправка сообщения со статусом сервера""" + try: + voice_bot_status = self.check_process_status('voice_bot') + helper_bot_status = self.check_process_status('helper_bot') + + message = f"""🖥 **Статус Сервера** | {system_info['current_time']} +--------------------------------- +**📊 Общая нагрузка:** +CPU: {system_info['cpu_percent']}% | LA: {system_info['load_avg_1m']} / {system_info['cpu_count']} | IO Wait: {system_info['disk_percent']}% + +**💾 Память:** +RAM: {system_info['ram_used']}/{system_info['ram_total']} GB ({system_info['ram_percent']}%) +Swap: {system_info['swap_used']}/{system_info['swap_total']} GB ({system_info['swap_percent']}%) + +**💿 Диск I/O:** +Read: {system_info['disk_read_speed']} | Write: {system_info['disk_write_speed']} +Диск загружен: {system_info['disk_io_percent']}% + +**🤖 Процессы:** +{voice_bot_status} voice-bot | {helper_bot_status} helper-bot +--------------------------------- +⏰ Uptime: {system_info['system_uptime']}""" + + await self.bot.send_message( + chat_id=self.group_for_logs, + text=message, + parse_mode='HTML' + ) + logger.info("Статус сервера отправлен") + + except Exception as e: + logger.error(f"Ошибка при отправке статуса сервера: {e}") + + async def send_alert_message(self, metric_name: str, current_value: float, details: str): + """Отправка сообщения об алерте""" + try: + message = f"""🚨 **ALERT: Высокая нагрузка на сервере!** +--------------------------------- +**Показатель:** {metric_name} +**Текущее значение:** {current_value}% ⚠️ +**Пороговое значение:** 80% + +**Детали:** +{details} + +**Сервер:** `{psutil.os.uname().nodename}` +**Время:** `{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}` +---------------------------------""" + + await self.bot.send_message( + chat_id=self.important_logs, + text=message, + parse_mode='HTML' + ) + logger.warning(f"Алерт отправлен: {metric_name} - {current_value}%") + + except Exception as e: + logger.error(f"Ошибка при отправке алерта: {e}") + + async def send_recovery_message(self, metric_name: str, current_value: float, peak_value: float): + """Отправка сообщения о восстановлении""" + try: + message = f"""✅ **RECOVERY: Нагрузка нормализовалась** +--------------------------------- +**Показатель:** {metric_name} +**Текущее значение:** {current_value}% ✔️ +**Было превышение:** До {peak_value}% + +**Сервер:** `{psutil.os.uname().nodename}` +**Время:** `{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}` +---------------------------------""" + + await self.bot.send_message( + chat_id=self.important_logs, + text=message, + parse_mode='HTML' + ) + logger.info(f"Сообщение о восстановлении отправлено: {metric_name}") + + except Exception as e: + logger.error(f"Ошибка при отправке сообщения о восстановлении: {e}") + + async def monitor_loop(self): + """Основной цикл мониторинга""" + logger.info("Модуль мониторинга сервера запущен") + + # Отправляем сообщение о запуске при первом запуске + if self.should_send_startup_status(): + await self.send_startup_message() + + while True: + try: + system_info = self.get_system_info() + if not system_info: + await asyncio.sleep(60) + continue + + # Проверка алертов + alerts, recoveries = self.check_alerts(system_info) + + # Отправка алертов + for metric_type, value, details in alerts: + metric_names = { + 'cpu': 'Использование CPU', + 'ram': 'Использование оперативной памяти', + 'disk': 'Заполнение диска (/)' + } + await self.send_alert_message(metric_names[metric_type], value, details) + + # Отправка сообщений о восстановлении + for metric_type, value in recoveries: + metric_names = { + 'cpu': 'Использование CPU', + 'ram': 'Использование оперативной памяти', + 'disk': 'Заполнение диска (/)' + } + # Находим пиковое значение (используем 80% как пример) + await self.send_recovery_message(metric_names[metric_type], value, 80.0) + + # Отправка статуса каждые 30 минут в 00 и 30 минут часа + if self.should_send_status(): + await self.send_status_message(system_info) + + # Пауза между проверками (1 минута) + await asyncio.sleep(60) + + except Exception as e: + logger.error(f"Ошибка в цикле мониторинга: {e}") + await asyncio.sleep(60) diff --git a/requirements.txt b/requirements.txt index ca35ae1..20cf7ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,9 @@ aiogram~=3.10.0 # Logging loguru==0.7.2 +# System monitoring +psutil~=6.1.0 + # Testing pytest==8.2.2 pytest-asyncio==1.1.0 diff --git a/run_helper.py b/run_helper.py index 5ea56c4..9579ba9 100644 --- a/run_helper.py +++ b/run_helper.py @@ -1,6 +1,7 @@ import asyncio import os import sys +import signal # Ensure project root is on sys.path for module resolution CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -9,6 +10,82 @@ if CURRENT_DIR not in sys.path: from helper_bot.main import start_bot from helper_bot.utils.base_dependency_factory import get_global_instance +from helper_bot.server_monitor import ServerMonitor + + +async def start_monitoring(bdf, bot): + """Запуск модуля мониторинга сервера""" + monitor = ServerMonitor( + bot=bot, + group_for_logs=bdf.settings['Telegram']['group_for_logs'], + important_logs=bdf.settings['Telegram']['important_logs'] + ) + return monitor + + +async def main(): + """Основная функция запуска""" + bdf = get_global_instance() + + # Создаем бота для мониторинга + from aiogram import Bot + from aiogram.client.default import DefaultBotProperties + + monitor_bot = Bot( + token=bdf.settings['Telegram']['bot_token'], + default=DefaultBotProperties(parse_mode='HTML'), + timeout=30.0 + ) + + # Создаем экземпляр монитора + monitor = await start_monitoring(bdf, monitor_bot) + + # Флаг для корректного завершения + shutdown_event = asyncio.Event() + + def signal_handler(signum, frame): + """Обработчик сигналов для корректного завершения""" + print(f"\nПолучен сигнал {signum}, завершаем работу...") + shutdown_event.set() + + # Регистрируем обработчики сигналов + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # Запускаем бота и мониторинг + bot_task = asyncio.create_task(start_bot(bdf)) + monitor_task = asyncio.create_task(monitor.monitor_loop()) + + try: + # Ждем сигнала завершения + await shutdown_event.wait() + print("Начинаем корректное завершение...") + + except KeyboardInterrupt: + print("Получен сигнал завершения...") + finally: + print("Отправляем сообщение об отключении...") + try: + # Отправляем сообщение об отключении + await monitor.send_shutdown_message() + except Exception as e: + print(f"Ошибка при отправке сообщения об отключении: {e}") + + print("Останавливаем задачи...") + # Отменяем задачи + bot_task.cancel() + monitor_task.cancel() + + # Ждем завершения задач + try: + await asyncio.gather(bot_task, monitor_task, return_exceptions=True) + except Exception as e: + print(f"Ошибка при остановке задач: {e}") + + # Закрываем сессию бота + await monitor_bot.session.close() + print("Бот корректно остановлен") + if __name__ == '__main__': - asyncio.run(start_bot(get_global_instance())) + asyncio.run(main()) diff --git a/tests/test_monitor.py b/tests/test_monitor.py new file mode 100644 index 0000000..130c649 --- /dev/null +++ b/tests/test_monitor.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +Тестовый скрипт для проверки модуля мониторинга сервера +""" +import asyncio +import sys +import os + +# Добавляем путь к проекту +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from helper_bot.server_monitor import ServerMonitor + + +class MockBot: + """Мок объект бота для тестирования""" + + async def send_message(self, chat_id, text, parse_mode=None): + print(f"\n{'='*60}") + print(f"Отправка в чат: {chat_id}") + print(f"Текст сообщения:") + print(text) + print(f"{'='*60}\n") + + +async def test_monitor(): + """Тестирование модуля мониторинга""" + print("🧪 Тестирование модуля мониторинга сервера") + print("=" * 60) + + # Создаем мок бота + mock_bot = MockBot() + + # Создаем монитор + monitor = ServerMonitor( + bot=mock_bot, + group_for_logs="-123456789", + important_logs="-987654321" + ) + + print("📊 Получение информации о системе...") + system_info = monitor.get_system_info() + + if system_info: + print("✅ Информация о системе получена успешно") + print(f"CPU: {system_info['cpu_percent']}%") + print(f"RAM: {system_info['ram_percent']}%") + print(f"Disk: {system_info['disk_percent']}%") + print(f"Uptime: {system_info['system_uptime']}") + + print("\n🤖 Проверка статуса процессов...") + voice_status = monitor.check_process_status('voice_bot') + helper_status = monitor.check_process_status('helper_bot') + print(f"Voice Bot: {voice_status}") + print(f"Helper Bot: {helper_status}") + + print("\n📝 Тестирование отправки статуса...") + await monitor.send_status_message(system_info) + + print("\n🚨 Тестирование отправки алерта...") + await monitor.send_alert_message( + "Использование CPU", + 85.5, + "Нагрузка за 1 мин: 2.5" + ) + + print("\n✅ Тестирование отправки сообщения о восстановлении...") + await monitor.send_recovery_message( + "Использование CPU", + 70.0, + 85.5 + ) + + else: + print("❌ Не удалось получить информацию о системе") + + print("\n🎯 Тестирование завершено!") + + +if __name__ == "__main__": + asyncio.run(test_monitor())