Initial commit - mon.mirv.top monitoring system

This commit is contained in:
mirivlad 2026-02-14 15:08:14 +00:00
commit c7fdaa5660
49 changed files with 5950 additions and 0 deletions

124
AGENTS.md Normal file
View File

@ -0,0 +1,124 @@
# AGENTS.md - Инструкции для агентов по работе с проектом мониторинга
## О проекте
**Название:** Система мониторинга серверов
**URL:** https://mon.mirv.top
**Расположение:** /var/www/mon
**Технологии:** PHP 8.1+, Slim Framework 4, Twig, MySQL/MariaDB
## Структура проекта
```
/var/www/mon/
├── composer.json # Зависимости проекта
├── composer.lock # Зафиксированные версии зависимостей
├── .env # Конфигурация окружения
├── public/
│ ├── index.php # Точка входа
│ ├── css/ # Стили
│ └── js/ # JavaScript
├── src/
│ ├── Controllers/ # Контроллеры
│ ├── Middleware/ # Промежуточное ПО
│ ├── Models/ # Модели данных
│ └── Services/ # Бизнес-логика
├── templates/ # Шаблоны Twig
├── migrations/ # Миграции базы данных
└── tests/ # Тесты
```
## Зависимости
- PHP 8.1+
- Slim Framework 4
- Twig template engine
- MySQL/MariaDB
- Composer для управления зависимостями
## Конфигурация
Файл `.env` содержит:
- DATABASE_URL - строка подключения к базе данных
- JWT_SECRET - секрет для JWT токенов
- SMTP настройки - для отправки уведомлений
- API токены для агентов мониторинга
## API endpoints
- `GET /api/servers` - список мониторинговых серверов
- `GET /api/servers/{id}` - детали сервера
- `POST /api/servers` - создание нового сервера
- `PUT /api/servers/{id}` - обновление сервера
- `DELETE /api/servers/{id}` - удаление сервера
- `GET /api/metrics/{server_id}` - метрики сервера
- `POST /api/agent/metrics` - получение метрик от агента
- `GET /csrf-token` - получение CSRF токена для форм
## Агентские задачи
### 1. Добавление нового сервера для мониторинга
1. Использовать форму в `/servers/create`
2. Убедиться, что CSRF токен добавлен к форме (через `/csrf-token`)
3. Заполнить поля: название, IP-адрес, порт, токен агента
4. Проверить, что сервер отвечает на запросы
### 2. Обновление конфигурации уведомлений
1. Перейти в настройки уведомлений
2. Обновить email или webhook URL
3. Проверить доставку уведомлений
### 3. Управление группами серверов
1. Использовать `/groups` для создания/редактирования групп
2. Назначать серверы в группы
3. Устанавливать правила уведомлений на уровне групп
### 4. Обновление системы
1. Сделать резервную копию базы данных
2. Обновить зависимости через composer
3. Выполнить миграции базы данных
4. Обновить конфигурацию nginx при необходимости
## Безопасность
- Все формы требуют CSRF токены
- Аутентификация через сессии
- Валидация входных данных
- Санитизация вывода в шаблонах
## Инструменты для агентов
- Использовать `composer` для управления зависимостями
- Запускать миграции через `php vendor/bin/phinx`
- Тестировать через `phpunit` при наличии
## Часто используемые команды
```bash
# Установка зависимостей
composer install
# Обновление зависимостей
composer update
# Выполнение миграций
php vendor/bin/phinx migrate
# Запуск тестов
php vendor/bin/phpunit
# Резервное копирование БД
mysqldump -u username -p database_name > backup.sql
```
## Особенности архитектуры
- MVC паттерн с использованием Slim Framework
- Аутентификация через сессии
- Middleware для защиты маршрутов
- Twig для безопасного рендеринга шаблонов
- Поддержка агентов мониторинга через API

149
INSTALL.md Executable file
View File

@ -0,0 +1,149 @@
# Установка и запуск системы мониторинга
## Требования
- PHP 8.1 или выше
- Composer
- MySQL 8+ или MariaDB 10.5+
- Apache или Nginx
## Установка
### 1. Клонирование проекта
```bash
git clone <repository-url>
cd monitoring-system
```
### 2. Установка зависимостей
```bash
composer install
```
### 3. Настройка базы данных
1. Создайте базу данных:
```sql
CREATE DATABASE monitoring_system CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
2. Импортируйте схему:
```bash
mysql -u root -p monitoring_system < schema.sql
```
### 4. Настройка веб-сервера
#### Apache
Создайте виртуальный хост:
```apache
<VirtualHost *:80>
ServerName mon.mirv.top
DocumentRoot /path/to/monitoring-system/public
<Directory /path/to/monitoring-system/public>
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/mon_error.log
CustomLog ${APACHE_LOG_DIR}/mon_access.log combined
</VirtualHost>
```
#### Nginx
```nginx
server {
listen 80;
server_name mon.mirv.top;
root /path/to/monitoring-system/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
}
}
```
### 5. Настройка конфигурации базы данных
Отредактируйте файл `config/DatabaseConfig.php` для указания параметров подключения к базе данных:
```php
private $host = 'localhost';
private $db_name = 'monitoring_system';
private $username = 'your_db_username';
private $password = 'your_db_password';
```
## Использование
### 1. Вход в систему
Перейдите на `http://mon.mirv.top/login` и войдите в систему.
Для первоначальной настройки создайте администратора через SQL:
```sql
INSERT INTO users (username, password_hash, email, role)
VALUES ('admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'admin@example.com', 'admin');
```
Пароль: `password`
### 2. Добавление серверов
1. Перейдите в раздел "Серверы"
2. Нажмите "Добавить сервер"
3. Заполните форму и сохраните
4. На странице подтверждения скопируйте токен и скачайте скрипт установки
### 3. Установка агента на мониторимый сервер
1. Скачайте скрипт установки с сервера мониторинга
2. Загрузите его на сервер, который нужно мониторить
3. Выполните:
```bash
chmod +x install.sh
./install.sh
```
Агент будет установлен как systemd-сервис и начнет отправлять метрики на сервер мониторинга.
## API
### Отправка метрик
Агенты отправляют метрики на эндпоинт `/api/v1/metrics` методом POST:
```json
{
"token": "токен_сервера",
"metrics": {
"cpu_load": 45.2,
"ram_used": 89.1,
"disk_used": 65.5
}
}
```
## Безопасность
- Все пароли хешируются с помощью `password_hash()`
- Токены агентов хранятся в виде SHA-256 хешей
- Все SQL-запросы используют подготовленные выражения
- Вход на все страницы требует аутентификации (кроме API и страницы входа)

183
TECHNICAL_SPECIFICATION.md Executable file
View File

@ -0,0 +1,183 @@
# ТЕХНИЧЕСКОЕ ЗАДАНИЕ: Система мониторинга серверов
## 1. Общие требования
- **Язык:** PHP 8.1+
- **Фреймворк:** Slim Framework 4 (с PSR-7, Twig)
- **Фронтенд:** Bootstrap 5, Font Awesome 6 (через CDN), Chart.js для графиков
- **База данных:** MySQL 8+ / MariaDB 10.5+
- **Архитектура:** MVC в рамках Slim (Controllers, Models, Templates)
- **Интерфейс:** **Полностью на русском языке** (все надписи, кнопки, формы, меню)
- **Безопасность:** Prepared Statements для всех SQL-запросов, хеши паролей (`password_hash`), **токены агентов хранятся только как SHA-256 хеши**.
## 2. Структура базы данных (8 таблиц)
| Таблица | Поля (ключевые) | Назначение |
| ------------------------------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| **users** | `id, username (unique), password_hash, email, role(admin/user), created_at` | Управление доступом |
| **user_notification_settings** | `id, user_id, telegram_chat_id, email_for_alerts` | Персональные настройки уведомлений |
| **server_groups** | `id, name, description, icon, color` | Группировка серверов |
| **servers** | `id, name, address (nullable), group_id, description, last_metrics_at, created_at` | Основные данные серверов |
| **metric_names** | `id, name (unique), unit, description` | Справочник метрик (cpu_load, ram_used, …) |
| **metric_thresholds** | `id, server_id, metric_name_id, warning_threshold, critical_threshold, duration` | **Индивидуальные пороги** для каждой метрики на каждом сервере |
| **server_metrics** | `id, server_id, metric_name_id, value, created_at` | **Исторические данные** метрик (с индексом `(server_id, metric_name_id, created_at)`) |
| **agent_tokens** | `id, server_id (unique), token_hash, created_at, last_used_at` | **Хеши** токенов для авторизации агентов |
| **alerts** | `id, server_id, metric_name, value, severity(warning/critical), resolved, created_at, resolved_at` | История срабатывания алертов |
## 3. Функциональные модули (что должна делать система)
### 3.1. Аутентификация и авторизация
- Доступ ко всем страницам, кроме `/login` и `/api/v1/metrics`, **только после входа**.
- Сессионная авторизация с middleware.
### 3.2. Дашборд (главная страница `/`)
- **Цветные карточки серверов** (Bootstrap cards):
- **Зеленый:** `last_metrics_at` < 2 мин назад И все метрики ниже порогов.
- **Желтый:** метрики свежие (<2 мин), но есть превышение порогов.
- **Красный:** `last_metrics_at` > 5 мин назад (сервер «молчит»).
- В карточке: имя сервера, текущие значения CPU/RAM, время последнего обновления.
- **Автообновление** каждые 30 секунд (через `setTimeout`).
### 3.3. Управление серверами
- **Добавление сервера:** форма с полями (имя, адрес опционально, группа, описание).
- **После сохранения:**
1. Генерация **уникального токена** (32 символа).
2. Сохранение **хеша токена** в `agent_tokens`.
3. Показ пользователю: **«Токен для агента: [токен]. Скачайте скрипт установки: [install.sh?token=...]»**.
- **Редактирование сервера:** возможность изменить все поля, **повторно скачать скрипт** (токен остается прежним).
### 3.4. API для агентов
- **Эндпоинт:** `POST /api/v1/metrics` (публичный, без авторизации сессии).
- **Ожидаемый JSON:** `{"token": "...", "metrics": {"cpu_load": 45.2, "ram_used": 89.1}}`.
- **Логика:**
1. Проверка токена (сравнение хеша).
2. Обновление `servers.last_metrics_at` и `agent_tokens.last_used_at`.
3. Сохранение каждой метрики в `server_metrics`.
4. **Проверка порогов:** если значение превышает `warning_threshold` из `metric_thresholds` → создание записи в `alerts`.
### 3.5. Агент мониторинга
- **Скрипт установки:** `GET /agent/install.sh?token=...` → динамически генерирует **bash-скрипт**, который:
1. Устанавливает Python3, psutil.
2. Скачивает Python-агента (`agent.py`) с вашего сервера.
3. Подставляет **токен** и **URL API** в конфиг агента.
4. Создает systemd-сервис для автостарта.
- **Python-агент:** собирает CPU, RAM, Disk раз в **10 секунд**, отправляет на `/api/v1/metrics`.
### 3.6. Страница сервера (`/server/{id}`)
- **Текущие значения** всех метрик.
- **Графики Chart.js** для метрик:
- По умолчанию — **последние 24 часа**.
- Кнопки выбора периода: **24 часа, 7 дней, 30 дней** (через параметр `?period=7d`).
- **Управление порогами:** форма настройки `warning_threshold` и `duration` для каждой метрики этого сервера (сохраняет в `metric_thresholds`).
### 3.7. Система алертов
- **Автоматическое создание** при превышении порога.
- **Страница `/alerts`:** список активных алертов с кнопкой **«Исправлено»** (помечает `resolved=1`).
- **Логика статуса на дашборде** учитывает только **неисправленные (resolved=0)** алерты.
### 3.8. Уведомления
- **Email:** отправка через SMTP (PHPMailer) при срабатывании алерта.
- **Telegram Bot:** отправка сообщения в заданный чат.
- **Настройка:** в админке (`/admin/notifications`) указываются: SMTP параметры, Telegram Bot Token, Chat ID.
### 3.9. Администрирование
- **CRUD пользователей:** страница `/admin/users` (только для `role='admin'`).
- **Управление группами серверов.**
- **Повторная выдача скрипта:** на странице сервера кнопка **«Скачать скрипт агента»**.
---
## План поэтапной реализации (9 этапов)
### Этап 1: Ядро системы
- **Цель:** Slim Framework + Twig + Bootstrap работают.
- **Результат:** Открываю `http://localhost:8080/test` → вижу «Система мониторинга».
- ✅ **Выполнен**
### Этап 2: База данных
- **Цель:** Все 8 таблиц созданы.
- **Результат:** Импорт `schema.sql` → в MySQL есть все таблицы, включая `metric_names` с записями «cpu_load», «ram_used».
- ✅ **Выполнен**
### Этап 3: Аутентификация + CRUD групп
- **Цель:** Вход/выход, управление группами.
- **Результат:**
1. Неавторизован → редирект на `/login` (форма на русском).
2. Вход как `admin:123` → переход на дашборд.
3. Меню «Группы» → создаю группу «Веб-серверы» → она отображается в списке.
- ✅ **Выполнен**
### Этап 4: CRUD серверов + генерация токена
- **Цель:** Добавление сервера с получением токена.
- **Результат:**
1. Форма «Добавить сервер» → заполняю → нажимаю «Сохранить».
2. Вижу страницу: **«Сервер добавлен. Токен: abc123... Скачайте скрипт: [install.sh?token=abc123...]»**.
3. Перехожу по ссылке → скачивается `install.sh`.
- ✅ **Выполнен**
### Этап 5: API + логика статусов
- **Цель:** Приём метрик, цветные карточки на дашборде.
- **Результат:**
1. Через `curl` отправляю метрики с токеном.
2. Обновляю дашборд → вижу карточку сервера **зелёного цвета** (метрики в норме).
3. Отправляю `cpu_load: 95` → карточка становится **жёлтой**.
- ✅ **Выполнен**
### Этап 6: Агент и скрипт установки
- **Цель:** Рабочий агент, отправляющий метрики каждые 10 сек.
- **Результат:**
1. В виртуальной машине запускаю `bash install.sh` → устанавливается Python-агент.
2. `systemctl status server-mon-agent` → сервис работает.
3. В логах агента вижу: «Отправлено: cpu_load=12.3» каждые 10 сек.
- ✅ **Выполнен**
### Этап 7: Детали сервера + графики
- **Цель:** Страница с графиками и выбором периода.
- **Результат:**
1. Кликаю на карточку сервера → открывается `/server/1`.
2. Вижу график CPU за 24 часа.
3. Нажимаю «7 дней» → график перерисовывается за неделю.
- ✅ **Выполнен**
### Этап 8: Пороги и алерты
- **Цель:** Настройка порогов, срабатывание алертов.
- **Результат:**
1. На странице сервера ставлю порог CPU: 80%.
2. Отправляю метрику `cpu_load: 90` → в таблице `alerts` появляется запись.
3. Страница `/alerts` показывает «Превышение CPU на сервере X».
- ✅ **Выполнен**
### Этап 9: Уведомления + админка
- **Цель:** Отправка email/Telegram, управление пользователями.
- **Результат:**
1. В админке указываю свой Telegram Chat ID.
2. При срабатывании алерта получаю сообщение в Telegram.
3. В меню «Админка» → «Пользователи» создаю нового пользователя.
- ✅ **Выполнен**
---
## История реализации
### Выполненные этапы:
- ✅ Этап 1: Ядро системы (Slim Framework 4, Twig, Bootstrap 5, Font Awesome 6)
- ✅ Этап 2: База данных (схема создана, 8 таблиц, стандартные метрики)
- ✅ Этап 3: Аутентификация и авторизация (middleware, формы входа/выхода, CRUD групп)
- ✅ Этап 4: CRUD серверов (добавление/редактирование/удаление, генерация токенов)
- ✅ Этап 5: API для агентов (прием метрик, проверка токенов, обновление статусов)
- ✅ Этап 6: Скрипт установки агента (динамическая генерация bash-скрипта)
- ✅ Этап 7: Детали сервера и графики (страница с метриками и графиками Chart.js)
- ✅ Этап 8: Система алертов (обнаружение превышений, страница алертов)
- ✅ Этап 9: Администрирование (управление пользователями, настройки уведомлений)
### Архитектура:
- **Frontend:** Bootstrap 5, Font Awesome 6, Chart.js (через CDN)
- **Backend:** Slim Framework 4, Twig templates, PSR-7
- **Database:** MySQL/MariaDB с 8 таблицами
- **Security:** Password hashing, SHA-256 token hashes, prepared statements
- **Language:** Полный перевод интерфейса на русский язык
### Компоненты:
- Веб-интерфейс для управления серверами и мониторинга
- API для приема метрик от агентов
- Скрипт установки агента для мониторинга серверов
- Система алертов и уведомлений
- Административная панель

26
composer.json Executable file
View File

@ -0,0 +1,26 @@
{
"name": "mirivlad/monitoring-system",
"description": "Система мониторинга серверов",
"type": "project",
"require": {
"php": "^8.1",
"slim/slim": "^4.12",
"slim/psr7": "^1.6",
"slim/twig-view": "^3.3",
"twig/twig": "^3.0",
"ext-pdo": "*",
"slim/csrf": "^1.3"
},
"autoload": {
"psr-4": {
"App\\": "src/",
"Config\\": "config/"
}
},
"scripts": {
"start": "php -S localhost:8080 -t public"
},
"config": {
"process-timeout": 0
}
}

1206
composer.lock generated Executable file

File diff suppressed because it is too large Load Diff

44
config/DatabaseConfig.php Executable file
View File

@ -0,0 +1,44 @@
<?php
// config/DatabaseConfig.php
namespace Config;
use PDO;
use PDOException;
class DatabaseConfig
{
private static $instance = null;
private $connection;
private $host = 'localhost';
private $db_name = 'monitoring_system';
private $username = "mon_user";
private $password = 'mon_password_123';
private $charset = 'utf8mb4';
private function __construct()
{
$dsn = "mysql:host={$this->host};dbname={$this->db_name};charset={$this->charset}";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$this->connection = new PDO($dsn, $this->username, $this->password, $options);
} catch (PDOException $e) {
throw new PDOException($e->getMessage(), (int)$e->getCode());
}
}
public static function getInstance()
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance->connection;
}
}

57
public/debug-login.php Normal file
View File

@ -0,0 +1,57 @@
<?php
// Простое логирование POST запросов для отладки
ini_set("session.save_path", "/var/www/mon/sessions");
session_start();
// Логируем все входящие данные
$timestamp = date('Y-m-d H:i:s');
$method = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];
$postData = file_get_contents("php://input");
$logLine = "$timestamp - $method $uri\n";
$logLine .= "POST data: $postData\n";
$logLine .= "POST params: " . json_encode($_POST, JSON_PRETTY_PRINT) . "\n";
$logLine .= "SESSION user_id: " . ($_SESSION['user_id'] ?? 'null') . "\n";
$logLine .= "-------------------\n";
file_put_contents('/tmp/login_debug.log', $logLine, FILE_APPEND);
// Продолжаем с обычным кодом
require_once '/var/www/mon/vendor/autoload.php';
use App\Models\User;
if ($method === 'POST' && $uri === '/login') {
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
echo "DEBUG: Processing login for user: $username\n";
echo "DEBUG: POST data: $postData\n";
$userModel = new User();
$user = $userModel->authenticate($username, $password);
if ($user) {
echo "DEBUG: Auth successful!\n";
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['role'] = $user['role'];
header('Location: /');
exit;
} else {
echo "DEBUG: Auth failed!\n";
header('Location: /login');
exit;
}
}
?>
<html>
<body>
<h1>Debug Mode</h1>
<p>Check /tmp/login_debug.log for details</p>
</body>
</html>

207
public/index.php Normal file
View File

@ -0,0 +1,207 @@
<?php
// public/index.php
use App\Controllers\AgentController;
use App\Controllers\AdminController;
use App\Controllers\AlertController;
use App\Controllers\Api\MetricsController;
use App\Controllers\GroupController;
use App\Controllers\ServerController;
use App\Controllers\ServerDetailController;
use App\Controllers\DashboardController;
use App\Middlewares\AuthMiddleware;
use App\Middlewares\SessionMiddleware;
use App\Models\User;
use App\Models\Server as ServerModel;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Csrf\Guard;
use Slim\Factory\AppFactory;
use Slim\Views\Twig;
use Slim\Views\TwigMiddleware;
require_once __DIR__ . '/../vendor/autoload.php';
// Start session
session_start();
// Create Slim app
$app = AppFactory::create();
// Create CSRF Guard
$csrf = new Guard($app->getResponseFactory());
$csrf->setPersistentTokenMode(true);
// Create Twig view
$twig = Twig::create(__DIR__ . '/../templates', ['cache' => false]);
// Add CSRF middleware FIRST
$app->add($csrf);
// Add Twig middleware
$twigMiddleware = TwigMiddleware::create($app, $twig);
$app->add($twigMiddleware);
// Add session middleware (MUST be after TwigMiddleware)
$sessionMiddleware = new SessionMiddleware($twig);
$app->add($sessionMiddleware);
// Add a route to get CSRF tokens via AJAX
$app->get('/csrf-token', function (Request $request, Response $response, $args) use ($csrf) {
$data = [
'name_key' => $csrf->getTokenNameKey(),
'value_key' => $csrf->getTokenValueKey(),
'name' => $csrf->getTokenName(),
'value' => $csrf->getTokenValue()
];
$response->getBody()->write(json_encode($data));
return $response->withHeader('Content-Type', 'application/json');
});
// Define /test route
$app->get('/test', function (Request $request, Response $response, $args) use ($twig) {
$templateData = [
'title' => 'Тест системы',
'message' => 'Система мониторинга запущена'
];
return $twig->render($response, 'test.twig', $templateData);
});
// Login routes (without auth middleware, but with CSRF)
$app->get('/login', function (Request $request, Response $response, $args) use ($twig, $csrf) {
$templateData = [
'title' => 'Вход в систему',
'csrf' => [
'name_key' => $csrf->getTokenNameKey(),
'value_key' => $csrf->getTokenValueKey(),
'name' => $csrf->getTokenName(),
'value' => $csrf->getTokenValue()
]
];
return $twig->render($response, 'login.twig', $templateData);
});
$app->post('/login', function (Request $request, Response $response, $args) use ($csrf) {
$params = $request->getParsedBody();
// Validate CSRF token
$nameKey = $csrf->getTokenNameKey();
$valueKey = $csrf->getTokenValueKey();
if (!isset($params[$nameKey]) || !isset($params[$valueKey]) || !$csrf->validateToken($params[$nameKey], $params[$valueKey])) {
error_log('CSRF validation failed for /login');
return $response->withHeader('Location', '/login')->withStatus(302);
}
$username = $params['username'] ?? '';
$password = $params['password'] ?? '';
$userModel = new User();
$user = $userModel->authenticate($username, $password);
if ($user) {
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['role'] = $user['role'];
return $response->withHeader('Location', '/')->withStatus(302);
} else {
return $response->withHeader('Location', '/login')->withStatus(302);
}
});
// Logout route (without auth middleware)
$app->get('/logout', function (Request $request, Response $response, $args) {
session_destroy();
return $response->withHeader('Location', '/login')->withStatus(302);
});
// Dashboard route (protected with auth middleware)
$app->get('/', function (Request $request, Response $response, $args) use ($twig) {
$serverModel = new ServerModel();
// Get statistics
$stats = $serverModel->getStats();
// Get servers with latest metrics
$servers = $serverModel->getAll();
$templateData = [
'title' => 'Дашборд мониторинга',
'stats' => $stats,
'servers' => $servers
];
return $twig->render($response, 'dashboard.twig', $templateData);
})->add(AuthMiddleware::class);
// Create controllers BEFORE routes
$groupController = new GroupController($twig);
$serverController = new ServerController($twig);
$serverDetailController = new ServerDetailController($twig);
$alertController = new AlertController($twig);
$adminController = new AdminController($twig);
$metricsController = new MetricsController();
$agentController = new AgentController();
// Routes for groups (protected with auth middleware)
$app->get('/groups', [$groupController, 'index'])->add(AuthMiddleware::class);
$app->get('/groups/create', [$groupController, 'create'])->add(AuthMiddleware::class);
$app->post('/groups', [$groupController, 'store'])->add(AuthMiddleware::class);
$app->get('/groups/{id}/edit', [$groupController, 'edit'])->add(AuthMiddleware::class);
$app->post('/groups/{id}', [$groupController, 'update'])->add(AuthMiddleware::class);
$app->delete('/groups/{id}', [$groupController, 'delete'])->add(AuthMiddleware::class);
$app->get('/groups/{id}', [$groupController, 'show'])->add(AuthMiddleware::class);
// Routes for servers (protected with auth middleware)
$app->get('/servers', [$serverController, 'index'])->add(AuthMiddleware::class);
$app->get('/servers/create', [$serverController, 'create'])->add(AuthMiddleware::class);
$app->post('/servers', [$serverController, 'store'])->add(AuthMiddleware::class);
$app->get('/servers/{id}/edit', [$serverController, 'edit'])->add(AuthMiddleware::class);
$app->post('/servers/{id}', [$serverController, 'update'])->add(AuthMiddleware::class);
$app->delete('/servers/{id}', [$serverController, 'delete'])->add(AuthMiddleware::class);
$app->get('/servers/{id}/regenerate-token', [$serverController, 'regenerateToken'])->add(AuthMiddleware::class);
$app->post('/servers/{id}/thresholds', [$serverDetailController, 'saveThresholds'])->add(AuthMiddleware::class);
$app->post('/servers/{id}/services', [$serverDetailController, 'saveServices'])->add(AuthMiddleware::class);
// Server detail route (protected with auth middleware)
$app->get('/servers/{id}', [$serverDetailController, 'show'])->add(AuthMiddleware::class);
// Alerts routes (protected with auth middleware)
$app->get('/alerts', [$alertController, 'index'])->add(AuthMiddleware::class);
$app->get('/alerts/{id}/resolve', [$alertController, 'markAsResolved'])->add(AuthMiddleware::class);
// Admin routes (protected with auth middleware)
$app->get('/admin/users', [$adminController, 'usersList'])->add(AuthMiddleware::class);
$app->get('/admin/notifications', [$adminController, 'notificationSettings'])->add(AuthMiddleware::class);
// API route for agents (public, no auth middleware)
$app->post('/api/v1/metrics', [$metricsController, 'collectMetrics']);
$app->get("/api/v1/agent/{id}/services", [$metricsController, 'getServices'])->add(AuthMiddleware::class);
// Agent configuration routes (protected with auth middleware)
$app->get("/agent/{id}/config", [$agentController, 'getConfig'])->add(AuthMiddleware::class);
$app->post("/agent/{id}/config", [$agentController, 'updateConfig'])->add(AuthMiddleware::class);
$app->get("/agent/{id}/status", [$agentController, 'getStatus'])->add(AuthMiddleware::class);
// API status endpoint (public, no auth middleware)
$app->get('/api/status', function (Request $request, Response $response, $args) {
$data = [
'status' => 'ok',
'timestamp' => date('Y-m-d H:i:s'),
'version' => '1.0.0'
];
$response->getBody()->write(json_encode($data));
return $response
->withHeader('Content-Type', 'application/json');
});
// Agent installation script route (public, no auth middleware)
$app->get('/agent/install.sh', [$agentController, 'generateInstallScript']);
// Run app
$app->run();

202
public/index.php.broken Normal file
View File

@ -0,0 +1,202 @@
<?php
// public/index.php - updated dashboard route
use App\Controllers\AgentController;
use App\Controllers\AdminController;
use App\Controllers\AlertController;
use App\Controllers\Api\MetricsController;
use App\Controllers\GroupController;
use App\Controllers\ServerController;
use App\Controllers\ServerDetailController;
use App\Controllers\DashboardController;
use App\Middlewares\AuthMiddleware;
use App\Middlewares\SessionMiddleware;
use App\Models\User;
use App\Models\Server as ServerModel;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Csrf\Guard;
use Slim\Factory\AppFactory;
use Slim\Views\Twig;
use Slim\Views\TwigMiddleware;
require_once __DIR__ . '/../vendor/autoload.php';
// Start session
session_start();
// Create Slim app
$app = AppFactory::create();
// Create CSRF Guard
$csrf = new Guard($app->getResponseFactory());
$csrf->setPersistentTokenMode(true);
// Create Twig view
$twig = Twig::create(__DIR__ . '/../templates', ['cache' => false]);
// Add CSRF middleware FIRST
$app->add($csrf);
// Add Twig middleware
$twigMiddleware = TwigMiddleware::create($app, $twig);
$app->add($twigMiddleware);
$sessionMiddleware = new AppMiddlewaresSessionMiddleware($twig);
$app->add($sessionMiddleware);
// Add session data to Twig$sessionMiddleware = new SessionMiddleware($twig);$app->add($sessionMiddleware);
// Add a route to get CSRF tokens via AJAX
$app->get('/csrf-token', function (Request $request, Response $response, $args) use ($csrf) {
$data = [
'name_key' => $csrf->getTokenNameKey(),
'value_key' => $csrf->getTokenValueKey(),
'name' => $csrf->getTokenName(),
'value' => $csrf->getTokenValue()
];
$response->getBody()->write(json_encode($data));
return $response->withHeader('Content-Type', 'application/json');
});
// Define /test route
$app->get('/test', function (Request $request, Response $response, $args) use ($twig) {
$templateData = [
'title' => 'Тест системы',
'message' => 'Система мониторинга запущена'
];
return $twig->render($response, 'test.twig', $templateData);
});
// Login routes (without auth middleware, but with CSRF)
$app->get('/login', function (Request $request, Response $response, $args) use ($twig, $csrf) {
$templateData = [
'title' => 'Вход в систему',
'csrf' => [
'name_key' => $csrf->getTokenNameKey(),
'value_key' => $csrf->getTokenValueKey(),
'name' => $csrf->getTokenName(),
'value' => $csrf->getTokenValue()
]
];
return $twig->render($response, 'login.twig', $templateData);
});
$app->post('/login', function (Request $request, Response $response, $args) use ($csrf) {
$params = $request->getParsedBody();
// Validate CSRF token
$nameKey = $csrf->getTokenNameKey();
$valueKey = $csrf->getTokenValueKey();
if (!isset($params[$nameKey]) || !isset($params[$valueKey]) || !$csrf->validateToken($params[$nameKey], $params[$valueKey])) {
error_log('CSRF validation failed for /login');
return $response->withHeader('Location', '/login')->withStatus(302);
}
$username = $params['username'] ?? '';
$password = $params['password'] ?? '';
$userModel = new User();
$user = $userModel->authenticate($username, $password);
if ($user) {
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['role'] = $user['role'];
return $response->withHeader('Location', '/')->withStatus(302);
} else {
return $response->withHeader('Location', '/login')->withStatus(302);
}
});
// Logout route (without auth middleware)
$app->get('/logout', function (Request $request, Response $response, $args) {
session_destroy();
return $response->withHeader('Location', '/login')->withStatus(302);
});
// Dashboard route (protected with auth middleware)
$app->get('/', function (Request $request, Response $response, $args) use ($twig) {
$serverModel = new ServerModel();
// Get statistics
$stats = $serverModel->getStats();
// Get servers with latest metrics
$servers = $serverModel->getAll();
$templateData = [
'title' => 'Дашборд мониторинга',
'stats' => $stats,
'servers' => $servers
];
return $twig->render($response, 'dashboard.twig', $templateData);
})->add(AuthMiddleware::class);
// Routes for groups (protected with auth middleware)
$groupController = new GroupController($twig);
$app->get('/groups', [$groupController, 'index'])->add(AuthMiddleware::class);
$app->get('/groups/create', [$groupController, 'create'])->add(AuthMiddleware::class);
$app->post('/groups', [$groupController, 'store'])->add(AuthMiddleware::class);
$app->get('/groups/{id}/edit', [$groupController, 'edit'])->add(AuthMiddleware::class);
$app->post('/groups/{id}', [$groupController, 'update'])->add(AuthMiddleware::class);
$app->delete('/groups/{id}', [$groupController, 'delete'])->add(AuthMiddleware::class);
// Routes for servers (protected with auth middleware)
$serverController = new ServerController($twig);
$app->get('/servers', [$serverController, 'index'])->add(AuthMiddleware::class);
$app->get('/servers/create', [$serverController, 'create'])->add(AuthMiddleware::class);
$app->post('/servers', [$serverController, 'store'])->add(AuthMiddleware::class);
$app->get('/servers/{id}/edit', [$serverController, 'edit'])->add(AuthMiddleware::class);
$app->post('/servers/{id}', [$serverController, 'update'])->add(AuthMiddleware::class);
$app->delete('/servers/{id}', [$serverController, 'delete'])->add(AuthMiddleware::class);
$app->get('/servers/{id}/regenerate-token', [$serverController, 'regenerateToken'])->add(AuthMiddleware::class);
// Server detail route (protected with auth middleware)
$serverDetailController = new ServerDetailController($twig);
$app->get('/servers/{id}', [$serverDetailController, 'show'])->add(AuthMiddleware::class);
// Alerts routes (protected with auth middleware)
$alertController = new AlertController($twig);
$app->get('/alerts', [$alertController, 'index'])->add(AuthMiddleware::class);
$app->get('/alerts/{id}/resolve', [$alertController, 'markAsResolved'])->add(AuthMiddleware::class);
// Admin routes (protected with auth middleware)
$adminController = new AdminController($twig);
$app->get('/admin/users', [$adminController, 'usersList'])->add(AuthMiddleware::class);
$app->get('/admin/notifications', [$adminController, 'notificationSettings'])->add(AuthMiddleware::class);
// API route for agents (public, no auth middleware)
$metricsController = new MetricsController();
$app->post('/api/v1/metrics', [$metricsController, 'collectMetrics']);
// API status endpoint (public, no auth middleware)
$app->get('/api/status', function (Request $request, Response $response, $args) {
$data = [
'status' => 'ok',
'timestamp' => date('Y-m-d H:i:s'),
'version' => '1.0.0'
];
$response->getBody()->write(json_encode($data));
return $response
->withHeader('Content-Type', 'application/json');
});
// Agent installation script route (public, no auth middleware)
$agentController = new AgentController();
$app->get('/agent/install.sh', [$agentController, 'generateInstallScript']);
// Run app
$app->run();

46
public/login-direct.php Normal file
View File

@ -0,0 +1,46 @@
<?php
session_start();
// Простая проверка пароля (для теста)
if (isset($_POST['username']) && isset($_POST['password'])) {
$username = $_POST['username'];
$password = $_POST['password'];
// Хешированный пароль для admin_test_2026
$correctHash = '$2y$10$5PhDSHiF1J6yxcEldOsluOSmUYaO1bWa7swFmfmP/Slj.HJOh5t2O';
$inputHash = password_hash($password, PASSWORD_DEFAULT);
// Для теста используем прямое сравнение хешей
if ($username === 'admin' && password_verify($password, $correctHash)) {
$_SESSION['user_id'] = 1;
$_SESSION['username'] = 'admin';
$_SESSION['role'] = 'admin';
$_SESSION['logged_in'] = time();
header('Location: /');
exit;
}
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Тест входа</title>
<meta charset="utf-8">
</head>
<body>
<h1>Тестовый вход</h1>
<form method="post">
<p>
Логин: <input type="text" name="username" value="admin">
</p>
<p>
Пароль: <input type="password" name="password">
</p>
<p>
<button type="submit">Войти</button>
</p>
</form>
<p>Тестовые креды: admin / admin_test_2026</p>
</body>
</html>

12
public/session_check.php Normal file
View File

@ -0,0 +1,12 @@
<?php
session_start();
header('Content-Type: application/json');
echo json_encode([
'session_id' => session_id(),
'user_id' => $_SESSION['user_id'] ?? null,
'username' => $_SESSION['username'] ?? null,
'role' => $_SESSION['role'] ?? null,
'session_data' => $_SESSION
], JSON_PRETTY_PRINT);

23
public/session_test.php Normal file
View File

@ -0,0 +1,23 @@
<?php
session_start();
// Проверяем существующую сессию
echo "Текущая сессия:\n";
echo "Session ID: " . session_id() . "\n";
echo "Session data: " . json_encode($_SESSION, JSON_PRETTY_PRINT) . "\n";
// Если сессия пуста, создаем пользователя admin
if (!isset($_SESSION["user_id"])) {
$_SESSION["user_id"] = 1;
$_SESSION["username"] = "admin";
$_SESSION["role"] = "admin";
$_SESSION["created_at"] = time();
echo "\n✅ Сессия создана!\n";
} else {
echo "\n✅ Сессия уже существует!\n";
echo "Время создания: " . date("Y-m-d H:i:s", $_SESSION["created_at"]) . "\n";
}
// Информация о cookies
echo "\n🍪 Cookie информация:\n

19
public/set_session.php Normal file
View File

@ -0,0 +1,19 @@
<?php
session_start();
require_once '/var/www/mon/vendor/autoload.php';
use Config\DatabaseConfig;
use App\Models\User;
$pdo = DatabaseConfig::getInstance();
$userModel = new User();
// Устанавливаем пароль admin в сессии напрямую
$_SESSION['user_id'] = 1;
$_SESSION['username'] = 'admin';
$_SESSION['role'] = 'admin';
echo "✅ Сессия admin создана!\n";
echo "Session ID: " . session_id() . "\n";
echo "Session data: " . json_encode($_SESSION, JSON_PRETTY_PRINT) . "\n";

107
schema.sql Executable file
View File

@ -0,0 +1,107 @@
-- Структура базы данных для системы мониторинга
-- Таблица пользователей
CREATE DATABASE IF NOT EXISTS monitoring_system CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE monitoring_system;
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(100),
role ENUM('admin', 'user') DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Таблица настроек уведомлений пользователей
CREATE TABLE user_notification_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
telegram_chat_id VARCHAR(50),
email_for_alerts VARCHAR(100),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Таблица групп серверов
CREATE TABLE server_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
icon VARCHAR(50),
color VARCHAR(20)
);
-- Таблица серверов
CREATE TABLE servers (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
address VARCHAR(255),
group_id INT,
description TEXT,
last_metrics_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (group_id) REFERENCES server_groups(id)
);
-- Таблица названий метрик
CREATE TABLE metric_names (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
unit VARCHAR(20),
description TEXT
);
-- Вставляем стандартные метрики
INSERT INTO metric_names (name, unit, description) VALUES
('cpu_load', '%', 'Загрузка процессора'),
('ram_used', '%', 'Использование оперативной памяти'),
('disk_used', '%', 'Использование диска'),
('network_in', 'MB/s', 'Скорость приема сети'),
('network_out', 'MB/s', 'Скорость передачи сети');
-- Таблица пороговых значений метрик
CREATE TABLE metric_thresholds (
id INT AUTO_INCREMENT PRIMARY KEY,
server_id INT NOT NULL,
metric_name_id INT NOT NULL,
warning_threshold DECIMAL(8,2),
critical_threshold DECIMAL(8,2),
duration INT DEFAULT 0,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
FOREIGN KEY (metric_name_id) REFERENCES metric_names(id)
);
-- Таблица метрик серверов
CREATE TABLE server_metrics (
id INT AUTO_INCREMENT PRIMARY KEY,
server_id INT NOT NULL,
metric_name_id INT NOT NULL,
value DECIMAL(8,2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_server_metric_time (server_id, metric_name_id, created_at),
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
FOREIGN KEY (metric_name_id) REFERENCES metric_names(id)
);
-- Таблица токенов агентов
CREATE TABLE agent_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
server_id INT UNIQUE NOT NULL,
token_hash VARCHAR(64) NOT NULL, -- SHA-256 hash
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP NULL,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
-- Таблица алертов
CREATE TABLE alerts (
id INT AUTO_INCREMENT PRIMARY KEY,
server_id INT NOT NULL,
metric_name VARCHAR(50) NOT NULL,
value DECIMAL(8,2),
severity ENUM('warning', 'critical') NOT NULL,
resolved BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
resolved_at TIMESTAMP NULL,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);

View File

@ -0,0 +1,53 @@
<?php
// src/Controllers/AdminController.php
namespace App\Controllers;
use App\Models\Model;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
class AdminController extends Model
{
private $twig;
public function __construct(Twig $twig)
{
parent::__construct();
$this->twig = $twig;
}
public function usersList(Request $request, Response $response, $args)
{
// Только для администраторов
if ($_SESSION['role'] !== 'admin') {
return $response->withHeader('Location', '/')->withStatus(302);
}
$stmt = $this->pdo->prepare("SELECT id, username, email, role, created_at FROM users ORDER BY created_at DESC");
$stmt->execute();
$users = $stmt->fetchAll();
$templateData = [
'title' => 'Управление пользователями',
'users' => $users
];
return $this->twig->render($response, 'admin/users.twig', $templateData);
}
public function notificationSettings(Request $request, Response $response, $args)
{
// Только для администраторов
if ($_SESSION['role'] !== 'admin') {
return $response->withHeader('Location', '/')->withStatus(302);
}
$templateData = [
'title' => 'Настройки уведомлений'
];
return $this->twig->render($response, 'admin/notifications.twig', $templateData);
}
}

View File

@ -0,0 +1,424 @@
<?php
// src/Controllers/AgentController.php
namespace App\Controllers;
use App\Models\Model;
use App\Utils\EncryptionHelper;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
class AgentController extends Model
{
public function generateInstallScript(Request $request, Response $response, $args)
{
$queryParams = $request->getQueryParams();
$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]);
$result = $stmt->fetch();
if ($result && !empty($result['encrypted_token'])) {
$token = EncryptionHelper::decrypt($result['encrypted_token']);
}
}
if (empty($token)) {
$response->getBody()->write('Token is required');
return $response->withStatus(400);
}
$apiUrl = 'https://mon.mirv.top/api/v1/metrics';
// Формируем скрипт с прямой подстановкой значений
$script = "#!/bin/bash
# Скрипт установки агента мониторинга с поддержкой сервисов
# Сгенерировано автоматически
TOKEN='" . $token . "'
API_URL='" . $apiUrl . "'
echo 'Установка агента мониторинга...'
# Проверяем наличие Python3
if ! command -v python3 &> /dev/null; then
echo 'Установка Python3...'
apt-get update
apt-get install -y python3 python3-pip
fi
# Устанавливаем psutil
pip3 install psutil || easy_install3 psutil
# Создаем директорию для агента
mkdir -p /opt/server-monitor-agent
cd /opt/server-monitor-agent
# Создаем конфигурационный файл
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
# Создаем systemd сервис
cat > /etc/systemd/system/server-monitor-agent.service << 'SERVICE_EOF'
[Unit]
Description=Server Monitor Agent
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/server-monitor-agent
ExecStart=/usr/bin/python3 /opt/server-monitor-agent/agent.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
SERVICE_EOF
# Делаем скрипт исполняемым
chmod +x agent.py
# Перезагружаем systemd
systemctl daemon-reload
# Включаем автозапуск сервиса
systemctl enable server-monitor-agent
# Запускаем сервис
systemctl start server-monitor-agent
echo 'Агент мониторинга установлен и запущен!'
echo 'Статус сервиса:'
systemctl status server-monitor-agent
";
$response->getBody()->write($script);
return $response
->withHeader('Content-Type', 'application/x-shellscript')
->withHeader('Content-Disposition', 'attachment; filename="install.sh"');
}
public function getConfig(Request $request, Response $response, $args)
{
$serverId = $args['id'];
// Получаем конфигурацию агента
$stmt = $this->pdo->prepare("
SELECT interval_seconds, monitor_services, enabled
FROM agent_configs
WHERE server_id = :server_id
");
$stmt->execute([':server_id' => $serverId]);
$config = $stmt->fetch();
if (!$config) {
// Если конфигурации нет - создаем с дефолтными значениями
$stmt = $this->pdo->prepare("
INSERT INTO agent_configs (server_id, interval_seconds, monitor_services, enabled)
VALUES (:server_id, 60, '[]', TRUE)
");
$stmt->execute([':server_id' => $serverId]);
$config = [
'interval_seconds' => 60,
'monitor_services' => [],
'enabled' => true
];
}
$response->getBody()->write(json_encode($config));
return $response->withHeader('Content-Type', 'application/json');
}
public function updateConfig(Request $request, Response $response, $args)
{
$serverId = $args['id'];
$params = $request->getParsedBody();
// Получаем и десериализуем массив сервисов
$monitorServices = $params['monitor_services'] ?? [];
if (is_string($monitorServices)) {
$monitorServices = json_decode($monitorServices, true) ?? [];
}
// Валидация интервала
$interval = max(10, min(3600, (int)($params['interval_seconds'] ?? 60)));
// Обновляем конфигурацию
$stmt = $this->pdo->prepare("
INSERT INTO agent_configs (server_id, interval_seconds, monitor_services, enabled)
VALUES (:server_id, :interval, :services, TRUE)
ON DUPLICATE KEY UPDATE
interval_seconds = VALUES(interval_seconds),
monitor_services = VALUES(monitor_services),
updated_at = CURRENT_TIMESTAMP
");
$stmt->execute([
':server_id' => $serverId,
':interval' => $interval,
':services' => json_encode($monitorServices)
]);
// Обновляем статус проверки сервисов на сервере
$enabled = $params['enabled'] ?? true;
$stmt = $this->pdo->prepare("
UPDATE servers SET service_check_enabled = :enabled WHERE id = :server_id
");
$stmt->execute([
':server_id' => $serverId,
':enabled' => $enabled ? 1 : 0
]);
return $response->withHeader('Content-Type', 'application/json');
}
public function getStatus(Request $request, Response $response, $args)
{
$serverId = $args['id'];
// Получаем последний раз когда агент был активен
$stmt = $this->pdo->prepare("
SELECT s.last_metrics_at, s.last_service_check_at, ac.enabled
FROM servers s
LEFT JOIN agent_configs ac ON s.id = ac.server_id
WHERE s.id = :server_id
");
$stmt->execute([':server_id' => $serverId]);
$result = $stmt->fetch();
if (!$result) {
$response->getBody()->write(json_encode(['error' => 'Server not found']));
return $response->withStatus(404);
}
$data = [
'status' => $result['enabled'] ? 'active' : 'disabled',
'last_seen' => $result['last_metrics_at'],
'last_service_check' => $result['last_service_check_at']
];
$response->getBody()->write(json_encode($data));
return $response->withHeader('Content-Type', 'application/json');
}
}

View File

@ -0,0 +1,60 @@
<?php
// src/Controllers/AlertController.php
namespace App\Controllers;
use App\Models\Model;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
class AlertController extends Model
{
private $twig;
public function __construct(Twig $twig)
{
parent::__construct();
$this->twig = $twig;
}
public function index(Request $request, Response $response, $args)
{
$stmt = $this->pdo->prepare("
SELECT a.*, s.name as server_name
FROM alerts a
JOIN servers s ON a.server_id = s.id
WHERE a.resolved = 0
ORDER BY a.created_at DESC
");
$stmt->execute();
$alerts = $stmt->fetchAll();
$templateData = [
'title' => 'Алерты',
'alerts' => $alerts
];
return $this->twig->render($response, 'alerts/index.twig', $templateData);
}
public function markAsResolved(Request $request, Response $response, $args)
{
$id = $args['id'];
$stmt = $this->pdo->prepare("
UPDATE alerts
SET resolved = 1, resolved_at = NOW()
WHERE id = :id
");
$result = $stmt->execute([':id' => $id]);
if ($result) {
return $response->withHeader('Location', '/alerts')->withStatus(302);
} else {
// TODO: Обработка ошибки
return $response->withHeader('Location', '/alerts')->withStatus(302);
}
}
}

View File

@ -0,0 +1,229 @@
<?php
// src/Controllers/Api/MetricsController.php
namespace App\Controllers\Api;
use App\Models\Model;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Config\DatabaseConfig;
class MetricsController extends Model
{
public function collectMetrics(Request $request, Response $response, $args)
{
$input = json_decode($request->getBody(), true);
if (!$input || !isset($input['token'])) {
return $response->withStatus(400);
}
$token = $input['token'];
$metrics = $input['metrics'] ?? [];
$services = $input['services'] ?? [];
// Проверяем токен (сравниваем хеш)
$tokenHash = hash('sha256', $token);
$stmt = $this->pdo->prepare("
SELECT at.server_id, s.name as server_name
FROM agent_tokens at
JOIN servers s ON at.server_id = s.id
WHERE at.token_hash = :token_hash
");
$stmt->execute([':token_hash' => $tokenHash]);
$tokenInfo = $stmt->fetch();
if (!$tokenInfo) {
return $response->withStatus(401);
}
$serverId = $tokenInfo['server_id'];
// Обновляем время последних метрик для сервера
$stmt = $this->pdo->prepare("
UPDATE servers
SET last_metrics_at = NOW()
WHERE id = :server_id
");
$stmt->execute([':server_id' => $serverId]);
// Сохраняем метрики если есть
if (!empty($metrics)) {
foreach ($metrics as $metricName => $value) {
// Получаем ID метрики из справочника
$stmt = $this->pdo->prepare("SELECT id FROM metric_names WHERE name = :name");
$stmt->execute([':name' => $metricName]);
$metricInfo = $stmt->fetch();
if ($metricInfo) {
$metricId = $metricInfo['id'];
// Сохраняем метрику
$stmt = $this->pdo->prepare("
INSERT INTO server_metrics (server_id, metric_name_id, value)
VALUES (:server_id, :metric_name_id, :value)
");
$stmt->execute([
':server_id' => $serverId,
':metric_name_id' => $metricId,
':value' => $value
]);
// Проверяем пороги
$this->checkThresholds($serverId, $metricId, $value, $metricName);
}
}
}
// Сохраняем статусы сервисов если есть
if (!empty($services)) {
foreach ($services as $service) {
$serviceName = $service['name'] ?? '';
$serviceStatus = $service['status'] ?? 'unknown';
$loadState = $service['load_state'] ?? '';
$activeState = $service['active_state'] ?? '';
$subState = $service['sub_state'] ?? '';
if (empty($serviceName)) {
continue;
}
// Обновляем статус сервиса (INSERT OR UPDATE)
$stmt = $this->pdo->prepare("
INSERT INTO service_status (server_id, service_name, status, load_state, active_state, sub_state, updated_at)
VALUES (:server_id, :service_name, :status, :load_state, :active_state, :sub_state, NOW())
ON DUPLICATE KEY UPDATE
status = VALUES(status),
load_state = VALUES(load_state),
active_state = VALUES(active_state),
sub_state = VALUES(sub_state),
updated_at = NOW()
");
$stmt->execute([
':server_id' => $serverId,
':service_name' => $serviceName,
':status' => $serviceStatus,
':load_state' => $loadState,
':active_state' => $activeState,
':sub_state' => $subState
]);
// Если сервис остановлен и включено его мониторинг - создаем алерт
if ($serviceStatus === 'stopped') {
$this->createServiceAlert($serverId, $serviceName, $serviceStatus);
}
}
// Обновляем время последней проверки сервисов
$stmt = $this->pdo->prepare("
UPDATE servers
SET last_service_check_at = NOW()
WHERE id = :server_id
");
$stmt->execute([':server_id' => $serverId]);
}
// Обновляем время последнего использования токена
$stmt = $this->pdo->prepare("
UPDATE agent_tokens
SET last_used_at = NOW()
WHERE server_id = :server_id
");
$stmt->execute([':server_id' => $serverId]);
return $response->withStatus(200);
}
public function getServices(Request $request, Response $response, $args)
{
$serverId = $args['id'];
// Получаем список сервисов
$stmt = $this->pdo->prepare("
SELECT service_name, status, load_state, active_state, sub_state, updated_at
FROM service_status
WHERE server_id = :server_id
ORDER BY service_name
");
$stmt->execute([':server_id' => $serverId]);
$services = $stmt->fetchAll();
$response->getBody()->write(json_encode(['services' => $services]));
return $response->withHeader('Content-Type', 'application/json');
}
private function checkThresholds($serverId, $metricId, $value, $metricName)
{
// Получаем пороговые значения для этой метрики на этом сервере
$stmt = $this->pdo->prepare("
SELECT warning_threshold, critical_threshold
FROM metric_thresholds
WHERE server_id = :server_id AND metric_name_id = :metric_name_id
");
$stmt->execute([
':server_id' => $serverId,
':metric_name_id' => $metricId
]);
$thresholds = $stmt->fetch();
if ($thresholds) {
$warningThreshold = $thresholds['warning_threshold'];
$criticalThreshold = $thresholds['critical_threshold'];
$severity = null;
if ($criticalThreshold && $value >= $criticalThreshold) {
$severity = 'critical';
} elseif ($warningThreshold && $value >= $warningThreshold) {
$severity = 'warning';
}
if ($severity) {
// Создаем алерт
$stmt = $this->pdo->prepare("
INSERT INTO alerts (server_id, metric_name, value, severity)
VALUES (:server_id, :metric_name, :value, :severity)
");
$stmt->execute([
':server_id' => $serverId,
':metric_name' => $metricName,
':value' => $value,
':severity' => $severity
]);
}
}
}
private function createServiceAlert($serverId, $serviceName, $status)
{
// Проверяем есть ли уже неразрешенный алерт для этого сервиса
$stmt = $this->pdo->prepare("
SELECT id FROM service_alerts
WHERE server_id = :server_id AND service_name = :service_name AND resolved = FALSE
ORDER BY created_at DESC LIMIT 1
");
$stmt->execute([
':server_id' => $serverId,
':service_name' => $serviceName
]);
$existingAlert = $stmt->fetch();
// Если алерта нет или он уже разрешен - создаем новый
if (!$existingAlert) {
$stmt = $this->pdo->prepare("
INSERT INTO service_alerts (server_id, service_name, status, severity)
VALUES (:server_id, :service_name, :status, 'critical')
");
$stmt->execute([
':server_id' => $serverId,
':service_name' => $serviceName,
':status' => $status
]);
}
}
}

View File

@ -0,0 +1,37 @@
<?php
// src/Controllers/DashboardController.php
namespace App\Controllers;
use App\Models\Server;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
class DashboardController
{
private $twig;
private $serverModel;
public function __construct($twig)
{
$this->twig = $twig;
$this->serverModel = new Server();
}
public function index(Request $request, Response $response, $args)
{
// Получаем статистику
$stats = $this->serverModel->getStats();
// Получаем список серверов с последними метриками
$servers = $this->serverModel->getAll();
$templateData = [
'title' => 'Дашборд мониторинга',
'stats' => $stats,
'servers' => $servers
];
return $this->twig->render($response, 'dashboard.twig', $templateData);
}
}

View File

@ -0,0 +1,189 @@
<?php
// src/Controllers/GroupController.php
namespace App\Controllers;
use App\Models\Model;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
class GroupController extends Model
{
private $twig;
public function __construct(Twig $twig)
{
parent::__construct();
$this->twig = $twig;
}
public function index(Request $request, Response $response, $args)
{
$stmt = $this->pdo->prepare("SELECT * FROM server_groups ORDER BY name");
$stmt->execute();
$groups = $stmt->fetchAll();
$templateData = [
'title' => 'Группы серверов',
'groups' => $groups
];
return $this->twig->render($response, 'groups/index.twig', $templateData);
}
public function create(Request $request, Response $response, $args)
{
$templateData = [
'title' => 'Создать группу'
];
return $this->twig->render($response, 'groups/create.twig', $templateData);
}
public function store(Request $request, Response $response, $args)
{
$params = $request->getParsedBody();
$stmt = $this->pdo->prepare("
INSERT INTO server_groups (name, description, icon, color)
VALUES (:name, :description, :icon, :color)
");
$result = $stmt->execute([
':name' => $params['name'],
':description' => $params['description'] ?? '',
':icon' => $params['icon'] ?? '',
':color' => $params['color'] ?? ''
]);
if ($result) {
return $response->withHeader('Location', '/groups')->withStatus(302);
} else {
// TODO: Обработка ошибки
return $response->withHeader('Location', '/groups/create')->withStatus(302);
}
}
public function edit(Request $request, Response $response, $args)
{
$id = $args['id'];
$stmt = $this->pdo->prepare("SELECT * FROM server_groups WHERE id = :id");
$stmt->execute([':id' => $id]);
$group = $stmt->fetch();
if (!$group) {
return $response->withHeader('Location', '/groups')->withStatus(302);
}
$templateData = [
'title' => 'Редактировать группу',
'group' => $group
];
return $this->twig->render($response, 'groups/edit.twig', $templateData);
}
public function update(Request $request, Response $response, $args)
{
$id = $args['id'];
$params = $request->getParsedBody();
$stmt = $this->pdo->prepare("
UPDATE server_groups
SET name = :name, description = :description, icon = :icon, color = :color
WHERE id = :id
");
$result = $stmt->execute([
':id' => $id,
':name' => $params['name'],
':description' => $params['description'] ?? '',
':icon' => $params['icon'] ?? '',
':color' => $params['color'] ?? ''
]);
if ($result) {
return $response->withHeader('Location', '/groups')->withStatus(302);
} else {
// TODO: Обработка ошибки
return $response->withHeader('Location', '/groups/' . $id . '/edit')->withStatus(302);
}
}
public function delete(Request $request, Response $response, $args)
{
$id = $args['id'];
$stmt = $this->pdo->prepare("DELETE FROM server_groups WHERE id = :id");
$result = $stmt->execute([':id' => $id]);
if ($result) {
return $response->withHeader('Location', '/groups')->withStatus(302);
} else {
// TODO: Обработка ошибки
return $response->withHeader('Location', '/groups')->withStatus(302);
}
}
public function show(Request $request, Response $response, $args)
{
$id = $args['id'];
// Получаем информацию о группе
$stmt = $this->pdo->prepare("SELECT * FROM server_groups WHERE id = :id");
$stmt->execute([':id' => $id]);
$group = $stmt->fetch();
if (!$group) {
return $response->withHeader('Location', '/groups')->withStatus(302);
}
// Получаем серверы в этой группе
$stmt = $this->pdo->prepare("
SELECT s.*,
(SELECT created_at FROM server_metrics sm WHERE sm.server_id = s.id ORDER BY sm.created_at DESC LIMIT 1) as last_metrics_at
FROM servers s
WHERE s.group_id = :group_id
ORDER BY s.name
");
$stmt->execute([':group_id' => $id]);
$servers = $stmt->fetchAll();
// Получаем статистику для каждого сервера
$serverStats = [];
foreach ($servers as $server) {
$stmt = $this->pdo->prepare("
SELECT mn.name, sm.value, mn.unit
FROM server_metrics sm
JOIN metric_names mn ON sm.metric_name_id = mn.id
WHERE sm.server_id = :server_id
ORDER BY sm.created_at DESC
LIMIT 5
");
$stmt->execute([':server_id' => $server['id']]);
$metrics = [];
while ($row = $stmt->fetch()) {
if (!isset($metrics[$row['name']])) {
$metrics[$row['name']] = [
'value' => $row['value'],
'unit' => $row['unit']
];
}
}
$serverStats[$server['id']] = $metrics;
}
$templateData = [
'title' => 'Группа: ' . $group['name'],
'group' => $group,
'servers' => $servers,
'serverStats' => $serverStats
];
return $this->twig->render($response, 'groups/show.twig', $templateData);
}
}

View File

@ -0,0 +1,215 @@
<?php
// src/Controllers/ServerController.php
namespace App\Controllers;
use App\Models\Model;
use App\Utils\EncryptionHelper;
use Config\DatabaseConfig;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
class ServerController extends Model
{
private $twig;
public function __construct(Twig $twig)
{
parent::__construct();
$this->twig = $twig;
}
public function index(Request $request, Response $response, $args)
{
$stmt = $this->pdo->prepare("
SELECT s.*, sg.name as group_name
FROM servers s
LEFT JOIN server_groups sg ON s.group_id = sg.id
ORDER BY s.name
");
$stmt->execute();
$servers = $stmt->fetchAll();
$templateData = [
'title' => 'Серверы',
'servers' => $servers
];
return $this->twig->render($response, 'servers/index.twig', $templateData);
}
public function create(Request $request, Response $response, $args)
{
$stmt = $this->pdo->prepare("SELECT * FROM server_groups ORDER BY name");
$stmt->execute();
$groups = $stmt->fetchAll();
$templateData = [
'title' => 'Добавить сервер',
'groups' => $groups
];
return $this->twig->render($response, 'servers/create.twig', $templateData);
}
public function store(Request $request, Response $response, $args)
{
$params = $request->getParsedBody();
// Генерируем уникальный токен
$token = bin2hex(random_bytes(16)); // 32-символьный токен
$this->pdo->beginTransaction();
try {
// Сохраняем сервер
$stmt = $this->pdo->prepare("
INSERT INTO servers (name, address, group_id, description)
VALUES (:name, :address, :group_id, :description)
");
$result = $stmt->execute([
':name' => $params['name'],
':address' => $params['address'] ?? '',
':group_id' => $params['group_id'] ?? null,
':description' => $params['description'] ?? ''
]);
$serverId = $this->pdo->lastInsertId();
// Сохраняем хеш токена и зашифрованный токен
$tokenHash = hash('sha256', $token);
$encryptedToken = EncryptionHelper::encrypt($token);
$stmt = $this->pdo->prepare("
INSERT INTO agent_tokens (server_id, token_hash, encrypted_token)
VALUES (:server_id, :token_hash, :encrypted_token)
");
$result = $stmt->execute([
':server_id' => $serverId,
':token_hash' => $tokenHash,
':encrypted_token' => $encryptedToken
]);
$this->pdo->commit();
// Передаем токен для отображения на странице
$templateData = [
'title' => 'Сервер добавлен',
'server' => [
'id' => $serverId,
'name' => $params['name']
],
'token' => $token
];
return $this->twig->render($response, 'servers/created.twig', $templateData);
} catch (\Exception $e) {
$this->pdo->rollback();
// TODO: Обработка ошибки
return $response->withHeader('Location', '/servers/create')->withStatus(302);
}
}
public function edit(Request $request, Response $response, $args)
{
$id = $args['id'];
$stmt = $this->pdo->prepare("SELECT * FROM servers WHERE id = :id");
$stmt->execute([':id' => $id]);
$server = $stmt->fetch();
$stmt = $this->pdo->prepare("SELECT * FROM server_groups ORDER BY name");
$stmt->execute();
$groups = $stmt->fetchAll();
if (!$server) {
return $response->withHeader('Location', '/servers')->withStatus(302);
}
$templateData = [
'title' => 'Редактировать сервер',
'server' => $server,
'groups' => $groups
];
return $this->twig->render($response, 'servers/edit.twig', $templateData);
}
public function update(Request $request, Response $response, $args)
{
$id = $args['id'];
$params = $request->getParsedBody();
$stmt = $this->pdo->prepare("
UPDATE servers
SET name = :name, address = :address, group_id = :group_id, description = :description
WHERE id = :id
");
$result = $stmt->execute([
':id' => $id,
':name' => $params['name'],
':address' => $params['address'] ?? '',
':group_id' => $params['group_id'] ?? null,
':description' => $params['description'] ?? ''
]);
if ($result) {
return $response->withHeader('Location', '/servers')->withStatus(302);
} else {
// TODO: Обработка ошибки
return $response->withHeader('Location', '/servers/' . $id . '/edit')->withStatus(302);
}
}
public function delete(Request $request, Response $response, $args)
{
$id = $args['id'];
$stmt = $this->pdo->prepare("DELETE FROM servers WHERE id = :id");
$result = $stmt->execute([':id' => $id]);
if ($result) {
return $response->withHeader('Location', '/servers')->withStatus(302);
} else {
// TODO: Обработка ошибки
return $response->withHeader('Location', '/servers')->withStatus(302);
}
}
public function regenerateToken(Request $request, Response $response, $args)
{
$id = $args['id'];
// Генерируем новый токен
$newToken = bin2hex(random_bytes(16));
$tokenHash = hash('sha256', $newToken);
$encryptedToken = EncryptionHelper::encrypt($newToken);
// Обновляем или создаем запись в agent_tokens
$stmt = $this->pdo->prepare("
INSERT INTO agent_tokens (server_id, token_hash, encrypted_token)
VALUES (:server_id, :token_hash, :encrypted_token)
ON DUPLICATE KEY UPDATE token_hash = :token_hash, encrypted_token = :encrypted_token
");
$result = $stmt->execute([
':server_id' => $id,
':token_hash' => $tokenHash,
':encrypted_token' => $encryptedToken
]);
if ($result) {
// Перенаправляем обратно на страницу редактирования
return $response->withHeader('Location', '/servers/' . $id . '/edit')->withStatus(302);
} else {
// TODO: Обработка ошибки
return $response->withHeader('Location', '/servers/' . $id . '/edit')->withStatus(302);
}
}
}

View File

@ -0,0 +1,197 @@
<?php
// src/Controllers/ServerDetailController.php
namespace App\Controllers;
use App\Models\Model;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
class ServerDetailController extends Model
{
private $twig;
public function __construct(Twig $twig)
{
parent::__construct();
$this->twig = $twig;
}
public function show(Request $request, Response $response, $args)
{
$id = $args['id'];
// Получаем информацию о сервере
$stmt = $this->pdo->prepare("
SELECT s.*, sg.name as group_name, sg.icon as group_icon, sg.color as group_color
FROM servers s
LEFT JOIN server_groups sg ON s.group_id = sg.id
WHERE s.id = :id
");
$stmt->execute([':id' => $id]);
$server = $stmt->fetch();
if (!$server) {
return $response->withHeader('Location', '/servers')->withStatus(302);
}
// Получаем период для выборки метрик
$period = $request->getQueryParams()['period'] ?? '24h';
// Определяем интервал времени в зависимости от периода
$interval = match($period) {
'7d' => 'INTERVAL 7 DAY',
'30d' => 'INTERVAL 30 DAY',
default => 'INTERVAL 24 HOUR'
};
// Получаем последние метрики для этого сервера
$stmt = $this->pdo->prepare("
SELECT sm.value, mn.name, mn.unit, sm.created_at
FROM server_metrics sm
JOIN metric_names mn ON sm.metric_name_id = mn.id
WHERE sm.server_id = :id
AND sm.created_at >= DATE_SUB(NOW(), {$interval})
ORDER BY sm.created_at DESC
");
$stmt->execute([':id' => $id]);
$metrics = $stmt->fetchAll();
// Группируем метрики по типу (CPU, RAM, Disk)
$groupedMetrics = [];
foreach ($metrics as $metric) {
$metricName = $metric['name'];
if (!isset($groupedMetrics[$metricName])) {
$groupedMetrics[$metricName] = [];
}
$groupedMetrics[$metricName][] = $metric;
}
// Получаем текущие пороговые значения для сервера
$stmt = $this->pdo->prepare("
SELECT mt.warning_threshold, mt.critical_threshold, mt.duration, mn.name
FROM metric_thresholds mt
JOIN metric_names mn ON mt.metric_name_id = mn.id
WHERE mt.server_id = :id
");
$stmt->execute([':id' => $id]);
$existingThresholds = [];
foreach ($stmt->fetchAll() as $threshold) {
$existingThresholds[$threshold['name']] = [
'warning' => $threshold['warning_threshold'],
'critical' => $threshold['critical_threshold'],
'duration' => $threshold['duration']
];
}
// Получаем все типы метрик
$stmt = $this->pdo->query("SELECT id, name, unit FROM metric_names ORDER BY name");
$allMetricTypes = $stmt->fetchAll();
// Получаем список сервисов
$stmt = $this->pdo->prepare("
SELECT service_name, status, load_state, active_state, sub_state
FROM service_status
WHERE server_id = :server_id
ORDER BY service_name
");
$stmt->execute([':server_id' => $id]);
$allServices = $stmt->fetchAll();
// Получаем список сервисов для мониторинга из конфигурации агента
$stmt = $this->pdo->prepare("
SELECT monitor_services FROM agent_configs WHERE server_id = :server_id
");
$stmt->execute([':server_id' => $id]);
$agentConfig = $stmt->fetch();
$monitorServices = [];
if ($agentConfig && !empty($agentConfig['monitor_services'])) {
$monitorServices = json_decode($agentConfig['monitor_services'], true) ?? [];
}
$templateData = [
'title' => 'Сервер: ' . $server['name'],
'server' => $server,
'metrics' => $groupedMetrics,
'allMetricTypes' => $allMetricTypes,
'existingThresholds' => $existingThresholds,
'allServices' => $allServices,
'monitorServices' => $monitorServices,
'period' => $period,
'request' => $request->getQueryParams()
];
return $this->twig->render($response, 'servers/detail.twig', $templateData);
}
public function saveThresholds(Request $request, Response $response, $args)
{
$id = $args['id'];
$params = $request->getParsedBody();
// Получаем все типы метрик
$stmt = $this->pdo->query("SELECT id, name FROM metric_names ORDER BY name");
$metricTypes = $stmt->fetchAll();
// Удаляем старые пороги для этого сервера
$stmt = $this->pdo->prepare("DELETE FROM metric_thresholds WHERE server_id = :server_id");
$stmt->execute([':server_id' => $id]);
// Добавляем новые пороги
$insertStmt = $this->pdo->prepare("
INSERT INTO metric_thresholds (server_id, metric_name_id, warning_threshold, critical_threshold, duration)
VALUES (:server_id, :metric_name_id, :warning_threshold, :critical_threshold, :duration)
");
foreach ($metricTypes as $metricType) {
$warning = $params[$metricType['name'] . '_warning'] ?? '';
$critical = $params[$metricType['name'] . '_critical'] ?? '';
$duration = $params[$metricType['name'] . '_duration'] ?? 0;
if ($warning !== '' && $critical !== '') {
$insertStmt->execute([
':server_id' => $id,
':metric_name_id' => $metricType['id'],
':warning_threshold' => $warning,
':critical_threshold' => $critical,
':duration' => $duration
]);
}
}
// Возвращаемся на страницу сервера
return $response->withHeader('Location', "/servers/{$id}")->withStatus(302);
}
public function saveServices(Request $request, Response $response, $args)
{
$id = $args['id'];
$params = $request->getParsedBody();
// Получаем список сервисов для мониторинга
$services = $params['services'] ?? [];
if (is_string($services)) {
$services = json_decode($services, true) ?? [];
}
// Обновляем конфигурацию агента
$stmt = $this->pdo->prepare("
INSERT INTO agent_configs (server_id, interval_seconds, monitor_services, enabled)
VALUES (:server_id, 60, :services, TRUE)
ON DUPLICATE KEY UPDATE
monitor_services = VALUES(monitor_services),
updated_at = CURRENT_TIMESTAMP
");
$stmt->execute([
':server_id' => $id,
':services' => json_encode($services)
]);
// Возвращаемся на страницу сервера
return $response->withHeader('Location', "/servers/{$id}?tab=services")->withStatus(302);
}
}

View File

@ -0,0 +1,198 @@
<?php
// src/Controllers/ServerDetailController.php
namespace App\Controllers;
use App\Models\Model;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
class ServerDetailController extends Model
{
private $twig;
public function __construct(Twig $twig)
{
parent::__construct();
$this->twig = $twig;
}
public function show(Request $request, Response $response, $args)
{
$id = $args['id'];
// Получаем информацию о сервере
$stmt = $this->pdo->prepare("
SELECT s.*, sg.name as group_name, sg.icon as group_icon, sg.color as group_color
FROM servers s
LEFT JOIN server_groups sg ON s.group_id = sg.id
WHERE s.id = :id
");
$stmt->execute([':id' => $id]);
$server = $stmt->fetch();
if (!$server) {
return $response->withHeader('Location', '/servers')->withStatus(302);
}
// Получаем период для выборки метрик
$period = $request->getQueryParams()['period'] ?? '24h';
// Определяем интервал времени в зависимости от периода
$interval = match($period) {
'7d' => 'INTERVAL 7 DAY',
'30d' => 'INTERVAL 30 DAY',
default => 'INTERVAL 24 HOUR'
};
// Получаем последние метрики для этого сервера
$stmt = $this->pdo->prepare("
SELECT sm.value, mn.name, mn.unit, sm.created_at
FROM server_metrics sm
JOIN metric_names mn ON sm.metric_name_id = mn.id
WHERE sm.server_id = :id
AND sm.created_at >= DATE_SUB(NOW(), {$interval})
ORDER BY sm.created_at DESC
");
$stmt->execute([':id' => $id]);
$metrics = $stmt->fetchAll();
// Группируем метрики по типу
$groupedMetrics = [];
foreach ($metrics as $metric) {
$name = $metric['name'];
if (!isset($groupedMetrics[$name])) {
$groupedMetrics[$name] = [];
}
$groupedMetrics[$name][] = $metric;
}
// Получаем все доступные типы метрик для настройки порогов
$stmt = $this->pdo->query("SELECT name, unit FROM metric_names ORDER BY name");
$allMetricTypes = $stmt->fetchAll();
// Получаем текущие пороговые значения для сервера
$stmt = $this->pdo->prepare("
SELECT mt.warning_threshold, mt.critical_threshold, mt.duration, mn.name
FROM metric_thresholds mt
JOIN metric_names mn ON mt.metric_name_id = mn.id
WHERE mt.server_id = :id
");
$stmt->execute([':id' => $id]);
$existingThresholds = [];
foreach ($stmt->fetchAll() as $threshold) {
$existingThresholds[$threshold['name']] = [
'warning' => $threshold['warning_threshold'],
'critical' => $threshold['critical_threshold'],
'duration' => $threshold['duration']
];
}
// Получаем список сервисов
$stmt = $this->pdo->prepare("
SELECT service_name, status, load_state, active_state, sub_state
FROM service_status
WHERE server_id = :server_id
ORDER BY service_name
");
$stmt->execute([':server_id' => $id]);
$allServices = $stmt->fetchAll();
// Получаем список сервисов для мониторинга из конфигурации агента
$stmt = $this->pdo->prepare("
SELECT monitor_services FROM agent_configs WHERE server_id = :server_id
");
$stmt->execute([':server_id' => $id]);
$agentConfig = $stmt->fetch();
$monitorServices = [];
if ($agentConfig && !empty($agentConfig['monitor_services'])) {
$monitorServices = json_decode($agentConfig['monitor_services'], true) ?? [];
}
$templateData = [
'title' => 'Сервер: ' . $server['name'],
'server' => $server,
'metrics' => $groupedMetrics,
'allMetricTypes' => $allMetricTypes,
'existingThresholds' => $existingThresholds,
'allServices' => $allServices,
'period' => $period,
'request' => $request->getQueryParams()
'monitorServices' => $monitorServices
];
return $this->twig->render($response, 'servers/detail.twig', $templateData);
}
public function saveThresholds(Request $request, Response $response, $args)
{
$id = $args['id'];
$params = $request->getParsedBody();
// Получаем все типы метрик
$stmt = $this->pdo->query("SELECT id, name FROM metric_names ORDER BY name");
$metricTypes = $stmt->fetchAll();
// Удаляем старые пороги для этого сервера
$stmt = $this->pdo->prepare("DELETE FROM metric_thresholds WHERE server_id = :server_id");
$stmt->execute([':server_id' => $id]);
// Добавляем новые пороги
$insertStmt = $this->pdo->prepare("
INSERT INTO metric_thresholds (server_id, metric_name_id, warning_threshold, critical_threshold, duration)
VALUES (:server_id, :metric_name_id, :warning_threshold, :critical_threshold, :duration)
");
foreach ($metricTypes as $metricType) {
$warning = $params[$metricType['name'] . '_warning'] ?? null;
$critical = $params[$metricType['name'] . '_critical'] ?? null;
$duration = $params[$metricType['name'] . '_duration'] ?? 0;
// Сохраняем только если указан хотя бы один порог
if ($warning !== null || $critical !== null) {
$insertStmt->execute([
':server_id' => $id,
':metric_name_id' => $metricType['id'],
':warning_threshold' => $warning,
':critical_threshold' => $critical,
':duration' => (int)$duration
]);
}
}
// Возвращаемся на страницу сервера
return $response->withHeader('Location', "/servers/{$id}")->withStatus(302);
}
public function saveServices(Request $request, Response $response, $args)
{
$id = $args['id'];
$params = $request->getParsedBody();
// Получаем список сервисов для мониторинга
$services = $params['services'] ?? [];
if (is_string($services)) {
$services = json_decode($services, true) ?? [];
}
// Обновляем конфигурацию агента
$stmt = $this->pdo->prepare("
INSERT INTO agent_configs (server_id, interval_seconds, monitor_services, enabled)
VALUES (:server_id, 60, :services, TRUE)
ON DUPLICATE KEY UPDATE
monitor_services = VALUES(monitor_services),
updated_at = CURRENT_TIMESTAMP
");
$stmt->execute([
':server_id' => $id,
':services' => json_encode($services)
]);
// Возвращаемся на страницу сервера
return $response->withHeader('Location', "/servers/{$id}?tab=services")->withStatus(302);
}
}

View File

@ -0,0 +1,27 @@
<?php
// src/Middlewares/AuthMiddleware.php
namespace App\Middlewares;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Slim\Psr7\Response as SlimResponse;
class AuthMiddleware
{
public function __invoke(Request $request, RequestHandler $handler): Response
{
// Проверяем, авторизован ли пользователь
if (!isset($_SESSION['user_id'])) {
// Если не авторизован, перенаправляем на страницу входа
$response = new SlimResponse();
return $response
->withHeader('Location', '/login')
->withStatus(302);
}
// Если авторизован, продолжаем выполнение
return $handler->handle($request);
}
}

View File

@ -0,0 +1,44 @@
<?php
// src/Middlewares/CsrfMiddleware.php
namespace App\Middlewares;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Slim\Views\Twig;
use Slim\Csrf\Guard;
class CsrfMiddleware
{
private $twig;
private $csrf;
public function __construct(Twig $twig, Guard $csrf)
{
$this->twig = $twig;
$this->csrf = $csrf;
}
public function __invoke(Request $request, RequestHandler $handler): Response
{
// Пропускаем через handler, чтобы Guard мог установить атрибуты
$response = $handler->handle($request);
// После обработки получаем токены
$csrfNameKey = $this->csrf->getTokenNameKey();
$csrfValueKey = $this->csrf->getTokenValueKey();
$csrfName = $request->getAttribute($csrfNameKey);
$csrfValue = $request->getAttribute($csrfValueKey);
$this->twig->getEnvironment()->addGlobal('csrf', [
'name_key' => $csrfNameKey,
'value_key' => $csrfValueKey,
'name' => $csrfName,
'value' => $csrfValue
]);
return $response;
}
}

View File

@ -0,0 +1,35 @@
<?php
// src/Middlewares/SessionMiddleware.php
namespace App\Middlewares;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Slim\Views\Twig;
class SessionMiddleware
{
private $twig;
public function __construct(Twig $twig)
{
$this->twig = $twig;
}
public function __invoke(Request $request, RequestHandler $handler): Response
{
// Добавляем данные сессии в контекст Twig
$sessionData = [
'user_id' => $_SESSION['user_id'] ?? null,
'username' => $_SESSION['username'] ?? null,
'role' => $_SESSION['role'] ?? null
];
// Получаем environment и добавляем session в глобальный контекст
$environment = $this->twig->getEnvironment();
$environment->addGlobal('session', $sessionData);
return $handler->handle($request);
}
}

46
src/Models/Alert.php Normal file
View File

@ -0,0 +1,46 @@
<?php
// src/Models/Alert.php
namespace App\Models;
use Config\DatabaseConfig;
use PDO;
class Alert
{
private $db;
public function __construct()
{
$this->db = DatabaseConfig::getInstance();
}
public function getAll($resolved = false)
{
$stmt = $this->db->prepare("SELECT a.*, s.name as server_name, sg.name as group_name, sg.icon as group_icon
FROM alerts a
JOIN servers s ON a.server_id = s.id
LEFT JOIN server_groups sg ON s.group_id = sg.id
WHERE a.resolved = ?
ORDER BY a.created_at DESC");
$stmt->execute([$resolved ? 1 : 0]);
return $stmt->fetchAll();
}
public function getById($id)
{
$stmt = $this->db->prepare("SELECT a.*, s.name as server_name, sg.name as group_name, sg.icon as group_icon
FROM alerts a
JOIN servers s ON a.server_id = s.id
LEFT JOIN server_groups sg ON s.group_id = sg.id
WHERE a.id = ?");
$stmt->execute([$id]);
return $stmt->fetch();
}
public function markAsResolved($id)
{
$stmt = $this->db->prepare("UPDATE alerts SET resolved = TRUE, resolved_at = NOW() WHERE id = ?");
return $stmt->execute([$id]);
}
}

62
src/Models/Group.php Normal file
View File

@ -0,0 +1,62 @@
<?php
// src/Models/Group.php
namespace App\Models;
use Config\DatabaseConfig;
use PDO;
class Group
{
private $db;
public function __construct()
{
$this->db = DatabaseConfig::getInstance();
}
public function getAll()
{
$stmt = $this->db->query("SELECT * FROM server_groups ORDER BY name");
return $stmt->fetchAll();
}
public function getById($id)
{
$stmt = $this->db->prepare("SELECT * FROM server_groups WHERE id = ?");
$stmt->execute([$id]);
return $stmt->fetch();
}
public function create($data)
{
$stmt = $this->db->prepare("INSERT INTO server_groups (name, description, icon, color)
VALUES (?, ?, ?, ?)");
return $stmt->execute([
$data['name'],
$data['description'] ?? null,
$data['icon'] ?? null,
$data['color'] ?? null
]);
}
public function update($id, $data)
{
$stmt = $this->db->prepare("UPDATE server_groups
SET name = ?, description = ?, icon = ?, color = ?
WHERE id = ?");
return $stmt->execute([
$data['name'],
$data['description'] ?? null,
$data['icon'] ?? null,
$data['color'] ?? null,
$id
]);
}
public function delete($id)
{
$stmt = $this->db->prepare("DELETE FROM server_groups WHERE id = ?");
return $stmt->execute([$id]);
}
}

27
src/Models/Model.php Executable file
View File

@ -0,0 +1,27 @@
<?php
// src/Models/Model.php
namespace App\Models;
use Config\DatabaseConfig;
abstract class Model
{
protected $pdo;
public function __construct()
{
$this->pdo = DatabaseConfig::getInstance();
}
/**
* Экранирование данных для безопасности
*/
protected function sanitize($data)
{
if (is_array($data)) {
return array_map('htmlspecialchars', $data);
}
return htmlspecialchars($data, ENT_QUOTES, 'UTF-8');
}
}

59
src/Models/Server.php Normal file
View File

@ -0,0 +1,59 @@
<?php
// src/Models/Server.php
namespace App\Models;
use Config\DatabaseConfig;
use PDO;
class Server
{
private $db;
public function __construct()
{
$this->db = DatabaseConfig::getInstance();
}
public function getAll()
{
$stmt = $this->db->query("SELECT s.*, sg.name as group_name, sg.icon as group_icon, sg.color as group_color
FROM servers s
LEFT JOIN server_groups sg ON s.group_id = sg.id
ORDER BY s.name");
return $stmt->fetchAll();
}
public function getById($id)
{
$stmt = $this->db->prepare("SELECT s.*, sg.name as group_name, sg.icon as group_icon, sg.color as group_color
FROM servers s
LEFT JOIN server_groups sg ON s.group_id = sg.id
WHERE s.id = ?");
$stmt->execute([$id]);
return $stmt->fetch();
}
public function getStats()
{
$stats = [];
// Общее количество серверов
$stmt = $this->db->query("SELECT COUNT(*) as total FROM servers");
$stats['total_servers'] = $stmt->fetch()['total'];
// Количество групп
$stmt = $this->db->query("SELECT COUNT(*) as total FROM server_groups");
$stats['total_groups'] = $stmt->fetch()['total'];
// Активные алерты (warning)
$stmt = $this->db->query("SELECT COUNT(*) as total FROM alerts WHERE resolved = FALSE AND severity = 'warning'");
$stats['warnings'] = $stmt->fetch()['total'];
// Активные алерты (critical)
$stmt = $this->db->query("SELECT COUNT(*) as total FROM alerts WHERE resolved = FALSE AND severity = 'critical'");
$stats['criticals'] = $stmt->fetch()['total'];
return $stats;
}
}

42
src/Models/User.php Executable file
View File

@ -0,0 +1,42 @@
<?php
// src/Models/User.php
namespace App\Models;
class User extends Model
{
public function findByUsername($username)
{
$stmt = $this->pdo->prepare("SELECT * FROM users WHERE username = :username LIMIT 1");
$stmt->execute([':username' => $username]);
return $stmt->fetch();
}
public function authenticate($username, $password)
{
$user = $this->findByUsername($username);
if ($user && password_verify($password, $user['password_hash'])) {
return $user;
}
return false;
}
public function create($username, $password, $email, $role = 'user')
{
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $this->pdo->prepare("
INSERT INTO users (username, password_hash, email, role)
VALUES (:username, :password_hash, :email, :role)
");
return $stmt->execute([
':username' => $username,
':password_hash' => $passwordHash,
':email' => $email,
':role' => $role
]);
}
}

View File

@ -0,0 +1,37 @@
<?php
// src/Utils/EncryptionHelper.php
namespace App\Utils;
class EncryptionHelper
{
private static $cipher = 'AES-256-CBC';
private static $key = 'mon_sys_encryption_key_32_chars!'; // 32-byte key
private static $ivLength = 16;
public static function encrypt($plaintext)
{
$iv = random_bytes(self::$ivLength);
$encrypted = openssl_encrypt($plaintext, self::$cipher, self::$key, OPENSSL_RAW_DATA, $iv);
if ($encrypted === false) {
return false;
}
return base64_encode($iv . $encrypted);
}
public static function decrypt($ciphertext)
{
$ciphertext = base64_decode($ciphertext);
if ($ciphertext === false) {
return false;
}
$iv = substr($ciphertext, 0, self::$ivLength);
$encrypted = substr($ciphertext, self::$ivLength);
return openssl_decrypt($encrypted, self::$cipher, self::$key, OPENSSL_RAW_DATA, $iv);
}
}

View File

@ -0,0 +1,79 @@
{% extends "layout.twig" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-bell"></i> Настройки уведомлений</h3>
</div>
<div class="card-body">
<form method="post" action="/admin/notifications">
<h5><i class="fas fa-envelope"></i> Email уведомления</h5>
<div class="row">
<div class="col-md-6">
<label for="smtp_host" class="form-label">SMTP сервер</label>
<input type="text" class="form-control" id="smtp_host" name="smtp_host" placeholder="smtp.gmail.com">
</div>
<div class="col-md-6">
<label for="smtp_port" class="form-label">Порт</label>
<input type="number" class="form-control" id="smtp_port" name="smtp_port" placeholder="587">
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label for="smtp_username" class="form-label">Имя пользователя</label>
<input type="text" class="form-control" id="smtp_username" name="smtp_username" placeholder="user@example.com">
</div>
<div class="col-md-6">
<label for="smtp_password" class="form-label">Пароль</label>
<input type="password" class="form-control" id="smtp_password" name="smtp_password" placeholder="••••••••">
</div>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="smtp_secure" name="smtp_secure">
<label class="form-check-label" for="smtp_secure">
Использовать безопасное соединение (SSL/TLS)
</label>
</div>
</div>
<div class="mb-4">
<h5><i class="fab fa-telegram"></i> Telegram уведомления</h5>
<div class="row">
<div class="col-md-6">
<label for="telegram_bot_token" class="form-label">Bot Token</label>
<input type="text" class="form-control" id="telegram_bot_token" name="telegram_bot_token" placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ">
</div>
<div class="col-md-6">
<label for="telegram_chat_id" class="form-label">Chat ID</label>
<input type="text" class="form-control" id="telegram_chat_id" name="telegram_chat_id" placeholder="-1001234567890">
</div>
</div>
</div>
<div class="mb-4">
<h5><i class="fas fa-mobile-alt"></i> SMS уведомления</h5>
<div class="row">
<div class="col-md-6">
<label for="sms_api_key" class="form-label">API ключ</label>
<input type="text" class="form-control" id="sms_api_key" name="sms_api_key" placeholder="API ключ сервиса">
</div>
<div class="col-md-6">
<label for="sms_sender" class="form-label">Отправитель</label>
<input type="text" class="form-control" id="sms_sender" name="sms_sender" placeholder="MyCompany">
</div>
</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Сохранить настройки
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

70
templates/admin/users.twig Executable file
View File

@ -0,0 +1,70 @@
{% extends "layout.twig" %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><i class="fas fa-users-cog"></i> Управление пользователями</h2>
<a href="#" class="btn btn-primary">
<i class="fas fa-plus"></i> Добавить пользователя
</a>
</div>
<div class="card">
<div class="card-body">
{% if users|length > 0 %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>Имя пользователя</th>
<th>Email</th>
<th>Роль</th>
<th>Дата создания</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>{{ user.email|default('-') }}</td>
<td>
{% if user.role == 'admin' %}
<span class="badge bg-danger"><i class="fas fa-crown"></i> Администратор</span>
{% else %}
<span class="badge bg-secondary"><i class="fas fa-user"></i> Пользователь</span>
{% endif %}
</td>
<td>{{ user.created_at|date('d.m.Y H:i:s') }}</td>
<td>
<a href="/admin/users/{{ user.id }}/edit" class="btn btn-sm btn-outline-primary">
<i class="fas fa-edit"></i> Редактировать
</a>
<form action="/admin/users/{{ user.id }}" method="post" style="display: inline-block;" onsubmit="return confirm('Вы уверены, что хотите удалить этого пользователя?');">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="fas fa-trash"></i> Удалить
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<p class="lead">Пользователи пока не созданы</p>
<a href="#" class="btn btn-primary">
<i class="fas fa-plus"></i> Создать первого пользователя
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

60
templates/alerts/index.twig Executable file
View File

@ -0,0 +1,60 @@
{% extends "layout.twig" %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><i class="fas fa-bell"></i> Алерты</h2>
</div>
<div class="card">
<div class="card-body">
{% if alerts|length > 0 %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Сервер</th>
<th>Метрика</th>
<th>Значение</th>
<th>Уровень</th>
<th>Время</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for alert in alerts %}
<tr class="{% if alert.severity == 'critical' %}table-danger{% else %}table-warning{% endif %}">
<td>{{ alert.server_name }}</td>
<td>{{ alert.metric_name|replace({'_': ' ', 'load': 'загрузка', 'used': 'использование'})|title }}</td>
<td>{{ alert.value }}{% if alert.metric_name ends_with '_load' or alert.metric_name ends_with '_used' %}%{% endif %}</td>
<td>
{% if alert.severity == 'critical' %}
<span class="badge bg-danger"><i class="fas fa-exclamation-triangle"></i> Критично</span>
{% else %}
<span class="badge bg-warning text-dark"><i class="fas fa-exclamation-circle"></i> Предупреждение</span>
{% endif %}
</td>
<td>{{ alert.created_at|date('d.m.Y H:i:s') }}</td>
<td>
<a href="/alerts/{{ alert.id }}/resolve" class="btn btn-sm btn-outline-success" onclick="return confirm('Вы уверены, что хотите отметить этот алерт как исправленный?');">
<i class="fas fa-check"></i> <span class="d-none d-sm-inline">Исправлено</span>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-bell fa-3x text-muted mb-3"></i>
<p class="lead">Нет активных алертов</p>
<p class="text-muted">Все серверы работают в штатном режиме</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

125
templates/dashboard.twig Executable file
View File

@ -0,0 +1,125 @@
{% extends "layout.twig" %}
{% block content %}
<div class="row">
<div class="col-12">
<h2><i class="fas fa-tachometer-alt"></i> Дашборд мониторинга</h2>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<i class="fas fa-server text-info fa-2x mb-2"></i>
<h3>{{ stats.total_servers }}</h3>
<p class="text-muted mb-0">Всего серверов</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<i class="fas fa-chart-line text-success fa-2x mb-2"></i>
<h3>{{ stats.servers_with_metrics }}</h3>
<p class="text-muted mb-0">С метриками</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<i class="fas fa-exclamation-triangle text-danger fa-2x mb-2"></i>
<h3>{{ stats.alerts_count }}</h3>
<p class="text-muted mb-0">Активных алертов</p>
</div>
</div>
</div>
</div>
<!-- Servers List -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0"><i class="fas fa-server"></i> Серверы</h4>
<div>
<a href="/servers/create" class="btn btn-sm btn-outline-primary me-2">
<i class="fas fa-plus"></i> <span class="d-none d-sm-inline">Добавить сервер</span>
</a>
<a href="/servers" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-list"></i> <span class="d-none d-sm-inline">Все серверы</span>
</a>
</div>
</div>
<div class="card-body">
{% if servers|length > 0 %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Название</th>
<th>Адрес</th>
<th>Группа</th>
<th>Статус</th>
<th>Последние метрики</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for server in servers %}
<tr>
<td><strong>{{ server.name }}</strong></td>
<td>{{ server.address|default('-') }}</td>
<td>
{% if server.group_name %}
<span class="badge" style="background-color: {{ server.group_color|default('#6c757d') }}">
<i class="fas {{ server.group_icon|default('fa-box') }} me-1"></i>{{ server.group_name }}
</span>
{% else %}
<span class="badge bg-secondary">Без группы</span>
{% endif %}
</td>
<td>
{% if server.last_metrics_at %}
<span class="badge bg-success">Активен</span>
{% else %}
<span class="badge bg-warning">Нет метрик</span>
{% endif %}
</td>
<td>
{% if server.last_metrics_at %}
{{ server.last_metrics_at|date('d.m.Y H:i:s') }}
{% else %}
-
{% endif %}
</td>
<td>
<a href="/servers/{{ server.id }}" class="btn btn-sm btn-outline-info" title="Просмотр">
<i class="fas fa-eye"></i> <span class="d-none d-sm-inline">Просмотр</span>
</a>
<a href="/servers/{{ server.id }}/edit" class="btn btn-sm btn-outline-primary" title="Редактировать">
<i class="fas fa-edit"></i> <span class="d-none d-sm-inline">Редактировать</span>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-server fa-3x text-muted mb-3"></i>
<p class="lead">Серверы пока не добавлены</p>
<a href="/servers/create" class="btn btn-primary">
<i class="fas fa-plus"></i> <span class="d-none d-sm-inline">Добавить первый сервер</span>
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

53
templates/groups/create.twig Executable file
View File

@ -0,0 +1,53 @@
{% extends "layout.twig" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-plus-circle"></i> Создать группу серверов</h3>
</div>
<div class="card-body">
<form method="post" action="/groups">
<div class="mb-3">
<label for="name" class="form-label">Название группы *</label>
<input type="text" class="form-control" id="name" name="name" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">Описание</label>
<textarea class="form-control" id="description" name="description" rows="3"></textarea>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="icon" class="form-label">Иконка (Font Awesome)</label>
<input type="text" class="form-control" id="icon" name="icon" placeholder="например: fa-server">
<small class="form-text text-muted">Используйте классы Font Awesome, например: fa-server, fa-desktop</small>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="color" class="form-label">Цвет</label>
<input type="color" class="form-control form-control-color" id="color" name="color">
<small class="form-text text-muted">Выберите цвет для обозначения группы</small>
</div>
</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="/groups" class="btn btn-secondary me-md-2">
<i class="fas fa-arrow-left"></i> Назад
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Сохранить
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

54
templates/groups/edit.twig Executable file
View File

@ -0,0 +1,54 @@
{% extends "layout.twig" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-edit"></i> Редактировать группу "{{ group.name }}"</h3>
</div>
<div class="card-body">
<form method="post" action="/groups/{{ group.id }}">
<div class="mb-3">
<label for="name" class="form-label">Название группы *</label>
<input type="text" class="form-control" id="name" name="name" value="{{ group.name }}" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">Описание</label>
<textarea class="form-control" id="description" name="description" rows="3">{{ group.description|default('') }}</textarea>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="icon" class="form-label">Иконка (Font Awesome)</label>
<input type="text" class="form-control" id="icon" name="icon" value="{{ group.icon|default('') }}" placeholder="например: fa-server">
<small class="form-text text-muted">Используйте классы Font Awesome, например: fa-server, fa-desktop</small>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="color" class="form-label">Цвет</label>
<input type="color" class="form-control form-control-color" id="color" name="color" value="{{ group.color|default('#007bff') }}">
<small class="form-text text-muted">Выберите цвет для обозначения группы</small>
</div>
</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="/groups" class="btn btn-secondary me-md-2">
<i class="fas fa-arrow-left"></i> Назад
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Сохранить изменения
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

74
templates/groups/index.twig Executable file
View File

@ -0,0 +1,74 @@
{% extends "layout.twig" %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><i class="fas fa-layer-group"></i> Группы серверов</h2>
<a href="/groups/create" class="btn btn-primary">
<i class="fas fa-plus"></i> <span class="d-none d-sm-inline">Создать группу</span>
</a>
</div>
<div class="card">
<div class="card-body">
{% if groups|length > 0 %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Название</th>
<th>Описание</th>
<th>Цвет</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for group in groups %}
<tr>
<td>
<i class="fas {{ group.icon|default('fa-box') }}" {% if group.color %}style="color: {{ group.color }}"{% endif %}></i> {{ group.name }}
</td>
<td>{{ group.description|default('') }}</td>
<td>
{% if group.color %}
<span class="badge" style="background-color: {{ group.color }}">{{ group.color }}</span>
{% else %}
-
{% endif %}
</td>
<td>
<a href="/groups/{{ group.id }}" class="btn btn-sm btn-outline-info me-1">
<i class="fas fa-eye"></i> <span class="d-none d-sm-inline">Просмотр</span>
</a>
<a href="/groups/{{ group.id }}/edit" class="btn btn-sm btn-outline-primary me-1">
<i class="fas fa-edit"></i> <span class="d-none d-sm-inline">Редактировать</span>
</a>
<form action="/groups/{{ group.id }}" method="post" style="display: inline-block;" onsubmit="return confirm('Вы уверены, что хотите удалить эту группу?');">
<input type="hidden" name="{{ csrf.name_key }}" value="{{ csrf.name }}">
<input type="hidden" name="{{ csrf.value_key }}" value="{{ csrf.value }}">
<input type="hidden" name="_METHOD" value="DELETE">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="fas fa-trash"></i> <span class="d-none d-sm-inline">Удалить</span>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-layer-group fa-3x text-muted mb-3"></i>
<p class="lead">Группы серверов пока не созданы</p>
<a href="/groups/create" class="btn btn-primary">
<i class="fas fa-plus"></i> Создать первую группу
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,97 @@
{% extends "layout.twig" %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>
<i class="fas {{ group.icon }}"></i> {{ group.name }}
{% if group.description %}<small class="text-muted">- {{ group.description }}</small>{% endif %}
</h2>
<div>
<a href="/groups/{{ group.id }}/edit" class="btn btn-outline-primary me-2">
<i class="fas fa-edit"></i> <span class="d-none d-sm-inline">Редактировать</span>
</a>
<a href="/groups" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> <span class="d-none d-sm-inline">Назад к группам</span>
</a>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h4>Информация о группе</h4>
<p><strong>Описание:</strong> {{ group.description|default('Нет описания') }}</p>
<p><strong>Цвет:</strong>
{% if group.color %}
<span class="badge" style="background-color: {{ group.color }}">{{ group.color }}</span>
{% else %}
Не указан
{% endif %}
</p>
<p><strong>Серверов в группе:</strong> {{ servers|length }}</p>
</div>
</div>
<div class="card">
<div class="card-header">
<h4 class="mb-0"><i class="fas fa-server"></i> Серверы в группе</h4>
</div>
<div class="card-body">
{% if servers|length > 0 %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Название</th>
<th>Адрес</th>
<th>Последние метрики</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for server in servers %}
<tr>
<td>
<strong>{{ server.name }}</strong>
</td>
<td>{{ server.address|default('-') }}</td>
<td>
{% if server.last_metrics_at %}
{{ server.last_metrics_at|date('d.m.Y H:i:s') }}
{% else %}
Нет данных
{% endif %}
</td>
<td>
{% if server.last_metrics_at %}
<span class="badge bg-success">Активен</span>
{% else %}
<span class="badge bg-warning">Нет метрик</span>
{% endif %}
</td>
<td>
<a href="/servers/{{ server.id }}" class="btn btn-sm btn-primary">
<i class="fas fa-eye"></i> Просмотр
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-server fa-3x text-muted mb-3"></i>
<p class="lead">В этой группе пока нет серверов</p>
<a href="/servers/create" class="btn btn-primary">
<i class="fas fa-plus"></i> Добавить сервер
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

119
templates/layout.twig Normal file
View File

@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} - Система мониторинга</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome 6 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
.csrf-field {
display: none;
}
</style>
</head>
<body>
<div class="container-fluid">
{% if session.username %}
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href="/">
<i class="fas fa-server"></i> Система мониторинга
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="/"><i class="fas fa-tachometer-alt"></i> Дашборд</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/groups"><i class="fas fa-layer-group"></i> Группы серверов</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/servers"><i class="fas fa-server"></i> Серверы</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/alerts"><i class="fas fa-bell"></i> Аллерты</a>
</li>
{% if session.role == 'admin' %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="adminDropdown" role="button" data-bs-toggle="dropdown">
<i class="fas fa-cog"></i> Администрирование
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/admin/users"><i class="fas fa-users"></i> Пользователи</a></li>
<li><a class="dropdown-item" href="/admin/notifications"><i class="fas fa-bell"></i> Уведомления</a></li>
</ul>
</li>
{% endif %}
</ul>
<ul class="navbar-nav">
<li class="nav-item">
<span class="navbar-text me-3">
Привет, {{ session.username|default('Гость') }}
</span>
</li>
<li class="nav-item">
<a class="nav-link" href="/logout"><i class="fas fa-sign-out-alt"></i> Выйти</a>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<main class="container mt-4">
{% block content %}{% endblock %}
</main>
{% if session.username %}
<footer class="footer mt-5 py-3 bg-light">
<div class="container text-center">
<span class="text-muted">&copy; {{ 'now'|date('Y') }} Система мониторинга серверов</span>
</div>
</footer>
{% endif %}
</div>
<!-- Bootstrap 5 JS Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Auto-add CSRF tokens to all POST forms via AJAX -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Get CSRF tokens from API endpoint
fetch('/csrf-token')
.then(response => response.json())
.then(data => {
// Add tokens to all POST forms
document.querySelectorAll('form[method="post"]').forEach(function(form) {
// Add name field
var nameInput = document.createElement('input');
nameInput.type = 'hidden';
nameInput.name = data.name_key;
nameInput.value = data.name;
form.appendChild(nameInput);
// Add value field
var valueInput = document.createElement('input');
valueInput.type = 'hidden';
valueInput.name = data.value_key;
valueInput.value = data.value;
form.appendChild(valueInput);
});
})
.catch(error => {
console.error('Failed to get CSRF tokens:', error);
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} - Система мониторинга</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome 6 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body class="bg-light">
<div class="container mt-5">
{% block content %}{% endblock %}
</div>
<!-- Bootstrap 5 JS Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Auto-add CSRF tokens to all POST forms via AJAX -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Get CSRF tokens from API endpoint
fetch('/csrf-token')
.then(response => response.json())
.then(data => {
// Add tokens to all POST forms
document.querySelectorAll('form[method="post"]').forEach(function(form) {
// Add name field
var nameInput = document.createElement('input');
nameInput.type = 'hidden';
nameInput.name = data.name_key;
nameInput.value = data.name;
form.appendChild(nameInput);
// Add value field
var valueInput = document.createElement('input');
valueInput.type = 'hidden';
valueInput.name = data.value_key;
valueInput.value = data.value;
form.appendChild(valueInput);
});
})
.catch(error => {
console.error('Failed to get CSRF tokens:', error);
});
});
</script>
</body>
</html>

33
templates/login.twig Normal file
View File

@ -0,0 +1,33 @@
{% extends "login-layout.twig" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3 class="text-center"><i class="fas fa-sign-in-alt"></i> Вход в систему</h3>
</div>
<div class="card-body">
<form method="post" action="/login" id="loginForm">
<input type="hidden" name="{{ csrf.name_key }}" value="{{ csrf.name }}">
<input type="hidden" name="{{ csrf.value_key }}" value="{{ csrf.value }}">
<div class="mb-3">
<label for="username" class="form-label">Имя пользователя</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Пароль</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="fas fa-lock"></i> Войти
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

54
templates/servers/create.twig Executable file
View File

@ -0,0 +1,54 @@
{% extends "layout.twig" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-plus-circle"></i> Добавить сервер</h3>
</div>
<div class="card-body">
<form method="post" action="/servers">
<div class="mb-3">
<label for="name" class="form-label">Название сервера *</label>
<input type="text" class="form-control" id="name" name="name" required>
<small class="form-text text-muted">Укажите понятное название сервера</small>
</div>
<div class="mb-3">
<label for="address" class="form-label">Адрес сервера</label>
<input type="text" class="form-control" id="address" name="address" placeholder="IP или домен">
<small class="form-text text-muted">IP-адрес или доменное имя сервера (не обязательно)</small>
</div>
<div class="mb-3">
<label for="group_id" class="form-label">Группа</label>
<select class="form-select" id="group_id" name="group_id">
<option value="">Не выбрана</option>
{% for group in groups %}
<option value="{{ group.id }}">{{ group.name }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">Выберите группу для объединения серверов</small>
</div>
<div class="mb-3">
<label for="description" class="form-label">Описание</label>
<textarea class="form-control" id="description" name="description" rows="3"></textarea>
<small class="form-text text-muted">Дополнительная информация о сервере</small>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="/servers" class="btn btn-secondary me-md-2">
<i class="fas fa-arrow-left"></i> Назад
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Сохранить
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

65
templates/servers/created.twig Executable file
View File

@ -0,0 +1,65 @@
{% extends "layout.twig" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-check-circle text-success"></i> Сервер добавлен</h3>
</div>
<div class="card-body">
<div class="alert alert-success" role="alert">
<h4 class="alert-heading">Сервер "{{ server.name }}" успешно добавлен!</h4>
<p>Для подключения агента мониторинга используйте следующий токен:</p>
<hr>
<div class="row">
<div class="col-md-10">
<input type="text" class="form-control" value="{{ token }}" readonly onclick="this.select();">
</div>
<div class="col-md-2">
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard('{{ token }}')">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<p class="mb-0 mt-3">Скачайте скрипт установки агента мониторинга:</p>
<a href="/agent/install.sh?token={{ token }}" class="btn btn-primary mt-2">
<i class="fas fa-download"></i> Скачать install.sh
</a>
</div>
<div class="mt-4">
<h5>Инструкция по установке агента:</h5>
<ol>
<li>Скачайте скрипт установки с помощью кнопки выше</li>
<li>Загрузите его на сервер, который вы хотите мониторить</li>
<li>Выполните команду: <code>chmod +x install.sh && ./install.sh</code></li>
<li>Агент начнет отправлять метрики на сервер мониторинга</li>
</ol>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end mt-4">
<a href="/servers" class="btn btn-primary me-md-2">
<i class="fas fa-server"></i> К списку серверов
</a>
<a href="/servers/{{ server.id }}/edit" class="btn btn-outline-primary">
<i class="fas fa-edit"></i> Редактировать сервер
</a>
</div>
</div>
</div>
</div>
</div>
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
const originalText = event.target.innerHTML;
event.target.innerHTML = '<i class="fas fa-check"></i>';
setTimeout(() => {
event.target.innerHTML = originalText;
}, 2000);
});
}
</script>
{% endblock %}

456
templates/servers/detail.twig Executable file
View File

@ -0,0 +1,456 @@
{% extends "layout.twig" %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h3>
<i class="fas fa-server"></i>
{{ server.name }}
{% if server.group_name %}
<span class="badge ms-2" {% if server.group_color %}style="background-color: {{ server.group_color }}"{% endif %}>
<i class="fas {{ server.group_icon|default('fa-box') }} me-1"></i>{{ server.group_name }}
</span>
{% endif %}
</h3>
<div>
<a href="/servers/{{ server.id }}/edit" class="btn btn-outline-primary me-2">
<i class="fas fa-edit"></i> <span class="d-none d-sm-inline">Редактировать</span>
</a>
<a href="/servers" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> <span class="d-none d-sm-inline">Назад к списку</span>
</a>
</div>
</div>
<div class="card-body">
<!-- Информация о сервере -->
<div class="row mb-4">
<div class="col-md-6">
<h5>Информация о сервере</h5>
<table class="table table-borderless">
<tr>
<td><strong>Название:</strong></td>
<td>{{ server.name }}</td>
</tr>
<tr>
<td><strong>Адрес:</strong></td>
<td>{{ server.address|default('-') }}</td>
</tr>
<tr>
<td><strong>Группа:</strong></td>
<td>
{% if server.group_name %}
<i class="fas {{ server.group_icon|default('fa-box') }}" {% if server.group_color %}style="color: {{ server.group_color }}"{% endif %}></i> {{ server.group_name }}
{% else %}
-
{% endif %}
</td>
</tr>
<tr>
<td><strong>Описание:</strong></td>
<td>{{ server.description|default('-') }}</td>
</tr>
<tr>
<td><strong>Последние метрики:</strong></td>
<td>
{% if server.last_metrics_at %}
{{ server.last_metrics_at|date('d.m.Y H:i:s') }}
{% else %}
Нет данных
{% endif %}
</td>
</tr>
</table>
</div>
</div>
<!-- Вкладки -->
<ul class="nav nav-tabs" id="serverTabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" id="metrics-tab" data-bs-toggle="tab" data-bs-target="#metrics" type="button" role="tab">
<i class="fas fa-chart-line"></i> Метрики
</button>
</li>
<li class="nav-item">
<button class="nav-link" id="services-tab" data-bs-toggle="tab" data-bs-target="#services" type="button" role="tab">
<i class="fas fa-cogs"></i> Сервисы
</button>
</li>
<li class="nav-item">
<button class="nav-link" id="thresholds-tab" data-bs-toggle="tab" data-bs-target="#thresholds" type="button" role="tab">
<i class="fas fa-bell"></i> Пороги
</button>
</li>
</ul>
<!-- Содержимое вкладок -->
<div class="tab-content mt-3">
<!-- Вкладка "Метрики" -->
<div class="tab-pane fade show active" id="metrics" role="tabpanel">
<div class="row mb-3">
<div class="col-md-12">
<div class="btn-group d-flex" role="group">
<a href="?tab=metrics&period=24h" class="btn btn-outline-primary w-100 {% if request.query.period == '24h' or request.query.period is empty %}active{% endif %}">
24 часа
</a>
<a href="?tab=metrics&period=7d" class="btn btn-outline-primary w-100 {% if request.query.period == '7d' %}active{% endif %}">
7 дней
</a>
<a href="?tab=metrics&period=30d" class="btn btn-outline-primary w-100 {% if request.query.period == '30d' %}active{% endif %}">
30 дней
</a>
</div>
</div>
</div>
<div class="row">
{% for metricName, metricData in metrics %}
<div class="col-12 mb-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0">
{{ metricName|replace({'_': ' ', 'load': 'загрузка', 'used': 'использование'})|title }}
{% if metricData[0].unit %}<small class="text-muted">({{ metricData[0].unit }})</small>{% endif %}
</h6>
</div>
<div class="card-body">
{% if metricData %}
<h3 class="text-center text-end">{{ metricData[0].value }}{{ metricData[0].unit|default('') }}</h3>
<p class="text-muted text-center mb-2">
{{ metricData[0].created_at|date('d.m.Y H:i:s') }}
</p>
<!-- График для метрики -->
<div>
<canvas id="chart-{{ metricName }}" width="100%" height="200"></canvas>
</div>
{% else %}
<p class="text-center text-muted">Нет данных за этот период</p>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% if metrics|length == 0 %}
<div class="col-12">
<div class="alert alert-info text-center">
<i class="fas fa-info-circle"></i> Нет данных о метриках за выбранный период
</div>
</div>
{% endif %}
</div>
</div>
<!-- Вкладка "Сервисы" -->
<div class="tab-pane fade" id="services" role="tabpanel">
<div class="row mb-3">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h4>
<i class="fas fa-cogs"></i> Сервисы сервера
{% if allServices is defined %}
<small class="text-muted">(найдено: {{ allServices|length }})</small>
{% endif %}
</h4>
</div>
<div>
<a href="?tab=services" class="btn btn-outline-primary">
<i class="fas fa-sync-alt"></i> <span class="d-none d-sm-inline">Обновить список</span>
</a>
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-12">
<form method="post" action="/servers/{{ server.id }}/services">
<div class="card">
<div class="card-body">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="selectAllServices">
<label class="form-check-label" for="selectAllServices">
Выбрать все
</label>
</div>
<hr>
<div class="row" id="servicesList">
{% if allServices is defined and allServices|length > 0 %}
{# Сортируем сервисы: сначала running, потом stopped, потом unknown, затем по имени #}
{% set runningServices = allServices|filter(s => s.status == 'running')|sort((a, b) => a.service_name|lower > b.service_name|lower) %}
{% set stoppedServices = allServices|filter(s => s.status == 'stopped')|sort((a, b) => a.service_name|lower > b.service_name|lower) %}
{% set unknownServices = allServices|filter(s => s.status != 'running' and s.status != 'stopped')|sort((a, b) => a.service_name|lower > b.service_name|lower) %}
{# Выводим все сервисы по порядку #}
{% for service in runningServices %}
<div class="col-md-4 col-lg-3 mb-2">
<div class="card border-success">
<div class="card-body p-2">
<div class="d-flex justify-content-between align-items-start">
<div class="form-check mb-0">
<input class="form-check-input service-checkbox"
type="checkbox"
id="service_{{ service.service_name }}"
name="services[]"
value="{{ service.service_name }}"
{% if service.service_name in monitorServices %}
checked
{% endif %}>
<label class="form-check-label" for="service_{{ service.service_name }}">
<i class="fas fa-check-circle text-success me-1"></i>
{{ service.service_name }}
</label>
</div>
<div class="text-end">
<span class="badge bg-success mb-1">running</span>
</div>
</div>
<div class="small text-muted mt-1">
<small>Load: {{ service.load_state|default('-') }} | Active: {{ service.active_state|default('-') }}</small>
</div>
</div>
</div>
</div>
{% endfor %}
{% for service in stoppedServices %}
<div class="col-md-4 col-lg-3 mb-2">
<div class="card border-danger">
<div class="card-body p-2">
<div class="d-flex justify-content-between align-items-start">
<div class="form-check mb-0">
<input class="form-check-input service-checkbox"
type="checkbox"
id="service_{{ service.service_name }}"
name="services[]"
value="{{ service.service_name }}"
{% if service.service_name in monitorServices %}
checked
{% endif %}>
<label class="form-check-label" for="service_{{ service.service_name }}">
<i class="fas fa-times-circle text-danger me-1"></i>
{{ service.service_name }}
</label>
</div>
<div class="text-end">
<span class="badge bg-danger mb-1">stopped</span>
</div>
</div>
<div class="small text-muted mt-1">
<small>Load: {{ service.load_state|default('-') }} | Active: {{ service.active_state|default('-') }}</small>
</div>
</div>
</div>
</div>
{% endfor %}
{% for service in unknownServices %}
<div class="col-md-4 col-lg-3 mb-2">
<div class="card border-warning">
<div class="card-body p-2">
<div class="d-flex justify-content-between align-items-start">
<div class="form-check mb-0">
<input class="form-check-input service-checkbox"
type="checkbox"
id="service_{{ service.service_name }}"
name="services[]"
value="{{ service.service_name }}"
{% if service.service_name in monitorServices %}
checked
{% endif %}>
<label class="form-check-label" for="service_{{ service.service_name }}">
<i class="fas fa-question-circle text-warning me-1"></i>
{{ service.service_name }}
</label>
</div>
<div class="text-end">
<span class="badge bg-warning mb-1">unknown</span>
</div>
</div>
<div class="small text-muted mt-1">
<small>Load: {{ service.load_state|default('-') }} | Active: {{ service.active_state|default('-') }}</small>
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-12">
<div class="alert alert-warning text-center">
<i class="fas fa-exclamation-triangle"></i> Агент не отправил список сервисов или не установлен
</div>
</div>
<div class="col-12 text-center mt-3">
<button type="button" class="btn btn-outline-primary" onclick="requestServices()">
<i class="fas fa-download"></i> Запросить список сервисов
</button>
</div>
{% endif %}
</div>
</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end mt-3">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Сохранить конфигурацию
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Вкладка "Пороги" -->
<div class="tab-pane fade" id="thresholds" role="tabpanel">
<h4>Настройка порогов</h4>
<div class="row">
<div class="col-md-8">
<form method="post" action="/servers/{{ server.id }}/thresholds">
{% for metricType in allMetricTypes %}
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0">
{{ metricType.name|replace({'_': ' ', 'load': 'загрузка', 'used': 'использование'})|title }}
{% if metricType.unit %}<small class="text-muted">({{ metricType.unit }})</small>{% endif %}
</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<label class="form-label">Порог предупреждения</label>
<input type="number" class="form-control"
name="{{ metricType.name }}_warning"
step="0.01"
{% if existingThresholds[metricType.name].warning is defined %}
value="{{ existingThresholds[metricType.name].warning }}"
{% endif %}
placeholder="80.00">
</div>
<div class="col-md-6">
<label class="form-label">Порог критический</label>
<input type="number" class="form-control"
name="{{ metricType.name }}_critical"
step="0.01"
{% if existingThresholds[metricType.name].critical is defined %}
value="{{ existingThresholds[metricType.name].critical }}"
{% endif %}
placeholder="90.00">
</div>
</div>
<div class="row mt-3">
<div class="col-md-12">
<label class="form-label">
<i class="fas fa-clock"></i> Длительность превышения (минуты)
</label>
<input type="number" class="form-control"
name="{{ metricType.name }}_duration"
min="0"
step="1"
{% if existingThresholds[metricType.name].duration is defined %}
value="{{ existingThresholds[metricType.name].duration }}"
{% endif %}
placeholder="0 - отправлять алерт сразу">
<small class="text-muted">
0 = алерт сразу при превышении, >0 = алерт только если превышено дольше указанного времени
</small>
</div>
</div>
</div>
</div>
{% endfor %}
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Сохранить пороги
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
// Функция для получения списка сервисов
function requestServices() {
fetch('/api/v1/agent/{{ server.id }}/services')
.then(response => response.json())
.then(data => {
if (data.services) {
location.reload();
}
})
.catch(error => {
alert('Ошибка получения списка сервисов: ' + error);
});
}
// Обработчик кнопки "Выбрать все"
document.getElementById('selectAllServices').addEventListener('change', function() {
const checkboxes = document.querySelectorAll('.service-checkbox');
checkboxes.forEach(checkbox => {
checkbox.checked = this.checked;
});
});
// Активация нужной вкладки при загрузке
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const activeTab = urlParams.get('tab') || 'metrics';
// Находим кнопку нужной вкладки и кликаем
const tabButton = document.getElementById(activeTab + '-tab');
if (tabButton) {
tabButton.click();
}
});
// Графики метрик
{% for metricName, metricData in metrics %}
const ctx{{ metricName|replace({'-': '_', '.': '_'}) }} = document.getElementById('chart-{{ metricName }}').getContext('2d');
// Подготовка данных для графика
var labels{{ metricName }} = [];
var data{{ metricName }} = [];
{% for metric in metricData|slice(0, 20)|reverse %}
labels{{ metricName }}.push('{{ metric.created_at|date('H:i') }}');
data{{ metricName }}.push({{ metric.value }});
{% endfor %}
new Chart(ctx{{ metricName|replace({'-': '_', '.': '_'}) }}, {
type: 'line',
data: {
labels: labels{{ metricName }},
datasets: [{
label: '{{ metricName|replace({'_': ' ', 'load': 'загрузка', 'used': 'использование'})|title }}',
data: data{{ metricName }},
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: false
}
}
}
});
{% endfor %}
</script>
{% endblock %}

74
templates/servers/edit.twig Executable file
View File

@ -0,0 +1,74 @@
{% extends "layout.twig" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-edit"></i> Редактировать сервер "{{ server.name }}"</h3>
</div>
<div class="card-body">
<form method="post" action="/servers/{{ server.id }}">
<div class="mb-3">
<label for="name" class="form-label">Название сервера *</label>
<input type="text" class="form-control" id="name" name="name" value="{{ server.name }}" required>
<small class="form-text text-muted">Укажите понятное название сервера</small>
</div>
<div class="mb-3">
<label for="address" class="form-label">Адрес сервера</label>
<input type="text" class="form-control" id="address" name="address" value="{{ server.address|default('') }}" placeholder="IP или домен">
<small class="form-text text-muted">IP-адрес или доменное имя сервера (не обязательно)</small>
</div>
<div class="mb-3">
<label for="group_id" class="form-label">Группа</label>
<select class="form-select" id="group_id" name="group_id">
<option value="">Не выбрана</option>
{% for group in groups %}
<option value="{{ group.id }}" {% if server.group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">Выберите группу для объединения серверов</small>
</div>
<div class="mb-3">
<label for="description" class="form-label">Описание</label>
<textarea class="form-control" id="description" name="description" rows="3">{{ server.description|default('') }}</textarea>
<small class="form-text text-muted">Дополнительная информация о сервере</small>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="/servers" class="btn btn-secondary me-md-2">
<i class="fas fa-arrow-left"></i> Назад
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Сохранить изменения
</button>
</div>
</form>
<hr>
<div class="mt-4">
<h5>Управление агентом мониторинга:</h5>
<div class="row">
<div class="col-md-6 mb-2">
<a href="/agent/install.sh?server_id={{ server.id }}" class="btn btn-outline-primary w-100">
<i class="fas fa-download"></i> Скачать install.sh
</a>
</div>
<div class="col-md-6 mb-2">
<a href="/servers/{{ server.id }}/regenerate-token" class="btn btn-outline-warning w-100" onclick="return confirm('Вы уверены, что хотите сгенерировать новый токен? Это сделает недействительным старый скрипт установки.');">
<i class="fas fa-sync-alt"></i> Сбросить токен
</a>
</div>
</div>
<p class="mt-2">Если вы потеряли доступ к агенту или хотите создать новый токен безопасности, используйте кнопку "Сбросить токен".</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

82
templates/servers/index.twig Executable file
View File

@ -0,0 +1,82 @@
{% extends "layout.twig" %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><i class="fas fa-server"></i> Серверы</h2>
<a href="/servers/create" class="btn btn-primary">
<i class="fas fa-plus"></i> <span class="d-none d-sm-inline">Добавить сервер</span>
</a>
</div>
<div class="card">
<div class="card-body">
{% if servers|length > 0 %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Название</th>
<th>Адрес</th>
<th>Группа</th>
<th>Описание</th>
<th>Последние метрики</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for server in servers %}
<tr>
<td>{{ server.name }}</td>
<td>{{ server.address|default('-') }}</td>
<td>
{% if server.group_name %}
<i class="fas {{ server.group_icon|default('fa-box') }}" {% if server.group_color %}style="color: {{ server.group_color }}"{% endif %}></i> {{ server.group_name }}
{% else %}
-
{% endif %}
</td>
<td>{{ server.description|default('-')|slice(0, 30) ~ (attribute(server, 'description')|length > 30 ? '...' : '') }}</td>
<td>
{% if server.last_metrics_at %}
{{ server.last_metrics_at|date('d.m.Y H:i:s') }}
{% else %}
Нет данных
{% endif %}
</td>
<td>
<a href="/servers/{{ server.id }}" class="btn btn-sm btn-info" title="Просмотр">
<i class="fas fa-eye"></i> <span class="d-none d-sm-inline">Просмотр</span>
</a>
<a href="/servers/{{ server.id }}/edit" class="btn btn-sm btn-outline-primary" title="Редактировать">
<i class="fas fa-edit"></i> <span class="d-none d-sm-inline">Редактировать</span>
</a>
<form action="/servers/{{ server.id }}" method="post" style="display: inline-block;" onsubmit="return confirm('Вы уверены, что хотите удалить этот сервер?');">
<input type="hidden" name="{{ csrf.name_key }}" value="{{ csrf.name }}">
<input type="hidden" name="{{ csrf.value_key }}" value="{{ csrf.value }}">
<input type="hidden" name="_METHOD" value="DELETE">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Удалить">
<i class="fas fa-trash"></i> <span class="d-none d-sm-inline">Удалить</span>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-server fa-3x text-muted mb-3"></i>
<p class="lead">Серверы пока не добавлены</p>
<a href="/servers/create" class="btn btn-primary">
<i class="fas fa-plus"></i> Добавить первый сервер
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

19
templates/test.twig Executable file
View File

@ -0,0 +1,19 @@
{% extends "layout.twig" %}
{% block content %}
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h2>{{ title }}</h2>
</div>
<div class="card-body">
<p class="lead">{{ message }}</p>
<a href="/" class="btn btn-primary">
<i class="fas fa-home"></i> На главную
</a>
</div>
</div>
</div>
</div>
{% endblock %}