Files
wgServer/internal/server/templates/index.html
2025-10-16 16:27:36 +07:00

1123 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>