feat: масштабирование, дашборд, алерты и тултипы

- Исправлена ось времени: старые данные слева, новые справа
- Подключён chartjs-plugin-zoom (колёсико, drag, pan)
- Переделаны кнопки периода: 1ч/6ч/24ч/7д/30д (по умолчанию 6ч)
- Добавлен cmdline в процессы тултипа (показывает полный путь)
- Улучшена логика алертов: нет спама, resolved уведомления
- Исправлено сохранение порогов (приведение типов)
- Исправлена страница алертов (Twig syntax: ends_with -> matches)
- Дашборд: цвета прогресс-баров по реальным порогам сервера

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
mirivlad 2026-04-13 10:24:15 +08:00
parent b875e57e4c
commit 0219fda95f
9 changed files with 313 additions and 59 deletions

7
public/chartjs-plugin-zoom.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -176,25 +176,106 @@ class MetricsController extends Model
}
if ($severity) {
// Создаем алерт
// Проверяем есть ли уже неразрешённый алерт для этой метрики
$stmt = $this->pdo->prepare("
INSERT INTO alerts (server_id, metric_name, value, severity)
VALUES (:server_id, :metric_name, :value, :severity)
SELECT id, severity FROM alerts
WHERE server_id = :server_id AND metric_name = :metric_name AND resolved = FALSE
ORDER BY created_at DESC LIMIT 1
");
$stmt->execute([
':server_id' => $serverId,
':metric_name' => $metricName,
':value' => $value,
':severity' => $severity
':metric_name' => $metricName
]);
$existingAlert = $stmt->fetch();
if ($existingAlert) {
// Алерт уже есть — обновляем значение но НЕ отправляем уведомление
$stmt = $this->pdo->prepare("
UPDATE alerts SET value = :value WHERE id = :id
");
$stmt->execute([
':value' => $value,
':id' => $existingAlert['id']
]);
// Если серьёзность повысилась (warning -> critical) — отправляем
if ($severity === 'critical' && $existingAlert['severity'] === 'warning') {
$stmt = $this->pdo->prepare("
UPDATE alerts SET severity = :severity WHERE id = :id
");
$stmt->execute([
':severity' => $severity,
':id' => $existingAlert['id']
]);
$this->notificationService->sendAlertNotification(
$serverName,
$metricName,
$value,
$severity,
$threshold
);
}
} else {
// Нового алерта нет — создаём и отправляем уведомление
$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
]);
$this->notificationService->sendAlertNotification(
$serverName,
$metricName,
$value,
$severity,
$threshold
);
}
}
}
// Всегда проверяем resolved — даже если пороги не настроены или удалены
// Если есть неразрешённый алерт а значение сейчас в норме — разрешаем
$stmt = $this->pdo->prepare("
SELECT id FROM alerts
WHERE server_id = :server_id AND metric_name = :metric_name AND resolved = FALSE
ORDER BY created_at DESC LIMIT 1
");
$stmt->execute([
':server_id' => $serverId,
':metric_name' => $metricName
]);
$existingAlert = $stmt->fetch();
if ($existingAlert) {
// Проверяем действительно ли значение в норме
// (если пороги есть — проверяем по ним, если нет — считаем что в норме)
$isNormal = true;
if ($thresholds) {
$w = $thresholds['warning_threshold'];
$c = $thresholds['critical_threshold'];
if (($c && $value >= $c) || ($w && $value >= $w)) {
$isNormal = false;
}
}
if ($isNormal) {
$stmt = $this->pdo->prepare("
UPDATE alerts SET resolved = TRUE, resolved_at = NOW() WHERE id = :id
");
$stmt->execute([':id' => $existingAlert['id']]);
// Отправляем уведомление
$this->notificationService->sendAlertNotification(
$serverName,
$metricName,
$value,
$severity,
$threshold
'resolved',
'Порог более не превышен'
);
}
}

View File

@ -25,7 +25,14 @@ class DashboardController
// Получаем список серверов со статусами для цветных карточек
$servers = $this->serverModel->getServersWithStatus();
// Загружаем пороги для каждого сервера
foreach ($servers as &$server) {
$t = $this->serverModel->getThresholds($server['id']);
$server['thresholds'] = $t;
file_put_contents('/tmp/thresholds_debug.log', "Server {$server['id']}: " . json_encode($t) . "\n", FILE_APPEND);
}
unset($server);
$templateData = [
'title' => 'Дашборд мониторинга',
@ -33,7 +40,6 @@ class DashboardController
'servers' => $servers
];
file_put_contents("/tmp/dashboard_debug.log", json_encode($servers) . "\n", FILE_APPEND);
return $this->twig->render($response, 'dashboard.twig', $templateData);
}
}

View File

@ -37,21 +37,69 @@ class ServerDetailController extends Model
return $response->withHeader('Location', '/servers')->withStatus(302);
}
// Получаем даты начала и окончания
// Получаем параметры
$queryParams = $request->getQueryParams();
$startDate = $queryParams['start'] ?? null;
$endDate = $queryParams['end'] ?? null;
$period = $queryParams['period'] ?? '24h';
$zoom = $queryParams['zoom'] ?? null;
// Если даты не указаны, используем последние 24 часа по умолчанию
// Если даты не указаны, вычисляем по period
if (!$startDate || !$endDate) {
$endDate = new DateTime();
$startDate = clone $endDate;
$startDate->modify('-24 hours');
switch ($period) {
case '1h':
$startDate->modify('-1 hour');
break;
case '6h':
$startDate->modify('-6 hours');
break;
case '7d':
$startDate->modify('-7 days');
break;
case '30d':
$startDate->modify('-30 days');
break;
case '24h':
default:
$startDate->modify('-24 hours');
break;
}
} else {
$startDate = new DateTime($startDate);
$endDate = new DateTime($endDate);
}
// Применяем zoom — ограничиваем end по zoom-периоду
if ($zoom && $zoom !== 'auto') {
$zoomEnd = new DateTime();
$zoomStart = clone $zoomEnd;
switch ($zoom) {
case '1h':
$zoomStart->modify('-1 hour');
break;
case '6h':
$zoomStart->modify('-6 hours');
break;
case '24h':
$zoomStart->modify('-24 hours');
break;
case '7d':
$zoomStart->modify('-7 days');
break;
case '30d':
$zoomStart->modify('-30 days');
break;
}
// Zoom не может выйти за рамки выбранного периода
if ($zoomStart < $startDate) $zoomStart = clone $startDate;
if ($zoomEnd > $endDate) $zoomEnd = clone $endDate;
$startDate = $zoomStart;
$endDate = $zoomEnd;
}
// Валидация: end > start
if ($endDate <= $startDate) {
$endDate = clone $startDate;
@ -185,7 +233,9 @@ class ServerDetailController extends Model
'startDate' => $startDate->format('Y-m-d\T H:i'),
'endDate' => $endDate->format('Y-m-d\T H:i'),
'aggregation' => $aggConfig,
'totalMinutes' => $totalMinutes
'totalMinutes' => $totalMinutes,
'period' => $period,
'zoom' => $zoom
];
return $this->twig->render($response, 'servers/detail.twig', $templateData);

View File

@ -124,6 +124,44 @@ class Server
$server['active_alerts'] = (int)$activeAlerts;
}
// Загружаем пороги для каждого сервера
foreach ($servers as &$server) {
$stmt = $this->db->prepare("SELECT mn.name, mt.warning_threshold, mt.critical_threshold FROM metric_thresholds mt JOIN metric_names mn ON mt.metric_name_id = mn.id WHERE mt.server_id = :server_id");
$stmt->execute([':server_id' => $server['id']]);
$thresholds = $stmt->fetchAll();
$server['thresholds'] = [];
foreach ($thresholds as $t) {
$server['thresholds'][$t['name']] = [
'warning' => (float)$t['warning_threshold'],
'critical' => (float)$t['critical_threshold']
];
}
}
unset($server);
return $servers;
}
public function getThresholds($serverId)
{
error_log("getThresholds called for server $serverId");
$stmt = $this->db->prepare("
SELECT mn.name, mt.warning_threshold, mt.critical_threshold
FROM metric_thresholds mt
JOIN metric_names mn ON mt.metric_name_id = mn.id
WHERE mt.server_id = :server_id
");
$stmt->execute([':server_id' => $serverId]);
$thresholds = $stmt->fetchAll();
error_log("getThresholds result for server $serverId: " . json_encode($thresholds));
$result = [];
foreach ($thresholds as $t) {
$result[$t['name']] = [
'warning' => (float)$t['warning_threshold'],
'critical' => (float)$t['critical_threshold']
];
}
error_log("getThresholds returning for server $serverId: " . json_encode($result));
return $result;
}
}

View File

@ -28,14 +28,26 @@ class NotificationService
*/
public function sendAlertNotification($serverName, $metricName, $value, $severity, $threshold)
{
$severityText = $severity === 'critical' ? 'КРИТИЧЕСКИЙ' : 'ПРЕДУПРЕЖДЕНИЕ';
$subject = "🚨 {$severityText}: Превышение порога {$metricName}";
$message = "Сервер: {$serverName}\n";
$message .= "Метрика: {$metricName}\n";
$message .= "Значение: {$value}\n";
$message .= "Порог: {$threshold}\n";
$message .= "Время: " . date('d.m.Y H:i:s') . "\n";
$message .= "Серьёзность: {$severityText}";
if ($severity === 'resolved') {
$severityText = 'ВОССТАНОВЛЕНИЕ';
$emoji = '✅';
$subject = "{$emoji} {$severityText}: {$metricName} в норме";
$message = "Сервер: {$serverName}\n";
$message .= "Метрика: {$metricName}\n";
$message .= "Текущее значение: {$value}\n";
$message .= "Статус: Порог более не превышен\n";
$message .= "Время: " . date('d.m.Y H:i:s');
} else {
$severityText = $severity === 'critical' ? 'КРИТИЧЕСКИЙ' : 'ПРЕДУПРЕЖДЕНИЕ';
$emoji = '🚨';
$subject = "{$emoji} {$severityText}: Превышение порога {$metricName}";
$message = "Сервер: {$serverName}\n";
$message .= "Метрика: {$metricName}\n";
$message .= "Значение: {$value}\n";
$message .= "Порог: {$threshold}\n";
$message .= "Время: " . date('d.m.Y H:i:s') . "\n";
$message .= "Серьёзность: {$severityText}";
}
// Отправка Email
if (!empty($this->settings['email_enabled']) && !empty($this->settings['smtp_host'])) {

View File

@ -27,7 +27,7 @@
<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>{{ alert.value }}{% if alert.metric_name matches '/_load$/' or alert.metric_name matches '/_used$/' %}%{% endif %}</td>
<td>
{% if alert.severity == 'critical' %}
<span class="badge bg-danger"><i class="fas fa-exclamation-triangle"></i> Критично</span>

View File

@ -54,8 +54,11 @@
</div>
<!-- Карточки серверов -->
<!-- DEBUG: {{ servers|json_encode }} -->
{% for s in servers %}<!-- DBG: id={{ s.id }} t={{ s.thresholds|json_encode }} -->{% endfor %}
<div class="row">
{% for server in servers %}
<!-- DEBUG: server_id={{ server.id }} thresholds={{ server.thresholds|json_encode }} ram_metric={{ server.latest_metrics["ram_used"].value }} -->
<div class="col-xl-4 col-lg-6 col-md-6 mb-4">
<div class="card h-100 server-card" data-server-id="{{ server.id }}" data-status="{{ server.status }}">
@ -98,6 +101,7 @@
<div class="mb-2"><span class="badge bg-info">Статус: {{ server.status }}</span></div>
<!-- Метрики -->
{% for s in servers %}<!-- DBG: id={{ s.id }} t={{ s.thresholds|json_encode }} -->{% endfor %}
<div class="row">
{% if server.latest_metrics['cpu_load'] is defined %}
<div class="col-6 mb-2">
@ -105,8 +109,20 @@
<small class="text-muted"><i class="fas fa-microchip"></i> CPU</small>
<strong>{{ server.latest_metrics['cpu_load'].value }}{{ server.latest_metrics['cpu_load'].unit }}</strong>
</div>
{% set cpu_t = server.thresholds['cpu_load']|default(null) %}
{% if cpu_t and server.latest_metrics['cpu_load'].value >= cpu_t.critical %}
{% set cpu_color = 'bg-danger' %}
{% elseif cpu_t and server.latest_metrics['cpu_load'].value >= cpu_t.warning %}
{% set cpu_color = 'bg-warning' %}
{% elseif server.latest_metrics['cpu_load'].value > 80 %}
{% set cpu_color = 'bg-danger' %}
{% elseif server.latest_metrics['cpu_load'].value > 60 %}
{% set cpu_color = 'bg-warning' %}
{% else %}
{% set cpu_color = 'bg-success' %}
{% endif %}
<div class="progress" style="height: 6px;">
<div class="progress-bar {% if server.latest_metrics['cpu_load'].value > 80 %}bg-danger{% elseif server.latest_metrics['cpu_load'].value > 60 %}bg-warning{% else %}bg-success{% endif %}"
<div class="progress-bar {{ cpu_color }}"
role="progressbar"
style="width: {{ server.latest_metrics['cpu_load'].value }}%"></div>
</div>
@ -119,8 +135,20 @@
<small class="text-muted"><i class="fas fa-memory"></i> RAM</small>
<strong>{{ server.latest_metrics['ram_used'].value }}{{ server.latest_metrics['ram_used'].unit }}</strong>
</div>
{% set ram_t = server.thresholds['ram_used']|default(null) %}
{% if ram_t and server.latest_metrics['ram_used'].value >= ram_t.critical %}
{% set ram_color = 'bg-danger' %}
{% elseif ram_t and server.latest_metrics['ram_used'].value >= ram_t.warning %}
{% set ram_color = 'bg-warning' %}
{% elseif server.latest_metrics['ram_used'].value > 80 %}
{% set ram_color = 'bg-danger' %}
{% elseif server.latest_metrics['ram_used'].value > 60 %}
{% set ram_color = 'bg-warning' %}
{% else %}
{% set ram_color = 'bg-success' %}
{% endif %}
<div class="progress" style="height: 6px;">
<div class="progress-bar {% if server.latest_metrics['ram_used'].value > 80 %}bg-danger{% elseif server.latest_metrics['ram_used'].value > 60 %}bg-warning{% else %}bg-success{% endif %}"
<div class="progress-bar {{ ram_color }}"
role="progressbar"
style="width: {{ server.latest_metrics['ram_used'].value }}%"></div>
</div>
@ -133,8 +161,20 @@
<small class="text-muted"><i class="fas fa-hdd"></i> Диск</small>
<strong>{{ server.latest_metrics['disk_used'].value }}{{ server.latest_metrics['disk_used'].unit }}</strong>
</div>
{% set disk_t = server.thresholds['disk_used']|default(null) %}
{% if disk_t and server.latest_metrics['disk_used'].value >= disk_t.critical %}
{% set disk_color = 'bg-danger' %}
{% elseif disk_t and server.latest_metrics['disk_used'].value >= disk_t.warning %}
{% set disk_color = 'bg-warning' %}
{% elseif server.latest_metrics['disk_used'].value > 80 %}
{% set disk_color = 'bg-danger' %}
{% elseif server.latest_metrics['disk_used'].value > 60 %}
{% set disk_color = 'bg-warning' %}
{% else %}
{% set disk_color = 'bg-success' %}
{% endif %}
<div class="progress" style="height: 6px;">
<div class="progress-bar {% if server.latest_metrics['disk_used'].value > 80 %}bg-danger{% elseif server.latest_metrics['disk_used'].value > 60 %}bg-warning{% else %}bg-success{% endif %}"
<div class="progress-bar {{ disk_color }}"
role="progressbar"
style="width: {{ server.latest_metrics['disk_used'].value }}%"></div>
</div>

View File

@ -88,46 +88,34 @@
<div class="tab-content mt-3">
<!-- Вкладка "Метрики" -->
<div class="tab-pane fade show active" id="metrics" role="tabpanel">
<div class="row mb-3">
<div class="row mt-2 mb-2">
<div class="col-md-12">
<!-- Отладка: period = {{ request.query.period }} -->
<!-- Отладка: period = {{ period }}, request = {{ request.period }} -->
<div class="btn-group d-flex" role="group">
<a href="?tab=metrics&amp;period=24h" class="btn btn-outline-primary w-100 {% if period == '24h' or period is empty %}active{% endif %}">
24 часа
</a>
<a href="?tab=metrics&amp;period=7d" class="btn btn-outline-primary w-100 {% if period == '7d' %}active{% endif %}">
7 дней
</a>
<a href="?tab=metrics&amp;period=30d" class="btn btn-outline-primary w-100 {% if period == '30d' %}active{% endif %}">
30 дней
</a>
</div>
<small class="text-muted">Период:</small>
</div>
</div>
<div class="row mt-2">
<div class="col-md-12">
<small class="text-muted">Масштаб:</small>
<div class="btn-group ms-2" role="group">
<a href="?tab=metrics&amp;period={{ period }}&amp;zoom=auto" class="btn btn-sm {% if not zoom or zoom == 'auto' %}btn-secondary{% else %}btn-outline-secondary{% endif %}">
авто
</a>
<a href="?tab=metrics&amp;period={{ period }}&amp;zoom=1h" class="btn btn-sm {% if zoom == '1h' %}btn-secondary{% else %}btn-outline-secondary{% endif %}">
<div class="col-md-12 mt-1">
<div class="btn-group d-flex" role="group">
<a href="?tab=metrics&amp;period=1h" class="btn btn-outline-primary w-100 {% if period == '1h' %}active{% endif %}">
</a>
<a href="?tab=metrics&amp;period={{ period }}&amp;zoom=6h" class="btn btn-sm {% if zoom == '6h' %}btn-secondary{% else %}btn-outline-secondary{% endif %}">
<a href="?tab=metrics&amp;period=6h" class="btn btn-outline-primary w-100 {% if period == '6h' or period is empty %}active{% endif %}">
</a>
<a href="?tab=metrics&amp;period={{ period }}&amp;zoom=24h" class="btn btn-sm {% if zoom == '24h' %}btn-secondary{% else %}btn-outline-secondary{% endif %}">
<a href="?tab=metrics&amp;period=24h" class="btn btn-outline-primary w-100 {% if period == '24h' %}active{% endif %}">
24ч
</a>
<a href="?tab=metrics&amp;period={{ period }}&amp;zoom=7d" class="btn btn-sm {% if zoom == '7d' %}btn-secondary{% else %}btn-outline-secondary{% endif %}">
<a href="?tab=metrics&amp;period=7d" class="btn btn-outline-primary w-100 {% if period == '7d' %}active{% endif %}">
</a>
<a href="?tab=metrics&amp;period={{ period }}&amp;zoom=30d" class="btn btn-sm {% if zoom == '30d' %}btn-secondary{% else %}btn-outline-secondary{% endif %}">
<a href="?tab=metrics&amp;period=30d" class="btn btn-outline-primary w-100 {% if period == '30d' %}active{% endif %}">
30д
</a>
</div>
<div class="btn-group d-flex mt-1" role="group">
<button type="button" class="btn btn-sm btn-outline-secondary w-100" onclick="resetAllZoom()" title="Сбросить интерактивный зум">
<i class="fas fa-search-minus"></i> Сбросить зум
</button>
</div>
<small class="text-muted">💡 Колёсико мыши = зум, перетаскивание = выделение области, Shift+колёсико = панорама</small>
</div>
</div>
@ -411,6 +399,8 @@
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="/chartjs-plugin-crosshair.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
<script src="/chartjs-plugin-zoom.min.js"></script>
<script>
// Функция для получения топ-процессов для указанного времени
@ -434,7 +424,7 @@ function fetchProcesses(serverId, time) {
lines.push('');
lines.push('🏆 Топ CPU:');
data.top_cpu.forEach(function(proc) {
lines.push(' ' + proc.name + ': ' + proc.value + '%');
lines.push(' ' + (proc.cmdline || proc.name) + ': ' + proc.value + '%');
});
}
@ -443,7 +433,7 @@ function fetchProcesses(serverId, time) {
lines.push('');
lines.push('💾 Топ RAM:');
data.top_ram.forEach(function(proc) {
lines.push(' ' + proc.name + ': ' + proc.value + '%');
lines.push(' ' + (proc.cmdline || proc.name) + ': ' + proc.value + '%');
});
}
@ -499,7 +489,7 @@ const ctx{{ metricName|replace({'-': '_', '.': '_'}) }} = document.getElementByI
var labels{{ metricName }} = [];
var data{{ metricName }} = [];
{% for metric in metricData|slice(0, 50000)|reverse %}
{% for metric in metricData|slice(0, 50000) %}
{% set time_val = metric.time_bucket|default(metric.created_at) %}
{% set time_format = metric.time_bucket ? 'd.m H:i' : 'H:i' %}
labels{{ metricName }}.push('{{ time_val|date(time_format) }}');
@ -596,7 +586,7 @@ const chart{{ metricName|replace({'-': '_', '.': '_'}) }} = new Chart(ctx{{ metr
lines.push('');
lines.push('TOP CPU:');
data.top_cpu.forEach(function(proc) {
lines.push(' ' + proc.name + ': ' + proc.value + '%');
lines.push(' ' + (proc.cmdline || proc.name) + ': ' + proc.value + '%');
});
}
{% elseif metricName == 'ram_used' %}
@ -605,7 +595,7 @@ const chart{{ metricName|replace({'-': '_', '.': '_'}) }} = new Chart(ctx{{ metr
lines.push('');
lines.push('TOP RAM:');
data.top_ram.forEach(function(proc) {
lines.push(' ' + proc.name + ': ' + proc.value + '%');
lines.push(' ' + (proc.cmdline || proc.name) + ': ' + proc.value + '%');
});
}
{% endif %}
@ -623,6 +613,24 @@ const chart{{ metricName|replace({'-': '_', '.': '_'}) }} = new Chart(ctx{{ metr
// }, 3000);
});
}
},
zoom: {
zoom: {
drag: {
enabled: true
},
pinch: {
enabled: true
},
wheel: {
enabled: true
},
mode: 'x'
},
pan: {
enabled: true,
mode: 'x'
}
}
}
}
@ -664,5 +672,17 @@ document.addEventListener('mousemove', function(e) {
{% endfor %}
});
// Сбросить зум на всех графиках
function resetAllZoom() {
{% for metricName, metricData in metrics %}
{% if metricName!="top_cpu_proc" and metricName!="top_ram_proc" %}
if (typeof chart{{ metricName|replace({'-': '_', '.': '_'}) }} !== 'undefined') {
chart{{ metricName|replace({'-': '_', '.': '_'}) }}.resetZoom();
}
{% endif %}
{% endfor %}
}
</script>
{% endblock %}