feat(metrics): replace period/zoom buttons with datetime range selector

This commit is contained in:
mirivlad 2026-02-22 02:45:59 +00:00
parent 6ca570ec1d
commit 9b64cee32c
2 changed files with 176 additions and 85 deletions

View File

@ -36,21 +36,43 @@ class ServerDetailController extends Model
return $response->withHeader('Location', '/servers')->withStatus(302); return $response->withHeader('Location', '/servers')->withStatus(302);
} }
// Получаем период и зум // Получаем даты начала и окончания
$period = $request->getQueryParams()['period'] ?? '24h'; $queryParams = $request->getQueryParams();
$zoom = $request->getQueryParams()['zoom'] ?? null; $startDate = $queryParams['start'] ?? null;
$endDate = $queryParams['end'] ?? null;
// Конфигурация агрегации
$aggConfig = $this->getAggregationConfig($period, $zoom); // Если даты не указаны, используем последние 24 часа по умолчанию
if (!$startDate || !$endDate) {
$endDate = new DateTime();
$startDate = clone $endDate;
$startDate->modify('-24 hours');
} else {
$startDate = new DateTime($startDate);
$endDate = new DateTime($endDate);
}
// Валидация: end > start
if ($endDate <= $startDate) {
$endDate = clone $startDate;
$endDate->modify('+24 hours');
}
// Вычисляем длительность периода для агрегации
$interval = $startDate->diff($endDate);
$totalMinutes = ($interval->days * 24 * 60) + ($interval->h * 60) + $interval->i;
// Конфигурация агрегации на основе дат
$aggConfig = $this->getAggregationConfigFromDates($startDate, $endDate, $totalMinutes);
$interval = $aggConfig['interval'];
$groupBy = $aggConfig['groupBy']; $groupBy = $aggConfig['groupBy'];
$bucketFormat = $aggConfig['format'];
// Форматируем даты для SQL
$startStr = $startDate->format('Y-m-d H:i:s');
$endStr = $endDate->format('Y-m-d H:i:s');
// Запрос с агрегацией если нужно // Запрос с агрегацией если нужно
if ($groupBy) { if ($groupBy) {
// Используем format из конфига
$bucketFormat = $aggConfig['format'] ?? '%Y-%m-%d %H:%i';
$sql = " $sql = "
SELECT SELECT
AVG(sm.value) as value, AVG(sm.value) as value,
@ -60,23 +82,27 @@ class ServerDetailController extends Model
FROM server_metrics sm FROM server_metrics sm
JOIN metric_names mn ON sm.metric_name_id = mn.id JOIN metric_names mn ON sm.metric_name_id = mn.id
WHERE sm.server_id = :id WHERE sm.server_id = :id
AND sm.created_at >= DATE_SUB(NOW(), {$interval}) AND sm.created_at >= :start_date
AND sm.created_at <= :end_date
{$groupBy} {$groupBy}
ORDER BY time_bucket DESC ORDER BY time_bucket ASC
"; ";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':id' => $id, ':start_date' => $startStr, ':end_date' => $endStr]);
} else { } else {
$sql = " $sql = "
SELECT sm.value, mn.name, mn.unit, sm.created_at SELECT sm.value, mn.name, mn.unit, sm.created_at
FROM server_metrics sm FROM server_metrics sm
JOIN metric_names mn ON sm.metric_name_id = mn.id JOIN metric_names mn ON sm.metric_name_id = mn.id
WHERE sm.server_id = :id WHERE sm.server_id = :id
AND sm.created_at >= DATE_SUB(NOW(), {$interval}) AND sm.created_at >= :start_date
ORDER BY sm.created_at DESC AND sm.created_at <= :end_date
ORDER BY sm.created_at ASC
"; ";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':id' => $id, ':start_date' => $startStr, ':end_date' => $endStr]);
} }
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':id' => $id]);
$metrics = $stmt->fetchAll(); $metrics = $stmt->fetchAll();
// Группируем метрики // Группируем метрики
@ -89,6 +115,25 @@ class ServerDetailController extends Model
$groupedMetrics[$metricName][] = $metric; $groupedMetrics[$metricName][] = $metric;
} }
// Сортируем метрики в фиксированном порядке: cpu_load → ram_used → disk_used → остальные
$priorityOrder = ['cpu_load', 'ram_used', 'disk_used'];
$sortedMetrics = [];
// Сначала добавляем приоритетные метрики в нужном порядке
foreach ($priorityOrder as $metricName) {
if (isset($groupedMetrics[$metricName])) {
$sortedMetrics[$metricName] = $groupedMetrics[$metricName];
unset($groupedMetrics[$metricName]);
}
}
// Затем добавляем остальные метрики (например, top_cpu_proc, top_ram_proc)
foreach ($groupedMetrics as $metricName => $metricData) {
$sortedMetrics[$metricName] = $metricData;
}
$groupedMetrics = $sortedMetrics;
// Пороги // Пороги
$stmt = $this->pdo->prepare(" $stmt = $this->pdo->prepare("
SELECT mt.warning_threshold, mt.critical_threshold, mt.duration, mn.name SELECT mt.warning_threshold, mt.critical_threshold, mt.duration, mn.name
@ -136,46 +181,53 @@ class ServerDetailController extends Model
'existingThresholds' => $existingThresholds, 'existingThresholds' => $existingThresholds,
'allServices' => $allServices, 'allServices' => $allServices,
'monitorServices' => $monitorServices, 'monitorServices' => $monitorServices,
'period' => $period, 'startDate' => $startDate->format('Y-m-d\T H:i'),
'zoom' => $zoom, 'endDate' => $endDate->format('Y-m-d\T H:i'),
'aggregation' => $aggConfig, 'aggregation' => $aggConfig,
'request' => $request->getQueryParams() 'totalMinutes' => $totalMinutes
]; ];
return $this->twig->render($response, 'servers/detail.twig', $templateData); return $this->twig->render($response, 'servers/detail.twig', $templateData);
} }
private function getAggregationConfig(string $period, ?string $zoom): array private function getAggregationConfigFromDates(DateTime $startDate, DateTime $endDate, int $totalMinutes): array
{ {
// Target: ~360 points on chart for any period/zoom // Target: ~400 points on chart for optimal performance
// Formula: aggregate_minutes = period_minutes / 360 // Formula: aggregate_minutes = total_minutes / 400
if ($zoom) { $targetPoints = 400;
return match($zoom) { $aggregateMinutes = ceil($totalMinutes / $targetPoints);
// 1h = 60 min / 360 = 0.17 → no aggregation (~360 points)
'1h' => ['interval' => 'INTERVAL 1 HOUR', 'groupBy' => null, 'aggregate_minutes' => 0, 'format' => '%Y-%m-%d %H:%i:%s'], // Определяем формат группировки на основе длительности агрегации
// 6h = 360 min / 360 = 1 min aggregation (~360 points) if ($aggregateMinutes <= 1) {
'6h' => ['interval' => 'INTERVAL 6 HOUR', 'groupBy' => "GROUP BY mn.id, DATE_FORMAT(sm.created_at, '%Y-%m-%d %H:%i')", 'aggregate_minutes' => 1, 'format' => '%Y-%m-%d %H:%i'], // Менее 1 минуты — без агрегации
// 24h = 1440 min / 360 = 4 min aggregation (~360 points) return [
'24h' => ['interval' => 'INTERVAL 24 HOUR', 'groupBy' => "GROUP BY mn.id, DATE_FORMAT(sm.created_at, '%Y-%m-%d %H:%i')", 'aggregate_minutes' => 4, 'format' => '%Y-%m-%d %H:%i'], 'groupBy' => null,
// 7d = 10080 min / 360 = 28 min aggregation (~360 points) 'format' => '%Y-%m-%d %H:%i:%s',
'7d' => ['interval' => 'INTERVAL 7 DAY', 'groupBy' => "GROUP BY mn.id, DATE_FORMAT(sm.created_at, '%Y-%m-%d %H:%i')", 'aggregate_minutes' => 28, 'format' => '%Y-%m-%d %H:%i'], 'aggregate_minutes' => 0
// 30d = 43200 min / 360 = 120 min (2 hours) aggregation (~360 points) ];
'30d' => ['interval' => 'INTERVAL 30 DAY', 'groupBy' => "GROUP BY mn.id, DATE_FORMAT(sm.created_at, '%Y-%m-%d %H:00')", 'aggregate_minutes' => 120, 'format' => '%Y-%m-%d %H:00'], } elseif ($aggregateMinutes < 60) {
default => ['interval' => 'INTERVAL 24 HOUR', 'groupBy' => null, 'aggregate_minutes' => 0, 'format' => '%Y-%m-%d %H:%i:%s'] // Минуты — группировка по минутам
}; return [
'groupBy' => "GROUP BY mn.id, DATE_FORMAT(sm.created_at, '%Y-%m-%d %H:%i')",
'format' => '%Y-%m-%d %H:%i',
'aggregate_minutes' => $aggregateMinutes
];
} elseif ($aggregateMinutes < 1440) {
// Часы — группировка по часам
return [
'groupBy' => "GROUP BY mn.id, DATE_FORMAT(sm.created_at, '%Y-%m-%d %H:00')",
'format' => '%Y-%m-%d %H:00',
'aggregate_minutes' => $aggregateMinutes
];
} else {
// Дни — группировка по дням
return [
'groupBy' => "GROUP BY mn.id, DATE_FORMAT(sm.created_at, '%Y-%m-%d')",
'format' => '%Y-%m-%d',
'aggregate_minutes' => $aggregateMinutes
];
} }
// Default: base period aggregation (~360 points)
return match($period) {
// 24h = 1440 min / 360 = 4 min
'24h' => ['interval' => 'INTERVAL 24 HOUR', 'groupBy' => "GROUP BY mn.id, DATE_FORMAT(sm.created_at, '%Y-%m-%d %H:%i')", 'aggregate_minutes' => 4, 'format' => '%Y-%m-%d %H:%i'],
// 7d = 10080 min / 360 = 28 min
'7d' => ['interval' => 'INTERVAL 7 DAY', 'groupBy' => "GROUP BY mn.id, DATE_FORMAT(sm.created_at, '%Y-%m-%d %H:%i')", 'aggregate_minutes' => 28, 'format' => '%Y-%m-%d %H:%i'],
// 30d = 43200 min / 360 = 120 min
'30d' => ['interval' => 'INTERVAL 30 DAY', 'groupBy' => "GROUP BY mn.id, DATE_FORMAT(sm.created_at, '%Y-%m-%d %H:00')", 'aggregate_minutes' => 120, 'format' => '%Y-%m-%d %H:00'],
default => ['interval' => 'INTERVAL 24 HOUR', 'groupBy' => null, 'aggregate_minutes' => 0, 'format' => '%Y-%m-%d %H:%i:%s']
};
} }
public function saveThresholds(Request $request, Response $response, $args) public function saveThresholds(Request $request, Response $response, $args)

View File

@ -90,44 +90,50 @@
<div class="tab-pane fade show active" id="metrics" role="tabpanel"> <div class="tab-pane fade show active" id="metrics" role="tabpanel">
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-12"> <div class="col-md-12">
<!-- Отладка: period = {{ request.query.period }} --> <form method="get" class="row g-2 align-items-end" id="periodForm">
<!-- Отладка: period = {{ period }}, request = {{ request.period }} --> <input type="hidden" name="tab" value="metrics">
<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 часа <div class="col-md-3">
</a> <label class="form-label mb-1">
<a href="?tab=metrics&amp;period=7d" class="btn btn-outline-primary w-100 {% if period == '7d' %}active{% endif %}"> <i class="fas fa-clock"></i> Быстрый выбор
7 дней </label>
</a> <select class="form-select" id="presetSelect" onchange="applyPreset()">
<a href="?tab=metrics&amp;period=30d" class="btn btn-outline-primary w-100 {% if period == '30d' %}active{% endif %}"> <option value="">-- Выбрать --</option>
30 дней <option value="30">Последние 30 минут</option>
</a> <option value="60">Последние 1 час</option>
</div> <option value="120">Последние 2 часа</option>
</div> <option value="360">Последние 6 часов</option>
</div> <option value="720">Последние 12 часов</option>
<div class="row mt-2"> <option value="1440">Последние 24 часа</option>
<div class="col-md-12"> </select>
<small class="text-muted">Масштаб:</small> </div>
<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 %}"> <!-- Дата начала -->
авто <div class="col-md-3">
</a> <label class="form-label mb-1">
<a href="?tab=metrics&amp;period={{ period }}&amp;zoom=1h" class="btn btn-sm {% if zoom == '1h' %}btn-secondary{% else %}btn-outline-secondary{% endif %}"> <i class="fas fa-calendar"></i> С
</label>
</a> <input type="datetime-local" class="form-control" name="start" id="startDate"
<a href="?tab=metrics&amp;period={{ period }}&amp;zoom=6h" class="btn btn-sm {% if zoom == '6h' %}btn-secondary{% else %}btn-outline-secondary{% endif %}"> value="{{ startDate }}" required>
</div>
</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 %}"> <!-- Дата окончания -->
24ч <div class="col-md-3">
</a> <label class="form-label mb-1">
<a href="?tab=metrics&amp;period={{ period }}&amp;zoom=7d" class="btn btn-sm {% if zoom == '7d' %}btn-secondary{% else %}btn-outline-secondary{% endif %}"> <i class="fas fa-calendar"></i> По
</label>
</a> <input type="datetime-local" class="form-control" name="end" id="endDate"
<a href="?tab=metrics&amp;period={{ period }}&amp;zoom=30d" class="btn btn-sm {% if zoom == '30d' %}btn-secondary{% else %}btn-outline-secondary{% endif %}"> value="{{ endDate }}" required>
30д </div>
</a>
</div> <!-- Кнопки -->
<div class="col-md-3">
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-search"></i> Применить
</button>
</div>
</form>
</div> </div>
</div> </div>
@ -634,4 +640,37 @@ if (canvas{{ metricName|replace({'-': '_', '.': '_'}) }}) {
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</script> </script>
<script>
// Применение пресета
function applyPreset() {
var select = document.getElementById("presetSelect");
var minutes = parseInt(select.value);
if (!minutes) return;
var now = new Date();
var startDate = new Date(now.getTime() - (minutes * 60 * 1000));
// Форматируем для datetime-local (YYYY-MM-DDTHH:MM)
function formatDateTimeLocal(date) {
var year = date.getFullYear();
var month = String(date.getMonth() + 1).padStart(2, "0");
var day = String(date.getDate()).padStart(2, "0");
var hours = String(date.getHours()).padStart(2, "0");
var mins = String(date.getMinutes()).padStart(2, "0");
return year + "-" + month + "-" + day + "T" + hours + ":" + mins;
}
document.getElementById("startDate").value = formatDateTimeLocal(startDate);
document.getElementById("endDate").value = formatDateTimeLocal(now);
}
// Автоотправка формы при изменении дат (опционально)
// document.getElementById("startDate").addEventListener("change", function() {
// document.getElementById("periodForm").submit();
// });
// document.getElementById("endDate").addEventListener("change", function() {
// document.getElementById("periodForm").submit();
// });
</script>
{% endblock %} {% endblock %}