1123 lines
42 KiB
HTML
1123 lines
42 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>WireGuard Panel</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: #f5f7fa;
|
||
color: #333;
|
||
transition: background 0.3s, color 0.3s;
|
||
}
|
||
|
||
/* Премиальная темная тема - Ferrari Style */
|
||
body.dark {
|
||
background: linear-gradient(135deg, #0f1419 0%, #1a1f2e 100%);
|
||
background-attachment: fixed;
|
||
color: #e8eaed;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
body.dark .header {
|
||
background: linear-gradient(135deg, #1e2530 0%, #252d3d 100%);
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||
border-bottom: 1px solid rgba(139, 148, 158, 0.1);
|
||
}
|
||
|
||
body.dark .header h1 {
|
||
background: linear-gradient(135deg, #c0c5ce 0%, #8b949e 100%);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
}
|
||
|
||
body.dark .section {
|
||
background: linear-gradient(135deg, #1a1f2e 0%, #252d3d 100%);
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||
border: 1px solid rgba(139, 148, 158, 0.1);
|
||
}
|
||
|
||
body.dark .server-card {
|
||
background: linear-gradient(135deg, #252d3d 0%, #1e2530 100%);
|
||
border-color: rgba(139, 148, 158, 0.15);
|
||
}
|
||
|
||
body.dark .client-row {
|
||
background: linear-gradient(135deg, #1e2530 0%, #252d3d 100%);
|
||
border: 1px solid rgba(139, 148, 158, 0.08);
|
||
}
|
||
|
||
body.dark .client-row:hover {
|
||
background: linear-gradient(135deg, #252d3d 0%, #2d3548 100%);
|
||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
body.dark .form-group input,
|
||
body.dark .form-group textarea,
|
||
body.dark .form-group select {
|
||
background: #1a1f2e;
|
||
border-color: rgba(139, 148, 158, 0.2);
|
||
color: #e8eaed;
|
||
}
|
||
|
||
body.dark .form-group input:focus,
|
||
body.dark .form-group textarea:focus,
|
||
body.dark .form-group select:focus {
|
||
border-color: rgba(192, 197, 206, 0.4);
|
||
box-shadow: 0 0 0 3px rgba(192, 197, 206, 0.1);
|
||
}
|
||
|
||
body.dark .modal-content {
|
||
background: linear-gradient(135deg, #1e2530 0%, #252d3d 100%);
|
||
color: #e8eaed;
|
||
border: 1px solid rgba(139, 148, 158, 0.1);
|
||
}
|
||
|
||
body.dark .btn {
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
body.dark .btn-secondary {
|
||
background: linear-gradient(135deg, #3d4556 0%, #2d3548 100%);
|
||
color: #c0c5ce;
|
||
border: 1px solid rgba(139, 148, 158, 0.15);
|
||
}
|
||
|
||
body.dark .btn-secondary:hover {
|
||
background: linear-gradient(135deg, #4d5566 0%, #3d4556 100%);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
body.dark .section-header {
|
||
border-bottom-color: rgba(139, 148, 158, 0.15);
|
||
}
|
||
|
||
body.dark .section-header h2 {
|
||
color: #c0c5ce;
|
||
}
|
||
|
||
body.dark .client-ip,
|
||
body.dark .client-stats {
|
||
color: #8b949e;
|
||
}
|
||
|
||
body.dark .empty-state,
|
||
body.dark .empty-clients {
|
||
color: #6c7480;
|
||
}
|
||
|
||
body.dark .server-title,
|
||
body.dark .client-name {
|
||
color: #e8eaed;
|
||
}
|
||
|
||
body.dark .card-subtitle,
|
||
body.dark .server-info {
|
||
color: #8b949e;
|
||
}
|
||
|
||
body.dark #portforward-list > div {
|
||
background: transparent !important;
|
||
border-bottom: 1px solid rgba(139, 148, 158, 0.1) !important;
|
||
}
|
||
|
||
body.dark #portforward-modal form {
|
||
background: transparent !important;
|
||
border-bottom: 1px solid rgba(139, 148, 158, 0.1) !important;
|
||
}
|
||
|
||
body.dark .port-input {
|
||
background: transparent !important;
|
||
border-color: rgba(139, 148, 158, 0.25) !important;
|
||
color: #e8eaed !important;
|
||
}
|
||
|
||
body.dark .port-input:focus {
|
||
border-color: rgba(192, 197, 206, 0.4) !important;
|
||
box-shadow: 0 0 0 3px rgba(192, 197, 206, 0.1) !important;
|
||
}
|
||
|
||
body.dark #portforward-list > div > div {
|
||
color: #c0c5ce !important;
|
||
}
|
||
|
||
.header {
|
||
background: white;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||
padding: 15px 25px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.header h1 {
|
||
font-size: 20px;
|
||
color: #667eea;
|
||
}
|
||
|
||
.btn {
|
||
padding: 8px 16px;
|
||
background: #667eea;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
text-decoration: none;
|
||
display: inline-block;
|
||
}
|
||
|
||
.btn:hover {
|
||
background: #5568d3;
|
||
}
|
||
|
||
.btn-sm {
|
||
padding: 5px 10px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: #e2e8f0;
|
||
color: #333;
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
background: #cbd5e0;
|
||
}
|
||
|
||
.btn-danger {
|
||
background: #f56565;
|
||
}
|
||
|
||
.btn-danger:hover {
|
||
background: #e53e3e;
|
||
}
|
||
|
||
.btn-success {
|
||
background: #48bb78;
|
||
}
|
||
|
||
.btn-success:hover {
|
||
background: #38a169;
|
||
}
|
||
|
||
.container {
|
||
max-width: 1400px;
|
||
margin: 20px auto;
|
||
padding: 0 20px;
|
||
}
|
||
|
||
.section {
|
||
background: white;
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.section-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 15px;
|
||
padding-bottom: 10px;
|
||
border-bottom: 2px solid #f0f0f0;
|
||
}
|
||
|
||
.section-header h2 {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.server-card {
|
||
border: 2px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
margin-bottom: 12px;
|
||
background: #fafafa;
|
||
}
|
||
|
||
.server-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.server-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.status-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
display: inline-block;
|
||
}
|
||
|
||
.status-on {
|
||
background: #48bb78;
|
||
}
|
||
|
||
.status-off {
|
||
background: #f56565;
|
||
}
|
||
|
||
.server-info {
|
||
display: flex;
|
||
gap: 15px;
|
||
font-size: 12px;
|
||
color: #666;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.server-actions {
|
||
display: flex;
|
||
gap: 5px;
|
||
}
|
||
|
||
.clients-container {
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid #e2e8f0;
|
||
}
|
||
|
||
.client-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 8px 10px;
|
||
background: white;
|
||
border-radius: 6px;
|
||
margin-bottom: 6px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.client-row:hover {
|
||
background: #f8f9fa;
|
||
}
|
||
|
||
.client-info {
|
||
flex: 1;
|
||
display: flex;
|
||
gap: 15px;
|
||
align-items: center;
|
||
}
|
||
|
||
.client-name {
|
||
font-weight: 600;
|
||
min-width: 120px;
|
||
}
|
||
|
||
.client-ip {
|
||
color: #666;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.client-stats {
|
||
display: flex;
|
||
gap: 12px;
|
||
font-size: 12px;
|
||
color: #666;
|
||
}
|
||
|
||
.client-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.modal {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
z-index: 1000;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.modal.active {
|
||
display: flex;
|
||
}
|
||
|
||
.modal-content {
|
||
background: white;
|
||
padding: 25px;
|
||
border-radius: 10px;
|
||
max-width: 450px;
|
||
width: 90%;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.modal-header h2 {
|
||
font-size: 18px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.form-group label {
|
||
display: block;
|
||
margin-bottom: 6px;
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.form-group input, .form-group textarea {
|
||
width: 100%;
|
||
padding: 10px;
|
||
border: 2px solid #e2e8f0;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.form-group input:focus, .form-group textarea:focus {
|
||
outline: none;
|
||
border-color: #667eea;
|
||
}
|
||
|
||
.modal-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
justify-content: flex-end;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 40px 20px;
|
||
color: #999;
|
||
}
|
||
|
||
.empty-clients {
|
||
text-align: center;
|
||
padding: 20px;
|
||
color: #999;
|
||
font-size: 13px;
|
||
}
|
||
|
||
#qr-image {
|
||
width: 100%;
|
||
max-width: 280px;
|
||
margin: 15px auto;
|
||
display: block;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.client-row {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
}
|
||
|
||
.client-info {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 5px;
|
||
}
|
||
|
||
.client-actions {
|
||
width: 100%;
|
||
}
|
||
|
||
.client-actions .btn {
|
||
flex: 1;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
<h1>WireGuard Panel</h1>
|
||
<div style="display: flex; gap: 10px;">
|
||
<button class="btn btn-sm" onclick="showCreateServerModal()">+ Сервер</button>
|
||
<button class="btn btn-sm btn-secondary" onclick="toggleTheme()">🌓 Тема</button>
|
||
<a href="/logout" class="btn btn-sm btn-secondary">Выход</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="container">
|
||
<div id="servers-list"></div>
|
||
</div>
|
||
|
||
<!-- Модальное окно создания сервера -->
|
||
<div class="modal" id="create-server-modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2>Создать WireGuard сервер</h2>
|
||
</div>
|
||
<form onsubmit="createServer(event)">
|
||
<div class="form-group">
|
||
<label>Название</label>
|
||
<input type="text" id="server-name" required placeholder="Мой VPN">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Адрес подсети</label>
|
||
<div style="display: flex; align-items: center; gap: 5px;">
|
||
<input type="text" id="server-address" required placeholder="10.0.0.1"
|
||
style="flex: 1;" oninput="updateAddressPreview()">
|
||
<span style="color: #999; font-weight: 600;">/24</span>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Порт</label>
|
||
<input type="number" id="server-port" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>DNS</label>
|
||
<input type="text" id="server-dns" required value="8.8.8.8, 1.1.1.1">
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button type="button" class="btn btn-secondary" onclick="hideCreateServerModal()">Отмена</button>
|
||
<button type="submit" class="btn">Создать</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Модальное окно добавления клиента -->
|
||
<div class="modal" id="add-client-modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2>Добавить клиента</h2>
|
||
</div>
|
||
<form onsubmit="createClient(event)">
|
||
<input type="hidden" id="client-server-id">
|
||
<div class="form-group">
|
||
<label>Имя клиента</label>
|
||
<input type="text" id="client-name" required placeholder="iPhone">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Комментарий (необязательно)</label>
|
||
<textarea id="client-comment" placeholder="Описание клиента"></textarea>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button type="button" class="btn btn-secondary" onclick="hideAddClientModal()">Отмена</button>
|
||
<button type="submit" class="btn">Создать</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Модальное окно редактирования клиента -->
|
||
<div class="modal" id="edit-client-modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2>Редактировать клиента</h2>
|
||
</div>
|
||
<form onsubmit="updateClient(event)">
|
||
<input type="hidden" id="edit-client-id">
|
||
<div class="form-group">
|
||
<label>Имя клиента</label>
|
||
<input type="text" id="edit-client-name" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Комментарий</label>
|
||
<textarea id="edit-client-comment" placeholder="Описание клиента"></textarea>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button type="button" class="btn btn-secondary" onclick="hideEditClientModal()">Отмена</button>
|
||
<button type="submit" class="btn">Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Модальное окно QR кода -->
|
||
<div class="modal" id="qr-modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2>QR код</h2>
|
||
</div>
|
||
<img id="qr-image" src="">
|
||
<div class="modal-actions">
|
||
<button class="btn" onclick="hideQRModal()">Закрыть</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Модальное окно проброса портов -->
|
||
<div class="modal" id="portforward-modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2>Проброс портов</h2>
|
||
</div>
|
||
|
||
<!-- Форма добавления -->
|
||
<form onsubmit="addPortForward(event)" style="background: #f8f9fa; padding: 12px; border-radius: 8px; margin-bottom: 15px;">
|
||
<input type="hidden" id="pf-client-id">
|
||
<div style="display: flex; gap: 8px; align-items: flex-end;">
|
||
<div class="form-group" style="margin-bottom: 0; flex: 1;">
|
||
<label style="font-size: 12px; margin-bottom: 4px;">Порт</label>
|
||
<input type="number" id="pf-port" required placeholder="22" style="padding: 8px; font-size: 13px;">
|
||
</div>
|
||
<div class="form-group" style="margin-bottom: 0; width: 100px;">
|
||
<label style="font-size: 12px; margin-bottom: 4px;">Протокол</label>
|
||
<select id="pf-protocol" style="padding: 8px; font-size: 13px;">
|
||
<option value="tcp">TCP</option>
|
||
<option value="udp">UDP</option>
|
||
<option value="both">TCP+UDP</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom: 0; flex: 2;">
|
||
<label style="font-size: 12px; margin-bottom: 4px;">Описание</label>
|
||
<input type="text" id="pf-description" placeholder="SSH" style="padding: 8px; font-size: 13px;">
|
||
</div>
|
||
<button type="submit" class="btn btn-sm" style="height: 34px;">+</button>
|
||
</div>
|
||
</form>
|
||
|
||
<!-- Список пробросов -->
|
||
<div id="portforward-list" style="max-height: 300px; overflow-y: auto;"></div>
|
||
|
||
<div class="modal-actions">
|
||
<button class="btn btn-secondary" onclick="hidePortForwardModal()">Закрыть</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let servers = [];
|
||
let clients = [];
|
||
|
||
window.addEventListener('load', () => {
|
||
// Загружаем тему из localStorage
|
||
const savedTheme = localStorage.getItem('theme');
|
||
if (savedTheme === 'dark') {
|
||
document.body.classList.add('dark');
|
||
}
|
||
|
||
loadData();
|
||
setInterval(loadData, 5000);
|
||
});
|
||
|
||
function toggleTheme() {
|
||
document.body.classList.toggle('dark');
|
||
const isDark = document.body.classList.contains('dark');
|
||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||
}
|
||
|
||
async function loadData() {
|
||
try {
|
||
const [serversRes, clientsRes] = await Promise.all([
|
||
fetch('/api/servers'),
|
||
fetch('/api/clients')
|
||
]);
|
||
servers = await serversRes.json() || [];
|
||
clients = await clientsRes.json() || [];
|
||
render();
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки:', error);
|
||
}
|
||
}
|
||
|
||
function render() {
|
||
const container = document.getElementById('servers-list');
|
||
|
||
if (servers.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="section">
|
||
<div class="empty-state">
|
||
<h3>Нет серверов</h3>
|
||
<p>Создайте новый сервер или импортируйте существующий</p>
|
||
<button class="btn" onclick="showCreateServerModal()" style="margin-top: 15px;">Создать сервер</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = servers.map(server => {
|
||
const serverClients = clients.filter(c => c.server_id === server.id);
|
||
return `
|
||
<div class="section">
|
||
<div class="server-card">
|
||
<div class="server-header">
|
||
<div class="server-title">
|
||
<span class="status-dot ${server.enabled ? 'status-on' : 'status-off'}"></span>
|
||
${escapeHtml(server.name)}
|
||
</div>
|
||
<div class="server-actions">
|
||
<button class="btn btn-sm ${server.enabled ? 'btn-secondary' : 'btn-success'}"
|
||
onclick="toggleServer('${server.id}')">
|
||
${server.enabled ? '⏸' : '▶'}
|
||
</button>
|
||
<button class="btn btn-sm btn-secondary" onclick="showAddClientModal('${server.id}')">
|
||
+ Клиент
|
||
</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteServer('${server.id}', '${escapeHtml(server.name)}')">
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="server-info">
|
||
<span>🔌 ${server.interface}</span>
|
||
<span>📡 ${server.address}</span>
|
||
<span>🌐 ${server.listen_port}</span>
|
||
<span>👥 ${serverClients.length} клиентов</span>
|
||
</div>
|
||
</div>
|
||
|
||
${serverClients.length > 0 ? `
|
||
<div class="clients-container">
|
||
${serverClients.map(client => `
|
||
<div class="client-row">
|
||
<div class="client-info">
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
${getClientStatus(client)}
|
||
<div>
|
||
<div class="client-name">${escapeHtml(client.name)}</div>
|
||
${client.comment ? `<div style="font-size: 11px; color: #999; margin-top: 2px;">${escapeHtml(client.comment)}</div>` : ''}
|
||
</div>
|
||
</div>
|
||
<div style="display: flex; flex-direction: column; gap: 3px; font-size: 12px;">
|
||
<div class="client-ip">VPN: ${client.address}</div>
|
||
${client.endpoint ? `<div style="color: #999;">IP: ${client.endpoint.split(':')[0]}</div>` : ''}
|
||
</div>
|
||
<div class="client-stats">
|
||
<span title="Получено">↓ ${formatBytes(client.rx_bytes)}</span>
|
||
<span title="Отправлено">↑ ${formatBytes(client.tx_bytes)}</span>
|
||
<span title="Последнее подключение">🕒 ${formatTime(client.last_handshake)}</span>
|
||
</div>
|
||
</div>
|
||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||
<div class="client-actions">
|
||
<button class="btn btn-sm btn-secondary" onclick="showPortForwardModal('${client.id}')">
|
||
🔀 Порты
|
||
</button>
|
||
<button class="btn btn-sm btn-secondary" onclick="showEditClientModal('${client.id}')">
|
||
✏️ Изменить
|
||
</button>
|
||
<button class="btn btn-sm btn-secondary" onclick="downloadConfig('${client.id}', '${escapeHtml(client.name)}')">
|
||
💾 Скачать
|
||
</button>
|
||
<button class="btn btn-sm btn-secondary" onclick="showQR('${client.id}')">
|
||
📱 QR
|
||
</button>
|
||
<button class="btn btn-sm ${client.enabled ? 'btn-secondary' : 'btn-success'}"
|
||
onclick="toggleClient('${client.id}')">
|
||
${client.enabled ? '⏸ Отключить' : '▶ Включить'}
|
||
</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteClient('${client.id}', '${escapeHtml(client.name)}')">
|
||
✕ Удалить
|
||
</button>
|
||
</div>
|
||
${client.port_forwards && client.port_forwards.length > 0 ?
|
||
`<div style="font-size: 11px; color: #667eea;">
|
||
🌐 ${client.port_forwards.map(pf =>
|
||
`${pf.port}${pf.description ? ' ('+pf.description+')' : ''}`
|
||
).join(' • ')}
|
||
</div>` : ''}
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
` : `
|
||
<div class="empty-clients">
|
||
Нет клиентов. <a href="#" onclick="showAddClientModal('${server.id}'); return false;">Добавить первого</a>
|
||
</div>
|
||
`}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// === СЕРВЕРЫ ===
|
||
|
||
async function showCreateServerModal() {
|
||
// Автоподбор доступной подсети и порта
|
||
try {
|
||
const response = await fetch('/api/servers');
|
||
const servers = await response.json() || [];
|
||
|
||
// Автоподбор подсети
|
||
let foundNetwork = null;
|
||
for (let i = 10; i < 255; i++) {
|
||
const network = `${i}.0.0.1`;
|
||
const isUsed = servers.some(s => s.address.startsWith(`${i}.0.0.`));
|
||
if (!isUsed) {
|
||
foundNetwork = network;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Автоподбор порта
|
||
let foundPort = 50000;
|
||
for (let port = 50000; port < 65535; port++) {
|
||
const isUsed = servers.some(s => s.listen_port === port);
|
||
if (!isUsed) {
|
||
foundPort = port;
|
||
break;
|
||
}
|
||
}
|
||
|
||
document.getElementById('server-address').value = foundNetwork || '10.0.0.1';
|
||
document.getElementById('server-port').value = foundPort;
|
||
} catch (error) {
|
||
document.getElementById('server-address').value = '10.0.0.1';
|
||
document.getElementById('server-port').value = '50000';
|
||
}
|
||
|
||
document.getElementById('create-server-modal').classList.add('active');
|
||
}
|
||
|
||
function hideCreateServerModal() {
|
||
document.getElementById('create-server-modal').classList.remove('active');
|
||
}
|
||
|
||
async function createServer(event) {
|
||
event.preventDefault();
|
||
|
||
const formData = new FormData();
|
||
formData.append('name', document.getElementById('server-name').value);
|
||
formData.append('address', document.getElementById('server-address').value);
|
||
formData.append('port', document.getElementById('server-port').value);
|
||
formData.append('dns', document.getElementById('server-dns').value);
|
||
|
||
try {
|
||
const response = await fetch('/api/server/create', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
if (response.ok) {
|
||
hideCreateServerModal();
|
||
await loadData(); // Сразу обновляем
|
||
document.getElementById('server-name').value = '';
|
||
} else {
|
||
alert('Ошибка: ' + await response.text());
|
||
}
|
||
} catch (error) {
|
||
alert('Ошибка создания сервера');
|
||
}
|
||
}
|
||
|
||
async function toggleServer(id) {
|
||
const formData = new FormData();
|
||
formData.append('id', id);
|
||
|
||
try {
|
||
await fetch('/api/server/toggle', { method: 'POST', body: formData });
|
||
await loadData();
|
||
} catch (error) {
|
||
console.error(error);
|
||
}
|
||
}
|
||
|
||
async function deleteServer(id, name) {
|
||
if (!confirm(`Удалить сервер "${name}"?\nВсе клиенты будут удалены!`)) return;
|
||
|
||
const formData = new FormData();
|
||
formData.append('id', id);
|
||
|
||
try {
|
||
await fetch('/api/server/delete', { method: 'POST', body: formData });
|
||
await loadData();
|
||
} catch (error) {
|
||
console.error(error);
|
||
}
|
||
}
|
||
|
||
// === КЛИЕНТЫ ===
|
||
|
||
function showAddClientModal(serverId) {
|
||
document.getElementById('client-server-id').value = serverId;
|
||
document.getElementById('add-client-modal').classList.add('active');
|
||
}
|
||
|
||
function hideAddClientModal() {
|
||
document.getElementById('add-client-modal').classList.remove('active');
|
||
document.getElementById('client-name').value = '';
|
||
document.getElementById('client-comment').value = '';
|
||
}
|
||
|
||
async function createClient(event) {
|
||
event.preventDefault();
|
||
|
||
const formData = new FormData();
|
||
formData.append('server_id', document.getElementById('client-server-id').value);
|
||
formData.append('name', document.getElementById('client-name').value);
|
||
formData.append('comment', document.getElementById('client-comment').value);
|
||
|
||
try {
|
||
await fetch('/api/client/create', { method: 'POST', body: formData });
|
||
hideAddClientModal();
|
||
await loadData();
|
||
} catch (error) {
|
||
alert('Ошибка создания клиента');
|
||
}
|
||
}
|
||
|
||
function showEditClientModal(clientId) {
|
||
const client = clients.find(c => c.id === clientId);
|
||
if (!client) return;
|
||
|
||
document.getElementById('edit-client-id').value = client.id;
|
||
document.getElementById('edit-client-name').value = client.name;
|
||
document.getElementById('edit-client-comment').value = client.comment || '';
|
||
document.getElementById('edit-client-modal').classList.add('active');
|
||
}
|
||
|
||
function hideEditClientModal() {
|
||
document.getElementById('edit-client-modal').classList.remove('active');
|
||
}
|
||
|
||
async function updateClient(event) {
|
||
event.preventDefault();
|
||
|
||
const formData = new FormData();
|
||
formData.append('id', document.getElementById('edit-client-id').value);
|
||
formData.append('name', document.getElementById('edit-client-name').value);
|
||
formData.append('comment', document.getElementById('edit-client-comment').value);
|
||
|
||
try {
|
||
await fetch('/api/client/update', { method: 'POST', body: formData });
|
||
hideEditClientModal();
|
||
await loadData();
|
||
} catch (error) {
|
||
alert('Ошибка обновления клиента');
|
||
}
|
||
}
|
||
|
||
async function toggleClient(id) {
|
||
const formData = new FormData();
|
||
formData.append('id', id);
|
||
|
||
try {
|
||
await fetch('/api/client/toggle', { method: 'POST', body: formData });
|
||
await loadData();
|
||
} catch (error) {
|
||
console.error(error);
|
||
}
|
||
}
|
||
|
||
async function deleteClient(id, name) {
|
||
if (!confirm(`Удалить клиента "${name}"?`)) return;
|
||
|
||
const formData = new FormData();
|
||
formData.append('id', id);
|
||
|
||
try {
|
||
await fetch('/api/client/delete', { method: 'POST', body: formData });
|
||
await loadData();
|
||
} catch (error) {
|
||
console.error(error);
|
||
}
|
||
}
|
||
|
||
function downloadConfig(id, name) {
|
||
window.location.href = `/api/client/download?id=${id}`;
|
||
}
|
||
|
||
function showQR(id) {
|
||
document.getElementById('qr-image').src = `/api/client/qr?id=${id}`;
|
||
document.getElementById('qr-modal').classList.add('active');
|
||
}
|
||
|
||
function hideQRModal() {
|
||
document.getElementById('qr-modal').classList.remove('active');
|
||
}
|
||
|
||
// === ПРОБРОС ПОРТОВ ===
|
||
|
||
function showPortForwardModal(clientId) {
|
||
const client = clients.find(c => c.id === clientId);
|
||
if (!client) return;
|
||
|
||
document.getElementById('pf-client-id').value = clientId;
|
||
renderPortForwards(client);
|
||
document.getElementById('portforward-modal').classList.add('active');
|
||
}
|
||
|
||
function hidePortForwardModal() {
|
||
document.getElementById('portforward-modal').classList.remove('active');
|
||
document.getElementById('pf-port').value = '';
|
||
document.getElementById('pf-description').value = '';
|
||
}
|
||
|
||
function renderPortForwards(client) {
|
||
const container = document.getElementById('portforward-list');
|
||
|
||
if (!client.port_forwards || client.port_forwards.length === 0) {
|
||
container.innerHTML = '<div style="text-align: center; padding: 20px; color: #999;">Нет пробросов портов</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = client.port_forwards.map((pf, idx) => `
|
||
<div style="padding: 10px 0; border-bottom: 1px solid rgba(139, 148, 158, 0.1); display: flex; align-items: center; gap: 10px;">
|
||
<div style="flex: 1;">
|
||
<input type="text" value="${escapeHtml(pf.description || '')}"
|
||
placeholder="Описание"
|
||
onchange="updatePortDescription('${client.id}', ${pf.port}, '${pf.protocol}', this.value)"
|
||
style="width: 100%; padding: 6px; border: 1px solid #e2e8f0; border-radius: 4px; font-size: 13px; background: transparent;"
|
||
class="port-input">
|
||
</div>
|
||
<div style="font-weight: 600; font-size: 13px; min-width: 80px; text-align: center;">
|
||
${pf.port}
|
||
</div>
|
||
<div style="font-size: 12px; color: #666; min-width: 70px; text-align: center;">
|
||
${pf.protocol === 'both' ? 'TCP/UDP' : pf.protocol.toUpperCase()}
|
||
</div>
|
||
<button class="btn btn-sm btn-danger" onclick="removePortForward('${client.id}', ${pf.port}, '${pf.protocol}')">
|
||
✕
|
||
</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
async function addPortForward(event) {
|
||
event.preventDefault();
|
||
|
||
const clientId = document.getElementById('pf-client-id').value;
|
||
const formData = new FormData();
|
||
formData.append('client_id', clientId);
|
||
formData.append('port', document.getElementById('pf-port').value);
|
||
formData.append('protocol', document.getElementById('pf-protocol').value);
|
||
formData.append('description', document.getElementById('pf-description').value);
|
||
|
||
try {
|
||
const response = await fetch('/api/client/portforward/add', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
if (response.ok) {
|
||
await loadData();
|
||
const client = clients.find(c => c.id === clientId);
|
||
renderPortForwards(client);
|
||
document.getElementById('pf-port').value = '';
|
||
document.getElementById('pf-description').value = '';
|
||
} else {
|
||
alert('Ошибка: ' + await response.text());
|
||
}
|
||
} catch (error) {
|
||
alert('Ошибка добавления проброса');
|
||
}
|
||
}
|
||
|
||
async function updatePortDescription(clientId, port, protocol, description) {
|
||
const client = clients.find(c => c.id === clientId);
|
||
if (!client) return;
|
||
|
||
// Обновляем локально
|
||
const pf = client.port_forwards.find(p => p.port === port && p.protocol === protocol);
|
||
if (pf) {
|
||
pf.description = description;
|
||
}
|
||
|
||
// Сохраняем на сервере
|
||
const formData = new FormData();
|
||
formData.append('id', clientId);
|
||
formData.append('name', client.name);
|
||
formData.append('comment', client.comment || '');
|
||
|
||
try {
|
||
await fetch('/api/client/update', { method: 'POST', body: formData });
|
||
saveDatabase();
|
||
} catch (error) {
|
||
console.error('Ошибка обновления:', error);
|
||
}
|
||
}
|
||
|
||
async function removePortForward(clientId, port, protocol) {
|
||
if (!confirm(`Удалить проброс порта ${port}/${protocol}?`)) return;
|
||
|
||
const formData = new FormData();
|
||
formData.append('client_id', clientId);
|
||
formData.append('port', port);
|
||
formData.append('protocol', protocol);
|
||
|
||
try {
|
||
const response = await fetch('/api/client/portforward/remove', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
if (response.ok) {
|
||
await loadData();
|
||
const client = clients.find(c => c.id === clientId);
|
||
renderPortForwards(client);
|
||
} else {
|
||
alert('Ошибка удаления проброса');
|
||
}
|
||
} catch (error) {
|
||
alert('Ошибка удаления проброса');
|
||
}
|
||
}
|
||
|
||
// === УТИЛИТЫ ===
|
||
|
||
function getClientStatus(client) {
|
||
// Проверяем статус на основе последнего handshake
|
||
if (!client.last_handshake || client.last_handshake === '0001-01-01T00:00:00Z') {
|
||
return '<span class="status-dot status-off" title="Не подключен"></span>';
|
||
}
|
||
|
||
const lastHandshake = new Date(client.last_handshake);
|
||
const now = new Date();
|
||
const diff = (now - lastHandshake) / 1000; // секунды
|
||
|
||
// Если handshake меньше 30 секунд назад - онлайн
|
||
// (PersistentKeepalive 10 сек * 3 = запас на задержки)
|
||
if (diff < 30) {
|
||
return '<span class="status-dot status-on" title="Онлайн"></span>';
|
||
}
|
||
|
||
return '<span class="status-dot status-off" title="Оффлайн"></span>';
|
||
}
|
||
|
||
function formatBytes(bytes) {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||
}
|
||
|
||
function formatTime(timestamp) {
|
||
if (!timestamp || timestamp === '0001-01-01T00:00:00Z') return 'никогда';
|
||
const date = new Date(timestamp);
|
||
const now = new Date();
|
||
const diff = Math.floor((now - date) / 1000);
|
||
if (diff < 60) return 'только что';
|
||
if (diff < 3600) return Math.floor(diff / 60) + ' мин';
|
||
if (diff < 86400) return Math.floor(diff / 3600) + ' ч';
|
||
return Math.floor(diff / 86400) + ' д';
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
window.addEventListener('click', (e) => {
|
||
if (e.target.classList.contains('modal')) {
|
||
e.target.classList.remove('active');
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|