761 lines
45 KiB
Twig
761 lines
45 KiB
Twig
{% 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 flex-wrap gap-2">
|
||
<h3 class="mb-0">
|
||
<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">
|
||
<ul class="nav nav-tabs mb-4" role="tablist">
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link active" id="metrics-tab" data-bs-toggle="tab" data-bs-target="#metrics" type="button" role="tab" aria-controls="metrics" aria-selected="true">
|
||
<i class="fas fa-chart-line me-1"></i> Метрики
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="services-tab" data-bs-toggle="tab" data-bs-target="#services" type="button" role="tab" aria-controls="services" aria-selected="false">
|
||
<i class="fas fa-cogs me-1"></i> Сервисы
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="thresholds-tab" data-bs-toggle="tab" data-bs-target="#thresholds" type="button" role="tab" aria-controls="thresholds" aria-selected="false">
|
||
<i class="fas fa-bell me-1"></i> Пороги
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
|
||
<div class="tab-content">
|
||
<div class="tab-pane fade show active" id="metrics" role="tabpanel" aria-labelledby="metrics-tab">
|
||
<div class="card mb-4">
|
||
<div class="card-body">
|
||
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-end">
|
||
<div>
|
||
<div class="small text-muted mb-2">Период отображения</div>
|
||
<div class="btn-group flex-wrap" role="group" aria-label="Период">
|
||
<a href="?period=1h&tab=metrics" class="btn btn-sm {% if period == '1h' %}btn-primary{% else %}btn-outline-primary{% endif %}">1 час</a>
|
||
<a href="?period=6h&tab=metrics" class="btn btn-sm {% if period == '6h' %}btn-primary{% else %}btn-outline-primary{% endif %}">6 часов</a>
|
||
<a href="?period=24h&tab=metrics" class="btn btn-sm {% if period == '24h' %}btn-primary{% else %}btn-outline-primary{% endif %}">24 часа</a>
|
||
<a href="?period=7d&tab=metrics" class="btn btn-sm {% if period == '7d' %}btn-primary{% else %}btn-outline-primary{% endif %}">7 дней</a>
|
||
<a href="?period=30d&tab=metrics" class="btn btn-sm {% if period == '30d' %}btn-primary{% else %}btn-outline-primary{% endif %}">30 дней</a>
|
||
</div>
|
||
</div>
|
||
<form method="get" class="row g-2 align-items-end">
|
||
<input type="hidden" name="tab" value="metrics">
|
||
<div class="col-auto">
|
||
<label for="start" class="form-label small text-muted mb-1">Начало</label>
|
||
<input type="datetime-local" class="form-control form-control-sm" id="start" name="start" value="{{ startDate }}">
|
||
</div>
|
||
<div class="col-auto">
|
||
<label for="end" class="form-label small text-muted mb-1">Конец</label>
|
||
<input type="datetime-local" class="form-control form-control-sm" id="end" name="end" value="{{ endDate }}">
|
||
</div>
|
||
<div class="col-auto">
|
||
<button type="submit" class="btn btn-sm btn-outline-secondary">Применить</button>
|
||
</div>
|
||
<div class="col-auto">
|
||
<button type="button" class="btn btn-sm btn-outline-dark" onclick="resetAllZoom()">Сбросить зум</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row g-3 mb-4">
|
||
<div class="col-md-4">
|
||
<div class="card h-100">
|
||
<div class="card-body">
|
||
<div class="text-muted small mb-1">Последняя метрика</div>
|
||
<div class="fw-semibold">
|
||
{% if server.last_seen %}
|
||
{{ server.last_seen|date('d.m.Y H:i:s') }}
|
||
{% else %}
|
||
Нет данных
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="card h-100">
|
||
<div class="card-body">
|
||
<div class="text-muted small mb-1">Аптайм</div>
|
||
<div class="fw-semibold">
|
||
{% if uptimeText %}
|
||
{{ uptimeText }}
|
||
{% else %}
|
||
Нет данных
|
||
{% endif %}
|
||
</div>
|
||
{% if latestUptime and latestUptime.created_at %}
|
||
<div class="small text-muted mt-1">{{ latestUptime.created_at|date('d.m.Y H:i:s') }}</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="card h-100">
|
||
<div class="card-body">
|
||
<div class="text-muted small mb-1">Выбранный период</div>
|
||
<div class="fw-semibold">{{ totalMinutes }} мин</div>
|
||
<div class="small text-muted mt-1">{{ period }}{% if zoom %}, zoom: {{ zoom }}{% endif %}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% if summaryCards is not empty %}
|
||
<div class="row g-3 mb-4">
|
||
{% for card in summaryCards %}
|
||
<div class="col-md-6 col-xl">
|
||
<div class="card h-100">
|
||
<div class="card-body">
|
||
<div class="small text-muted mb-1">{{ card.title }}</div>
|
||
<div class="fw-semibold">{{ card.value }}</div>
|
||
<div class="small text-muted mt-1">{{ card.subtitle }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if not displayMetrics or displayMetrics is empty %}
|
||
<div class="alert alert-warning">
|
||
<i class="fas fa-info-circle"></i>
|
||
Метрики для отображения не выбраны. Настройте их в <a href="/servers/{{ server.id }}/edit">редактировании сервера</a>.
|
||
</div>
|
||
{% else %}
|
||
{% if simpleMetricCharts is empty and networkCharts is empty and temperatureChart.datasets is empty and diskCharts is empty %}
|
||
<div class="alert alert-info">
|
||
<i class="fas fa-info-circle"></i> Нет данных для выбранных метрик за указанный период.
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% for chart in simpleMetricCharts %}
|
||
<div class="card mb-4">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<h6 class="mb-0">{{ chart.title }}</h6>
|
||
<div class="text-end">
|
||
<div class="fw-semibold">{{ chart.lastValue }}{{ chart.unit }}</div>
|
||
{% if chart.id == 'ram_used' and chart.details %}
|
||
<div class="small text-muted">
|
||
Всего: {{ chart.details.totalGb }} ГБ | Занято: {{ chart.details.usedGb }} ГБ
|
||
</div>
|
||
<div class="small text-muted">
|
||
Свободно: {{ chart.details.freeGb }} ГБ
|
||
</div>
|
||
{% endif %}
|
||
<div class="small text-muted">{{ chart.lastTime }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div style="height: 260px;">
|
||
<canvas id="chart-{{ chart.id }}"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
|
||
{% for chart in networkCharts %}
|
||
<div class="card mb-4">
|
||
<div class="card-header">
|
||
<h6 class="mb-0"><i class="fas fa-network-wired me-1"></i>{{ chart.title }}</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div style="height: 260px;">
|
||
<canvas id="chart-net-{{ chart.id }}"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
|
||
<div class="card mb-4">
|
||
<div class="card-header">
|
||
<h6 class="mb-0"><i class="fas fa-thermometer-half me-1"></i>Температуры</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if temperatureChart.datasets is not empty %}
|
||
<div style="height: 320px;">
|
||
<canvas id="chart-temperatures"></canvas>
|
||
</div>
|
||
{% else %}
|
||
<div class="alert alert-info mb-0">
|
||
Температурные датчики не выбраны или по ним нет данных.
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
{% if diskCharts is not empty %}
|
||
{% for disk in diskCharts %}
|
||
<div class="col-md-6 col-xl-4 mb-4">
|
||
<div class="card h-100">
|
||
<div class="card-body text-center">
|
||
<h6 class="card-title">{{ disk.title }}</h6>
|
||
<div class="mb-3">
|
||
<span class="badge bg-primary">{{ disk.percent }}%</span>
|
||
</div>
|
||
{% if disk.totalGb %}
|
||
<div class="small text-muted mb-2">Всего: {{ disk.totalGb }} ГБ</div>
|
||
<div class="small mb-3">
|
||
<span class="text-danger">Занято: {{ disk.usedGb }} ГБ</span>
|
||
<span class="mx-1">|</span>
|
||
<span class="text-success">Свободно: {{ disk.freeGb }} ГБ</span>
|
||
</div>
|
||
{% endif %}
|
||
<div style="max-width: 180px; margin: 0 auto;">
|
||
<canvas id="disk-chart-{{ disk.id }}"></canvas>
|
||
</div>
|
||
<div class="small text-muted mt-3">{{ disk.updatedAt }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
{% elseif displayMetrics|filter(v => v starts with 'disk_used_')|length > 0 %}
|
||
<div class="col-12">
|
||
<div class="alert alert-info">
|
||
Данные по дискам выбраны, но за этот период не найдены.
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div class="tab-pane fade" id="services" role="tabpanel" aria-labelledby="services-tab">
|
||
<div class="row mb-3">
|
||
<div class="col-12">
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<h4 class="mb-0">
|
||
<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 %}
|
||
{% 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>
|
||
<span class="badge bg-success">running</span>
|
||
</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>
|
||
<span class="badge bg-danger">stopped</span>
|
||
</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>
|
||
<span class="badge bg-warning">unknown</span>
|
||
</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" aria-labelledby="thresholds-tab">
|
||
<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"><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" {% 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"><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" {% 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"><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" {% 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>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.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>
|
||
const managedCharts = [];
|
||
|
||
function requestServices() {
|
||
fetch('/api/v1/agent/{{ server.id }}/services')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.services) {
|
||
location.reload();
|
||
}
|
||
})
|
||
.catch(error => {
|
||
alert('Ошибка получения списка сервисов: ' + error);
|
||
});
|
||
}
|
||
|
||
function fetchProcesses(serverId, time) {
|
||
return fetch('/api/v1/agent/' + serverId + '/processes?time=' + encodeURIComponent(time))
|
||
.then(response => response.json())
|
||
.catch(() => ({ top_cpu: [], top_ram: [] }));
|
||
}
|
||
|
||
function renderProcessTooltip(context, chartMeta) {
|
||
let tooltipEl = document.getElementById('chartjs-tooltip-' + chartMeta.id);
|
||
if (!tooltipEl) {
|
||
tooltipEl = document.createElement('div');
|
||
tooltipEl.id = 'chartjs-tooltip-' + chartMeta.id;
|
||
tooltipEl.style.opacity = 0;
|
||
tooltipEl.style.position = 'absolute';
|
||
tooltipEl.style.background = 'rgba(33, 37, 41, 0.92)';
|
||
tooltipEl.style.color = '#fff';
|
||
tooltipEl.style.borderRadius = '6px';
|
||
tooltipEl.style.padding = '10px 12px';
|
||
tooltipEl.style.fontSize = '12px';
|
||
tooltipEl.style.pointerEvents = 'none';
|
||
tooltipEl.style.whiteSpace = 'pre-line';
|
||
tooltipEl.style.zIndex = 1080;
|
||
document.body.appendChild(tooltipEl);
|
||
}
|
||
|
||
const tooltip = context.tooltip;
|
||
if (!tooltip || tooltip.opacity === 0 || !tooltip.dataPoints || !tooltip.dataPoints.length) {
|
||
tooltipEl.style.opacity = 0;
|
||
return;
|
||
}
|
||
|
||
const point = tooltip.dataPoints[0];
|
||
const dataIndex = point.dataIndex;
|
||
const timestamp = chartMeta.timestamps[dataIndex] || chartMeta.labels[dataIndex];
|
||
const value = point.raw;
|
||
const valueText = `${point.dataset.label}: ${value}${chartMeta.unit || ''}`;
|
||
|
||
tooltipEl.innerHTML = `Время: ${chartMeta.labels[dataIndex]}<br>${valueText}<br><span class="text-muted">Загрузка...</span>`;
|
||
tooltipEl.style.opacity = 1;
|
||
|
||
const position = context.chart.canvas.getBoundingClientRect();
|
||
tooltipEl.style.left = position.left + window.pageXOffset + tooltip.caretX + 12 + 'px';
|
||
tooltipEl.style.top = position.top + window.pageYOffset + tooltip.caretY + 'px';
|
||
|
||
fetchProcesses({{ server.id }}, timestamp).then(function(data) {
|
||
const lines = [`Время: ${chartMeta.labels[dataIndex]}`, valueText];
|
||
|
||
if (chartMeta.id === 'ram_used' && chartMeta.details && chartMeta.details.totalGb) {
|
||
const totalGb = Number(chartMeta.details.totalGb);
|
||
const usedGb = Number(((Number(value) / 100) * totalGb).toFixed(1));
|
||
const freeGb = Number((totalGb - usedGb).toFixed(1));
|
||
lines.push(`Всего: ${totalGb.toFixed(1)} ГБ`);
|
||
lines.push(`Занято: ${usedGb.toFixed(1)} ГБ`);
|
||
lines.push(`Свободно: ${freeGb.toFixed(1)} ГБ`);
|
||
}
|
||
|
||
const processList = chartMeta.id === 'cpu_load' ? (data.top_cpu || []) : (data.top_ram || []);
|
||
|
||
if (processList.length > 0) {
|
||
lines.push('');
|
||
lines.push(chartMeta.id === 'cpu_load' ? 'Топ CPU:' : 'Топ RAM:');
|
||
processList.slice(0, 5).forEach(function(proc) {
|
||
lines.push(`${proc.cmdline || proc.name}: ${proc.value}%`);
|
||
});
|
||
}
|
||
|
||
tooltipEl.innerHTML = lines.join('<br>');
|
||
});
|
||
}
|
||
|
||
function createLineChart(canvasId, labels, datasets, unit, chartMeta = null) {
|
||
const canvas = document.getElementById(canvasId);
|
||
if (!canvas) {
|
||
return;
|
||
}
|
||
|
||
const chart = new Chart(canvas.getContext('2d'), {
|
||
type: 'line',
|
||
data: { labels, datasets },
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: { mode: 'index', intersect: false },
|
||
plugins: {
|
||
legend: { display: datasets.length > 1, position: 'top' },
|
||
tooltip: chartMeta ? {
|
||
enabled: false,
|
||
external: function(context) {
|
||
renderProcessTooltip(context, chartMeta);
|
||
}
|
||
} : {
|
||
enabled: true
|
||
},
|
||
zoom: {
|
||
zoom: {
|
||
wheel: { enabled: true },
|
||
pinch: { enabled: true },
|
||
drag: { enabled: true },
|
||
mode: 'x'
|
||
},
|
||
pan: {
|
||
enabled: true,
|
||
mode: 'x'
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: false,
|
||
ticks: {
|
||
callback: function(value) {
|
||
return unit ? value + unit : value;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
managedCharts.push(chart);
|
||
return chart;
|
||
}
|
||
|
||
function createDoughnutChart(canvasId, percent) {
|
||
const canvas = document.getElementById(canvasId);
|
||
if (!canvas) {
|
||
return;
|
||
}
|
||
|
||
const color = percent >= 85 ? '#dc3545' : (percent >= 70 ? '#fd7e14' : '#198754');
|
||
|
||
new Chart(canvas.getContext('2d'), {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels: ['Использовано', 'Свободно'],
|
||
datasets: [{
|
||
data: [percent, Math.max(0, 100 - percent)],
|
||
backgroundColor: [color, '#e9ecef'],
|
||
borderWidth: 0
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
cutout: '68%',
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
callbacks: {
|
||
label: function(context) {
|
||
return context.label + ': ' + context.parsed.toFixed(1) + '%';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
const selectAllServices = document.getElementById('selectAllServices');
|
||
if (selectAllServices) {
|
||
selectAllServices.addEventListener('change', function() {
|
||
document.querySelectorAll('.service-checkbox').forEach(function(checkbox) {
|
||
checkbox.checked = selectAllServices.checked;
|
||
});
|
||
});
|
||
}
|
||
|
||
const simpleMetricCharts = {{ simpleMetricCharts|json_encode|raw }};
|
||
simpleMetricCharts.forEach(function(chart) {
|
||
const datasets = [{
|
||
label: chart.title,
|
||
data: chart.values,
|
||
borderColor: chart.color,
|
||
backgroundColor: chart.color + '22',
|
||
fill: true,
|
||
tension: 0.2,
|
||
pointRadius: 0,
|
||
borderWidth: 2
|
||
}];
|
||
|
||
if (chart.thresholds && chart.thresholds.warning !== null) {
|
||
datasets.push({
|
||
label: 'Warning',
|
||
data: chart.labels.map(() => Number(chart.thresholds.warning)),
|
||
borderColor: '#fd7e14',
|
||
backgroundColor: 'transparent',
|
||
fill: false,
|
||
tension: 0,
|
||
pointRadius: 0,
|
||
borderWidth: 1.5,
|
||
borderDash: [6, 4]
|
||
});
|
||
}
|
||
|
||
if (chart.thresholds && chart.thresholds.critical !== null) {
|
||
datasets.push({
|
||
label: 'Critical',
|
||
data: chart.labels.map(() => Number(chart.thresholds.critical)),
|
||
borderColor: '#dc3545',
|
||
backgroundColor: 'transparent',
|
||
fill: false,
|
||
tension: 0,
|
||
pointRadius: 0,
|
||
borderWidth: 1.5,
|
||
borderDash: [3, 3]
|
||
});
|
||
}
|
||
|
||
createLineChart(
|
||
'chart-' + chart.id,
|
||
chart.labels,
|
||
datasets,
|
||
chart.unit,
|
||
{
|
||
id: chart.id,
|
||
labels: chart.labels,
|
||
timestamps: chart.timestamps || [],
|
||
unit: chart.unit,
|
||
details: chart.details || null
|
||
}
|
||
);
|
||
});
|
||
|
||
const networkCharts = {{ networkCharts|json_encode|raw }};
|
||
networkCharts.forEach(function(chart) {
|
||
createLineChart(
|
||
'chart-net-' + chart.id,
|
||
chart.labels,
|
||
chart.datasets.map(function(dataset) {
|
||
return {
|
||
label: dataset.label,
|
||
data: dataset.values,
|
||
borderColor: dataset.color,
|
||
backgroundColor: dataset.color + '22',
|
||
fill: false,
|
||
tension: 0.2,
|
||
pointRadius: 0,
|
||
borderWidth: 2
|
||
};
|
||
}),
|
||
chart.unit,
|
||
null
|
||
);
|
||
});
|
||
|
||
const temperatureChart = {{ temperatureChart|json_encode|raw }};
|
||
if (temperatureChart.datasets && temperatureChart.datasets.length > 0) {
|
||
createLineChart(
|
||
'chart-temperatures',
|
||
temperatureChart.labels,
|
||
temperatureChart.datasets.map(function(dataset) {
|
||
return {
|
||
label: dataset.label,
|
||
data: dataset.values,
|
||
borderColor: dataset.color,
|
||
backgroundColor: dataset.color + '22',
|
||
fill: false,
|
||
tension: 0.2,
|
||
pointRadius: 0,
|
||
borderWidth: 2
|
||
};
|
||
}),
|
||
temperatureChart.unit || '',
|
||
null
|
||
);
|
||
}
|
||
|
||
const diskCharts = {{ diskCharts|json_encode|raw }};
|
||
diskCharts.forEach(function(disk) {
|
||
createDoughnutChart('disk-chart-' + disk.id, disk.percent);
|
||
});
|
||
});
|
||
|
||
function resetAllZoom() {
|
||
managedCharts.forEach(function(chart) {
|
||
if (chart && typeof chart.resetZoom === 'function') {
|
||
chart.resetZoom();
|
||
}
|
||
});
|
||
}
|
||
</script>
|
||
{% endblock %}
|