Files
vpn-proxy/web/index.html
Dmitriy Petrov 2d61830d08 refactor: реорганизация структуры проекта на логические папки
- Созданы директории: docker/, scripts/, config/
- Перемещены файлы Docker (Dockerfile, entrypoint.sh) в docker/
- Перемещены утилитарные скрипты в scripts/
- Шаблон конфигурации перенесен в config/
- Веб-сервер перемещен в web/ и переименован в server.py
- Обновлены пути в docker-compose.yml, Dockerfile и entrypoint.sh
2025-12-23 17:51:50 +03:00

449 lines
13 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>VPN Proxy Control</title>
<style>
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-glass: rgba(255, 255, 255, 0.03);
--border-color: rgba(255, 255, 255, 0.08);
--text-primary: #e8e8ec;
--text-secondary: #8b8b9e;
--accent: #6366f1;
--accent-glow: rgba(99, 102, 241, 0.4);
--success: #22c55e;
--error: #ef4444;
--warning: #f59e0b;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
background-image:
radial-gradient(ellipse at top, rgba(99, 102, 241, 0.1) 0%, transparent 50%),
radial-gradient(ellipse at bottom right, rgba(168, 85, 247, 0.08) 0%, transparent 50%);
}
.container {
width: 100%;
max-width: 520px;
}
.card {
background: var(--bg-glass);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: 24px;
padding: 2.5rem;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.3),
0 2px 4px -2px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.logo {
width: 64px;
height: 64px;
background: linear-gradient(135deg, var(--accent), #a855f7);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.25rem;
font-size: 1.75rem;
box-shadow: 0 8px 32px var(--accent-glow);
}
h1 {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.02em;
margin-bottom: 0.5rem;
}
.subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
}
.status-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: var(--bg-secondary);
border-radius: 12px;
margin-bottom: 1.5rem;
border: 1px solid var(--border-color);
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-secondary);
transition: all 0.3s ease;
}
.status-indicator.active {
background: var(--success);
box-shadow: 0 0 12px rgba(34, 197, 94, 0.5);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.status-text {
flex: 1;
}
.status-label {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.125rem;
}
.status-value {
font-size: 0.9rem;
font-weight: 500;
}
.form-group {
margin-bottom: 1.25rem;
}
label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--text-secondary);
}
.input-wrapper {
position: relative;
}
input[type="text"] {
width: 100%;
padding: 1rem 1.25rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
color: var(--text-primary);
font-size: 0.9rem;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
transition: all 0.2s ease;
}
input[type="text"]::placeholder {
color: var(--text-secondary);
opacity: 0.6;
}
input[type="text"]:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.btn {
width: 100%;
padding: 1rem;
border: none;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), #8b5cf6);
color: white;
box-shadow: 0 4px 20px var(--accent-glow);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px var(--accent-glow);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-icon {
font-size: 1.1rem;
}
.spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.message {
margin-top: 1.25rem;
padding: 1rem;
border-radius: 12px;
font-size: 0.875rem;
display: none;
align-items: flex-start;
gap: 0.75rem;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message.show {
display: flex;
}
.message.success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
color: var(--success);
}
.message.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: var(--error);
}
.message-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.footer {
text-align: center;
margin-top: 1.5rem;
color: var(--text-secondary);
font-size: 0.8rem;
}
.footer a {
color: var(--accent);
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
.hint {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
/* Responsive */
@media (max-width: 480px) {
body {
padding: 1rem;
}
.card {
padding: 1.5rem;
border-radius: 20px;
}
h1 {
font-size: 1.25rem;
}
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="header">
<div class="logo">🔐</div>
<h1>VPN Proxy Control</h1>
<p class="subtitle">Управление подключением sing-box</p>
</div>
<div class="status-bar" id="statusBar">
<div class="status-indicator" id="statusIndicator"></div>
<div class="status-text">
<div class="status-label">Статус</div>
<div class="status-value" id="statusValue">Загрузка...</div>
</div>
</div>
<form id="proxyForm">
<div class="form-group">
<label for="urlInput">VLESS / Subscription URL</label>
<div class="input-wrapper">
<input
type="text"
id="urlInput"
placeholder="vless://... или https://subscription.link"
autocomplete="off"
spellcheck="false"
>
</div>
<p class="hint">Вставьте VLESS ссылку или URL подписки</p>
</div>
<button type="submit" class="btn btn-primary" id="submitBtn">
<span class="btn-icon"></span>
<span id="btnText">Применить</span>
</button>
</form>
<div class="message" id="message">
<span class="message-icon" id="messageIcon"></span>
<span id="messageText"></span>
</div>
</div>
<div class="footer">
Proxy работает на порту <strong>8082</strong>
<a href="https://github.com/SagerNet/sing-box" target="_blank">sing-box</a>
</div>
</div>
<script>
const form = document.getElementById('proxyForm');
const urlInput = document.getElementById('urlInput');
const submitBtn = document.getElementById('submitBtn');
const btnText = document.getElementById('btnText');
const message = document.getElementById('message');
const messageIcon = document.getElementById('messageIcon');
const messageText = document.getElementById('messageText');
const statusIndicator = document.getElementById('statusIndicator');
const statusValue = document.getElementById('statusValue');
// Fetch initial status
async function fetchStatus() {
try {
const res = await fetch('/status');
const data = await res.json();
if (data.active) {
statusIndicator.classList.add('active');
statusValue.textContent = data.tag
? `${data.tag} (${data.server})`
: 'Активен';
} else {
statusIndicator.classList.remove('active');
statusValue.textContent = 'Не настроен';
}
} catch (e) {
statusValue.textContent = 'Ошибка загрузки';
}
}
function showMessage(type, text) {
message.className = `message show ${type}`;
messageIcon.textContent = type === 'success' ? '✓' : '✕';
messageText.textContent = text;
}
function setLoading(loading) {
submitBtn.disabled = loading;
if (loading) {
btnText.innerHTML = '<div class="spinner"></div>';
} else {
btnText.textContent = 'Применить';
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const url = urlInput.value.trim();
if (!url) {
showMessage('error', 'Введите URL');
return;
}
message.className = 'message';
setLoading(true);
try {
const res = await fetch('/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const data = await res.json();
if (data.success) {
showMessage('success', data.message || 'Конфигурация применена!');
urlInput.value = '';
fetchStatus();
} else {
showMessage('error', data.error || 'Произошла ошибка');
}
} catch (e) {
showMessage('error', `Ошибка сети: ${e.message}`);
} finally {
setLoading(false);
}
});
// Initial load
fetchStatus();
// Refresh status every 30 seconds
setInterval(fetchStatus, 30000);
</script>
</body>
</html>