feat: Добавлена веб-панель управления VPN-прокси и Docker-конфигурация.

This commit is contained in:
2025-12-27 20:01:38 +03:00
parent 6a9d454d2a
commit b65b48d82b
8 changed files with 785 additions and 374 deletions

View File

@@ -340,6 +340,163 @@
cursor: pointer;
}
/* Tabs */
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.tab {
flex: 1;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 10px;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.tab:hover {
border-color: var(--accent);
color: var(--text-primary);
}
.tab.active {
background: var(--accent);
border-color: var(--accent);
color: white;
box-shadow: 0 4px 12px var(--accent-glow);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Server List */
.server-list {
max-height: 300px;
overflow-y: auto;
margin-bottom: 1rem;
}
.server-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 10px;
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.server-item:hover {
border-color: var(--accent);
background: rgba(99, 102, 241, 0.05);
}
.server-item.selected {
border-color: var(--accent);
background: rgba(99, 102, 241, 0.1);
box-shadow: 0 0 0 2px var(--accent-glow);
}
.server-radio {
width: 18px;
height: 18px;
border: 2px solid var(--border-color);
border-radius: 50%;
position: relative;
flex-shrink: 0;
transition: all 0.2s ease;
}
.server-item.selected .server-radio {
border-color: var(--accent);
}
.server-item.selected .server-radio::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 8px;
height: 8px;
background: var(--accent);
border-radius: 50%;
}
.server-info {
flex: 1;
min-width: 0;
}
.server-name {
font-weight: 500;
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.server-details {
font-size: 0.75rem;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
}
.server-type {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
background: rgba(99, 102, 241, 0.15);
color: var(--accent);
border-radius: 4px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.03em;
}
.btn-secondary {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.btn-secondary:hover {
border-color: var(--accent);
background: rgba(99, 102, 241, 0.1);
}
.btn-group {
display: flex;
gap: 0.75rem;
}
.btn-group .btn {
flex: 1;
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.empty-state-icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
}
/* Responsive */
@media (max-width: 480px) {
body {
@@ -354,6 +511,15 @@
h1 {
font-size: 1.25rem;
}
.tabs {
flex-direction: column;
gap: 0.25rem;
}
.btn-group {
flex-direction: column;
}
}
</style>
</head>
@@ -375,21 +541,62 @@
</div>
</div>
<form id="proxyForm">
<!-- Tabs -->
<div class="tabs">
<button class="tab active" data-tab="subscription">📡 Подписка</button>
<button class="tab" data-tab="vless">🔑 VLESS Ключ</button>
</div>
<!-- Subscription Tab Content -->
<div id="subscription-tab" class="tab-content active">
<div class="form-group">
<label for="urlInput">VLESS Key</label>
<label for="subUrlInput">URL подписки</label>
<div class="input-wrapper">
<input type="text" id="urlInput" placeholder="vless://..." autocomplete="off"
<input type="text" id="subUrlInput" placeholder="https://..." autocomplete="off"
spellcheck="false">
</div>
<p class="hint">Вставьте VLESS ссылку</p>
<p class="hint">Вставьте ссылку подписки (sing-box формат)</p>
</div>
<button type="submit" class="btn btn-primary" id="submitBtn">
<span class="btn-icon"></span>
<span id="btnText">Применить</span>
<button type="button" class="btn btn-secondary" id="fetchServersBtn" style="margin-bottom: 1rem;">
<span class="btn-icon">🔄</span>
<span id="fetchBtnText">Загрузить серверы</span>
</button>
</form>
<div id="serverListContainer" style="display: none;">
<label style="margin-bottom: 0.75rem; display: block;">Выберите сервер</label>
<div class="server-list" id="serverList"></div>
<button type="button" class="btn btn-primary" id="applySubBtn">
<span class="btn-icon"></span>
<span id="applySubBtnText">Применить</span>
</button>
</div>
<div id="emptyServers" class="empty-state" style="display: none;">
<div class="empty-state-icon">📭</div>
<p>Серверы не загружены</p>
</div>
</div>
<!-- VLESS Tab Content -->
<div id="vless-tab" class="tab-content">
<form id="proxyForm">
<div class="form-group">
<label for="urlInput">VLESS Key</label>
<div class="input-wrapper">
<input type="text" id="urlInput" placeholder="vless://..." autocomplete="off"
spellcheck="false">
</div>
<p class="hint">Вставьте VLESS ссылку</p>
</div>
<button type="submit" class="btn btn-primary" id="submitBtn">
<span class="btn-icon"></span>
<span id="btnText">Применить</span>
</button>
</form>
</div>
<div class="message" id="message">
<span class="message-icon" id="messageIcon"></span>
@@ -416,6 +623,7 @@
</div>
<script>
// DOM Elements
const form = document.getElementById('proxyForm');
const urlInput = document.getElementById('urlInput');
const submitBtn = document.getElementById('submitBtn');
@@ -426,6 +634,36 @@
const statusIndicator = document.getElementById('statusIndicator');
const statusValue = document.getElementById('statusValue');
// Subscription elements
const subUrlInput = document.getElementById('subUrlInput');
const fetchServersBtn = document.getElementById('fetchServersBtn');
const fetchBtnText = document.getElementById('fetchBtnText');
const serverListContainer = document.getElementById('serverListContainer');
const serverList = document.getElementById('serverList');
const emptyServers = document.getElementById('emptyServers');
const applySubBtn = document.getElementById('applySubBtn');
const applySubBtnText = document.getElementById('applySubBtnText');
// State
let subscriptionConfig = null;
let selectedServer = null;
let subscriptionUrl = null;
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const targetTab = tab.dataset.tab;
document.getElementById(`${targetTab}-tab`).classList.add('active');
// Hide message when switching tabs
message.className = 'message';
});
});
// Fetch initial status
async function fetchStatus() {
try {
@@ -452,15 +690,16 @@
messageText.textContent = text;
}
function setLoading(loading) {
submitBtn.disabled = loading;
function setLoading(btn, textEl, loading, defaultText = 'Применить') {
btn.disabled = loading;
if (loading) {
btnText.innerHTML = '<div class="spinner"></div>';
textEl.innerHTML = '<div class="spinner"></div>';
} else {
btnText.textContent = 'Применить';
textEl.textContent = defaultText;
}
}
// VLESS form submit
form.addEventListener('submit', async (e) => {
e.preventDefault();
@@ -471,7 +710,7 @@
}
message.className = 'message';
setLoading(true);
setLoading(submitBtn, btnText, true);
try {
const res = await fetch('/apply', {
@@ -492,12 +731,174 @@
} catch (e) {
showMessage('error', `Ошибка сети: ${e.message}`);
} finally {
setLoading(false);
setLoading(submitBtn, btnText, false);
}
});
// Fetch servers from subscription
fetchServersBtn.addEventListener('click', async () => {
const url = subUrlInput.value.trim();
if (!url) {
showMessage('error', 'Введите URL подписки');
return;
}
message.className = 'message';
setLoading(fetchServersBtn, fetchBtnText, true, 'Загрузить серверы');
try {
const res = await fetch('/fetch-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const data = await res.json();
if (data.success && data.servers && data.servers.length > 0) {
subscriptionConfig = data.config;
subscriptionUrl = url; // Save URL for persistence
renderServerList(data.servers);
serverListContainer.style.display = 'block';
emptyServers.style.display = 'none';
showMessage('success', `Найдено ${data.servers.length} сервер(ов)`);
} else {
serverListContainer.style.display = 'none';
emptyServers.style.display = 'block';
showMessage('error', data.error || 'Серверы не найдены');
}
} catch (e) {
showMessage('error', `Ошибка сети: ${e.message}`);
} finally {
setLoading(fetchServersBtn, fetchBtnText, false, 'Загрузить серверы');
}
});
// Render server list
function renderServerList(servers, savedServerTag = null) {
serverList.innerHTML = '';
selectedServer = null;
servers.forEach((server, index) => {
const item = document.createElement('div');
item.className = 'server-item';
item.dataset.tag = server.tag;
item.innerHTML = `
<div class="server-radio"></div>
<div class="server-info">
<div class="server-name">${escapeHtml(server.tag)}</div>
<div class="server-details">${escapeHtml(server.server)}:${server.port}</div>
</div>
<span class="server-type">${server.type}</span>
`;
item.addEventListener('click', () => {
document.querySelectorAll('.server-item').forEach(i => i.classList.remove('selected'));
item.classList.add('selected');
selectedServer = server.tag;
});
// Select saved server or first by default
const shouldSelect = savedServerTag
? server.tag === savedServerTag
: index === 0;
if (shouldSelect) {
item.classList.add('selected');
selectedServer = server.tag;
}
serverList.appendChild(item);
});
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Apply subscription with selected server
applySubBtn.addEventListener('click', async () => {
if (!subscriptionConfig) {
showMessage('error', 'Сначала загрузите серверы');
return;
}
if (!selectedServer) {
showMessage('error', 'Выберите сервер');
return;
}
message.className = 'message';
setLoading(applySubBtn, applySubBtnText, true);
try {
const res = await fetch('/apply-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config: subscriptionConfig,
selectedServer: selectedServer,
subUrl: subscriptionUrl
})
});
const data = await res.json();
if (data.success) {
showMessage('success', data.message || 'Конфигурация применена!');
fetchStatus();
} else {
showMessage('error', data.error || 'Произошла ошибка');
}
} catch (e) {
showMessage('error', `Ошибка сети: ${e.message}`);
} finally {
setLoading(applySubBtn, applySubBtnText, false);
}
});
// Load saved subscription
async function loadSavedSubscription() {
try {
const res = await fetch('/subscription');
const data = await res.json();
if (data.saved && data.url) {
subUrlInput.value = data.url;
subscriptionUrl = data.url;
// Auto-load servers from saved subscription
setLoading(fetchServersBtn, fetchBtnText, true, 'Загрузить серверы');
try {
const subRes = await fetch('/fetch-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: data.url })
});
const subData = await subRes.json();
if (subData.success && subData.servers && subData.servers.length > 0) {
subscriptionConfig = subData.config;
renderServerList(subData.servers, data.selectedServer);
serverListContainer.style.display = 'block';
emptyServers.style.display = 'none';
}
} finally {
setLoading(fetchServersBtn, fetchBtnText, false, 'Загрузить серверы');
}
}
} catch (e) {
console.log('No saved subscription found');
}
}
// Initial load
fetchStatus();
loadSavedSubscription();
// Refresh status every 30 seconds
setInterval(fetchStatus, 30000);