Files
amneziavpnphp/templates/servers/view.twig
T

419 lines
18 KiB
Twig

{% extends "layout.twig" %}
{% block title %}{{ server.name }}{% 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">Server Info</h3>
<dl class="space-y-2">
<div><dt class="text-sm text-gray-600">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>
<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">
<input name="name" placeholder="{{ t('clients.name') }}" required class="w-full px-3 py-2 border rounded" id="clientName">
<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>
<!-- 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>
<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>
{% 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">{{ 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('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.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-gray-400">{{ t('clients.never_expires') }}</span>
{% endif %}
</td>
<td class="px-6 py-4 text-sm">
<div class="text-gray-600">
{{ (client.bytes_sent|default(0) / 1024 / 1024)|number_format(2) }} MB
</div>
<div class="text-gray-600">
{{ (client.bytes_received|default(0) / 1024 / 1024)|number_format(2) }} MB
</div>
</td>
<td class="px-6 py-4 text-sm">
{% 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 text-xs">
<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 text-xs">
{{ used_gb }} / {{ limit_gb }} GB ({{ percentage }}%)
</span>
{% else %}
<span class="text-gray-600">{{ used_gb }} / {{ limit_gb }} GB</span>
{% endif %}
{% else %}
<span class="text-gray-400">{{ t('clients.unlimited') }}</span>
{% endif %}
</td>
<td class="px-6 py-4 text-sm">
{% if client.last_handshake %}
<span class="text-gray-600">{{ client.last_handshake }}</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="submit" class="text-orange-600 hover:text-orange-800 mr-2" onclick="return confirm('{{ 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="submit" class="text-red-600 hover:text-red-800" onclick="return confirm('{{ 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>
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 = '';
}
}
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');
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);
}
}
</script>
{% endblock %}