From 66d4d021ba2be412f846107da6982b9d4317fa6c Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 25 Apr 2026 16:36:14 +0800 Subject: [PATCH] Fix tooltips and uptime chart issues - Fix tooltip data access: use chart data instead of API response - Exclude uptime from metrics charts (SQL and template filters) - Fix fetchProcesses() time format handling - Add try-catch and proper Response handling in getProcesses API - Fix Slim Response write() chaining issue - Add last_seen to server query --- public/index.php | 5 + src/Controllers/Api/MetricsApiController.php | 173 +++++++++++++++++++ src/Controllers/Api/MetricsController.php | 119 +++++++------ src/Controllers/ServerDetailController.php | 19 +- templates/servers/detail.twig | 72 +++----- 5 files changed, 291 insertions(+), 97 deletions(-) create mode 100644 src/Controllers/Api/MetricsApiController.php diff --git a/public/index.php b/public/index.php index 49c09d0..c5d2dce 100755 --- a/public/index.php +++ b/public/index.php @@ -6,6 +6,7 @@ use App\Controllers\AgentController; use App\Controllers\AdminController; use App\Controllers\AlertController; use App\Controllers\Api\MetricsController; +use App\Controllers\Api\MetricsApiController; use App\Controllers\GroupController; use App\Controllers\ServerController; use App\Controllers\ServerDetailController; @@ -187,6 +188,10 @@ $agentController = new AgentController(); $dashboardApiController = new DashboardController($twig); $app->get('/api/dashboard/stats', [$dashboardApiController, 'getDashboardData'])->add(AuthMiddleware::class); +// API для метрик сервера (динамическая загрузка) +$metricsApiController = new MetricsApiController(); +$app->get('/api/servers/{id}/metrics', [$metricsApiController, 'getServerMetrics'])->add(AuthMiddleware::class); + // Routes for groups (protected with auth middleware and csrf) $groupsGroup = $app->group('/groups', function ($group) use ($groupController) { $group->get('', [$groupController, 'index']); diff --git a/src/Controllers/Api/MetricsApiController.php b/src/Controllers/Api/MetricsApiController.php new file mode 100644 index 0000000..454c8e5 --- /dev/null +++ b/src/Controllers/Api/MetricsApiController.php @@ -0,0 +1,173 @@ +getQueryParams(); + $period = $queryParams['period'] ?? '7d'; + $startParam = $queryParams['start'] ?? null; + $endParam = $queryParams['end'] ?? null; + $zoom = $queryParams['zoom'] ?? null; + + // Вычисляем даты + $endDate = new DateTime(); + $startDate = clone $endDate; + + if ($startParam && $endParam) { + // Используем переданные даты + $startDate = new DateTime($startParam); + $endDate = new DateTime($endParam); + } else { + // Вычисляем по period + 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; + } + } + + // Вычисляем интервал агрегации + $totalMinutes = ($endDate->getTimestamp() - $startDate->getTimestamp()) / 60; + $aggregationMinutes = max(1, ceil($totalMinutes / self::MAX_POINTS)); + $aggregationSeconds = $aggregationMinutes * 60; + + // Получаем метрики для графиков (исключая uptime и top_*) + $stmt = $this->pdo->prepare(" + SELECT + mn.name, + mn.unit, + FLOOR(TIMESTAMPDIFF(SECOND, :start, sm.created_at) / :agg) as bucket, + AVG(sm.value) as value, + MAX(sm.created_at) as created_at + FROM server_metrics sm + JOIN metric_names mn ON sm.metric_name_id = mn.id + WHERE sm.server_id = :server_id + AND sm.created_at BETWEEN :start AND :end + AND mn.name NOT IN ('uptime') + AND mn.name NOT LIKE '%_proc' + AND ( + mn.name IN ('cpu_load', 'ram_used') + OR mn.name LIKE 'disk_used_%' + OR mn.name LIKE 'net_in_%' + OR mn.name LIKE 'net_out_%' + OR mn.name LIKE 'temp_%' + ) + GROUP BY mn.name, bucket + ORDER BY mn.name, bucket + "); + + $stmt->execute([ + ':server_id' => $id, + ':start' => $startDate->format('Y-m-d H:i:s'), + ':end' => $endDate->format('Y-m-d H:i:s'), + ':agg' => $aggregationSeconds + ]); + + $rawData = $stmt->fetchAll(); + + // Группируем по метрикам + $datasets = []; + $labels = []; + $minTime = null; + $maxTime = null; + + foreach ($rawData as $row) { + $metricName = $row['name']; + $time = $row['created_at']; + + if (!isset($datasets[$metricName])) { + $datasets[$metricName] = []; + } + + $datasets[$metricName][] = (float)$row['value']; + + // Собираем уникальные метки времени + if (!in_array($time, $labels)) { + $labels[] = $time; + } + + if ($minTime === null || $time < $minTime) $minTime = $time; + if ($maxTime === null || $time > $maxTime) $maxTime = $time; + } + + // Форматируем labels + $formattedLabels = array_map(function($label) { + return (new DateTime($label))->format('d.m H:i'); + }, $labels); + + // Получаем TOP процессы для последней точки + $topCpu = []; + $topRam = []; + + $stmt = $this->pdo->prepare(" + SELECT mn.name, sm.value + FROM server_metrics sm + JOIN metric_names mn ON sm.metric_name_id = mn.id + WHERE sm.server_id = :server_id AND mn.name = 'top_cpu_proc' + ORDER BY sm.created_at DESC + LIMIT 5 + "); + $stmt->execute([':server_id' => $id]); + $topData = $stmt->fetchAll(); + if ($topData) { + $topCpu = json_decode($topData[0]['value'] ?? '[]', true) ?? []; + } + + $stmt = $this->pdo->prepare(" + SELECT sm.value + FROM server_metrics sm + JOIN metric_names mn ON sm.metric_name_id = mn.id + WHERE sm.server_id = :server_id AND mn.name = 'top_ram_proc' + ORDER BY sm.created_at DESC + LIMIT 1 + "); + $stmt->execute([':server_id' => $id]); + $topRamData = $stmt->fetch(); + if ($topRamData) { + $topRam = json_decode($topRamData['value'] ?? '[]', true) ?? []; + } + + // Формируем ответ + $result = [ + 'period' => $period, + 'start' => $startDate->format('Y-m-d H:i:s'), + 'end' => $endDate->format('Y-m-d H:i:s'), + 'aggregation_minutes' => $aggregationMinutes, + 'total_points' => count($labels), + 'labels' => $formattedLabels, + 'datasets' => $datasets, + 'top_cpu' => $topCpu, + 'top_ram' => $topRam + ]; + + $response->getBody()->write(json_encode($result, JSON_UNESCAPED_UNICODE)); + return $response->withHeader('Content-Type', 'application/json'); + } +} \ No newline at end of file diff --git a/src/Controllers/Api/MetricsController.php b/src/Controllers/Api/MetricsController.php index 252793b..0a56a18 100755 --- a/src/Controllers/Api/MetricsController.php +++ b/src/Controllers/Api/MetricsController.php @@ -436,60 +436,75 @@ class MetricsController extends Model public function getProcesses(Request $request, Response $response, $args) { - $serverId = $args['id']; - $timeParam = $request->getQueryParams()['time'] ?? null; + try { + $serverId = $args['id']; + $timeParam = $request->getQueryParams()['time'] ?? null; - if (!$timeParam) { - return $response->withStatus(400)->getBody()->write(json_encode(['error' => 'Time parameter required'])); + if (!$timeParam) { + $response->getBody()->write(json_encode(['error' => 'Time parameter required'])); + return $response->withStatus(400)->withHeader('Content-Type', 'application/json'); + } + + $timestamp = strtotime($timeParam); + // Парсинг формата d.m H:i (12.04 07:48) + if ($timestamp === false && preg_match("/^(\d{1,2})\.(\d{2}) (\d{1,2}):(\d{2})$/", $timeParam, $m)) { + $timestamp = strtotime(date("Y") . "-" . $m[2] . "-" . $m[1] . " " . $m[3] . ":" . $m[4] . ":00"); + } + + if ($timestamp === false && preg_match('/^\d{1,2}:\d{2}$/', $timeParam)) { + $today = date('Y-m-d'); + $timestamp = strtotime($today . ' ' . $timeParam); + } + + // Если timestamp всё ещё false или равен 0 (1970) - возвращаем пустой результат + if ($timestamp === false || $timestamp < 0) { + $response->getBody()->write(json_encode([ + 'top_cpu' => [], + 'top_ram' => [], + 'time' => $timeParam, + 'error' => 'Invalid time format' + ])); + return $response->withHeader('Content-Type', 'application/json'); + } + + $time = date('Y-m-d H:i:s', $timestamp); + + $stmt = $this->pdo->prepare(" + SELECT value FROM server_metrics sm + JOIN metric_names mn ON sm.metric_name_id = mn.id + WHERE sm.server_id = :server_id AND mn.name = 'top_cpu_proc' + AND sm.created_at BETWEEN DATE_SUB(:time1, INTERVAL 30 SECOND) AND DATE_ADD(:time2, INTERVAL 30 SECOND) + ORDER BY ABS(TIMESTAMPDIFF(SECOND, sm.created_at, :time3)) LIMIT 1 + "); + $stmt->execute([':server_id' => $serverId, ':time1' => $time, ':time2' => $time, ':time3' => $time]); + $topCpuResult = $stmt->fetch(); + + $stmt = $this->pdo->prepare(" + SELECT value FROM server_metrics sm + JOIN metric_names mn ON sm.metric_name_id = mn.id + WHERE sm.server_id = :server_id AND mn.name = 'top_ram_proc' + AND sm.created_at BETWEEN DATE_SUB(:time1, INTERVAL 30 SECOND) AND DATE_ADD(:time2, INTERVAL 30 SECOND) + ORDER BY ABS(TIMESTAMPDIFF(SECOND, sm.created_at, :time3)) LIMIT 1 + "); + $stmt->execute([':server_id' => $serverId, ':time1' => $time, ':time2' => $time, ':time3' => $time]); + $topRamResult = $stmt->fetch(); + + $topCpu = $topCpuResult ? json_decode($topCpuResult['value'], true) : []; + $topRam = $topRamResult ? json_decode($topRamResult['value'], true) : []; + + $response->getBody()->write(json_encode([ + 'top_cpu' => $topCpu, + 'top_ram' => $topRam, + 'time' => $time + ])); + + return $response->withHeader('Content-Type', 'application/json'); + } catch (\Exception $e) { + $response->getBody()->write(json_encode([ + 'error' => $e->getMessage() + ])); + return $response->withStatus(500)->withHeader('Content-Type', 'application/json'); } - - $timestamp = strtotime($timeParam); - // Парсинг формата d.m H:i (12.04 07:48) - if ($timestamp === false && preg_match("/^(\d{1,2})\.(\d{2}) (\d{1,2}):(\d{2})$/", $timeParam, $m)) { - $timestamp = strtotime(date("Y") . "-" . $m[2] . "-" . $m[1] . " " . $m[3] . ":" . $m[4] . ":00"); - } - - if ($timestamp === false && preg_match('/^\d{1,2}:\d{2}$/', $timeParam)) { - $today = date('Y-m-d'); - $timestamp = strtotime($today . ' ' . $timeParam); - } - - if ($timestamp === false) { - return $response->withStatus(400)->getBody()->write(json_encode(['error' => 'Invalid time format'])); - } - - $time = date('Y-m-d H:i:s', $timestamp); - - $stmt = $this->pdo->prepare(" - SELECT value FROM server_metrics sm - JOIN metric_names mn ON sm.metric_name_id = mn.id - WHERE sm.server_id = :server_id AND mn.name = 'top_cpu_proc' - AND sm.created_at BETWEEN DATE_SUB(:time1, INTERVAL 30 SECOND) AND DATE_ADD(:time2, INTERVAL 30 SECOND) - ORDER BY ABS(TIMESTAMPDIFF(SECOND, sm.created_at, :time3)) LIMIT 1 - "); - $stmt->execute([':server_id' => $serverId, ':time1' => $time, ':time2' => $time, ':time3' => $time]); - $topCpuResult = $stmt->fetch(); - - $stmt = $this->pdo->prepare(" - SELECT value FROM server_metrics sm - JOIN metric_names mn ON sm.metric_name_id = mn.id - WHERE sm.server_id = :server_id AND mn.name = 'top_ram_proc' - AND sm.created_at BETWEEN DATE_SUB(:time1, INTERVAL 30 SECOND) AND DATE_ADD(:time2, INTERVAL 30 SECOND) - ORDER BY ABS(TIMESTAMPDIFF(SECOND, sm.created_at, :time3)) LIMIT 1 - "); - $stmt->execute([':server_id' => $serverId, ':time1' => $time, ':time2' => $time, ':time3' => $time]); - $topRamResult = $stmt->fetch(); - - $topCpu = $topCpuResult ? json_decode($topCpuResult['value'], true) : []; - $topRam = $topRamResult ? json_decode($topRamResult['value'], true) : []; - - $response->getBody()->write(json_encode([ - 'top_cpu' => $topCpu, - 'top_ram' => $topRam, - 'time' => $time - ])); - - return $response->withHeader('Content-Type', 'application/json'); } public function getMetrics(Request $request, Response $response, $args) diff --git a/src/Controllers/ServerDetailController.php b/src/Controllers/ServerDetailController.php index 62fcdcc..02be308 100755 --- a/src/Controllers/ServerDetailController.php +++ b/src/Controllers/ServerDetailController.php @@ -25,7 +25,8 @@ class ServerDetailController extends Model // Получаем информацию о сервере $stmt = $this->pdo->prepare(" - SELECT s.*, sg.name as group_name, sg.icon as group_icon, sg.color as group_color + SELECT s.*, sg.name as group_name, sg.icon as group_icon, sg.color as group_color, + (SELECT MAX(sm.created_at) FROM server_metrics sm WHERE sm.server_id = s.id) as last_seen FROM servers s LEFT JOIN server_groups sg ON s.group_id = sg.id WHERE s.id = :id @@ -133,6 +134,7 @@ class ServerDetailController extends Model WHERE sm.server_id = :id AND sm.created_at >= :start_date AND sm.created_at <= :end_date + AND mn.name != 'uptime' {$groupBy} ORDER BY time_bucket ASC "; @@ -146,6 +148,7 @@ class ServerDetailController extends Model WHERE sm.server_id = :id AND sm.created_at >= :start_date AND sm.created_at <= :end_date + AND mn.name != 'uptime' ORDER BY sm.created_at ASC "; $stmt = $this->pdo->prepare($sql); @@ -246,6 +249,19 @@ class ServerDetailController extends Model $monitorServices = json_decode($agentConfig['monitor_services'], true) ?? []; } + // Получаем последние значения метрик (для виджета аптайма) + $stmt = $this->pdo->prepare(" + SELECT mn.name, sm.value, sm.created_at + FROM server_metrics sm + JOIN metric_names mn ON sm.metric_name_id = mn.id + WHERE sm.server_id = :id + AND mn.name = 'uptime' + ORDER BY sm.created_at DESC + LIMIT 1 + "); + $stmt->execute([':id' => $id]); + $latestUptime = $stmt->fetch(); + $templateData = [ 'title' => 'Сервер: ' . $server['name'], 'server' => $server, @@ -254,6 +270,7 @@ class ServerDetailController extends Model 'existingThresholds' => $existingThresholds, 'allServices' => $allServices, 'monitorServices' => $monitorServices, + 'latestUptime' => $latestUptime, 'startDate' => $startDate->format('Y-m-d\T H:i'), 'endDate' => $endDate->format('Y-m-d\T H:i'), 'aggregation' => $aggConfig, diff --git a/templates/servers/detail.twig b/templates/servers/detail.twig index c182fbd..40f3a0c 100755 --- a/templates/servers/detail.twig +++ b/templates/servers/detail.twig @@ -69,8 +69,8 @@
Время работы
- {% if server.latest_metrics.uptime is defined %} - {% set uptime_sec = server.latest_metrics.uptime.value %} + {% if latestUptime is defined %} + {% set uptime_sec = latestUptime.value %}
{% if uptime_sec >= 86400 %} {{ (uptime_sec / 86400)|round(0, 'floor') }}д @@ -83,7 +83,7 @@ {% endif %}
- {{ server.created_at|date('d.m.Y H:i') }} + {{ server.last_seen|date('d.m.Y H:i') }} {% else %}
@@ -581,7 +581,8 @@ function fetchProcesses(serverId, time) { return new Promise(function(resolve) { var fullTime = time; - if (time && time.indexOf('-') === -1) { + // Если нет пробела - значит только время, добавляем текущую дату + if (time && time.indexOf(' ') === -1) { var now = new Date(); var year = now.getFullYear(); var month = String(now.getMonth() + 1).padStart(2, '0'); @@ -666,7 +667,7 @@ var diskTotalGB = { // Графики метрик {% for metricName, metricData in metrics %} -{% if metricName!="top_cpu_proc" and metricName!="top_ram_proc" and metricName!="disk_used" and not (metricName starts with "disk_used_") and not (metricName starts with "disk_total_gb_") and metricName!="ram_total_gb" and not (metricName starts with "net_in_") and not (metricName starts with "net_out_") and metricName!="network_rx" and metricName!="network_tx" and not (metricName starts with "temp_") %} +{% if metricName!="top_cpu_proc" and metricName!="top_ram_proc" and metricName!="disk_used" and not (metricName starts with "disk_used_") and not (metricName starts with "disk_total_gb_") and metricName!="ram_total_gb" and not (metricName starts with "net_in_") and not (metricName starts with "net_out_") and metricName!="network_rx" and metricName!="network_tx" and not (metricName starts with "temp_") and metricName!="uptime" %} const ctx{{ metricName|replace({'-': '_', '.': '_'}) }} = document.getElementById('chart-{{ metricName }}').getContext('2d'); // Подготовка данных для графика @@ -744,35 +745,25 @@ const chart{{ metricName|replace({'-': '_', '.': '_'}) }} = new Chart(ctx{{ metr var dataIndex = context.tooltip._active[0].index; var time = labels{{ metricName }}[dataIndex]; + var metricValue = context.chart.data.datasets[0].data[dataIndex]; - // Fetch processes - fetch('/api/v1/agent/' + {{ server.id }} + '/processes?time=' + encodeURIComponent(time)) - .then(response => response.json()) - .then(data => { - var lines = []; - lines.push('Время: ' + time); + fetchProcesses({{ server.id }}, time).then(function(processLines) { + var lines = []; + lines.push('Время: ' + time); {% if metricName == 'ram_used' %} - var ramPct = data{{ metricName }}[dataIndex]; + var ramPct = metricValue; if (ramTotalGB !== null) { var ramUsed = (ramPct / 100 * ramTotalGB).toFixed(1); var ramFree = (ramTotalGB - ramUsed).toFixed(1); lines.push('Всего: ' + ramTotalGB.toFixed(1) + ' ГБ'); - lines.push('Занято: ' + ramUsed + ' ГБ'); + lines.push('Занято: ' + ramUsed + ' ГБ (' + ramPct + '%)'); lines.push('Свободно: ' + ramFree + ' ГБ'); - lines.push(''); } else { lines.push('RAM: ' + ramPct + '%'); lines.push('(данные о памяти недоступны)'); - lines.push(''); - } - if (data.top_ram && data.top_ram.length > 0) { - lines.push('TOP RAM:'); - data.top_ram.forEach(function(proc) { - lines.push(' ' + ((proc.cmdline || '').trim() || proc.name) + ': ' + proc.value + '%'); - }); } {% elseif metricName starts with 'disk_used_' %} - var diskPct = data{{ metricName }}[dataIndex]; + var diskPct = metricValue; var iface = '{{ metricName }}'.replace('disk_used_', ''); var diskTotal = diskTotalGB[iface] || 0; var diskUsed = (diskPct / 100 * diskTotal).toFixed(1); @@ -782,31 +773,24 @@ const chart{{ metricName|replace({'-': '_', '.': '_'}) }} = new Chart(ctx{{ metr lines.push('Свободно: ' + diskFree + ' ГБ'); {% else %} {% if metricName == 'cpu_load' %} - lines.push('CPU: ' + data{{ metricName }}[dataIndex] + '%'); - if (data.top_cpu && data.top_cpu.length > 0) { - lines.push(''); - lines.push('TOP CPU:'); - data.top_cpu.forEach(function(proc) { - lines.push(' ' + ((proc.cmdline || '').trim() || proc.name) + ': ' + proc.value + '%'); - }); - } + lines.push('CPU: ' + metricValue + '%'); {% else %} - lines.push('Значение: ' + data{{ metricName }}[dataIndex]); + lines.push('Значение: ' + metricValue); {% endif %} {% endif %} + + if (processLines.length > 0) { + lines.push(''); + lines = lines.concat(processLines); + } - // Show tooltip - var position = context.chart.canvas.getBoundingClientRect(); - tooltipEl.innerHTML = lines.join('
'); - tooltipEl.style.opacity = 1; - tooltipEl.style.left = position.left + window.pageXOffset + context.tooltip.caretX + 10 + 'px'; - tooltipEl.style.top = position.top + window.pageYOffset + context.tooltip.caretY + 'px'; - - // Hide after 3 seconds -// setTimeout(function() { -// tooltipEl.style.opacity = 0; -// }, 3000); - }); + // Show tooltip + var position = context.chart.canvas.getBoundingClientRect(); + tooltipEl.innerHTML = lines.join('
'); + tooltipEl.style.opacity = 1; + tooltipEl.style.left = position.left + window.pageXOffset + context.tooltip.caretX + 10 + 'px'; + tooltipEl.style.top = position.top + window.pageYOffset + context.tooltip.caretY + 'px'; + }); } }, zoom: { @@ -848,7 +832,7 @@ chart{{ metricName|replace({'-': '_', '.': '_'}) }}.canvas.addEventListener('mou // Глобальный обработчик для скрытия тултипов при уходе курсора за пределы canvas document.addEventListener('mousemove', function(e) { {% for metricName, metricData in metrics %} -{% if metricName!="top_cpu_proc" and metricName!="top_ram_proc" and metricName!="disk_used" and not (metricName starts with "disk_used_") and not (metricName starts with "disk_total_gb_") and metricName!="ram_total_gb" and not (metricName starts with "net_in_") and not (metricName starts with "net_out_") and metricName!="network_rx" and metricName!="network_tx" and not (metricName starts with "temp_") %} +{% if metricName!="top_cpu_proc" and metricName!="top_ram_proc" and metricName!="disk_used" and not (metricName starts with "disk_used_") and not (metricName starts with "disk_total_gb_") and metricName!="ram_total_gb" and not (metricName starts with "net_in_") and not (metricName starts with "net_out_") and metricName!="network_rx" and metricName!="network_tx" and not (metricName starts with "temp_") and metricName!="uptime" %} (function() { var canvas = chart{{ metricName|replace({'-': '_', '.': '_'}) }}.canvas; var rect = canvas.getBoundingClientRect();