983 lines
40 KiB
Twig
983 lines
40 KiB
Twig
{% extends "layout.twig" %}
|
|
{% block title %}{{ server.name }}{% endblock %}
|
|
|
|
{% block styles %}
|
|
<style>
|
|
.metric-mini {
|
|
margin-top: 16px;
|
|
padding-top: 16px;
|
|
border-top: 1px solid #e5e7eb;
|
|
}
|
|
.metric-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
}
|
|
.metric-icon {
|
|
width: 24px;
|
|
text-align: center;
|
|
font-size: 14px;
|
|
}
|
|
.metric-label {
|
|
font-size: 0.75rem;
|
|
color: #6b7280;
|
|
min-width: 60px;
|
|
}
|
|
.metric-chart {
|
|
height: 30px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
.metric-value {
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: #111827;
|
|
min-width: 70px;
|
|
text-align: right;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="max-w-7xl mx-auto px-4 py-8">
|
|
<div class="mb-6">
|
|
<h1 class="text-3xl font-bold">{{ server.name }}</h1>
|
|
<p class="text-gray-600">{{ server.host }}</p>
|
|
</div>
|
|
|
|
{% if import_message %}
|
|
<div class="mb-6 {% if import_message.type == 'success' %}bg-green-50 border-green-400 text-green-700{% else %}bg-red-50 border-red-400 text-red-700{% endif %} border px-4 py-3 rounded">
|
|
<i class="fas {% if import_message.type == 'success' %}fa-check-circle{% else %}fa-exclamation-circle{% endif %} mr-2"></i>
|
|
{{ import_message.text }}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
|
<div class="bg-white rounded shadow p-6">
|
|
<h3 class="font-bold mb-4">{{ t('servers.server_info') }}</h3>
|
|
<dl class="space-y-2">
|
|
<div><dt class="text-sm text-gray-600">{{ t('common.status') }}</dt><dd><span class="px-2 py-1 bg-green-100 text-green-800 rounded text-sm">{{ server.status }}</span></dd></div>
|
|
<div><dt class="text-sm text-gray-600">VPN Port</dt><dd>{{ server.vpn_port }}</dd></div>
|
|
<div><dt class="text-sm text-gray-600">Subnet</dt><dd>{{ server.vpn_subnet }}</dd></div>
|
|
|
|
</dl>
|
|
|
|
<div class="mt-4 flex items-center gap-2">
|
|
<button type="button" id="uninstallAllBtn" class="px-3 py-1 bg-gray-600 text-white rounded text-sm">Удалить все протоколы</button>
|
|
<span id="uninstallMsg" class="ml-3 text-sm text-gray-600"></span>
|
|
</div>
|
|
|
|
<div class="mt-4">
|
|
<label class="block text-sm text-gray-600 mb-1">Добавить протокол</label>
|
|
<div class="flex items-center gap-2">
|
|
<select id="availableProtocolSelect" class="px-3 py-2 border rounded">
|
|
{% for p in available_protocols %}
|
|
<option value="{{ p.id }}">{{ p.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<button id="activateProtocolBtn" class="px-3 py-1 bg-green-600 text-white rounded text-sm">Установить</button>
|
|
<span id="activateMsg" class="ml-3 text-sm text-gray-600"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Установка протоколов выполняется только через Настройки -->
|
|
|
|
<div class="mt-4">
|
|
<label class="block text-sm text-gray-600 mb-1">Установленные протоколы</label>
|
|
<div class="space-y-2">
|
|
{% for sp in server_protocols %}
|
|
<div class="border rounded px-3 py-2">
|
|
<div class="flex items-center justify-between">
|
|
<div class="text-sm font-medium">{{ sp.name }} <span class="text-gray-500">({{ sp.slug }})</span></div>
|
|
<button type="button" class="px-3 py-1 bg-red-600 text-white rounded text-sm btn-uninstall-sp" data-slug="{{ sp.slug }}">Удалить</button>
|
|
</div>
|
|
<div class="mt-1 text-xs text-gray-600">
|
|
{% if sp.server_host %}<span>Host: {{ sp.server_host }}</span>{% endif %}
|
|
{% if sp.server_port %}<span class="ml-2">Port: {{ sp.server_port }}</span>{% endif %}
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="text-sm text-gray-500">Нет установленных протоколов</div>
|
|
{% endfor %}
|
|
</div>
|
|
<div id="uninstallSpMsg" class="mt-2 text-sm text-gray-600"></div>
|
|
</div>
|
|
|
|
{% if server.status == 'active' %}
|
|
<div class="metric-mini" id="serverMetrics">
|
|
<div class="metric-row">
|
|
<i class="fas fa-microchip metric-icon" style="color:#dc3545"></i>
|
|
<span class="metric-label">CPU</span>
|
|
<canvas id="cpuSparkline" class="metric-chart"></canvas>
|
|
<div class="metric-value" id="cpuValue">--</div>
|
|
</div>
|
|
<div class="metric-row">
|
|
<i class="fas fa-memory metric-icon" style="color:#ffc107"></i>
|
|
<span class="metric-label">RAM</span>
|
|
<canvas id="ramSparkline" class="metric-chart"></canvas>
|
|
<div class="metric-value" id="ramValue">--</div>
|
|
</div>
|
|
<div class="metric-row">
|
|
<i class="fas fa-hdd metric-icon" style="color:#17a2b8"></i>
|
|
<span class="metric-label">Disk</span>
|
|
<canvas id="diskSparkline" class="metric-chart"></canvas>
|
|
<div class="metric-value" id="diskValue">--</div>
|
|
</div>
|
|
<div class="metric-row">
|
|
<i class="fas fa-network-wired metric-icon" style="color:#28a745"></i>
|
|
<span class="metric-label">Network</span>
|
|
<canvas id="networkSparkline" class="metric-chart"></canvas>
|
|
<div class="metric-value" id="networkValue">--</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<div class="bg-white rounded shadow p-6">
|
|
<h3 class="font-bold mb-4">{{ t('clients.create') }}</h3>
|
|
<form method="POST" action="/servers/{{ server.id }}/clients/create" class="space-y-3" id="createClientForm">
|
|
<div>
|
|
<input name="name" placeholder="{{ t('clients.name') }}" required class="w-full px-3 py-2 border rounded" id="clientName">
|
|
</div>
|
|
<div>
|
|
<input name="login" placeholder="Логин (уникально на сервере, пусто — из имени)" class="w-full px-3 py-2 border rounded" id="clientLogin">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm text-gray-600 mb-1">{{ t('ai.protocol_type') }}</label>
|
|
<select name="protocol_id" class="w-full px-3 py-2 border rounded">
|
|
{% for sp in server_protocols %}
|
|
<option value="{{ sp.protocol_id }}">{{ sp.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm text-gray-600 mb-1">{{ t('clients.expiration') }}</label>
|
|
<select name="expires_in_days" class="w-full px-3 py-2 border rounded mb-2" id="expirationSelect" onchange="toggleExpirationInput()">
|
|
<option value="" selected>{{ t('clients.never_expires') }}</option>
|
|
<option value="7">7 {{ t('common.days') }}</option>
|
|
<option value="30">30 {{ t('common.days') }}</option>
|
|
<option value="60">60 {{ t('common.days') }}</option>
|
|
<option value="90">90 {{ t('common.days') }}</option>
|
|
<option value="180">180 {{ t('common.days') }}</option>
|
|
<option value="365">365 {{ t('common.days') }}</option>
|
|
<option value="custom">{{ t('clients.custom_seconds') }}</option>
|
|
</select>
|
|
<input type="number" name="expires_in_seconds" id="expirationSeconds" class="w-full px-3 py-2 border rounded" placeholder="{{ t('clients.enter_seconds') }}" style="display:none;" min="1">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm text-gray-600 mb-1">{{ t('clients.traffic_limit') }}</label>
|
|
<select name="traffic_limit_gb" class="w-full px-3 py-2 border rounded mb-2" id="trafficSelect" onchange="toggleTrafficInput()">
|
|
<option value="" selected>{{ t('clients.unlimited') }}</option>
|
|
<option value="1">1 GB</option>
|
|
<option value="5">5 GB</option>
|
|
<option value="10">10 GB</option>
|
|
<option value="25">25 GB</option>
|
|
<option value="50">50 GB</option>
|
|
<option value="100">100 GB</option>
|
|
<option value="250">250 GB</option>
|
|
<option value="500">500 GB</option>
|
|
<option value="1000">1000 GB (1 TB)</option>
|
|
<option value="custom">{{ t('clients.custom_mb') }}</option>
|
|
</select>
|
|
<input type="number" name="traffic_limit_mb" id="trafficMegabytes" class="w-full px-3 py-2 border rounded" placeholder="{{ t('clients.enter_megabytes') }}" style="display:none;" min="1">
|
|
</div>
|
|
<button type="submit" class="gradient-bg text-white px-4 py-2 rounded w-full" id="createClientBtn">
|
|
<span id="createClientText">{{ t('form.create') }}</span>
|
|
<i class="fas fa-spinner fa-spin" id="createClientSpinner" style="display:none;"></i>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white rounded shadow p-6 mb-8">
|
|
<h3 class="font-bold mb-4 flex items-center gap-2">
|
|
<i class="fas fa-file-import text-purple-500"></i>
|
|
{{ t('servers.config_import_title') }}
|
|
</h3>
|
|
<p class="text-sm text-gray-600 mb-4">{{ t('servers.config_import_hint') }}</p>
|
|
<form method="POST" action="/servers/{{ server.id }}/config/import" enctype="multipart/form-data" class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('servers.config_import_type_label') }}</label>
|
|
<select name="import_type" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md">
|
|
<option value="panel_backup">{{ t('servers.config_import_type_panel') }}</option>
|
|
<option value="amnezia_app">{{ t('servers.config_import_type_amnezia') }}</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('servers.config_import_file_label') }}</label>
|
|
<input type="file" name="config_file" accept=".json,.backup" required class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md">
|
|
<p class="text-xs text-gray-500 mt-1">{{ t('servers.config_import_file_hint') }}</p>
|
|
</div>
|
|
<button type="submit" class="gradient-bg text-white px-4 py-2 rounded">
|
|
<i class="fas fa-upload mr-2"></i>{{ t('servers.config_import_submit') }}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Backup Section -->
|
|
<div class="bg-white rounded shadow mb-8">
|
|
<div class="px-6 py-4 border-b flex justify-between items-center">
|
|
<h3 class="font-bold">{{ t('backups.title') }}</h3>
|
|
<button onclick="createBackup({{ server.id }})" class="gradient-bg text-white px-4 py-2 rounded text-sm">
|
|
<i class="fas fa-save"></i> {{ t('backups.create') }}
|
|
</button>
|
|
</div>
|
|
<div id="backupsList" class="p-4">
|
|
<div class="text-center text-gray-500 py-4">
|
|
<i class="fas fa-spinner fa-spin"></i> {{ t('form.loading') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-white rounded shadow">
|
|
<div class="px-6 py-4 border-b flex justify-between items-center">
|
|
<h3 class="font-bold">{{ t('clients.title') }} ({{ clients|length }})</h3>
|
|
<div class="flex items-center gap-3">
|
|
<form method="GET" action="/servers/{{ server.id }}" class="flex items-center gap-2">
|
|
<label class="text-sm text-gray-600">{{ t('ai.protocol_type') }}</label>
|
|
<select name="protocol_id" class="px-3 py-2 border rounded" onchange="this.form.submit()">
|
|
<option value="">Все</option>
|
|
{% for sp in server_protocols %}
|
|
<option value="{{ sp.protocol_id }}" {% if selected_protocol_id == sp.protocol_id %}selected{% endif %}>{{ sp.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</form>
|
|
<button onclick="syncAllStats({{ server.id }})" class="text-purple-600 hover:text-purple-800 text-sm">
|
|
<i class="fas fa-sync-alt"></i> {{ t('clients.sync_stats') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{% if clients|length > 0 %}
|
|
<table class="w-full">
|
|
<thead class="bg-gray-50">
|
|
<tr>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.name') }}</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Логин</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('ai.protocol_type') }}</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.ip') }}</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.status') }}</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.expiration') }}</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.traffic') }}</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.traffic_limit') }}</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('common.speed') }}</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.last_handshake') }}</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.actions') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for client in clients %}
|
|
<tr class="border-t">
|
|
<td class="px-6 py-4">{{ client.name }}</td>
|
|
<td class="px-6 py-4">{{ client.name }}</td>
|
|
<td class="px-6 py-4">
|
|
{% if client.protocol_name %}
|
|
<span class="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">{{ client.protocol_name }}</span>
|
|
{% else %}
|
|
<span class="text-gray-400">-</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="px-6 py-4">{{ client.client_ip }}</td>
|
|
<td class="px-6 py-4">
|
|
{% if client.name in online_logins %}
|
|
<span class="px-2 py-1 bg-green-100 text-green-800 rounded text-xs"><i class="fas fa-wifi mr-1"></i>Online</span>
|
|
{% elseif client.status == 'active' %}
|
|
<span class="px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">{{ t('status.active') }}</span>
|
|
{% else %}
|
|
<span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">{{ t('status.disabled') }}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="px-6 py-4 text-sm">
|
|
{% if client.expires_at %}
|
|
{% set expires_ts = client.expires_at|date('U') %}
|
|
{% set now_ts = "now"|date('U') %}
|
|
{% set diff_days = ((expires_ts - now_ts) / 86400)|round %}
|
|
|
|
{% if diff_days < 0 %}
|
|
<span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">
|
|
<i class="fas fa-exclamation-circle"></i> {{ t('clients.expired') }}
|
|
</span>
|
|
{% elseif diff_days <= 7 %}
|
|
<span class="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs">
|
|
<i class="fas fa-clock"></i> {{ diff_days }} {{ t('common.days') }}
|
|
</span>
|
|
{% else %}
|
|
<span class="text-gray-600">{{ client.expires_at|date('Y-m-d') }}</span>
|
|
{% endif %}
|
|
{% else %}
|
|
<span class="text-green-500 text-xl" title="{{ t('clients.never_expires') }}">∞</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="px-2 py-2 text-xs">
|
|
<div class="text-gray-600 font-mono">
|
|
↑{{ (client.bytes_sent|default(0) / 1024 / 1024)|number_format(2) }} MB
|
|
</div>
|
|
<div class="text-gray-600 font-mono">
|
|
↓{{ (client.bytes_received|default(0) / 1024 / 1024)|number_format(2) }} MB
|
|
</div>
|
|
</td>
|
|
<td class="px-2 py-2 text-xs text-center">
|
|
{% if client.traffic_limit %}
|
|
{% set total_traffic = (client.bytes_sent|default(0) + client.bytes_received|default(0)) %}
|
|
{% set limit_gb = (client.traffic_limit / 1073741824)|number_format(2) %}
|
|
{% set used_gb = (total_traffic / 1073741824)|number_format(2) %}
|
|
{% set percentage = ((total_traffic / client.traffic_limit) * 100)|round %}
|
|
|
|
{% if percentage >= 100 %}
|
|
<span class="px-2 py-1 bg-red-100 text-red-800 rounded">
|
|
<i class="fas fa-exclamation-circle"></i> {{ t('clients.overlimit') }}
|
|
</span>
|
|
{% elseif percentage >= 80 %}
|
|
<span class="px-2 py-1 bg-yellow-100 text-yellow-800 rounded">
|
|
{{ used_gb }} / {{ limit_gb }} GB ({{ percentage }}%)
|
|
</span>
|
|
{% else %}
|
|
<span class="text-gray-600">{{ used_gb }} / {{ limit_gb }} GB</span>
|
|
{% endif %}
|
|
{% else %}
|
|
<span class="text-green-500 text-lg" title="{{ t('clients.unlimited') }}">∞</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="px-2 py-2 text-xs">
|
|
<div class="flex flex-col items-center" style="width: 120px; max-width: 120px;">
|
|
<div style="height: 30px; width: 100%;">
|
|
<canvas id="clientSparkline-{{ client.id }}"></canvas>
|
|
</div>
|
|
<div id="client-speed-{{ client.id }}" class="text-gray-600 text-[10px] mt-1 font-mono text-center leading-tight">
|
|
<div class="text-green-600 whitespace-nowrap">↑{{ ((client.speed_up|default(0) * 8) / 1000000)|number_format(2) }} Mbit</div>
|
|
<div class="text-blue-600 whitespace-nowrap">↓{{ ((client.speed_down|default(0) * 8) / 1000000)|number_format(2) }} Mbit</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="px-2 py-2 text-xs whitespace-nowrap text-right">
|
|
{% if client.last_handshake %}
|
|
<span class="text-gray-600 block">{{ client.last_handshake|split(' ')|first }}</span>
|
|
<span class="text-gray-400 block">{{ client.last_handshake|split(' ')|last }}</span>
|
|
{% else %}
|
|
<span class="text-gray-400">{{ t('clients.never') }}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="px-6 py-4">
|
|
<a href="/clients/{{ client.id }}" class="text-purple-600 hover:text-purple-800 mr-2">{{ t('servers.view') }}</a>
|
|
{% if client.status == 'active' %}
|
|
<form method="POST" action="/clients/{{ client.id }}/revoke" style="display:inline;">
|
|
<button type="button" class="text-orange-600 hover:text-orange-800 mr-2" onclick="confirmAction(this, '{{ t('clients.revoke_confirm') }}')">{{ t('clients.revoke') }}</button>
|
|
</form>
|
|
{% else %}
|
|
<form method="POST" action="/clients/{{ client.id }}/restore" style="display:inline;">
|
|
<button type="submit" class="text-green-600 hover:text-green-800 mr-2">{{ t('clients.restore') }}</button>
|
|
</form>
|
|
{% endif %}
|
|
<form method="POST" action="/clients/{{ client.id }}/delete" style="display:inline;">
|
|
<button type="button" class="text-red-600 hover:text-red-800" onclick="confirmAction(this, '{{ t('clients.delete_confirm') }}')">{{ t('clients.delete') }}</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% else %}
|
|
<div class="p-12 text-center text-gray-500">{{ t('clients.no_clients') }}</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
async function confirmAction(btn, message) {
|
|
if (await showConfirmModal(message)) {
|
|
btn.closest('form').submit();
|
|
}
|
|
}
|
|
|
|
function toggleExpirationInput() {
|
|
const select = document.getElementById('expirationSelect');
|
|
const input = document.getElementById('expirationSeconds');
|
|
if (select.value === 'custom') {
|
|
input.style.display = 'block';
|
|
input.required = true;
|
|
input.focus();
|
|
} else {
|
|
input.style.display = 'none';
|
|
input.required = false;
|
|
input.value = '';
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const uninstallAllBtn = document.getElementById('uninstallAllBtn');
|
|
const msg = document.getElementById('uninstallMsg');
|
|
|
|
if (uninstallAllBtn) {
|
|
uninstallAllBtn.addEventListener('click', async function(e) {
|
|
console.log('uninstallAllBtn clicked');
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!confirm('Удалить все Amnezia-контейнеры на сервере?')) {
|
|
console.log('User canceled');
|
|
return;
|
|
}
|
|
console.log('Starting uninstall all...');
|
|
uninstallAllBtn.disabled = true;
|
|
msg.innerHTML = '<i class="fas fa-circle-notch fa-spin text-red-600 mr-2"></i><span class="text-gray-700">Удаление всех контейнеров...</span>';
|
|
try {
|
|
const res = await fetch(`/servers/{{ server.id }}/protocols/uninstall-all`, { method: 'POST', credentials: 'same-origin' });
|
|
const data = await res.json();
|
|
console.log('Response:', data);
|
|
if (data.success) {
|
|
msg.textContent = data.message || 'Успешно';
|
|
setTimeout(() => location.reload(), 1200);
|
|
} else {
|
|
msg.textContent = data.error || 'Ошибка';
|
|
}
|
|
} catch (e) {
|
|
console.error('Error:', e);
|
|
msg.textContent = e.message;
|
|
}
|
|
uninstallAllBtn.disabled = false;
|
|
});
|
|
}
|
|
|
|
const activateBtn = document.getElementById('activateProtocolBtn');
|
|
if (activateBtn) {
|
|
activateBtn.addEventListener('click', async function() {
|
|
const select = document.getElementById('availableProtocolSelect');
|
|
const msg2 = document.getElementById('activateMsg');
|
|
msg2.textContent = '';
|
|
const pid = select ? select.value : '';
|
|
if (!pid) { msg2.textContent = 'Нет доступных протоколов'; return; }
|
|
activateBtn.disabled = true;
|
|
msg2.innerHTML = '<i class="fas fa-circle-notch fa-spin text-blue-600 mr-2"></i><span class="text-gray-700">Установка протокола...</span>';
|
|
try {
|
|
const res = await fetch('/servers/{{ server.id }}/protocols/activate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: 'protocol_id=' + encodeURIComponent(pid)
|
|
});
|
|
let data;
|
|
const ct = res.headers.get('content-type') || '';
|
|
if (ct.includes('application/json')) { data = await res.json(); } else { data = { error: await res.text() }; }
|
|
if (res.ok && data && data.success !== false && !data.error) {
|
|
msg2.textContent = 'Готово';
|
|
setTimeout(() => location.reload(), 1000);
|
|
} else {
|
|
msg2.textContent = (data && data.error) ? data.error : ('Ошибка установки (' + res.status + ')');
|
|
}
|
|
} catch (e) {
|
|
msg2.textContent = e.message || 'Ошибка связи';
|
|
}
|
|
activateBtn.disabled = false;
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll('.btn-uninstall-sp').forEach(btn => {
|
|
btn.addEventListener('click', async function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
const confirmed = await showConfirmModal('Удалить протокол и всех его клиентов?', 'Удаление протокола');
|
|
if (!confirmed) return;
|
|
const slug = btn.getAttribute('data-slug');
|
|
const m = document.getElementById('uninstallSpMsg');
|
|
m.textContent = '';
|
|
btn.disabled = true;
|
|
m.innerHTML = '<i class="fas fa-circle-notch fa-spin text-red-600 mr-2"></i><span class="text-gray-700">Удаление протокола...</span>';
|
|
try {
|
|
const resp = await fetch('/servers/{{ server.id }}/protocols/' + encodeURIComponent(slug) + '/uninstall', { method: 'POST', credentials: 'same-origin' });
|
|
let data;
|
|
const ct = resp.headers.get('content-type') || '';
|
|
if (ct.includes('application/json')) { data = await resp.json(); } else { data = { error: await resp.text() }; }
|
|
if (resp.ok && data && !data.error) {
|
|
m.textContent = 'Удалено. Клиенты: ' + (data.clients_removed || 0);
|
|
setTimeout(() => location.reload(), 800);
|
|
} else {
|
|
m.textContent = (data && data.error) ? data.error : ('Ошибка удаления (' + resp.status + ')');
|
|
}
|
|
} catch (e) {
|
|
m.textContent = e.message || 'Ошибка связи';
|
|
}
|
|
btn.disabled = false;
|
|
});
|
|
});
|
|
|
|
|
|
});
|
|
|
|
function toggleTrafficInput() {
|
|
const select = document.getElementById('trafficSelect');
|
|
const input = document.getElementById('trafficMegabytes');
|
|
if (select.value === 'custom') {
|
|
input.style.display = 'block';
|
|
input.required = true;
|
|
input.focus();
|
|
} else {
|
|
input.style.display = 'none';
|
|
input.required = false;
|
|
input.value = '';
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const form = document.getElementById('createClientForm');
|
|
const clientNameInput = document.getElementById('clientName');
|
|
|
|
const clientLoginInput = document.getElementById('clientLogin');
|
|
if (clientLoginInput) {
|
|
clientLoginInput.addEventListener('input', function(e) {
|
|
let value = e.target.value;
|
|
let sanitized = value.replace(/\s+/g, '_');
|
|
if (value !== sanitized) {
|
|
e.target.value = sanitized;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (form) {
|
|
form.addEventListener('submit', function(e) {
|
|
const btn = document.getElementById('createClientBtn');
|
|
const text = document.getElementById('createClientText');
|
|
const spinner = document.getElementById('createClientSpinner');
|
|
|
|
// Show spinner and disable button
|
|
btn.disabled = true;
|
|
text.style.display = 'none';
|
|
spinner.style.display = 'inline-block';
|
|
|
|
// Form will submit normally
|
|
});
|
|
}
|
|
});
|
|
|
|
async function syncAllStats(serverId) {
|
|
try {
|
|
const response = await fetch(`/servers/${serverId}/sync-stats`, {
|
|
method: 'POST'
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
location.reload();
|
|
} else {
|
|
alert('Failed to sync stats: ' + (data.error || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
alert('Error: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// Load backups on page load
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadBackups({{ server.id }});
|
|
});
|
|
|
|
async function loadBackups(serverId) {
|
|
try {
|
|
const response = await fetch(`/api/servers/${serverId}/backups`, {
|
|
credentials: 'same-origin'
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
document.getElementById('backupsList').innerHTML = '<div class="text-center text-red-500 py-4">{{ t("message.error") }}: {{ t("backups.login_required") }}</div>';
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success && data.backups.length > 0) {
|
|
let html = '<div class="space-y-2">';
|
|
data.backups.forEach(backup => {
|
|
const size = (backup.backup_size / 1024 / 1024).toFixed(2);
|
|
const statusClass = backup.status === 'completed' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800';
|
|
|
|
html += `
|
|
<div class="flex items-center justify-between p-3 border rounded">
|
|
<div class="flex-1">
|
|
<div class="font-medium">${backup.backup_name}</div>
|
|
<div class="text-sm text-gray-600">
|
|
${backup.clients_count} {{ t('clients.title') }} • ${size} MB • ${backup.created_at}
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<span class="px-2 py-1 ${statusClass} rounded text-xs">${backup.status}</span>
|
|
${backup.status === 'completed' ? `
|
|
<button onclick="restoreBackup(${serverId}, ${backup.id})" class="text-blue-600 hover:text-blue-800 px-3 py-1 border rounded text-sm">
|
|
<i class="fas fa-undo"></i> {{ t('backups.restore') }}
|
|
</button>
|
|
` : ''}
|
|
<button onclick="deleteBackup(${backup.id})" class="text-red-600 hover:text-red-800 px-3 py-1 border rounded text-sm">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
html += '</div>';
|
|
document.getElementById('backupsList').innerHTML = html;
|
|
} else {
|
|
document.getElementById('backupsList').innerHTML = '<div class="text-center text-gray-500 py-4">{{ t("backups.no_backups") }}</div>';
|
|
}
|
|
} catch (error) {
|
|
document.getElementById('backupsList').innerHTML = `<div class="text-center text-red-500 py-4">Error: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function createBackup(serverId) {
|
|
if (!confirm('{{ t("backups.create_confirm") }}')) return;
|
|
|
|
// Show loading state
|
|
document.getElementById('backupsList').innerHTML = '<div class="text-center text-gray-500 py-4"><i class="fas fa-spinner fa-spin"></i> {{ t("form.processing") }}</div>';
|
|
|
|
try {
|
|
const response = await fetch(`/api/servers/${serverId}/backup`, {
|
|
method: 'POST',
|
|
credentials: 'same-origin'
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
alert('{{ t("backups.created_success") }}');
|
|
loadBackups(serverId);
|
|
} else {
|
|
alert('Error: ' + (data.error || 'Unknown error'));
|
|
loadBackups(serverId);
|
|
}
|
|
} catch (error) {
|
|
alert('Error: ' + error.message);
|
|
loadBackups(serverId);
|
|
}
|
|
}
|
|
|
|
async function restoreBackup(serverId, backupId) {
|
|
if (!confirm('{{ t("backups.restore_confirm") }}')) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/servers/${serverId}/restore`, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ backup_id: backupId })
|
|
});
|
|
|
|
const data = await response.json();
|
|
console.log('Restore response:', data);
|
|
|
|
if (data.success !== false) {
|
|
let message = `{{ t("backups.restored_success") }}: ${data.restored} {{ t("clients.title") }}`;
|
|
|
|
if (data.failed > 0) {
|
|
message += `\n\nПропущено: ${data.failed}`;
|
|
if (data.errors && data.errors.length > 0) {
|
|
message += '\n\nПричины:\n' + data.errors.slice(0, 5).join('\n');
|
|
if (data.errors.length > 5) {
|
|
message += `\n... и ещё ${data.errors.length - 5}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
alert(message);
|
|
if (data.restored > 0) {
|
|
location.reload();
|
|
} else {
|
|
loadBackups(serverId);
|
|
}
|
|
} else {
|
|
let errorMsg = 'Error: ';
|
|
if (data.error) {
|
|
errorMsg += data.error;
|
|
} else if (data.errors && data.errors.length > 0) {
|
|
errorMsg += data.errors.join(', ');
|
|
} else {
|
|
errorMsg += 'Unknown error';
|
|
}
|
|
alert(errorMsg);
|
|
}
|
|
} catch (error) {
|
|
console.error('Restore error:', error);
|
|
alert('Error: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function deleteBackup(backupId) {
|
|
if (!confirm('{{ t("backups.delete_confirm") }}')) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/backups/${backupId}`, {
|
|
method: 'DELETE',
|
|
credentials: 'same-origin'
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
alert('{{ t("backups.deleted_success") }}');
|
|
loadBackups({{ server.id }});
|
|
} else {
|
|
alert('Error: ' + (data.error || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
alert('Error: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// Server metrics sparklines
|
|
{% if server.status == 'active' %}
|
|
const serverId = {{ server.id }};
|
|
let sparklineCharts = {};
|
|
|
|
function initSparklines() {
|
|
const chartConfig = {
|
|
type: 'line',
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: { enabled: false }
|
|
},
|
|
scales: {
|
|
x: { display: false },
|
|
y: { display: false, beginAtZero: true }
|
|
},
|
|
elements: {
|
|
line: { borderWidth: 2, tension: 0.4 },
|
|
point: { radius: 0 }
|
|
}
|
|
}
|
|
};
|
|
|
|
sparklineCharts.cpu = new Chart(document.getElementById('cpuSparkline'), {
|
|
...chartConfig,
|
|
data: {
|
|
labels: [],
|
|
datasets: [{
|
|
data: [],
|
|
borderColor: '#dc3545',
|
|
backgroundColor: 'rgba(220, 53, 69, 0.1)',
|
|
fill: true
|
|
}]
|
|
},
|
|
options: {
|
|
...chartConfig.options,
|
|
scales: {
|
|
...chartConfig.options.scales,
|
|
y: { display: false, beginAtZero: true, max: 100 }
|
|
}
|
|
}
|
|
});
|
|
|
|
sparklineCharts.ram = new Chart(document.getElementById('ramSparkline'), {
|
|
...chartConfig,
|
|
data: {
|
|
labels: [],
|
|
datasets: [{
|
|
data: [],
|
|
borderColor: '#ffc107',
|
|
backgroundColor: 'rgba(255, 193, 7, 0.1)',
|
|
fill: true
|
|
}]
|
|
}
|
|
});
|
|
|
|
sparklineCharts.disk = new Chart(document.getElementById('diskSparkline'), {
|
|
...chartConfig,
|
|
data: {
|
|
labels: [],
|
|
datasets: [{
|
|
data: [],
|
|
borderColor: '#17a2b8',
|
|
backgroundColor: 'rgba(23, 162, 184, 0.1)',
|
|
fill: true
|
|
}]
|
|
}
|
|
});
|
|
|
|
sparklineCharts.network = new Chart(document.getElementById('networkSparkline'), {
|
|
...chartConfig,
|
|
data: {
|
|
labels: [],
|
|
datasets: [{
|
|
data: [],
|
|
borderColor: '#28a745',
|
|
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
|
fill: true
|
|
}]
|
|
}
|
|
});
|
|
}
|
|
|
|
async function updateServerMetrics() {
|
|
try {
|
|
const response = await fetch(`/api/servers/${serverId}/metrics?hours=1`, {
|
|
credentials: 'same-origin'
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
console.log('Metrics response:', data);
|
|
|
|
if (data.success && data.metrics.length > 0) {
|
|
const metrics = data.metrics.slice(-20); // Last 20 points
|
|
const latest = metrics[metrics.length - 1];
|
|
|
|
// Update values with more details
|
|
const cpuVal = parseFloat(latest.cpu_percent).toFixed(1);
|
|
document.getElementById('cpuValue').textContent = `${cpuVal}%`;
|
|
|
|
const ramUsed = Math.round(latest.ram_used_mb / 1024 * 10) / 10;
|
|
const ramTotal = Math.round(latest.ram_total_mb / 1024 * 10) / 10;
|
|
const ramPercent = (latest.ram_used_mb / latest.ram_total_mb * 100).toFixed(0);
|
|
document.getElementById('ramValue').textContent = `${ramPercent}% (${ramUsed}G)`;
|
|
|
|
const diskUsed = parseFloat(latest.disk_used_gb).toFixed(0);
|
|
const diskTotal = parseFloat(latest.disk_total_gb).toFixed(0);
|
|
const diskPercent = (latest.disk_used_gb / latest.disk_total_gb * 100).toFixed(0);
|
|
document.getElementById('diskValue').textContent = `${diskPercent}% (${diskUsed}G)`;
|
|
|
|
const netRx = parseFloat(latest.network_rx_mbps).toFixed(1);
|
|
const netTx = parseFloat(latest.network_tx_mbps).toFixed(1);
|
|
document.getElementById('networkValue').textContent = `↓${netRx} ↑${netTx}`;
|
|
|
|
// Update sparklines
|
|
const labels = metrics.map((_, i) => i);
|
|
|
|
sparklineCharts.cpu.data.labels = labels;
|
|
sparklineCharts.cpu.data.datasets[0].data = metrics.map(m => parseFloat(m.cpu_percent));
|
|
sparklineCharts.cpu.update('none');
|
|
|
|
sparklineCharts.ram.data.labels = labels;
|
|
sparklineCharts.ram.data.datasets[0].data = metrics.map(m => (m.ram_used_mb / m.ram_total_mb * 100));
|
|
sparklineCharts.ram.update('none');
|
|
|
|
sparklineCharts.disk.data.labels = labels;
|
|
sparklineCharts.disk.data.datasets[0].data = metrics.map(m => (m.disk_used_gb / m.disk_total_gb * 100));
|
|
sparklineCharts.disk.update('none');
|
|
|
|
sparklineCharts.network.data.labels = labels;
|
|
sparklineCharts.network.data.datasets[0].data = metrics.map(m => parseFloat(m.network_rx_mbps) + parseFloat(m.network_tx_mbps));
|
|
sparklineCharts.network.update('none');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch metrics:', error);
|
|
// Show error in UI
|
|
document.getElementById('cpuValue').textContent = 'Error';
|
|
document.getElementById('ramValue').textContent = 'Error';
|
|
document.getElementById('diskValue').textContent = 'Error';
|
|
document.getElementById('networkValue').textContent = 'Error';
|
|
}
|
|
}
|
|
|
|
// Initialize sparklines if active
|
|
if (document.getElementById('cpuSparkline')) {
|
|
initSparklines();
|
|
updateServerMetrics();
|
|
setInterval(updateServerMetrics, 30000); // Update every 30 seconds
|
|
}
|
|
|
|
// Update client speeds
|
|
let clientCharts = {};
|
|
|
|
async function updateClientSpeeds() {
|
|
const clientRows = document.querySelectorAll('[id^="client-speed-"]');
|
|
|
|
for (const row of clientRows) {
|
|
const clientId = row.id.replace('client-speed-', '');
|
|
const canvasId = `clientSparkline-${clientId}`;
|
|
const canvas = document.getElementById(canvasId);
|
|
|
|
try {
|
|
const response = await fetch(`/api/clients/${clientId}/metrics?hours=24`, { // Fetch 24h for sparkline
|
|
credentials: 'same-origin'
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success && data.metrics) {
|
|
const metrics = data.metrics; // Use all points for chart
|
|
|
|
// 1. Render/Update Chart
|
|
if (canvas) {
|
|
const labels = metrics.map((_, i) => i);
|
|
const dataUp = metrics.map(m => (parseFloat(m.speed_up_kbps) / 1000)); // Mbps
|
|
const dataDown = metrics.map(m => (parseFloat(m.speed_down_kbps) / 1000)); // Mbps
|
|
|
|
if (clientCharts[clientId]) {
|
|
// Update existing chart
|
|
clientCharts[clientId].data.labels = labels;
|
|
clientCharts[clientId].data.datasets[0].data = dataUp;
|
|
clientCharts[clientId].data.datasets[1].data = dataDown;
|
|
clientCharts[clientId].update('none');
|
|
} else {
|
|
// Create new chart
|
|
clientCharts[clientId] = new Chart(canvas, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [
|
|
{
|
|
label: 'Up',
|
|
data: dataUp,
|
|
borderColor: '#16a34a', // green-600
|
|
borderWidth: 1.5,
|
|
pointRadius: 0,
|
|
fill: false,
|
|
tension: 0.4
|
|
},
|
|
{
|
|
label: 'Down',
|
|
data: dataDown,
|
|
borderColor: '#2563eb', // blue-600
|
|
borderWidth: 1.5,
|
|
pointRadius: 0,
|
|
fill: false,
|
|
tension: 0.4
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
|
scales: {
|
|
x: { display: false },
|
|
y: { display: false, beginAtZero: true }
|
|
},
|
|
animation: false
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// 2. Update Text Badge (Last Known Speed)
|
|
if (metrics.length > 0) {
|
|
const latest = metrics[metrics.length - 1];
|
|
const speedUp = (parseFloat(latest.speed_up_kbps) / 1000).toFixed(2);
|
|
const speedDown = (parseFloat(latest.speed_down_kbps) / 1000).toFixed(2);
|
|
|
|
row.innerHTML = `
|
|
<div class="text-green-600 whitespace-nowrap">↑${speedUp} Mbit</div>
|
|
<div class="text-blue-600 whitespace-nowrap">↓${speedDown} Mbit</div>
|
|
`;
|
|
} else {
|
|
row.innerHTML = '<span class="text-gray-400">-</span>';
|
|
}
|
|
|
|
} else {
|
|
row.innerHTML = '<span class="text-xs text-gray-400">-</span>';
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to fetch metrics for client ${clientId}:`, error);
|
|
row.innerHTML = '<span class="text-xs text-gray-400">Error</span>';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update client speeds on load and every 30 seconds
|
|
if (document.querySelector('[id^="client-speed-"]')) {
|
|
updateClientSpeeds();
|
|
setInterval(updateClientSpeeds, 30000);
|
|
}
|
|
{% endif %}
|
|
</script>
|
|
{% endblock %}
|