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
This commit is contained in:
mirivlad 2026-04-25 16:36:14 +08:00
parent 3fecc21565
commit 66d4d021ba
5 changed files with 291 additions and 97 deletions

View File

@ -6,6 +6,7 @@ use App\Controllers\AgentController;
use App\Controllers\AdminController; use App\Controllers\AdminController;
use App\Controllers\AlertController; use App\Controllers\AlertController;
use App\Controllers\Api\MetricsController; use App\Controllers\Api\MetricsController;
use App\Controllers\Api\MetricsApiController;
use App\Controllers\GroupController; use App\Controllers\GroupController;
use App\Controllers\ServerController; use App\Controllers\ServerController;
use App\Controllers\ServerDetailController; use App\Controllers\ServerDetailController;
@ -187,6 +188,10 @@ $agentController = new AgentController();
$dashboardApiController = new DashboardController($twig); $dashboardApiController = new DashboardController($twig);
$app->get('/api/dashboard/stats', [$dashboardApiController, 'getDashboardData'])->add(AuthMiddleware::class); $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) // Routes for groups (protected with auth middleware and csrf)
$groupsGroup = $app->group('/groups', function ($group) use ($groupController) { $groupsGroup = $app->group('/groups', function ($group) use ($groupController) {
$group->get('', [$groupController, 'index']); $group->get('', [$groupController, 'index']);

View File

@ -0,0 +1,173 @@
<?php
// src/Controllers/Api/MetricsApiController.php
namespace App\Controllers\Api;
use App\Models\Model;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use DateTime;
class MetricsApiController extends Model
{
private const MAX_POINTS = 500;
public function getServerMetrics(Request $request, Response $response, $args)
{
$id = $args['id'];
// Параметры
$queryParams = $request->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');
}
}

View File

@ -436,11 +436,13 @@ class MetricsController extends Model
public function getProcesses(Request $request, Response $response, $args) public function getProcesses(Request $request, Response $response, $args)
{ {
try {
$serverId = $args['id']; $serverId = $args['id'];
$timeParam = $request->getQueryParams()['time'] ?? null; $timeParam = $request->getQueryParams()['time'] ?? null;
if (!$timeParam) { if (!$timeParam) {
return $response->withStatus(400)->getBody()->write(json_encode(['error' => 'Time parameter required'])); $response->getBody()->write(json_encode(['error' => 'Time parameter required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
} }
$timestamp = strtotime($timeParam); $timestamp = strtotime($timeParam);
@ -454,8 +456,15 @@ class MetricsController extends Model
$timestamp = strtotime($today . ' ' . $timeParam); $timestamp = strtotime($today . ' ' . $timeParam);
} }
if ($timestamp === false) { // Если timestamp всё ещё false или равен 0 (1970) - возвращаем пустой результат
return $response->withStatus(400)->getBody()->write(json_encode(['error' => 'Invalid time format'])); 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); $time = date('Y-m-d H:i:s', $timestamp);
@ -490,6 +499,12 @@ class MetricsController extends Model
])); ]));
return $response->withHeader('Content-Type', 'application/json'); 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');
}
} }
public function getMetrics(Request $request, Response $response, $args) public function getMetrics(Request $request, Response $response, $args)

View File

@ -25,7 +25,8 @@ class ServerDetailController extends Model
// Получаем информацию о сервере // Получаем информацию о сервере
$stmt = $this->pdo->prepare(" $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 FROM servers s
LEFT JOIN server_groups sg ON s.group_id = sg.id LEFT JOIN server_groups sg ON s.group_id = sg.id
WHERE s.id = :id WHERE s.id = :id
@ -133,6 +134,7 @@ class ServerDetailController extends Model
WHERE sm.server_id = :id WHERE sm.server_id = :id
AND sm.created_at >= :start_date AND sm.created_at >= :start_date
AND sm.created_at <= :end_date AND sm.created_at <= :end_date
AND mn.name != 'uptime'
{$groupBy} {$groupBy}
ORDER BY time_bucket ASC ORDER BY time_bucket ASC
"; ";
@ -146,6 +148,7 @@ class ServerDetailController extends Model
WHERE sm.server_id = :id WHERE sm.server_id = :id
AND sm.created_at >= :start_date AND sm.created_at >= :start_date
AND sm.created_at <= :end_date AND sm.created_at <= :end_date
AND mn.name != 'uptime'
ORDER BY sm.created_at ASC ORDER BY sm.created_at ASC
"; ";
$stmt = $this->pdo->prepare($sql); $stmt = $this->pdo->prepare($sql);
@ -246,6 +249,19 @@ class ServerDetailController extends Model
$monitorServices = json_decode($agentConfig['monitor_services'], true) ?? []; $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 = [ $templateData = [
'title' => 'Сервер: ' . $server['name'], 'title' => 'Сервер: ' . $server['name'],
'server' => $server, 'server' => $server,
@ -254,6 +270,7 @@ class ServerDetailController extends Model
'existingThresholds' => $existingThresholds, 'existingThresholds' => $existingThresholds,
'allServices' => $allServices, 'allServices' => $allServices,
'monitorServices' => $monitorServices, 'monitorServices' => $monitorServices,
'latestUptime' => $latestUptime,
'startDate' => $startDate->format('Y-m-d\T H:i'), 'startDate' => $startDate->format('Y-m-d\T H:i'),
'endDate' => $endDate->format('Y-m-d\T H:i'), 'endDate' => $endDate->format('Y-m-d\T H:i'),
'aggregation' => $aggConfig, 'aggregation' => $aggConfig,

View File

@ -69,8 +69,8 @@
<h6 class="mb-0"><i class="fas fa-clock"></i> Время работы</h6> <h6 class="mb-0"><i class="fas fa-clock"></i> Время работы</h6>
</div> </div>
<div class="card-body text-center d-flex flex-column justify-content-center"> <div class="card-body text-center d-flex flex-column justify-content-center">
{% if server.latest_metrics.uptime is defined %} {% if latestUptime is defined %}
{% set uptime_sec = server.latest_metrics.uptime.value %} {% set uptime_sec = latestUptime.value %}
<div class="mb-2"> <div class="mb-2">
{% if uptime_sec >= 86400 %} {% if uptime_sec >= 86400 %}
<span class="badge bg-success fs-6 me-1">{{ (uptime_sec / 86400)|round(0, 'floor') }}д</span> <span class="badge bg-success fs-6 me-1">{{ (uptime_sec / 86400)|round(0, 'floor') }}д</span>
@ -83,7 +83,7 @@
{% endif %} {% endif %}
</div> </div>
<small class="text-muted"> <small class="text-muted">
<i class="fas fa-play"></i> {{ server.created_at|date('d.m.Y H:i') }} <i class="fas fa-play"></i> {{ server.last_seen|date('d.m.Y H:i') }}
</small> </small>
{% else %} {% else %}
<div class="text-muted"> <div class="text-muted">
@ -581,7 +581,8 @@
function fetchProcesses(serverId, time) { function fetchProcesses(serverId, time) {
return new Promise(function(resolve) { return new Promise(function(resolve) {
var fullTime = time; var fullTime = time;
if (time && time.indexOf('-') === -1) { // Если нет пробела - значит только время, добавляем текущую дату
if (time && time.indexOf(' ') === -1) {
var now = new Date(); var now = new Date();
var year = now.getFullYear(); var year = now.getFullYear();
var month = String(now.getMonth() + 1).padStart(2, '0'); var month = String(now.getMonth() + 1).padStart(2, '0');
@ -666,7 +667,7 @@ var diskTotalGB = {
// Графики метрик // Графики метрик
{% for metricName, metricData in metrics %} {% 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'); 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 dataIndex = context.tooltip._active[0].index;
var time = labels{{ metricName }}[dataIndex]; var time = labels{{ metricName }}[dataIndex];
var metricValue = context.chart.data.datasets[0].data[dataIndex];
// Fetch processes fetchProcesses({{ server.id }}, time).then(function(processLines) {
fetch('/api/v1/agent/' + {{ server.id }} + '/processes?time=' + encodeURIComponent(time))
.then(response => response.json())
.then(data => {
var lines = []; var lines = [];
lines.push('Время: ' + time); lines.push('Время: ' + time);
{% if metricName == 'ram_used' %} {% if metricName == 'ram_used' %}
var ramPct = data{{ metricName }}[dataIndex]; var ramPct = metricValue;
if (ramTotalGB !== null) { if (ramTotalGB !== null) {
var ramUsed = (ramPct / 100 * ramTotalGB).toFixed(1); var ramUsed = (ramPct / 100 * ramTotalGB).toFixed(1);
var ramFree = (ramTotalGB - ramUsed).toFixed(1); var ramFree = (ramTotalGB - ramUsed).toFixed(1);
lines.push('Всего: ' + ramTotalGB.toFixed(1) + ' ГБ'); lines.push('Всего: ' + ramTotalGB.toFixed(1) + ' ГБ');
lines.push('Занято: ' + ramUsed + ' ГБ'); lines.push('Занято: ' + ramUsed + ' ГБ (' + ramPct + '%)');
lines.push('Свободно: ' + ramFree + ' ГБ'); lines.push('Свободно: ' + ramFree + ' ГБ');
lines.push('');
} else { } else {
lines.push('RAM: ' + ramPct + '%'); lines.push('RAM: ' + ramPct + '%');
lines.push('(данные о памяти недоступны)'); 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_' %} {% elseif metricName starts with 'disk_used_' %}
var diskPct = data{{ metricName }}[dataIndex]; var diskPct = metricValue;
var iface = '{{ metricName }}'.replace('disk_used_', ''); var iface = '{{ metricName }}'.replace('disk_used_', '');
var diskTotal = diskTotalGB[iface] || 0; var diskTotal = diskTotalGB[iface] || 0;
var diskUsed = (diskPct / 100 * diskTotal).toFixed(1); var diskUsed = (diskPct / 100 * diskTotal).toFixed(1);
@ -782,30 +773,23 @@ const chart{{ metricName|replace({'-': '_', '.': '_'}) }} = new Chart(ctx{{ metr
lines.push('Свободно: ' + diskFree + ' ГБ'); lines.push('Свободно: ' + diskFree + ' ГБ');
{% else %} {% else %}
{% if metricName == 'cpu_load' %} {% if metricName == 'cpu_load' %}
lines.push('CPU: ' + data{{ metricName }}[dataIndex] + '%'); lines.push('CPU: ' + metricValue + '%');
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 + '%');
});
}
{% else %} {% else %}
lines.push('Значение: ' + data{{ metricName }}[dataIndex]); lines.push('Значение: ' + metricValue);
{% endif %} {% endif %}
{% endif %} {% endif %}
if (processLines.length > 0) {
lines.push('');
lines = lines.concat(processLines);
}
// Show tooltip // Show tooltip
var position = context.chart.canvas.getBoundingClientRect(); var position = context.chart.canvas.getBoundingClientRect();
tooltipEl.innerHTML = lines.join('<br>'); tooltipEl.innerHTML = lines.join('<br>');
tooltipEl.style.opacity = 1; tooltipEl.style.opacity = 1;
tooltipEl.style.left = position.left + window.pageXOffset + context.tooltip.caretX + 10 + 'px'; tooltipEl.style.left = position.left + window.pageXOffset + context.tooltip.caretX + 10 + 'px';
tooltipEl.style.top = position.top + window.pageYOffset + context.tooltip.caretY + 'px'; tooltipEl.style.top = position.top + window.pageYOffset + context.tooltip.caretY + 'px';
// Hide after 3 seconds
// setTimeout(function() {
// tooltipEl.style.opacity = 0;
// }, 3000);
}); });
} }
}, },
@ -848,7 +832,7 @@ chart{{ metricName|replace({'-': '_', '.': '_'}) }}.canvas.addEventListener('mou
// Глобальный обработчик для скрытия тултипов при уходе курсора за пределы canvas // Глобальный обработчик для скрытия тултипов при уходе курсора за пределы canvas
document.addEventListener('mousemove', function(e) { document.addEventListener('mousemove', function(e) {
{% for metricName, metricData in metrics %} {% 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() { (function() {
var canvas = chart{{ metricName|replace({'-': '_', '.': '_'}) }}.canvas; var canvas = chart{{ metricName|replace({'-': '_', '.': '_'}) }}.canvas;
var rect = canvas.getBoundingClientRect(); var rect = canvas.getBoundingClientRect();