Docker·Site·Web · BIGGRATZ

EndPoll: мониторинг сайтов и собственная страница статуса сервисов на PHP

20 сентября 2026· 9 мин чтения· biggratz_ru

Для владельца нескольких сайтов важно не только знать, что они работают, но и иметь красивую публичную страницу статуса. Uptime Kuma — отличный инструмент, но иногда хочется чего-то более легковесного и минималистичного. В этой статье мы разберем установку EndPoll на VPS с Ubuntu 24.04, настройку уведомлений на Яндекс.Почту (актуально для российских серверов) и создание кастомной статусной страницы на PHP, которая берет данные из API EndPoll.

👉 Живой пример: посмотреть, как выглядит итоговая страница статуса, можно по адресу status.biggratz.ru.

Почему EndPoll?

EndPoll — это легковесный инструмент мониторинга и статусных страниц. Его ключевые особенности:

  • Минимализм: чистый дашборд без лишнего визуального шума.
  • Конфигурация через YAML: никаких баз данных и сложных веб-интерфейсов для настройки. Вы просто правите текстовый файл.
  • JSON API: встроенный эндпоинт /api/hosts, который отдает статусы в машиночитаемом формате. Это позволяет легко создавать собственные фронтенды.
  • Легкость: Docker-образ весит около 17 МБ.

Шаг 1. Установка EndPoll через Docker

Предполагается, что Docker уже установлен на вашем VPS. Если нет — установите его стандартным способом для Ubuntu 24.04.

Создайте директорию для проекта и перейдите в нее:

mkdir -p ~/endpoll && cd ~/endpoll

Запустите контейнер одной командой:

sudo docker run -d \
  --name endpoll \
  --restart unless-stopped \
  -p 8822:8822 \
  -v $(pwd)/config.yaml:/app/config.yaml \
  -v $(pwd)/data:/app/data \
  exelban/endpoll:latest

Что делают эти флаги:

  • -p 8822:8822 — открывает веб-интерфейс на порту 8822.
  • -v .../config.yaml:/app/config.yaml — монтирует ваш конфиг внутрь контейнера. Изменения в файле подхватываются автоматически (hot reload).
  • -v .../data:/app/data — сохраняет историю проверок на хосте, чтобы она не терялась при перезапуске.
  • --restart unless-stopped — гарантирует автозапуск после ребута VPS.

После запуска дашборд доступен по адресу http://IP_ВАШЕГО_VPS:8822. Не забудьте открыть порт 8822 в файрволе.

Шаг 2. Настройка конфигурации и уведомлений

Создайте файл config.yaml в папке ~/endpoll. Вот полный рабочий пример с четырьмя сайтами и уведомлениями на Яндекс.Почту:

# Глобальные настройки
interval: 30s
timeout: 60s
failureThreshold: 2

# Настройки интерфейса
ui:
  title: "Мониторинг BigGratz"
  hideURL: false

# Уведомления
notifications:
  initializationMessage: true    # Отправит письмо при старте (удобно для проверки SMTP)
  shutdownMessage: false

  smtp:
    host: "smtp.yandex.ru"
    port: 587                    # ⚠️ Важно: используйте 587, порт 465 часто заблокирован на VPS
    username: "your_email@yandex.ru"
    password: "ВАШ_ПАРОЛЬ_ПРИЛОЖЕНИЯ"  # Создайте пароль приложения в Яндекс ID!
    from: "your_email@yandex.ru"
    to:
      - "your_email@yandex.ru"

# Сайты для мониторинга
hosts:
  - name: "biggratz.ru"
    url: "https://biggratz.ru"
  - name: "eda4you.ru"
    url: "https://eda4you.ru"
  - name: "vault33.ru"
    url: "https://vault33.ru"
  - name: "fallout.vault33.ru"
    url: "https://fallout.vault33.ru"

⚠️ Важные нюансы для российских VPS

  1. Telegram не работает. API Telegram заблокирован на уровне DPI в российских дата-центрах. Уведомления просто не дойдут. Используйте Email (SMTP) — это надежно.
  2. Порт 465 часто закрыт. Многие хостеры блокируют исходящий SSL-SMTP. Порт 587 (STARTTLS) обычно открыт. Если письма не уходят — проверьте доступность порта командой timeout 5 bash -c 'cat < /dev/null > /dev/tcp/smtp.yandex.ru/587'.
  3. Пароль приложения. Обычный пароль от Яндекс.Почты не подойдет для SMTP. Зайдите в Яндекс ID → Безопасность → Пароли приложений и создайте отдельный пароль для EndPoll.

Совет: После успешной проверки уведомлений замените initializationMessage: true на false, чтобы не получать письмо при каждом перезапуске контейнера.

Шаг 3. Собственная страница статуса на PHP

Стандартный дашборд EndPoll минималистичен и не поддерживает кастомизацию дизайна. Но благодаря JSON API (/api/hosts) мы можем создать свою красивую страницу, которая отображает статусы в любом стиле.

Подготовка

Установите PHP и Nginx (если еще не установлены):

sudo apt install -y php-fpm php-curl nginx

Создайте директорию для страницы:

sudo mkdir -p /var/www/status/img

Положите вашу фоновую картинку в /var/www/status/img/hero.png.

Код страницы

Создайте файл /var/www/status/index.php:

<?php
// URL API EndPoll (используйте localhost, если PHP и EndPoll на одном сервере)
$apiUrl = 'http://localhost:8822/api/hosts';

// Кастомные имена и ссылки (ключ = id из EndPoll API)
$overrides = [
    'rgeE-T' => ['BigGratz',        'https://biggratz.ru'],
    'HTC8yP' => ['Eda4You',         'https://eda4you.ru'],
    'H9cGDY' => ['Vault33',         'https://vault33.ru'],
    'GzHN-y' => ['Fallout Vault33', 'https://fallout.vault33.ru'],
];

// Запрос к API
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 5,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

$data = null;
if ($httpCode === 200 && $response) {
    $data = json_decode($response, true);
}

$overallStatus = $data['status'] ?? 'unknown';
$hosts = $data['hosts'] ?? [];

function statusLabel(string $s): string {
    return match ($s) {
        'up'       => 'Работает',
        'down'     => 'Недоступен',
        'degraded' => 'Сбои',
        default    => 'Неизвестно',
    };
}
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Статус сервисов</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    min-height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 24px;
    /* 🖼️ Фон: градиент + ваша картинка. 
       Прозрачность 0.45/0.55 — баланс между видимостью картинки и читаемостью текста */
    background:
      linear-gradient(135deg, rgba(15, 20, 40, 0.45), rgba(30, 15, 50, 0.55)),
      url('/img/hero.png') center/cover no-repeat fixed;
    color: #fff;
  }

  .card {
    background: rgba(20, 22, 35, 0.75);
    backdrop-filter: blur(16px);
    -webkit-backdrop-filter: blur(16px);
    border: 1px solid rgba(255, 255, 255, 0.1);
    border-radius: 24px;
    padding: 40px;
    max-width: 680px;
    width: 100%;
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
  }

  .header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 32px;
  }

  h1 { font-size: 1.8em; font-weight: 600; letter-spacing: -0.02em; }

  .overall {
    display: flex; align-items: center; gap: 8px;
    font-size: 0.9em; font-weight: 500;
    padding: 6px 14px; border-radius: 20px;
    background: rgba(255, 255, 255, 0.05);
  }
  .overall.up       { color: #4ade80; }
  .overall.down     { color: #f87171; }
  .overall.degraded { color: #fbbf24; }
  .overall.unknown  { color: #94a3b8; }

  .overall .pulse {
    width: 8px; height: 8px; border-radius: 50%;
    background: currentColor;
    box-shadow: 0 0 10px currentColor;
    animation: pulse 2s infinite;
  }
  @keyframes pulse {
    0%, 100% { opacity: 1; }
    50%      { opacity: 0.4; }
  }

  .site {
    display: flex; align-items: center; justify-content: space-between;
    padding: 18px 20px; margin-bottom: 10px;
    background: rgba(255, 255, 255, 0.03);
    border-radius: 14px; text-decoration: none; color: inherit;
    transition: all 0.2s ease; border: 1px solid transparent;
  }
  .site:hover {
    background: rgba(255, 255, 255, 0.08);
    border-color: rgba(255, 255, 255, 0.15);
    transform: translateY(-2px);
  }

  .site-info { display: flex; flex-direction: column; gap: 4px; }
  .site-name { font-weight: 500; font-size: 1.05em; }
  .site-meta { font-size: 0.78em; color: #8892b0; }

  .status {
    display: flex; align-items: center; gap: 8px;
    font-size: 0.85em; font-weight: 600;
    padding: 5px 12px; border-radius: 12px; flex-shrink: 0;
  }
  .status.up       { color: #4ade80; background: rgba(74, 222, 128, 0.1); }
  .status.down     { color: #f87171; background: rgba(248, 113, 113, 0.1); }
  .status.degraded { color: #fbbf24; background: rgba(251, 191, 36, 0.1); }
  .status.unknown  { color: #94a3b8; background: rgba(148, 163, 184, 0.1); }

  .dot {
    width: 10px; height: 10px; border-radius: 50%;
    background: currentColor; box-shadow: 0 0 10px currentColor;
  }

  .footer {
    margin-top: 28px; padding-top: 20px;
    border-top: 1px solid rgba(255, 255, 255, 0.08);
    text-align: center; color: #64748b; font-size: 0.8em;
  }
  .footer a {
    color: #64748b; text-decoration: none;
    border-bottom: 1px dashed rgba(100, 116, 139, 0.4);
  }
  .footer a:hover { color: #94a3b8; }

  .error {
    padding: 20px; background: rgba(248, 113, 113, 0.1);
    border: 1px solid rgba(248, 113, 113, 0.2);
    border-radius: 12px; color: #f87171; font-size: 0.9em;
  }
</style>
</head>
<body>
  <div class="card">
    <div class="header">
      <h1>🛰️ Статус сервисов</h1>
      <div class="overall <?= htmlspecialchars($overallStatus) ?>">
        <span class="pulse"></span>
        <?= $overallStatus === 'up' ? 'Всё работает' : statusLabel($overallStatus) ?>
      </div>
    </div>

    <?php if ($data === null): ?>
      <div class="error">Не удалось получить данные от EndPoll.</div>
    <?php else: ?>
      <?php foreach ($hosts as $host): ?>
        <?php
          $id     = $host['id'] ?? '';
          $name   = $overrides[$id][0] ?? ($host['name'] ?? 'Без имени');
          $link   = $overrides[$id][1] ?? ($host['url'] ?? '#');
          $status = $host['status'] ?? 'unknown';
          $code   = $host['last']['code'] ?? null;
          $rt     = $host['last']['responseTimeMs'] ?? null;
          $meta = [];
          if ($code !== null) $meta[] = "HTTP $code";
          if ($rt   !== null) $meta[] = "{$rt} ms";
        ?>
        <a class="site" href="<?= htmlspecialchars($link) ?>" target="_blank" rel="noopener">
          <span class="site-info">
            <span class="site-name"><?= htmlspecialchars($name) ?></span>
            <?php if ($meta): ?>
              <span class="site-meta"><?= htmlspecialchars(implode(' · ', $meta)) ?></span>
            <?php endif; ?>
          </span>
          <span class="status <?= htmlspecialchars($status) ?>">
            <span class="dot"></span>
            <?= statusLabel($status) ?>
          </span>
        </a>
      <?php endforeach; ?>
    <?php endif; ?>

    <div class="footer">
      Данные: <a href="http://localhost:8822/" target="_blank">EndPoll</a>
      · Обновлено <?= date('H:i:s') ?>
    </div>
  </div>

  <script>setTimeout(() => location.reload(), 30000);</script>
</body>
</html>

Настройка Nginx

Создайте конфиг /etc/nginx/sites-available/status:

server {
    listen 80;
    server_name _;  # Замените на ваш домен, когда он появится
    root /var/www/status;
    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.3-fpm.sock;
    }
}

Активируйте и перезапустите:

sudo ln -s /etc/nginx/sites-available/status /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default  # Уберите дефолтный конфиг!
sudo nginx -t && sudo systemctl reload nginx

⚠️ Частая ошибка: Если после настройки видите «Welcome to nginx!» вместо своей страницы — значит, дефолтный конфиг не удален и перехватывает запросы. Убедитесь, что в /etc/nginx/sites-enabled/ остался только ваш конфиг status.

Как узнать ID сайтов для массива $overrides

Откройте API EndPoll в браузере: http://IP_VPS:8822/api/hosts. В JSON-ответе у каждого хоста есть поле id (например, "id": "rgeE-T"). Именно эти значения нужно использовать как ключи в массиве $overrides.

Фоновая картинка

Картинка должна лежать в /var/www/status/img/hero.png. Права доступа:

sudo chown -R www-data:www-data /var/www/status
sudo chmod 644 /var/www/status/img/hero.png

Если картинка не видна — скорее всего, градиент слишком плотный. В CSS уменьшите значения прозрачности (например, с 0.45/0.55 до 0.25/0.35). Если картинка открывается напрямую по http://IP/img/hero.png, но не видна на странице — проблема точно в градиенте, а не в Nginx.

Итог

Мы получили двухуровневую систему мониторинга:

КомпонентАдресНазначение
EndPollhttp://IP:8822Бэкенд: проверка доступности, история, SMTP-уведомления
PHP-страницаhttp://IP (порт 80)Фасад: красивый дизайн, кастомные имена, фон
JSON APIhttp://IP:8822/api/hostsСвязующее звено между ними

EndPoll выполняет всю тяжелую работу по мониторингу и оповещениям, а PHP-страница дает полную свободу в дизайне. При необходимости можно добавить кэширование API-запросов, автообновление без перезагрузки через fetch(), или защиту паролем через auth_basic в Nginx.

Похожие материалы