Files
amneziavpnphp/templates/servers/view.twig
T
2026-01-30 19:27:02 +03:00

981 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.login|default('-') }}</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.status == 'active' %}
<span class="px-2 py-1 bg-green-100 text-green-800 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') }}">&infin;</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') }}">&infin;</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 %}