1052 lines
61 KiB
Twig
Executable File
1052 lines
61 KiB
Twig
Executable File
{% 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&period=1h" class="btn btn-outline-primary w-100 {% if period == '1h' %}active{% endif %}">
|
||
1ч
|
||
</a>
|
||
<a href="?tab=metrics&period=6h" class="btn btn-outline-primary w-100 {% if period == '6h' or period is empty %}active{% endif %}">
|
||
6ч
|
||
</a>
|
||
<a href="?tab=metrics&period=24h" class="btn btn-outline-primary w-100 {% if period == '24h' %}active{% endif %}">
|
||
24ч
|
||
</a>
|
||
<a href="?tab=metrics&period=7d" class="btn btn-outline-primary w-100 {% if period == '7d' %}active{% endif %}">
|
||
7д
|
||
</a>
|
||
<a href="?tab=metrics&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_") and metricName!="network_rx" and metricName!="network_tx" and not (metricName starts with "temp_") %}
|
||
<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|round(2) }}{{ 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 %}
|
||
|
||
{% if net_interfaces|length > 0 %}
|
||
{% 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 %}
|
||
{% else %}
|
||
<div class="alert alert-warning mb-4">
|
||
<i class="fas fa-network-wired"></i> Данные о сетевых интерфейсах не получены
|
||
</div>
|
||
{% endif %}
|
||
|
||
|
||
|
||
|
||
<!-- Температуры: один общий график -->
|
||
{% set has_temps = false %}
|
||
{% for metricName in metrics|keys %}
|
||
{% if metricName starts with 'temp_' %}
|
||
{% set has_temps = true %}
|
||
{% endif %}
|
||
{% endfor %}
|
||
|
||
<div class="row mb-4">
|
||
<div class="col-12">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6 class="mb-0"><i class="fas fa-thermometer-half"></i> Температуры</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if has_temps %}
|
||
<canvas id="chart-temperatures" width="100%" height="300"></canvas>
|
||
{% else %}
|
||
<div class="alert alert-info mb-0">
|
||
<i class="fas fa-thermometer-half"></i> Температурные датчики недоступны (возможно виртуальный сервер)
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Диски: Doughnut графики -->
|
||
{% set has_disk_parts = false %}
|
||
{% for metricName in metrics|keys %}
|
||
{% if metricName starts with 'disk_used_' and metricName != 'disk_used' %}
|
||
{% set has_disk_parts = true %}
|
||
{% endif %}
|
||
{% endfor %}
|
||
|
||
<div class="row mb-3">
|
||
{% if has_disk_parts %}
|
||
{% 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 %}
|
||
{% else %}
|
||
<div class="col-12">
|
||
<div class="alert alert-warning">
|
||
<i class="fas fa-hdd"></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>
|
||
<p class="text-muted mb-3">
|
||
<i class="fas fa-info-circle"></i> 0 = алерт сразу при превышении, >0 = алерт только если превышено дольше указанного времени. Оставьте поле пустым для отключения порога.
|
||
</p>
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<form method="post" action="/servers/{{ server.id }}/thresholds">
|
||
{% for metricType in allMetricTypes %}
|
||
{% set metricUnit = '%' %}
|
||
{% set metricLabel = metricType.name %}
|
||
{% if metricType.name starts with 'temp_' %}
|
||
{% set metricUnit = '°C' %}
|
||
{% set metricLabel = 'Температура ' ~ (metricType.name|replace({'temp_': '', '_': ' '}))|title %}
|
||
{% elseif metricType.name == 'cpu_load' %}
|
||
{% set metricLabel = 'Загрузка CPU' %}
|
||
{% elseif metricType.name == 'ram_used' %}
|
||
{% set metricLabel = 'Использование RAM' %}
|
||
{% elseif metricType.name starts with 'disk_used_' %}
|
||
{% set iface = metricType.name|replace({'disk_used_': ''}) %}
|
||
{% if iface == 'root' %}{% set metricLabel = 'Диск (корень /)' %}
|
||
{% elseif iface == 'home' %}{% set metricLabel = 'Диск (/home)' %}
|
||
{% elseif iface == 'boot' %}{% set metricLabel = 'Диск (/boot)' %}
|
||
{% elseif iface == 'mnt_data' %}{% set metricLabel = 'Диск (/mnt/data)' %}
|
||
{% else %}{% set metricLabel = 'Диск (/' ~ (iface|replace({'_': '/'})) ~ ')' %}
|
||
{% endif %}
|
||
{% elseif metricType.name starts with 'net_in_' %}
|
||
{% set iface = metricType.name|replace({'net_in_': ''}) %}
|
||
{% set metricLabel = 'Сеть входящая (' ~ iface ~ ')' %}
|
||
{% elseif metricType.name starts with 'net_out_' %}
|
||
{% set iface = metricType.name|replace({'net_out_': ''}) %}
|
||
{% set metricLabel = 'Сеть исходящая (' ~ iface ~ ')' %}
|
||
{% endif %}
|
||
<div class="card mb-2">
|
||
<div class="card-body py-2">
|
||
<div class="row align-items-center">
|
||
<div class="col-md-3 mb-2 mb-md-0">
|
||
<strong>{{ metricLabel }}</strong>
|
||
<small class="text-muted">({{ metricUnit }})</small>
|
||
</div>
|
||
<div class="col-md-3 mb-2 mb-md-0">
|
||
<div class="input-group input-group-sm" title="Порог предупреждения">
|
||
<span class="input-group-text" title="Порог предупреждения"><i class="fas fa-exclamation-triangle text-warning"></i></span>
|
||
<input type="number" class="form-control" style="max-width: 90px;"
|
||
name="{{ metricType.name }}_warning"
|
||
step="0.01"
|
||
placeholder="80"
|
||
title="Порог предупреждения"
|
||
{% if existingThresholds[metricType.name].warning is defined %}
|
||
value="{{ existingThresholds[metricType.name].warning }}"
|
||
{% endif %}>
|
||
<span class="input-group-text">{{ metricUnit }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3 mb-2 mb-md-0">
|
||
<div class="input-group input-group-sm" title="Порог критический">
|
||
<span class="input-group-text" title="Порог критический"><i class="fas fa-exclamation-circle text-danger"></i></span>
|
||
<input type="number" class="form-control" style="max-width: 90px;"
|
||
name="{{ metricType.name }}_critical"
|
||
step="0.01"
|
||
placeholder="90"
|
||
title="Порог критический"
|
||
{% if existingThresholds[metricType.name].critical is defined %}
|
||
value="{{ existingThresholds[metricType.name].critical }}"
|
||
{% endif %}>
|
||
<span class="input-group-text">{{ metricUnit }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="input-group input-group-sm" title="Длительность превышения (минуты)">
|
||
<span class="input-group-text" title="Длительность превышения"><i class="fas fa-clock"></i></span>
|
||
<input type="number" class="form-control" style="max-width: 90px;"
|
||
name="{{ metricType.name }}_duration"
|
||
min="0"
|
||
step="1"
|
||
placeholder="0"
|
||
title="Длительность превышения (минуты)"
|
||
{% if existingThresholds[metricType.name].duration is defined %}
|
||
value="{{ existingThresholds[metricType.name].duration }}"
|
||
{% endif %}>
|
||
<span class="input-group-text">мин</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
|
||
<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>
|
||
</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 : 'null' }};
|
||
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_") and metricName!="network_rx" and metricName!="network_tx" and not (metricName starts with "temp_") %}
|
||
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];
|
||
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('Свободно: ' + 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 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_") and metricName!="network_rx" and metricName!="network_tx" and not (metricName starts with "temp_") %}
|
||
(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 %}
|
||
|
||
|
||
// График температур — ВСЕ temp_* на одном графике
|
||
(function() {
|
||
var ctx = document.getElementById('chart-temperatures');
|
||
if (!ctx) return;
|
||
|
||
var tempColors = [
|
||
{ border: 'rgba(255, 99, 132, 1)', bg: 'rgba(255, 99, 132, 0.1)' },
|
||
{ border: 'rgba(75, 192, 192, 1)', bg: 'rgba(75, 192, 192, 0.1)' },
|
||
{ border: 'rgba(255, 159, 64, 1)', bg: 'rgba(255, 159, 64, 0.1)' },
|
||
{ border: 'rgba(255, 205, 86, 1)', bg: 'rgba(255, 205, 86, 0.1)' },
|
||
{ border: 'rgba(201, 203, 207, 1)', bg: 'rgba(201, 203, 207, 0.1)' },
|
||
{ border: 'rgba(54, 162, 235, 1)', bg: 'rgba(54, 162, 235, 0.1)' },
|
||
{ border: 'rgba(153, 102, 255, 1)', bg: 'rgba(153, 102, 255, 0.1)' },
|
||
{ border: 'rgba(255, 99, 255, 1)', bg: 'rgba(255, 99, 255, 0.1)' },
|
||
{ border: 'rgba(100, 200, 100, 1)', bg: 'rgba(100, 200, 100, 0.1)' },
|
||
{ border: 'rgba(200, 150, 50, 1)', bg: 'rgba(200, 150, 50, 0.1)' }
|
||
];
|
||
|
||
var labels = [];
|
||
var labelsFilled = false;
|
||
var datasets = [];
|
||
var colorIdx = 0;
|
||
|
||
{% for mn, md in metrics %}
|
||
{% if mn starts with 'temp_' %}
|
||
if (!labelsFilled) {
|
||
{% for p in md|slice(-1000) %}
|
||
labels.push('{{ p.time_bucket|default(p.created_at)|date("d.m H:i") }}');
|
||
{% endfor %}
|
||
labelsFilled = true;
|
||
}
|
||
|
||
(function() {
|
||
var data = [];
|
||
{% for p in md|slice(-1000) %}
|
||
data.push({{ p.value }});
|
||
{% endfor %}
|
||
|
||
var color = tempColors[colorIdx % tempColors.length];
|
||
colorIdx++;
|
||
|
||
datasets.push({
|
||
label: '{{ mn|replace({'temp_': '', '_': ' '})|title }}',
|
||
data: data,
|
||
borderColor: color.border,
|
||
backgroundColor: color.bg,
|
||
fill: false,
|
||
tension: 0.1,
|
||
pointRadius: 1,
|
||
borderWidth: 2
|
||
});
|
||
})();
|
||
{% endif %}
|
||
{% endfor %}
|
||
|
||
var chart = new Chart(ctx.getContext('2d'), {
|
||
type: 'line',
|
||
data: { labels: labels, datasets: datasets },
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: { mode: 'index', intersect: false },
|
||
plugins: {
|
||
legend: { display: true, position: 'top', labels: { boxWidth: 12, font: { size: 11 } } },
|
||
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: false, ticks: { callback: function(v) { return v + '°C'; } } } }
|
||
}
|
||
});
|
||
|
||
window.chartTemperatures = chart;
|
||
})();
|
||
|
||
// 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_") and metricName!="network_rx" and metricName!="network_tx" and not (metricName starts with "temp_") %}
|
||
if (typeof chart{{ metricName|replace({'-': '_', '.': '_'}) }} !== 'undefined') {
|
||
chart{{ metricName|replace({'-': '_', '.': '_'}) }}.resetZoom();
|
||
}
|
||
{% endif %}
|
||
{% endfor %}
|
||
if (typeof window.chartTemperatures !== 'undefined') {
|
||
window.chartTemperatures.resetZoom();
|
||
}
|
||
}
|
||
|
||
</script>
|
||
{% endblock %}
|