mirvmon/templates/servers/detail.twig

886 lines
51 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 mt-2 mb-2">
<div class="col-md-12">
<small class="text-muted">Период:</small>
</div>
<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=6h" class="btn btn-outline-primary w-100 {% if period == '6h' or period is empty %}active{% endif %}">
</a>
<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=7d" class="btn btn-outline-primary w-100 {% if period == '7d' %}active{% endif %}">
</a>
<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>
<div class="row">
{% 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_") %}
<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>
<!-- Графики сетевых интерфейсов -->
{% set net_interfaces = [] %}
{% for metricName in metrics|keys %}
{% if metricName starts with 'net_in_' %}
{% set iface = metricName|replace({'net_in_': ''}) %}
{% set net_interfaces = net_interfaces|merge([iface]) %}
{% endif %}
{% endfor %}
{% for iface in net_interfaces %}
{% if metrics['net_in_' ~ iface] is defined and metrics['net_out_' ~ iface] is defined %}
<div class="row">
<div class="col-12 mb-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="fas fa-network-wired"></i> Сеть: {{ iface }}</h6>
</div>
<div class="card-body">
<canvas id="chart-net-{{ iface }}" width="100%" height="200"></canvas>
</div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
<!-- Диски: Doughnut графики -->
<div class="row mb-3">
{% for metricName, metricData in metrics %}
{% if metricName starts with 'disk_used_' and metricName != 'disk_used' %}
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<h6 class="card-title mb-2">
{% if metricName == 'disk_used_root' %}/ (корень)
{% elseif metricName == 'disk_used_home' %}/home
{% elseif metricName == 'disk_used_boot' %}/boot
{% elseif metricName == 'disk_used_mnt_data' %}/mnt/data
{% else %}{{ metricName|replace({'disk_used_': '', '_': ' '})|title }}
{% endif %}
</h6>
{% set pct = metricData[0].value|round(1) %}
{% set iface = metricName|replace({'disk_used_': ''}) %}
{% set totalGB = metrics['disk_total_gb_' ~ iface][0].value|default(0) %}
{% set usedGB = (pct / 100 * totalGB)|round(1) %}
{% set freeGB = (totalGB - usedGB)|round(1) %}
<div class="mb-1">
<span class="badge bg-success">Свободно: {{ freeGB }} ГБ</span>
<span class="badge bg-danger ms-1">Занято: {{ usedGB }} ГБ</span>
</div>
<p class="text-muted small mb-1">{{ pct }}% из {{ totalGB }} ГБ</p>
<p class="text-muted small">{{ metricData[0].created_at|date('d.m.Y H:i') }}</p>
<div style="max-width: 150px; margin: 0 auto;"><canvas id="chart-{{ metricName }}"></canvas></div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</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 src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
<script src="/chartjs-plugin-zoom.min.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.cmdline || 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.cmdline || 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();
}
});
// Параметры системы
var ramTotalGB = {{ metrics['ram_total_gb'] is defined ? metrics['ram_total_gb'][0].value : 0 }};
var diskTotalGB = {
{% for m, _data in metrics %}
{% if m starts with 'disk_total_gb_' %}
'{{ m|replace({'disk_total_gb_': ''}) }}': {{ metrics[m][0].value|default(0) }},
{% endif %}
{% endfor %}
};
// Графики метрик
{% 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_") %}
const ctx{{ metricName|replace({'-': '_', '.': '_'}) }} = document.getElementById('chart-{{ metricName }}').getContext('2d');
// Подготовка данных для графика
var labels{{ metricName }} = [];
var data{{ metricName }} = [];
{% 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) }}');
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 }},
{% if metricName == 'cpu_load' %}{% set lineColor = 'rgba(54, 162, 235, 1)' %}{% set fillColor = 'rgba(54, 162, 235, 0.15)' %}{% elseif metricName == 'ram_used' %}{% set lineColor = 'rgba(153, 102, 255, 1)' %}{% set fillColor = 'rgba(153, 102, 255, 0.15)' %}{% elseif metricName starts with 'disk_used_' %}{% set lineColor = 'rgba(255, 159, 64, 1)' %}{% set fillColor = 'rgba(255, 159, 64, 0.15)' %}{% else %}{% set lineColor = 'rgba(75, 192, 192, 1)' %}{% set fillColor = 'rgba(75, 192, 192, 0.15)' %}{% endif %}
borderColor: '{{ lineColor }}',
backgroundColor: '{{ fillColor }}',
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) {
tooltipEl.style.opacity = 0;
return;
}
// Проверка: курсор внутри chartArea (все 4 границы)
var chartArea = context.chart.chartArea;
if (chartArea && context.tooltip.caretX !== undefined && context.tooltip.caretY !== undefined) {
if (context.tooltip.caretX < chartArea.left || context.tooltip.caretX > chartArea.right ||
context.tooltip.caretY < chartArea.top || context.tooltip.caretY > chartArea.bottom) {
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);
{% if metricName == 'ram_used' %}
var ramPct = data{{ metricName }}[dataIndex];
var ramUsed = (ramPct / 100 * ramTotalGB).toFixed(1);
var ramFree = (ramTotalGB - ramUsed).toFixed(1);
lines.push('Всего: ' + ramTotalGB.toFixed(1) + ' ГБ');
lines.push('Занято: ' + ramUsed + ' ГБ');
lines.push('Свободно: ' + ramFree + ' ГБ');
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 iface = '{{ metricName }}'.replace('disk_used_', '');
var diskTotal = diskTotalGB[iface] || 0;
var diskUsed = (diskPct / 100 * diskTotal).toFixed(1);
var diskFree = (diskTotal - diskUsed).toFixed(1);
lines.push('Всего: ' + diskTotal.toFixed(1) + ' ГБ');
lines.push('Занято: ' + diskUsed + ' ГБ');
lines.push('Свободно: ' + diskFree + ' ГБ');
{% else %}
lines.push('Значение: ' + data{{ metricName }}[dataIndex]);
{% if metricName == 'cpu_load' %}
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 + '%');
});
}
{% endif %}
{% endif %}
// Show tooltip
var position = context.chart.canvas.getBoundingClientRect();
tooltipEl.innerHTML = lines.join('<br>');
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);
});
}
},
zoom: {
zoom: {
drag: {
enabled: true
},
pinch: {
enabled: true
},
wheel: {
enabled: true
},
mode: 'x'
},
pan: {
enabled: true,
mode: 'x'
}
}
}
}
});
// Скрывать tooltip при уходе курсора с canvas в любую сторону
chart{{ metricName|replace({'-': '_', '.': '_'}) }}.canvas.addEventListener('mouseleave', function() {
var tooltipEl = document.getElementById('chartjs-tooltip-{{ server.id }}-{{ metricName }}');
if (tooltipEl) {
tooltipEl.style.opacity = 0;
}
chart{{ metricName|replace({'-': '_', '.': '_'}) }}.tooltip._active = [];
chart{{ metricName|replace({'-': '_', '.': '_'}) }}.draw();
});
{% endif %}
{% endfor %}
// Глобальный обработчик mousemove для скрытия тултипов при уходе курсора за пределы canvas
// Глобальный обработчик для скрытия тултипов при уходе курсора за пределы 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_") %}
(function() {
var canvas = chart{{ metricName|replace({'-': '_', '.': '_'}) }}.canvas;
var rect = canvas.getBoundingClientRect();
var isOverCanvas = (e.clientX >= rect.left && e.clientX <= rect.right &&
e.clientY >= rect.top && e.clientY <= rect.bottom);
if (!isOverCanvas) {
var tooltipEl = document.getElementById('chartjs-tooltip-{{ server.id }}-{{ metricName }}');
if (tooltipEl && tooltipEl.style.opacity !== '0') {
tooltipEl.style.opacity = 0;
chart{{ metricName|replace({'-': '_', '.': '_'}) }}.tooltip._active = [];
chart{{ metricName|replace({'-': '_', '.': '_'}) }}.draw();
}
}
})();
{% endif %}
{% endfor %}
});
// Графики сетевых интерфейсов (две линии: In зелёная, Out красная)
{% set net_interfaces = [] %}
{% for metricName in metrics|keys %}
{% if metricName starts with 'net_in_' %}
{% set net_interfaces = net_interfaces|merge([metricName|replace({'net_in_': ''})]) %}
{% endif %}
{% endfor %}
{% for iface in net_interfaces %}
{% if metrics['net_in_' ~ iface] is defined and metrics['net_out_' ~ iface] is defined %}
(function() {
var ctx = document.getElementById('chart-net-{{ iface }}');
if (!ctx) return;
var labels = [];
var inData = [];
var outData = [];
{% for m in metrics['net_in_' ~ iface]|slice(0, 500)|reverse %}
labels.push('{{ m.created_at|date("d.m H:i") }}');
inData.push({{ m.value }});
{% endfor %}
{% set outMetrics = metrics['net_out_' ~ iface]|slice(0, 500)|reverse %}
{% for m in outMetrics %}
outData.push({{ m.value }});
{% endfor %}
new Chart(ctx.getContext('2d'), {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Входящий (In)',
data: inData,
borderColor: 'rgba(25, 135, 84, 1)',
backgroundColor: 'rgba(25, 135, 84, 0.1)',
fill: true,
tension: 0.1,
pointRadius: 0,
borderWidth: 1.5
},
{
label: 'Исходящий (Out)',
data: outData,
borderColor: 'rgba(220, 53, 69, 1)',
backgroundColor: 'rgba(220, 53, 69, 0.1)',
fill: true,
tension: 0.1,
pointRadius: 0,
borderWidth: 1.5
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: true, position: 'top' },
tooltip: { enabled: true, mode: 'index', intersect: false },
zoom: {
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' },
pan: { enabled: true, mode: 'x' }
}
},
scales: {
y: { beginAtZero: true, ticks: { callback: function(v) { return v + '%'; } } }
}
}
});
})();
{% endif %}
{% endfor %}
// Doughnut графики для разделов дисков
{% for metricName, metricData in metrics %}
{% if metricName starts with 'disk_used_' and metricName != 'disk_used' %}
{% if metricData %}
(function() {
var ctx = document.getElementById('chart-{{ metricName }}');
if (ctx) {
var value = {{ metricData[0].value }};
var color = value > 80 ? 'rgba(220, 53, 69, 0.8)' : value > 60 ? 'rgba(255, 193, 7, 0.8)' : 'rgba(25, 135, 84, 0.8)';
var colorBorder = value > 80 ? 'rgba(220, 53, 69, 1)' : value > 60 ? 'rgba(255, 193, 7, 1)' : 'rgba(25, 135, 84, 1)';
new Chart(ctx.getContext('2d'), {
type: 'doughnut',
data: {
labels: ['Использовано', 'Свободно'],
datasets: [{
data: [value, 100 - value],
backgroundColor: [color, 'rgba(200, 200, 200, 0.3)'],
borderColor: [colorBorder, 'rgba(200, 200, 200, 0.5)'],
borderWidth: 1
}]
},
options: {
responsive: true,
cutout: '65%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(context) {
return context.label + ': ' + context.parsed.toFixed(1) + '%';
}
}
}
}
}
});
}
})();
{% endif %}
{% endif %}
{% endfor %}
// Сбросить зум на всех графиках
function resetAllZoom() {
{% 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_") %}
if (typeof chart{{ metricName|replace({'-': '_', '.': '_'}) }} !== 'undefined') {
chart{{ metricName|replace({'-': '_', '.': '_'}) }}.resetZoom();
}
{% endif %}
{% endfor %}
}
</script>
{% endblock %}