refactor: реорганизация структуры проекта на логические папки

- Созданы директории: docker/, scripts/, config/
- Перемещены файлы Docker (Dockerfile, entrypoint.sh) в docker/
- Перемещены утилитарные скрипты в scripts/
- Шаблон конфигурации перенесен в config/
- Веб-сервер перемещен в web/ и переименован в server.py
- Обновлены пути в docker-compose.yml, Dockerfile и entrypoint.sh
This commit is contained in:
2025-12-23 17:51:50 +03:00
parent 3e2edc8c10
commit 2d61830d08
10 changed files with 1395 additions and 1 deletions

448
web/index.html Normal file
View File

@@ -0,0 +1,448 @@
<!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>