bbb0fbeeb9
- Добавлены custom input поля для expiration (секунды) и traffic (МБ) - Добавлена функциональность редактирования клиента - Исправлена migration 007 (AFTER expires_at) - Удалены дублирующиеся миграции (0025, 0044, 0053, 0057) - Удалён старый init.sql (заменён на 001_init.sql) - Добавлены переводы для custom полей на 6 языках
411 lines
17 KiB
Twig
411 lines
17 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>
|
|
<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.traffic_sent|default(0) / 1024 / 1024)|number_format(2) }} MB
|
|
</div>
|
|
<div class="text-gray-600">
|
|
↓ {{ (client.traffic_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.traffic_sent|default(0) + client.traffic_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 %}
|