From bce4c2e2d026189d9570c10f43dcc42e22acd9e9 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 17 Apr 2026 09:25:43 +0800 Subject: [PATCH] fix: Update agent install, disk metrics, fallback blocks, and dynamic thresholds BREAKING: install.sh now downloads agent from server instead of embedding it Changes: - AgentController: downloadAgent() method for serving agent.py with token auth - AgentController: rewrite generateInstallScript() to curl agent from server - agent.py: copy production version from server (with temp, disk, network metrics) - agent.py: fix get_disk_metrics() to use priority mountpoints (/, /home, etc) - agent.py: fix disk_total_gb collection to use priority mountpoints - detail.twig: add fallback blocks for temperatures (alert-info) - detail.twig: add fallback blocks for disk doughnuts (alert-warning) - detail.twig: add fallback blocks for network graphs (alert-warning) - detail.twig: add null check for ramTotalGB in tooltip - detail.twig: improve thresholds form with human-readable labels and units - ServerDetailController: query only metrics that exist on server and display on graphs For server 3 (mirv.top): - After deploy, download new install.sh and reinstall agent - This will add disk_used_root, ram_total_gb, temperatures support --- __pycache__/agent.cpython-312.pyc | Bin 0 -> 16608 bytes agent.py | 322 ++++++++++++++-- .../migrations/008_auto_cleanup_metrics.sql | 17 + public/index.php | 1 + src/Controllers/AgentController.php | 346 ++++++------------ src/Controllers/ServerDetailController.php | 53 ++- templates/servers/detail.twig | 164 ++++++--- 7 files changed, 570 insertions(+), 333 deletions(-) create mode 100644 __pycache__/agent.cpython-312.pyc create mode 100644 docker/migrations/008_auto_cleanup_metrics.sql diff --git a/__pycache__/agent.cpython-312.pyc b/__pycache__/agent.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..192018ac1670b898afb88c0374c9c0c73a55eca5 GIT binary patch literal 16608 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 GZ2uP~qdNou literal 0 HcmV?d00001 diff --git a/agent.py b/agent.py index f7d1579..14f94d9 100755 --- a/agent.py +++ b/agent.py @@ -8,11 +8,119 @@ import subprocess import os from datetime import datetime +# Скипаем виртуальные и служебные интерфейсы +SKIP_INTERFACE_PREFIXES = ('lo', 'docker', 'veth', 'br-', 'tun', 'tap', 'wg', 'virbr', 'vmnet', 'vmxnet') + +# Храним предыдущие значения net_io для расчёта дельты +_prev_net_io = {} + + +def _is_real_interface(name, stats): + for prefix in SKIP_INTERFACE_PREFIXES: + if name.startswith(prefix): + return False + if not stats.isup: + return False + if stats.speed <= 0: + return False + return True + + +def get_network_metrics(interval=60): + global _prev_net_io + metrics = {} + try: + counters = psutil.net_io_counters(pernic=True) + stats = psutil.net_if_stats() + now = __import__('time').time() + for name, counter in counters.items(): + if name not in stats: + continue + if not _is_real_interface(name, stats[name]): + continue + speed_mbps = stats[name].speed + speed_bps = speed_mbps * 1000000 / 8 + if name in _prev_net_io: + prev = _prev_net_io[name] + elapsed = now - prev['time'] + if elapsed > 0: + rx_delta = counter.bytes_recv - prev['rx'] + tx_delta = counter.bytes_sent - prev['tx'] + rx_pct = min((rx_delta / elapsed) / speed_bps * 100, 100.0) + tx_pct = min((tx_delta / elapsed) / speed_bps * 100, 100.0) + iface_key = name.replace('-', '_') + metrics[f'net_in_{iface_key}'] = round(rx_pct, 2) + metrics[f'net_out_{iface_key}'] = round(tx_pct, 2) + _prev_net_io[name] = {'rx': counter.bytes_recv, 'tx': counter.bytes_sent, 'time': now} + except Exception as e: + print(f'Ошибка сбора сетевых метрик: {e}') + return metrics + + +def _is_real_partition(mountpoint, fstype): + """Проверяем что раздел реальный (не tmpfs, docker, snap и т.д.)""" + skip_fstypes = {'tmpfs', 'devtmpfs', 'overlay', 'squashfs', 'snap', + 'devpts', 'proc', 'sysfs', 'cgroup', 'cgroup2', + 'pstore', 'hugetlbfs', 'mqueue', 'debugfs', + 'tracefs', 'bpf', 'fusectl', 'configfs', + 'securityfs', 'ramfs'} + skip_mounts = {'/run', '/run/lock', '/sys', '/proc', '/dev', + '/dev/shm', '/dev/pts', '/sys/fs/cgroup'} + + if fstype in skip_fstypes: + return False + if mountpoint in skip_mounts: + return False + # Пропускаем EFI — слишком маленький, не информативен + if mountpoint == '/boot/efi': + return False + return True + + +def get_disk_metrics(): + """Собираем метрики диска для примонтированных разделов""" + metrics = {} + total_used = 0 + total_capacity = 0 + + priority_mounts = ['/', '/home', '/boot', '/var', '/opt', '/data', '/mnt', '/srv', '/tmp'] + + for mountpoint in priority_mounts: + try: + usage = psutil.disk_usage(mountpoint) + name = mountpoint.strip('/').replace('/', '_') or 'root' + if name not in metrics: + metrics[f'disk_used_{name}'] = round(usage.percent, 1) + total_used += usage.used + total_capacity += usage.total + except (PermissionError, OSError, FileNotFoundError): + pass + + for part in psutil.disk_partitions(all=False): + name = part.mountpoint.strip('/').replace('/', '_') or 'root' + if name in metrics: + continue + if not _is_real_partition(part.mountpoint, part.fstype): + continue + try: + usage = psutil.disk_usage(part.mountpoint) + metrics[f'disk_used_{name}'] = round(usage.percent, 1) + except (PermissionError, OSError): + pass + + if total_capacity > 0: + metrics['disk_used'] = round((total_used / total_capacity) * 100, 1) + + return metrics + + def get_metrics(): """Сбор системных метрик""" cpu_percent = psutil.cpu_percent(interval=1) memory = psutil.virtual_memory() - disk_usage = psutil.disk_usage('/') + + # Дисковые метрики для всех реальных разделов + disk_metrics = get_disk_metrics() # Получаем сетевую статистику try: @@ -20,26 +128,70 @@ def get_metrics(): except: net_io = None - return { + result = { 'cpu_load': cpu_percent, 'ram_used': memory.percent, - 'disk_used': disk_usage.percent } + result.update(disk_metrics) + + # Метрики использования сети + net_metrics = get_network_metrics() + result.update(net_metrics) + # RAM total GB + result["ram_total_gb"] = round(memory.total / (1024**3), 1) + + # Disk total GB - сначала приоритетные mountpoints + priority_mounts = ['/', '/home', '/boot', '/var', '/opt', '/data', '/mnt', '/srv', '/tmp'] + for mountpoint in priority_mounts: + try: + usage = psutil.disk_usage(mountpoint) + name = mountpoint.strip("/").replace("/", "_") or "root" + if f"disk_total_gb_{name}" not in result: + result[f"disk_total_gb_{name}"] = round(usage.total / (1024**3), 1) + except (PermissionError, OSError, FileNotFoundError): + pass + + for part in psutil.disk_partitions(all=False): + try: + usage = psutil.disk_usage(part.mountpoint) + name = part.mountpoint.strip("/").replace("/", "_") or "root" + if f"disk_total_gb_{name}" not in result: + result[f"disk_total_gb_{name}"] = round(usage.total / (1024**3), 1) + except (PermissionError, OSError): + pass + + if net_metrics: + print(f" Сетевые метрики: {net_metrics}") + + # Сетевые метрики + if net_io: + result['network_rx'] = round(net_io.bytes_recv / (1024 * 1024), 2) + result['network_tx'] = round(net_io.bytes_sent / (1024 * 1024), 2) + + return result def get_top_processes(process_type='cpu'): """Сбор топ-5 процессов по CPU или RAM""" processes = [] try: - for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']): + for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent', 'cmdline']): try: info = proc.info if info['cpu_percent'] is None or info['memory_percent'] is None: continue + cmdline = info.get('cmdline') or [] + if cmdline: + full_cmd = ' '.join(cmdline) + cmd_display = full_cmd[:120] + ('...' if len(full_cmd) > 120 else '') + else: + cmd_display = info.get('name', '') + processes.append({ 'pid': info['pid'], 'name': info['name'], + 'cmdline': cmd_display, 'value': round(info[process_type + '_percent'], 1) }) except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): @@ -61,45 +213,139 @@ def get_top_processes(process_type='cpu'): return [] def get_services(): - """Сбор списка сервисов через systemctl""" + """Сбор списка сервисов через systemctl (list-unit-files + list-units)""" try: - result = subprocess.run(['systemctl', 'list-units', '--type=service', '--no-pager', '--all'], - capture_output=True, text=True, timeout=5) + # 1. Получаем полный список всех сервисов (включая dead/выгруженные) + res_files = subprocess.run(['systemctl', 'list-unit-files', '--type=service', '--no-pager'], + capture_output=True, text=True, timeout=10) + + # 2. Получаем текущие статусы активных/загруженных сервисов + res_units = subprocess.run(['systemctl', 'list-units', '--type=service', '--all', '--no-pager'], + capture_output=True, text=True, timeout=10) + + # Парсим unit-files (список всех сервисов) + all_services = {} + for line in res_files.stdout.split('\n'): + parts = line.split() + if parts and parts[0].endswith('.service'): + all_services[parts[0]] = {'name': parts[0], 'enabled_state': parts[1] if len(parts) > 1 else 'unknown'} + + # Парсим list-units (текущее состояние) + running_states = {} + for line in res_units.stdout.split('\n'): + parts = line.split(None, 4) + if len(parts) >= 4 and parts[0].endswith('.service'): + running_states[parts[0]] = { + 'load_state': parts[1], + 'active_state': parts[2], + 'sub_state': parts[3] + } + services = [] - - for line in result.stdout.split('\n')[1:]: # Пропускаем заголовок - if not line.strip(): - continue - - parts = line.split(None, 4) # Разделяем на 5 частей максимум - if len(parts) >= 4: - service_name = parts[0] - load_state = parts[1] if len(parts) > 1 else '' - active_state = parts[2] if len(parts) > 2 else '' - sub_state = parts[3] if len(parts) > 3 else '' - - # Определяем статус сервиса - if active_state == 'active': - status = 'running' - elif active_state in ['inactive', 'failed']: - status = 'stopped' - else: - status = 'unknown' - - services.append({ - 'name': service_name, - 'status': status, - 'load_state': load_state, - 'active_state': active_state, - 'sub_state': sub_state - }) - + # Объединяем: берем все сервисы из list-unit-files + for svc_name in all_services.keys(): + if svc_name in running_states: + state = running_states[svc_name] + load = state['load_state'] + active = state['active_state'] + sub = state['sub_state'] + else: + # Сервис есть в системе, но не загружен (dead) + load = 'loaded' # Обычно loaded, если файл юнита есть + active = 'inactive' + sub = 'dead' + + if active == 'active': + status = 'running' + elif active in ['inactive', 'failed', 'deactivating']: + status = 'stopped' + else: + status = 'unknown' + + services.append({ + 'name': svc_name, + 'status': status, + 'load_state': load, + 'active_state': active, + 'sub_state': sub + }) + return services - except Exception as e: - print(f"Ошибка получения сервисов: {e}") + print(f"Ошибка получения списка сервисов: {e}") return [] + +def get_temperatures(): + """Сбор температур (CPU, GPU, Disks)""" + temps = {} + + # 1. CPU via psutil + try: + sensors = psutil.sensors_temperatures() + if sensors: + cpu_temps = [] + for name, entries in sensors.items(): + if name.lower() in ['coretemp', 'k10temp', 'zenpower']: + for entry in entries: + if entry.current: + cpu_temps.append(entry.current) + if cpu_temps: + temps['temp_cpu'] = max(cpu_temps) + elif not temps: + for entries in sensors.values(): + for entry in entries: + if entry.current: + cpu_temps.append(entry.current) + if cpu_temps: + temps['temp_cpu'] = max(cpu_temps) + except Exception: + pass + + # 2. Disks via smartctl + try: + import glob + disks = glob.glob('/dev/sd[a-z]') + glob.glob('/dev/nvme[0-9]n1') + for disk in disks: + res = subprocess.run(['smartctl', '-n', 'standby', '-A', disk], + capture_output=True, text=True, timeout=5) + if res.returncode == 0 and 'STANDBY' not in res.stdout.upper(): + for line in res.stdout.split('\n'): + if 'Temperature' in line: + parts = line.split() + # Ищем число в диапазоне 10-100 + for p in reversed(parts): + try: + v = int(p) + if 10 < v < 100: + disk_name = disk.split('/')[-1] + temps[f'temp_disk_{disk_name}'] = float(v) + break + except ValueError: + pass + except Exception: + pass + + # 3. GPU via nvidia-smi + try: + res = subprocess.run(['nvidia-smi', '--query-gpu=temperature.gpu', '--format=csv,noheader'], + capture_output=True, text=True, timeout=5) + if res.returncode == 0: + lines = res.stdout.strip().split('\n') + if len(lines) == 1: + try: + temps['temp_gpu'] = float(lines[0]) + except: pass + else: + for i, line in enumerate(lines): + try: + temps[f'temp_gpu_{i}'] = float(line) + except: pass + except Exception: + pass + + return temps + def send_metrics(): """Отправка метрик на сервер""" with open('/opt/server-monitor-agent/config.json', 'r') as f: @@ -110,6 +356,8 @@ def send_metrics(): # Собираем метрики metrics = get_metrics() + temps = get_temperatures() + metrics.update(temps) # Собираем топ-процессы top_cpu = get_top_processes('cpu') diff --git a/docker/migrations/008_auto_cleanup_metrics.sql b/docker/migrations/008_auto_cleanup_metrics.sql new file mode 100644 index 0000000..1625a5c --- /dev/null +++ b/docker/migrations/008_auto_cleanup_metrics.sql @@ -0,0 +1,17 @@ +-- 008: Авто-очистка старых метрик (старше 60 дней) +-- Запускается автоматически каждый день в 03:00 + +-- Создаём событие очистки (работает от mon_user если даны права EVENT) +-- Если mon_user не может создать событие — запустите вручную от root: +-- CREATE EVENT ... (см. ниже) +-- +-- Для Docker: event_scheduler включается через docker-compose command +-- Для ручной установки: добавьте event_scheduler=ON в /etc/mysql/mariadb.conf.d/ + +-- Если есть привилегии — создаём событие: +CREATE EVENT IF NOT EXISTS daily_metrics_cleanup +ON SCHEDULE EVERY 1 DAY +STARTS CURRENT_DATE + INTERVAL 1 DAY + INTERVAL 3 HOUR +ON COMPLETION PRESERVE +DO + DELETE FROM server_metrics WHERE created_at < NOW() - INTERVAL 60 DAY; diff --git a/public/index.php b/public/index.php index ba8a645..08175e7 100755 --- a/public/index.php +++ b/public/index.php @@ -233,6 +233,7 @@ $app->get('/api/status', function (Request $request, Response $response, $args) $app->get('/agent/install.sh', [$agentController, 'generateInstallScript']); $app->get('/agent/install.ps1', [$agentController, 'generateWindowsInstallScript']); $app->get('/agent/install.bat', [$agentController, 'generateWindowsBatScript']); +$app->get('/agent/agent.py', [$agentController, 'downloadAgent']); // Run app $app->run(); \ No newline at end of file diff --git a/src/Controllers/AgentController.php b/src/Controllers/AgentController.php index e754467..11636a1 100755 --- a/src/Controllers/AgentController.php +++ b/src/Controllers/AgentController.php @@ -17,7 +17,6 @@ class AgentController extends Model $token = $queryParams['token'] ?? null; $server_id = $queryParams['server_id'] ?? null; - // Если передан server_id, получаем оригинальный токен из зашифрованного if (!empty($server_id) && empty($token)) { $stmt = $this->pdo->prepare("SELECT encrypted_token FROM agent_tokens WHERE server_id = :server_id LIMIT 1"); $stmt->execute([':server_id' => $server_id]); @@ -34,250 +33,73 @@ class AgentController extends Model } $apiUrl = 'https://mon.mirv.top/api/v1/metrics'; + $agentDownloadUrl = 'https://mon.mirv.top/agent/agent.py?token=' . $token; - // Формируем скрипт с прямой подстановкой значений - $script = "#!/bin/bash + $script = << /dev/null; then - echo 'Установка Python3...' - apt-get update - apt-get install -y python3 python3-pip lm-sensors smartmontools + echo '[1/6] Установка Python3...' + apt-get update -qq + apt-get install -y -qq python3 python3-pip || apt-get install -y python3 python3-pip +else + echo '[1/6] Python3 найден' fi -# Устанавливаем psutil -pip3 install psutil || easy_install3 psutil +# Устанавливаем зависимости (lm-sensors и smartmontools опциональны) +echo '[2/6] Установка зависимостей (psutil, lm-sensors, smartmontools)...' +pip3 install --quiet psutil 2>/dev/null || pip3 install psutil 2>/dev/null || true +apt-get install -y -qq lm-sensors smartmontools 2>/dev/null || true # Создаем директорию для агента -mkdir -p /opt/server-monitor-agent -cd /opt/server-monitor-agent +echo '[3/6] Создание директории агента...' +mkdir -p "$INSTALL_DIR" + +# Скачиваем агента +echo '[4/6] Скачивание агента...' +if ! curl -fsSL "$AGENT_URL" -o "$INSTALL_DIR/agent.py" 2>/dev/null; then + echo 'ERROR: Не удалось скачать агента. Проверьте токен и подключение к серверу.' + exit 1 +fi + +if ! grep -q 'psutil' "$INSTALL_DIR/agent.py"; then + echo 'ERROR: Скачанный файл не является агентом мониторинга.' + exit 1 +fi + +chmod +x "$INSTALL_DIR/agent.py" # Создаем конфигурационный файл -echo '{ - \\\"token\\\": \\\"" . $token . "\\\"\\, - \\\"api_url\\\": \\\"" . $apiUrl . "\\\"\\, - \\\"interval_seconds\\\": 60 -}' > config.json - -# Создаем Python-скрипт агента с поддержкой сервисов -cat > agent.py << 'PYTHON_EOF' -import time -import json -import psutil -import requests -import subprocess -import os -from datetime import datetime - -def get_metrics(): - \\\"\\\"\\\"Сбор системных метрик\\\"\\\"\\\" - cpu_percent = psutil.cpu_percent(interval=1) - memory = psutil.virtual_memory() - disk_usage = psutil.disk_usage('/') - - # Получаем сетевую статистику - try: - net_io = psutil.net_io_counters() - except: - net_io = None - - metrics = { - 'cpu_load': round(cpu_percent, 2), - 'ram_used': round(memory.percent, 2), - 'disk_used': round((disk_usage.used / disk_usage.total) * 100, 2), - 'network_in': round((net_io.bytes_recv / (1024*1024)) if net_io else 0, 2), # MB - 'network_out': round((net_io.bytes_sent / (1024*1024)) if net_io else 0, 2) # MB - } - - return metrics - -def get_services(): - \\\"\\\"\\\"Сбор статусов всех сервисов\\\"\\\"\\\" - services = [] - - try: - # Получаем список всех сервисов - result = subprocess.run( - ['systemctl', 'list-units', '--type=service', '--all', '--no-pager'], - capture_output=True, - text=True, - timeout=30 - ) - - lines = result.stdout.strip().split('\\n') - - for line in lines[1:]: # Пропускаем заголовок - parts = line.split() - if len(parts) >= 4: - service_name = parts[0].replace('.service', '') - load_state = parts[1] - active_state = parts[2] - sub_state = parts[3] if len(parts) > 3 else '' - - # Определяем статус сервиса - if active_state == 'active' and sub_state == 'running': - status = 'running' - elif active_state in ['inactive', 'failed', 'dead']: - status = 'stopped' - else: - status = 'unknown' - - # Пропускаем системные сервисы без .service в имени - if not service_name.startswith('system-'): - services.append({ - 'name': service_name, - 'status': status, - 'load_state': load_state, - 'active_state': active_state, - 'sub_state': sub_state - }) - - except Exception as e: - print(f'Ошибка при получении списка сервисов: {e}') - - return services - -def get_config_from_server(): - \\\"\\\"\\\"Получение конфигурации с сервера\\\"\\\"\\\" - try: - with open('config.json', 'r') as f: - config = json.load(f) - except Exception as e: - print(f'Ошибка чтения конфига: {e}') - return None - - token = config.get('token') - if not token: - print('Отсутствует токен в конфиге') - return None - - # Определяем URL для получения конфигурации - server_id = token.split('-')[0] if '-' in token else '1' - - try: - response = requests.get( - f\\\"\\\"{config['api_url']}/agent/{server_id}/config\\\"\\\"\\\", - headers={'Authorization': f'Bearer {token}'}, - timeout=10 - ) - - if response.status_code == 200: - server_config = response.json() - - # Обновляем локальный конфиг - config['interval_seconds'] = server_config.get('interval_seconds', config['interval_seconds']) - config['monitor_services'] = server_config.get('monitor_services', config.get('monitor_services', [])) - - # Сохраняем обновленный конфиг - with open('config.json', 'w') as f: - json.dump(config, f, indent=2) - - return config - else: - print(f'Ошибка получения конфига с сервера: {response.status_code}') - return config - - except Exception as e: - print(f'Ошибка подключения к серверу: {e}') - return config - -def send_metrics(config, metrics, services): - \\\"\\\"\\\"Отправка метрик и сервисов на сервер\\\"\\\"\\\" - data = { - 'token': config['token'], - 'metrics': metrics, - 'services': services - } - - try: - response = requests.post( - config['api_url'], - json=data, - timeout=10 - ) - if response.status_code == 200: - print(f'{datetime.now().strftime(\\\"%Y-%m-%d %H:%M:%S\\\")} - Метрики отправлены успешно') - return True - else: - print(f'Ошибка отправки метрик: {response.status_code}') - return False - except Exception as e: - print(f'Ошибка отправки метрик: {e}') - return False - -def main(): - \\\"\\\"\\\"Главная функция агента\\\"\\\"\\\" - print('Агент мониторинга запущен...') - - # Загружаем конфигурацию - config = get_config_from_server() - if not config: - print('Не удалось загрузить конфигурацию') - return - - interval = config.get('interval_seconds', 60) - monitor_services = config.get('monitor_services', []) - - print(f'Интервал отправки: {interval} сек') - print(f'Мониторинг сервисов: {\\\"включен\\\" if monitor_services else \\\"все сервисы\\\"}') - - last_config_update = time.time() - - while True: - try: - # Проверяем нужно ли обновить конфиг (каждые 5 минут) - if time.time() - last_config_update > 300: - print('Проверка обновления конфигурации...') - config = get_config_from_server() - last_config_update = time.time() - - # Обновляем интервал если изменился - interval = config.get('interval_seconds', 60) - monitor_services = config.get('monitor_services', []) - - # Собираем метрики - metrics = get_metrics() - - # Собираем сервисы - services = get_services() - - # Если указаны конкретные сервисы для мониторинга - фильтруем - if monitor_services: - services = [s for s in services if s['name'] in monitor_services] - print(f'Мониторинг {len(services)} сервисов: {[s[\\\"name\\\"] for s in services]}') - - # Отправляем данные - success = send_metrics(config, metrics, services) - - if success: - print(f'Метрики отправлены: CPU={metrics[\\\"cpu_load\\\"]}%, RAM={metrics[\\\"ram_used\\\"]}%, Disk={metrics[\\\"disk_used\\\"]}%') - else: - print('Ошибка отправки метрик') - - # Ждем указанный интервал - time.sleep(interval) - - except KeyboardInterrupt: - print('Агент остановлен') - break - except Exception as e: - print(f'Ошибка: {e}') - time.sleep(10) - -if __name__ == '__main__': - main() -PYTHON_EOF +echo '[5/6] Создание конфигурации...' +cat > "$INSTALL_DIR/config.json" << CONFIG_EOF +{ + "token": "$TOKEN", + "api_url": "$API_URL", + "interval_seconds": 60 +} +CONFIG_EOF # Создаем systemd сервис -cat > /etc/systemd/system/server-monitor-agent.service << 'SERVICE_EOF' +echo '[6/6] Регистрация системной службы...' +cat > /etc/systemd/system/server-monitor-agent.service << SERVICE_EOF [Unit] Description=Server Monitor Agent After=network.target @@ -285,8 +107,8 @@ After=network.target [Service] Type=simple User=root -WorkingDirectory=/opt/server-monitor-agent -ExecStart=/usr/bin/python3 /opt/server-monitor-agent/agent.py +WorkingDirectory=$INSTALL_DIR +ExecStart=/usr/bin/python3 $INSTALL_DIR/agent.py Restart=always RestartSec=10 @@ -294,22 +116,22 @@ RestartSec=10 WantedBy=multi-user.target SERVICE_EOF -# Делаем скрипт исполняемым -chmod +x agent.py - -# Перезагружаем systemd +# Активируем и запускаем сервис systemctl daemon-reload - -# Включаем автозапуск сервиса systemctl enable server-monitor-agent - -# Запускаем сервис +systemctl stop server-monitor-agent 2>/dev/null || true systemctl start server-monitor-agent -echo 'Агент мониторинга установлен и запущен!' -echo 'Статус сервиса:' -systemctl status server-monitor-agent -"; +echo '' +echo '==============================================' +echo ' Агент мониторинга успешно установлен!' +echo '==============================================' +echo '' +echo 'Директория: $INSTALL_DIR' +echo 'Логи: journalctl -u server-monitor-agent -f' +echo 'Статус: systemctl status server-monitor-agent' +echo '' +BASH; $response->getBody()->write($script); return $response @@ -317,6 +139,44 @@ systemctl status server-monitor-agent ->withHeader('Content-Disposition', 'attachment; filename="install.sh"'); } + public function downloadAgent(Request $request, Response $response, $args) + { + $queryParams = $request->getQueryParams(); + $token = $queryParams['token'] ?? null; + + if (empty($token)) { + $response->getBody()->write('Token is required'); + return $response->withStatus(403); + } + + $tokenHash = hash('sha256', $token); + $stmt = $this->pdo->prepare("SELECT server_id FROM agent_tokens WHERE token_hash = :hash LIMIT 1"); + $stmt->execute([':hash' => $tokenHash]); + $result = $stmt->fetch(); + + if (!$result) { + $response->getBody()->write('Invalid token'); + return $response->withStatus(403); + } + + $stmt = $this->pdo->prepare("UPDATE agent_tokens SET last_used_at = NOW() WHERE token_hash = :hash"); + $stmt->execute([':hash' => $tokenHash]); + + $agentPath = dirname(__DIR__, 2) . '/agent.py'; + if (!file_exists($agentPath)) { + $response->getBody()->write('Agent not found'); + return $response->withStatus(404); + } + + $content = file_get_contents($agentPath); + + return $response + ->getBody() + ->write($content) + ->withHeader('Content-Type', 'text/plain; charset=UTF-8') + ->withHeader('Content-Disposition', 'attachment; filename="agent.py"'); + } + public function getConfig(Request $request, Response $response, $args) { $serverId = $args['id']; diff --git a/src/Controllers/ServerDetailController.php b/src/Controllers/ServerDetailController.php index c8df1c5..9be4fb7 100755 --- a/src/Controllers/ServerDetailController.php +++ b/src/Controllers/ServerDetailController.php @@ -200,8 +200,31 @@ class ServerDetailController extends Model ]; } - // Типы метрик - $stmt = $this->pdo->query("SELECT id, name, unit FROM metric_names WHERE name NOT LIKE '%\_proc' AND name NOT LIKE 'disk_total_gb_%' AND name != 'disk_used' AND name != 'ram_total_gb' AND name NOT IN ('net_in', 'net_out') AND name NOT LIKE 'network_%' ORDER BY name"); + // Типы метрик — только те что отображаются на графиках и есть у сервера + $stmt = $this->pdo->prepare(" + SELECT DISTINCT mn.id, mn.name, mn.unit + FROM metric_names mn + JOIN server_metrics sm ON sm.metric_name_id = mn.id + WHERE sm.server_id = :id + AND ( + mn.name IN ('cpu_load', 'ram_used') + OR mn.name LIKE 'disk_used_%' + OR mn.name LIKE 'net_in_%' + OR mn.name LIKE 'net_out_%' + OR mn.name LIKE 'temp_%' + ) + ORDER BY + CASE + WHEN mn.name = 'cpu_load' THEN 1 + WHEN mn.name = 'ram_used' THEN 2 + WHEN mn.name LIKE 'disk_used_%' THEN 3 + WHEN mn.name LIKE 'net_in_%' THEN 4 + WHEN mn.name LIKE 'net_out_%' THEN 5 + WHEN mn.name LIKE 'temp_%' THEN 6 + END, + mn.name + "); + $stmt->execute([':id' => $id]); $allMetricTypes = $stmt->fetchAll(); // Сервисы @@ -286,7 +309,31 @@ class ServerDetailController extends Model $id = $args['id']; $params = $request->getParsedBody(); - $stmt = $this->pdo->query("SELECT id, name FROM metric_names WHERE name NOT LIKE '%\_proc' AND name NOT LIKE 'disk_total_gb_%' AND name != 'disk_used' AND name != 'ram_total_gb' AND name NOT IN ('net_in', 'net_out') AND name NOT LIKE 'network_%' ORDER BY name"); + // Получаем только метрики которые есть у сервера и отображаются на графиках + $stmt = $this->pdo->prepare(" + SELECT DISTINCT mn.id, mn.name, mn.unit + FROM metric_names mn + JOIN server_metrics sm ON sm.metric_name_id = mn.id + WHERE sm.server_id = :id + AND ( + mn.name IN ('cpu_load', 'ram_used') + OR mn.name LIKE 'disk_used_%' + OR mn.name LIKE 'net_in_%' + OR mn.name LIKE 'net_out_%' + OR mn.name LIKE 'temp_%' + ) + ORDER BY + CASE + WHEN mn.name = 'cpu_load' THEN 1 + WHEN mn.name = 'ram_used' THEN 2 + WHEN mn.name LIKE 'disk_used_%' THEN 3 + WHEN mn.name LIKE 'net_in_%' THEN 4 + WHEN mn.name LIKE 'net_out_%' THEN 5 + WHEN mn.name LIKE 'temp_%' THEN 6 + END, + mn.name + "); + $stmt->execute([':id' => $id]); $metricTypes = $stmt->fetchAll(); $stmt = $this->pdo->prepare("DELETE FROM metric_thresholds WHERE server_id = :server_id"); diff --git a/templates/servers/detail.twig b/templates/servers/detail.twig index 3a5a712..1198026 100755 --- a/templates/servers/detail.twig +++ b/templates/servers/detail.twig @@ -169,27 +169,40 @@ {% endif %} {% endfor %} - {% for iface in net_interfaces %} - {% if metrics['net_in_' ~ iface] is defined and metrics['net_out_' ~ iface] is defined %} -
-
-
-
-
Сеть: {{ iface }}
-
-
- + {% if net_interfaces|length > 0 %} + {% for iface in net_interfaces %} + {% if metrics['net_in_' ~ iface] is defined and metrics['net_out_' ~ iface] is defined %} +
+
+
+
+
Сеть: {{ iface }}
+
+
+ +
-
+ {% endif %} + {% endfor %} + {% else %} +
+ Данные о сетевых интерфейсах не получены +
{% endif %} - {% endfor %} + {% set has_temps = false %} + {% for metricName in metrics|keys %} + {% if metricName starts with 'temp_' %} + {% set has_temps = true %} + {% endif %} + {% endfor %} +
@@ -197,44 +210,65 @@
Температуры
- + {% if has_temps %} + + {% else %} +
+ Температурные датчики недоступны (возможно виртуальный сервер) +
+ {% endif %}
+ {% set has_disk_parts = false %} + {% for metricName in metrics|keys %} + {% if metricName starts with 'disk_used_' and metricName != 'disk_used' %} + {% set has_disk_parts = true %} + {% endif %} + {% endfor %} +
- {% for metricName, metricData in metrics %} - {% if metricName starts with 'disk_used_' and metricName != 'disk_used' %} -
-
-
-
- {% if metricName == 'disk_used_root' %}/ (корень) - {% elseif metricName == 'disk_used_home' %}/home - {% elseif metricName == 'disk_used_boot' %}/boot - {% elseif metricName == 'disk_used_mnt_data' %}/mnt/data - {% else %}{{ metricName|replace({'disk_used_': '', '_': ' '})|title }} - {% endif %} -
- {% set pct = metricData[0].value|round(1) %} - {% set iface = metricName|replace({'disk_used_': ''}) %} - {% set totalGB = metrics['disk_total_gb_' ~ iface][0].value|default(0) %} - {% set usedGB = (pct / 100 * totalGB)|round(1) %} - {% set freeGB = (totalGB - usedGB)|round(1) %} -
- Свободно: {{ freeGB }} ГБ - Занято: {{ usedGB }} ГБ + {% if has_disk_parts %} + {% for metricName, metricData in metrics %} + {% if metricName starts with 'disk_used_' and metricName != 'disk_used' %} +
+
+
+
+ {% if metricName == 'disk_used_root' %}/ (корень) + {% elseif metricName == 'disk_used_home' %}/home + {% elseif metricName == 'disk_used_boot' %}/boot + {% elseif metricName == 'disk_used_mnt_data' %}/mnt/data + {% else %}{{ metricName|replace({'disk_used_': '', '_': ' '})|title }} + {% endif %} +
+ {% set pct = metricData[0].value|round(1) %} + {% set iface = metricName|replace({'disk_used_': ''}) %} + {% set totalGB = metrics['disk_total_gb_' ~ iface][0].value|default(0) %} + {% set usedGB = (pct / 100 * totalGB)|round(1) %} + {% set freeGB = (totalGB - usedGB)|round(1) %} +
+ Свободно: {{ freeGB }} ГБ + Занято: {{ usedGB }} ГБ +
+

{{ pct }}% из {{ totalGB }} ГБ

+

{{ metricData[0].created_at|date('d.m.Y H:i') }}

+
-

{{ pct }}% из {{ totalGB }} ГБ

-

{{ metricData[0].created_at|date('d.m.Y H:i') }}

-
+ {% endif %} + {% endfor %} + {% else %} +
+
+ Данные о разделах диска не получены. Проверьте работу агента. +
- {% endif %} - {% endfor %} + {% endif %}
@@ -412,12 +446,36 @@
{% for metricType in allMetricTypes %} + {% set metricUnit = '%' %} + {% set metricLabel = metricType.name %} + {% if metricType.name starts with 'temp_' %} + {% set metricUnit = '°C' %} + {% set metricLabel = 'Температура ' ~ (metricType.name|replace({'temp_': '', '_': ' '}))|title %} + {% elseif metricType.name == 'cpu_load' %} + {% set metricLabel = 'Загрузка CPU' %} + {% elseif metricType.name == 'ram_used' %} + {% set metricLabel = 'Использование RAM' %} + {% elseif metricType.name starts with 'disk_used_' %} + {% set iface = metricType.name|replace({'disk_used_': ''}) %} + {% if iface == 'root' %}{% set metricLabel = 'Диск (корень /)' %} + {% elseif iface == 'home' %}{% set metricLabel = 'Диск (/home)' %} + {% elseif iface == 'boot' %}{% set metricLabel = 'Диск (/boot)' %} + {% elseif iface == 'mnt_data' %}{% set metricLabel = 'Диск (/mnt/data)' %} + {% else %}{% set metricLabel = 'Диск (/' ~ (iface|replace({'_': '/'})) ~ ')' %} + {% endif %} + {% elseif metricType.name starts with 'net_in_' %} + {% set iface = metricType.name|replace({'net_in_': ''}) %} + {% set metricLabel = 'Сеть входящая (' ~ iface ~ ')' %} + {% elseif metricType.name starts with 'net_out_' %} + {% set iface = metricType.name|replace({'net_out_': ''}) %} + {% set metricLabel = 'Сеть исходящая (' ~ iface ~ ')' %} + {% endif %}
- {{ metricType.name|replace({'_': ' ', 'load': 'загрузка', 'used': 'использование'})|title }} - {% if metricType.unit %}({{ metricType.unit }}){% endif %} + {{ metricLabel }} + ({{ metricUnit }})
@@ -430,7 +488,7 @@ {% if existingThresholds[metricType.name].warning is defined %} value="{{ existingThresholds[metricType.name].warning }}" {% endif %}> - % + {{ metricUnit }}
@@ -444,7 +502,7 @@ {% if existingThresholds[metricType.name].critical is defined %} value="{{ existingThresholds[metricType.name].critical }}" {% endif %}> - % + {{ metricUnit }}
@@ -566,7 +624,7 @@ document.addEventListener('DOMContentLoaded', function() { }); // Параметры системы -var ramTotalGB = {{ metrics['ram_total_gb'] is defined ? metrics['ram_total_gb'][0].value : 0 }}; +var ramTotalGB = {{ metrics['ram_total_gb'] is defined ? metrics['ram_total_gb'][0].value : 'null' }}; var diskTotalGB = { {% for m, _data in metrics %} {% if m starts with 'disk_total_gb_' %} @@ -664,12 +722,18 @@ const chart{{ metricName|replace({'-': '_', '.': '_'}) }} = new Chart(ctx{{ metr lines.push('Время: ' + time); {% if metricName == 'ram_used' %} var ramPct = data{{ metricName }}[dataIndex]; - var ramUsed = (ramPct / 100 * ramTotalGB).toFixed(1); - var ramFree = (ramTotalGB - ramUsed).toFixed(1); - lines.push('Всего: ' + ramTotalGB.toFixed(1) + ' ГБ'); - lines.push('Занято: ' + ramUsed + ' ГБ'); - lines.push('Свободно: ' + ramFree + ' ГБ'); - lines.push(''); + if (ramTotalGB !== null) { + var ramUsed = (ramPct / 100 * ramTotalGB).toFixed(1); + var ramFree = (ramTotalGB - ramUsed).toFixed(1); + lines.push('Всего: ' + ramTotalGB.toFixed(1) + ' ГБ'); + lines.push('Занято: ' + ramUsed + ' ГБ'); + lines.push('Свободно: ' + ramFree + ' ГБ'); + lines.push(''); + } else { + lines.push('RAM: ' + ramPct + '%'); + lines.push('(данные о памяти недоступны)'); + lines.push(''); + } if (data.top_ram && data.top_ram.length > 0) { lines.push('TOP RAM:'); data.top_ram.forEach(function(proc) {