mirvmon/templates/servers/detail.twig

677 lines
41 KiB
Twig
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "layout.twig" %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h3>
<i class="fas fa-server"></i>
{{ server.name }}
{% if server.group_name %}
<span class="badge ms-2" {% if server.group_color %}style="background-color: {{ server.group_color }}"{% endif %}>
<i class="fas {{ server.group_icon|default('fa-box') }} me-1"></i>{{ server.group_name }}
</span>
{% endif %}
</h3>
<div>
<a href="/servers/{{ server.id }}/edit" class="btn btn-outline-primary me-2">
<i class="fas fa-edit"></i> <span class="d-none d-sm-inline">Редактировать</span>
</a>
<a href="/servers" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> <span class="d-none d-sm-inline">Назад к списку</span>
</a>
</div>
</div>
<div class="card-body">
<!-- Информация о сервере -->
<div class="row mb-4">
<div class="col-md-6">
<h5>Информация о сервере</h5>
<table class="table table-borderless">
<tr>
<td><strong>Название:</strong></td>
<td>{{ server.name }}</td>
</tr>
<tr>
<td><strong>Адрес:</strong></td>
<td>{{ server.address|default('-') }}</td>
</tr>
<tr>
<td><strong>Группа:</strong></td>
<td>
{% if server.group_name %}
<i class="fas {{ server.group_icon|default('fa-box') }}" {% if server.group_color %}style="color: {{ server.group_color }}"{% endif %}></i> {{ server.group_name }}
{% else %}
-
{% endif %}
</td>
</tr>
<tr>
<td><strong>Описание:</strong></td>
<td>{{ server.description|default('-') }}</td>
</tr>
<tr>
<td><strong>Последние метрики:</strong></td>
<td>
{% if server.last_metrics_at %}
{{ server.last_metrics_at|date('d.m.Y H:i:s') }}
{% else %}
Нет данных
{% endif %}
</td>
</tr>
</table>
</div>
</div>
<!-- Вкладки -->
<ul class="nav nav-tabs" id="serverTabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" id="metrics-tab" data-bs-toggle="tab" data-bs-target="#metrics" type="button" role="tab">
<i class="fas fa-chart-line"></i> Метрики
</button>
</li>
<li class="nav-item">
<button class="nav-link" id="services-tab" data-bs-toggle="tab" data-bs-target="#services" type="button" role="tab">
<i class="fas fa-cogs"></i> Сервисы
</button>
</li>
<li class="nav-item">
<button class="nav-link" id="thresholds-tab" data-bs-toggle="tab" data-bs-target="#thresholds" type="button" role="tab">
<i class="fas fa-bell"></i> Пороги
</button>
</li>
</ul>
<!-- Содержимое вкладок -->
<div class="tab-content mt-3">
<!-- Вкладка "Метрики" -->
<div class="tab-pane fade show active" id="metrics" role="tabpanel">
<div class="row mb-3">
<div class="col-md-12">
<form method="get" class="row g-2 align-items-end" id="periodForm">
<input type="hidden" name="tab" value="metrics">
<!-- Пресеты -->
<div class="col-md-3">
<label class="form-label mb-1">
<i class="fas fa-clock"></i> Быстрый выбор
</label>
<select class="form-select" id="presetSelect" onchange="applyPreset()">
<option value="">-- Выбрать --</option>
<option value="30">Последние 30 минут</option>
<option value="60">Последние 1 час</option>
<option value="120">Последние 2 часа</option>
<option value="360">Последние 6 часов</option>
<option value="720">Последние 12 часов</option>
<option value="1440">Последние 24 часа</option>
</select>
</div>
<!-- Дата начала -->
<div class="col-md-3">
<label class="form-label mb-1">
<i class="fas fa-calendar"></i> С
</label>
<input type="datetime-local" class="form-control" name="start" id="startDate"
value="{{ startDate }}" required>
</div>
<!-- Дата окончания -->
<div class="col-md-3">
<label class="form-label mb-1">
<i class="fas fa-calendar"></i> По
</label>
<input type="datetime-local" class="form-control" name="end" id="endDate"
value="{{ endDate }}" required>
</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 class="row">
{% for metricName, metricData in metrics %}
{% if metricName!="top_cpu_proc" and metricName!="top_ram_proc" %}
<div class="col-12 mb-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0">
{{ metricName|replace({'_': ' ', 'load': 'загрузка', 'used': 'использование'})|title }}
{% if metricData[0].unit %}<small class="text-muted">({{ metricData[0].unit }})</small>{% endif %}
</h6>
</div>
<div class="card-body">
{% if metricData %}
<h3 class="text-center text-end">{{ metricData[0].value }}{{ metricData[0].unit|default('') }}</h3>
<p class="text-muted text-center mb-2">
{{ metricData[0].created_at|date('d.m.Y H:i:s') }}
</p>
<!-- График для метрики -->
<div>
<canvas id="chart-{{ metricName }}" width="100%" height="200"></canvas>
</div>
{% else %}
<p class="text-center text-muted">Нет данных за этот период</p>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% endfor %}
{% if metrics|length == 0 %}
<div class="col-12">
<div class="alert alert-info text-center">
<i class="fas fa-info-circle"></i> Нет данных о метриках за выбранный период
</div>
</div>
{% endif %}
</div>
</div>
<!-- Вкладка "Сервисы" -->
<div class="tab-pane fade" id="services" role="tabpanel">
<div class="row mb-3">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h4>
<i class="fas fa-cogs"></i> Сервисы сервера
{% if allServices is defined %}
<small class="text-muted">(найдено: {{ allServices|length }})</small>
{% endif %}
</h4>
</div>
<div>
<a href="?tab=services" class="btn btn-outline-primary">
<i class="fas fa-sync-alt"></i> <span class="d-none d-sm-inline">Обновить список</span>
</a>
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-12">
<form method="post" action="/servers/{{ server.id }}/services">
<div class="card">
<div class="card-body">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="selectAllServices">
<label class="form-check-label" for="selectAllServices">
Выбрать все
</label>
</div>
<hr>
<div class="row" id="servicesList">
{% if allServices is defined and allServices|length > 0 %}
{# Сортируем сервисы: сначала running, потом stopped, потом unknown, затем по имени #}
{% set runningServices = allServices|filter(s => s.status == 'running')|sort((a, b) => a.service_name|lower > b.service_name|lower) %}
{% set stoppedServices = allServices|filter(s => s.status == 'stopped')|sort((a, b) => a.service_name|lower > b.service_name|lower) %}
{% set unknownServices = allServices|filter(s => s.status != 'running' and s.status != 'stopped')|sort((a, b) => a.service_name|lower > b.service_name|lower) %}
{# Выводим все сервисы по порядку #}
{% for service in runningServices %}
<div class="col-md-4 col-lg-3 mb-2">
<div class="card border-success">
<div class="card-body p-2">
<div class="d-flex justify-content-between align-items-start">
<div class="form-check mb-0">
<input class="form-check-input service-checkbox"
type="checkbox"
id="service_{{ service.service_name }}"
name="services[]"
value="{{ service.service_name }}"
{% if service.service_name in monitorServices %}
checked
{% endif %}>
<label class="form-check-label" for="service_{{ service.service_name }}">
<i class="fas fa-check-circle text-success me-1"></i>
{{ service.service_name }}
</label>
</div>
<div class="text-end">
<span class="badge bg-success mb-1">running</span>
</div>
</div>
<div class="small text-muted mt-1">
<small>Load: {{ service.load_state|default('-') }} | Active: {{ service.active_state|default('-') }}</small>
</div>
</div>
</div>
</div>
{% endfor %}
{% for service in stoppedServices %}
<div class="col-md-4 col-lg-3 mb-2">
<div class="card border-danger">
<div class="card-body p-2">
<div class="d-flex justify-content-between align-items-start">
<div class="form-check mb-0">
<input class="form-check-input service-checkbox"
type="checkbox"
id="service_{{ service.service_name }}"
name="services[]"
value="{{ service.service_name }}"
{% if service.service_name in monitorServices %}
checked
{% endif %}>
<label class="form-check-label" for="service_{{ service.service_name }}">
<i class="fas fa-times-circle text-danger me-1"></i>
{{ service.service_name }}
</label>
</div>
<div class="text-end">
<span class="badge bg-danger mb-1">stopped</span>
</div>
</div>
<div class="small text-muted mt-1">
<small>Load: {{ service.load_state|default('-') }} | Active: {{ service.active_state|default('-') }}</small>
</div>
</div>
</div>
</div>
{% endfor %}
{% for service in unknownServices %}
<div class="col-md-4 col-lg-3 mb-2">
<div class="card border-warning">
<div class="card-body p-2">
<div class="d-flex justify-content-between align-items-start">
<div class="form-check mb-0">
<input class="form-check-input service-checkbox"
type="checkbox"
id="service_{{ service.service_name }}"
name="services[]"
value="{{ service.service_name }}"
{% if service.service_name in monitorServices %}
checked
{% endif %}>
<label class="form-check-label" for="service_{{ service.service_name }}">
<i class="fas fa-question-circle text-warning me-1"></i>
{{ service.service_name }}
</label>
</div>
<div class="text-end">
<span class="badge bg-warning mb-1">unknown</span>
</div>
</div>
<div class="small text-muted mt-1">
<small>Load: {{ service.load_state|default('-') }} | Active: {{ service.active_state|default('-') }}</small>
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-12">
<div class="alert alert-warning text-center">
<i class="fas fa-exclamation-triangle"></i> Агент не отправил список сервисов или не установлен
</div>
</div>
<div class="col-12 text-center mt-3">
<button type="button" class="btn btn-outline-primary" onclick="requestServices()">
<i class="fas fa-download"></i> Запросить список сервисов
</button>
</div>
{% endif %}
</div>
</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end mt-3">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Сохранить конфигурацию
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Вкладка "Пороги" -->
<div class="tab-pane fade" id="thresholds" role="tabpanel">
<h4>Настройка порогов</h4>
<div class="row">
<div class="col-md-8">
<form method="post" action="/servers/{{ server.id }}/thresholds">
{% for metricType in allMetricTypes %}
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0">
{{ metricType.name|replace({'_': ' ', 'load': 'загрузка', 'used': 'использование'})|title }}
{% if metricType.unit %}<small class="text-muted">({{ metricType.unit }})</small>{% endif %}
</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<label class="form-label">Порог предупреждения</label>
<input type="number" class="form-control"
name="{{ metricType.name }}_warning"
step="0.01"
{% if existingThresholds[metricType.name].warning is defined %}
value="{{ existingThresholds[metricType.name].warning }}"
{% endif %}
placeholder="80.00">
</div>
<div class="col-md-6">
<label class="form-label">Порог критический</label>
<input type="number" class="form-control"
name="{{ metricType.name }}_critical"
step="0.01"
{% if existingThresholds[metricType.name].critical is defined %}
value="{{ existingThresholds[metricType.name].critical }}"
{% endif %}
placeholder="90.00">
</div>
</div>
<div class="row mt-3">
<div class="col-md-12">
<label class="form-label">
<i class="fas fa-clock"></i> Длительность превышения (минуты)
</label>
<input type="number" class="form-control"
name="{{ metricType.name }}_duration"
min="0"
step="1"
{% if existingThresholds[metricType.name].duration is defined %}
value="{{ existingThresholds[metricType.name].duration }}"
{% endif %}
placeholder="0 - отправлять алерт сразу">
<small class="text-muted">
0 = алерт сразу при превышении, >0 = алерт только если превышено дольше указанного времени
</small>
</div>
</div>
</div>
</div>
{% endfor %}
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Сохранить пороги
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
// Функция для получения топ-процессов для указанного времени
function fetchProcesses(serverId, time) {
return new Promise(function(resolve) {
var fullTime = time;
if (time && time.indexOf('-') === -1) {
var now = new Date();
var year = now.getFullYear();
var month = String(now.getMonth() + 1).padStart(2, '0');
var day = String(now.getDate()).padStart(2, '0');
fullTime = year + '-' + month + '-' + day + ' ' + time;
}
fetch('/api/v1/agent/' + serverId + '/processes?time=' + encodeURIComponent(fullTime))
.then(response => response.json())
.then(data => {
var lines = [];
// Добавляем топ-процессы CPU
if (data.top_cpu && data.top_cpu.length > 0) {
lines.push('');
lines.push('🏆 Топ CPU:');
data.top_cpu.forEach(function(proc) {
lines.push(' ' + proc.name + ': ' + proc.value + '%');
});
}
// Добавляем топ-процессы RAM
if (data.top_ram && data.top_ram.length > 0) {
lines.push('');
lines.push('💾 Топ RAM:');
data.top_ram.forEach(function(proc) {
lines.push(' ' + proc.name + ': ' + proc.value + '%');
});
}
resolve(lines);
})
.catch(function() {
resolve([]);
});
});
}
// Функция для получения списка сервисов
function requestServices() {
fetch('/api/v1/agent/{{ server.id }}/services')
.then(response => response.json())
.then(data => {
if (data.services) {
location.reload();
}
})
.catch(error => {
alert('Ошибка получения списка сервисов: ' + error);
});
}
// Обработчик кнопки "Выбрать все"
document.getElementById('selectAllServices').addEventListener('change', function() {
const checkboxes = document.querySelectorAll('.service-checkbox');
checkboxes.forEach(checkbox => {
checkbox.checked = this.checked;
});
});
// Активация нужной вкладки при загрузке
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const activeTab = urlParams.get('tab') || 'metrics';
// Находим кнопку нужной вкладки и кликаем
const tabButton = document.getElementById(activeTab + '-tab');
if (tabButton) {
tabButton.click();
}
});
// Графики метрик
{% for metricName, metricData in metrics %}
{% if metricName!="top_cpu_proc" and metricName!="top_ram_proc" %}
const ctx{{ metricName|replace({'-': '_', '.': '_'}) }} = document.getElementById('chart-{{ metricName }}').getContext('2d');
// Подготовка данных для графика
var labels{{ metricName }} = [];
var data{{ metricName }} = [];
{% for metric in metricData|slice(0, 400)|reverse %}
{% set time_val = metric.time_bucket|default(metric.created_at) %}
{% set time_format = metric.time_bucket and aggregation.aggregate_minutes >= 60 ? 'd.m H:i' : 'H:i' %}
labels{{ metricName }}.push('{{ time_val|date(time_format) }}');
data{{ metricName }}.push({{ metric.value|raw }});
{% endfor %}
const chart{{ metricName|replace({'-': '_', '.': '_'}) }} = new Chart(ctx{{ metricName|replace({'-': '_', '.': '_'}) }}, {
type: 'line',
data: {
labels: labels{{ metricName }},
datasets: [{
label: '{{ metricName|replace({'_': ' ', 'load': 'загрузка', 'used': 'использование'})|title }}',
data: data{{ metricName }},
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: false
}
},
plugins: {
tooltip: {
enabled: false,
mode: 'index',
intersect: false,
{% set metricType = metricName %}
external: function(context) {
// Tooltip element
var tooltipEl = document.getElementById('chartjs-tooltip-' + {{ server.id }} + '-' + '{{ metricName }}');
if (!tooltipEl) {
tooltipEl = document.createElement('div');
tooltipEl.id = 'chartjs-tooltip-' + {{ server.id }} + '-' + '{{ metricName }}';
tooltipEl.style.opacity = 0;
tooltipEl.style.position = 'absolute';
tooltipEl.style.background = 'rgba(0,0,0,0.7)';
tooltipEl.style.color = 'white';
tooltipEl.style.borderRadius = '3px';
tooltipEl.style.padding = '10px';
tooltipEl.style.fontSize = '12px';
tooltipEl.style.pointerEvents = 'none';
document.body.appendChild(tooltipEl);
}
// Прячем если курсор ушел с графика
if (!context.tooltip._active || context.tooltip._active.length === 0 || canvas{{ metricName|replace({'-': '_', '.': '_'}) }}._tooltipHidden) {
tooltipEl.style.opacity = 0;
return;
}
var dataIndex = context.tooltip._active[0].index;
var time = labels{{ metricName }}[dataIndex];
// Fetch processes
fetch('/api/v1/agent/' + {{ server.id }} + '/processes?time=' + encodeURIComponent(time))
.then(response => response.json())
.then(data => {
var lines = [];
lines.push('Время: ' + time);
lines.push('Значение: ' + data{{ metricName }}[dataIndex]);
{% if metricName == 'cpu_load' %}
// Показываем только top_cpu
if (data.top_cpu && data.top_cpu.length > 0) {
lines.push('');
lines.push('TOP CPU:');
data.top_cpu.forEach(function(proc) {
lines.push(' ' + proc.name + ': ' + proc.value + '%');
});
}
{% elseif metricName == 'ram_used' %}
// Показываем только top_ram
if (data.top_ram && data.top_ram.length > 0) {
lines.push('');
lines.push('TOP RAM:');
data.top_ram.forEach(function(proc) {
lines.push(' ' + proc.name + ': ' + proc.value + '%');
});
}
{% endif %}
// Show tooltip
var position = context.chart.canvas.getBoundingClientRect();
tooltipEl.innerHTML = lines.join('<br>');
tooltipEl.style.visibility = 'visible';
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);
});
}
}
}
}
});
// Скрывать tooltip при уходе курсора с canvas в любую сторону
var canvas{{ metricName|replace({'-': '_', '.': '_'}) }} = document.getElementById('chart-{{ metricName }}');
if (canvas{{ metricName|replace({'-': '_', '.': '_'}) }}) {
canvas{{ metricName|replace({'-': '_', '.': '_'}) }}._tooltipHidden = false;
canvas{{ metricName|replace({'-': '_', '.': '_'}) }}.addEventListener('mouseout', function() {
canvas{{ metricName|replace({'-': '_', '.': '_'}) }}._tooltipHidden = true;
var tooltipEl = document.getElementById('chartjs-tooltip-{{ server.id }}-{{ metricName }}');
if (tooltipEl) {
tooltipEl.style.opacity = '0';
}
});
canvas{{ metricName|replace({'-': '_', '.': '_'}) }}.addEventListener('mouseleave', function() {
canvas{{ metricName|replace({'-': '_', '.': '_'}) }}._tooltipHidden = true;
var tooltipEl = document.getElementById('chartjs-tooltip-{{ server.id }}-{{ metricName }}');
if (tooltipEl) {
tooltipEl.style.opacity = '0';
}
});
canvas{{ metricName|replace({'-': '_', '.': '_'}) }}.addEventListener('mousemove', function() {
canvas{{ metricName|replace({'-': '_', '.': '_'}) }}._tooltipHidden = false;
});
}
{% endif %}
{% endfor %}
</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 %}