1201 lines
58 KiB
HTML
1201 lines
58 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}mita panel{% endblock %}
|
||
{% block content %}
|
||
|
||
<!-- DASHBOARD -->
|
||
<div class="page active" id="page-dashboard">
|
||
<h1>Дашборд</h1>
|
||
<p class="subtitle">Обзор сервера в реальном времени</p>
|
||
<div class="grid-4" id="stat-cards">
|
||
<div class="stat-card"><div class="stat-label">IP сервера</div><div class="stat-value accent" style="font-size:1.1rem" id="s-server-ip">—</div></div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">IP WARP</div>
|
||
<div class="stat-value ok" style="font-size:1.1rem" id="s-warp-ip">—</div>
|
||
<div id="s-warp-status" style="margin-top:6px"><span class="badge badge-off">—</span></div>
|
||
</div>
|
||
<div class="stat-card"><div class="stat-label">Трафик за неделю</div><div class="stat-value accent" id="s-week">—</div><div class="stat-sub">ГБ</div></div>
|
||
<div class="stat-card"><div class="stat-label">Трафик за месяц</div><div class="stat-value ok" id="s-month">—</div><div class="stat-sub">ГБ</div></div>
|
||
</div>
|
||
<div class="grid-2">
|
||
<div class="stat-card">
|
||
<div class="stat-label">Порты mita</div>
|
||
<div class="stat-value" style="font-size:1.1rem" id="s-port">—</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Пользователей</div>
|
||
<div class="stat-value" id="s-users">—</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Статус mita</div>
|
||
<div id="s-mita-status" style="margin-top:6px"><span class="badge badge-off">—</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-title">Мониторинг сервера</div>
|
||
<div class="grid-4" style="margin-bottom:0">
|
||
<div class="stat-card">
|
||
<div class="stat-label">CPU</div>
|
||
<div class="stat-value accent" id="s-cpu">—</div>
|
||
<div class="stat-sub">%</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">RAM</div>
|
||
<div class="stat-value" id="s-ram-pct">—</div>
|
||
<div class="stat-sub" id="s-ram-detail" style="white-space:nowrap">—</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Диск</div>
|
||
<div class="stat-value" id="s-disk-pct">—</div>
|
||
<div class="stat-sub" id="s-disk-detail" style="white-space:nowrap">—</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Аптайм</div>
|
||
<div class="stat-value" style="font-size:1.1rem" id="s-uptime">—</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-title">Быстрые действия</div>
|
||
<div class="btn-group">
|
||
<button class="btn btn-secondary" onclick="loadDashboard()">⟳ Обновить</button>
|
||
<button class="btn btn-secondary" onclick="showPage('users',document.querySelectorAll('.nav-item')[1])">Управление пользователями</button>
|
||
<button class="btn btn-secondary" onclick="showPage('ssl',document.querySelectorAll('.nav-item')[2])">Настройка SSL</button>
|
||
<button class="btn btn-secondary" onclick="showClients()">📱 Клиенты mieru</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Clients modal trigger -->
|
||
<div id="clients-info" style="display:none"></div>
|
||
</div>
|
||
|
||
<!-- USERS -->
|
||
<div class="page" id="page-users">
|
||
<div class="section-head">
|
||
<div><h1>Пользователи</h1><p class="subtitle">Создание и управление доступом</p></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-title">Создать пользователей</div>
|
||
<div class="form-row">
|
||
<div class="form-group" style="max-width:180px">
|
||
<label>Режим создания</label>
|
||
<select id="create-mode" onchange="toggleCreateMode()">
|
||
<option value="auto">Авто (случайные имена)</option>
|
||
<option value="manual">Вручную (свои имена)</option>
|
||
<option value="bulk-manual">Несколько вручную</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="count-group" style="max-width:120px">
|
||
<label>Количество</label>
|
||
<input type="number" id="create-count" value="1" min="1" max="50">
|
||
</div>
|
||
<div class="form-group" id="single-name-group">
|
||
<label>Имя пользователя</label>
|
||
<input type="text" id="create-name" placeholder="например: vasya">
|
||
</div>
|
||
<div class="form-group" style="max-width:160px">
|
||
<label>Сложность пароля</label>
|
||
<select id="create-pwd-mode">
|
||
<option value="hard">Сложный</option>
|
||
<option value="easy">Лёгкий</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<button class="btn btn-primary" onclick="createUsers()">
|
||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4"/></svg>
|
||
Создать
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="bulk-names-group" style="display:none">
|
||
<label style="font-size:.8rem;color:var(--muted);display:block;margin-bottom:6px">Имена (по одному на строку)</label>
|
||
<textarea id="bulk-names" rows="4" style="width:100%;background:var(--surface2);border:1px solid var(--border);
|
||
color:var(--text);padding:10px;border-radius:var(--radius);font-family:var(--mono);font-size:.85rem;resize:vertical;outline:none"
|
||
placeholder="alice bob charlie"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-title">Список пользователей</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Пользователь</th>
|
||
<th title="Трафик за последние 24 часа">День</th>
|
||
<th title="Примерный трафик за 7 дней (на основе 30-дневных данных)">7 дней</th>
|
||
<th title="Трафик за последние 30 дней">30 дней</th>
|
||
<th>WARP</th>
|
||
<th>Правила WARP</th>
|
||
<th>Конфиг</th>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="users-tbody"><tr><td colspan="8" style="color:var(--muted);text-align:center;padding:20px">Загрузка...</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SSL -->
|
||
<div class="page" id="page-ssl">
|
||
<h1>SSL-сертификаты</h1>
|
||
<p class="subtitle">Защита панели управления</p>
|
||
|
||
<div class="card" id="ssl-status-card">
|
||
<div class="card-title">Текущий сертификат</div>
|
||
<div id="ssl-current"><div style="color:var(--muted)">Загрузка...</div></div>
|
||
</div>
|
||
|
||
<div class="grid-2">
|
||
<div class="card">
|
||
<div class="card-title">SSH-туннель</div>
|
||
<p style="color:var(--muted);font-size:.875rem;margin-bottom:16px;line-height:1.6">
|
||
Панель будет слушать только <strong style="color:var(--text)">127.0.0.1</strong> — недоступна снаружи.
|
||
Подключение через зашифрованный SSH-туннель с вашего компьютера.
|
||
</p>
|
||
<div id="ssh-tunnel-info" style="font-size:.82rem;color:var(--muted);margin-bottom:14px;line-height:1.7">
|
||
Загрузка...
|
||
</div>
|
||
<button class="btn btn-warn" id="ssh-tunnel-btn" onclick="switchToSSHTunnel()">
|
||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"/></svg>
|
||
Переключить на SSH-туннель
|
||
</button>
|
||
<div id="ssh-result" style="margin-top:12px"></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-title">Let's Encrypt (бесплатный)</div>
|
||
<p style="color:var(--muted);font-size:.875rem;margin-bottom:16px;line-height:1.6">
|
||
Доверенный сертификат на <strong style="color:var(--text)">90 дней</strong>, продлевается автоматически.
|
||
Требует домен, указывающий на этот сервер, и открытый порт 80.
|
||
</p>
|
||
<div class="form-group" style="margin-bottom:10px">
|
||
<label>Домен</label>
|
||
<input type="text" id="le-domain" placeholder="panel.example.com">
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:14px">
|
||
<label>Email (необязательно)</label>
|
||
<input type="email" id="le-email" placeholder="admin@example.com">
|
||
</div>
|
||
<button class="btn btn-ok" onclick="getLetsEncrypt()">
|
||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"/></svg>
|
||
Получить сертификат
|
||
</button>
|
||
<div id="le-result" style="margin-top:12px"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-title">Перезапуск панели</div>
|
||
<p style="color:var(--muted);font-size:.875rem;margin-bottom:14px;line-height:1.6">
|
||
После смены сертификата необходимо перезапустить панель, чтобы применить изменения.
|
||
</p>
|
||
<button class="btn btn-warn" id="restart-panel-btn" onclick="restartPanel()">
|
||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||
Перезапустить панель
|
||
</button>
|
||
<div id="restart-result" style="margin-top:12px"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-title">Сменить порт веб-панели</div>
|
||
<p style="color:var(--muted);font-size:.875rem;margin-bottom:14px;line-height:1.6">
|
||
Если порт заблокирован провайдером — смените на другой. Панель перезапустится, страница попробует переподключиться.
|
||
</p>
|
||
<div class="form-row">
|
||
<div class="form-group" style="max-width:180px">
|
||
<label>Новый порт</label>
|
||
<input type="number" id="new-panel-port" value="" min="1024" max="65535" placeholder="8080">
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-warn" onclick="changePanelPort()">Сменить порт</button>
|
||
<div id="change-port-result" style="margin-top:12px"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SECURITY / FAIL2BAN -->
|
||
<div class="page" id="page-security">
|
||
<h1>Безопасность</h1>
|
||
<p class="subtitle">Защита панели и настройка fail2ban</p>
|
||
<div class="card" id="f2b-status-card">
|
||
<div class="card-title">Статус fail2ban</div>
|
||
<div id="f2b-status-body"><div style="color:var(--muted)">Загрузка...</div></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-title">Настройка лимита попыток входа</div>
|
||
<p style="color:var(--muted);font-size:.875rem;margin-bottom:18px;line-height:1.6">
|
||
Лимит работает на двух уровнях: встроенный счётчик панели (мгновенно) и fail2ban (блокировка на сетевом уровне).
|
||
</p>
|
||
<div style="margin-bottom:16px">
|
||
<div class="card-title" style="margin-bottom:12px">Быстрые варианты</div>
|
||
<div class="btn-group">
|
||
<button class="btn btn-secondary" onclick="applyPreset(3,1800)">Строгий — 3 / 30 min</button>
|
||
<button class="btn btn-secondary" onclick="applyPreset(5,3600)">Стандарт — 5 / 1 chas</button>
|
||
<button class="btn btn-secondary" onclick="applyPreset(10,900)">Мягкий — 10 / 15 min</button>
|
||
<button class="btn btn-secondary" onclick="applyPreset(3,86400)">Жёсткий — 3 / 24 ch</button>
|
||
</div>
|
||
</div>
|
||
<div class="divider"></div>
|
||
<div class="card-title" style="margin-bottom:12px">Ручная настройка</div>
|
||
<div class="form-row">
|
||
<div class="form-group" style="max-width:200px">
|
||
<label>Попыток до блокировки</label>
|
||
<input type="number" id="f2b-maxretry" value="5" min="1" max="100">
|
||
</div>
|
||
<div class="form-group" style="max-width:220px">
|
||
<label>Время блокировки</label>
|
||
<select id="f2b-bantime" onchange="toggleCustomBantime(this.value)">
|
||
<option value="300">5 минут</option>
|
||
<option value="900">15 минут</option>
|
||
<option value="1800">30 минут</option>
|
||
<option value="3600" selected>1 chas</option>
|
||
<option value="7200">2 часа</option>
|
||
<option value="86400">24 часа</option>
|
||
<option value="604800">7 дней</option>
|
||
<option value="0">Указать вручную (сек)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="custom-bantime-group" style="display:none;max-width:160px">
|
||
<label>Секунды</label>
|
||
<input type="number" id="f2b-bantime-custom" value="3600" min="60">
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
|
||
<label class="toggle">
|
||
<input type="checkbox" id="f2b-install" checked>
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
<span style="font-size:.875rem;color:var(--muted)">Установить fail2ban если не установлен</span>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="saveF2bConfig()">Применить</button>
|
||
<div id="f2b-result" style="margin-top:12px"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TELEGRAM BOT -->
|
||
<div class="page" id="page-bot">
|
||
<h1>Telegram бот</h1>
|
||
<p class="subtitle">Управление сервером через Telegram</p>
|
||
|
||
<div class="card" id="bot-status-card">
|
||
<div class="card-title">Статус бота</div>
|
||
<div id="bot-status-body"><div style="color:var(--muted)">Загрузка...</div></div>
|
||
</div>
|
||
|
||
<div class="card" id="bot-install-card" style="display:none">
|
||
<div class="card-title">Установка бота</div>
|
||
<p style="color:var(--muted);font-size:.875rem;margin-bottom:18px;line-height:1.6">
|
||
Создайте бота через <strong style="color:var(--accent)">@BotFather</strong> в Telegram и скопируйте токен.
|
||
Затем нажмите «Определить ID» и отправьте любое сообщение своему боту.
|
||
</p>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Токен бота (от @BotFather)</label>
|
||
<input type="text" id="bot-token" placeholder="123456:ABC-DEF1234gh...">
|
||
</div>
|
||
<div class="form-group" style="max-width:240px">
|
||
<label>Ваш Telegram ID</label>
|
||
<div style="display:flex;gap:8px">
|
||
<input type="text" id="bot-admin-id" placeholder="123456789" style="flex:1">
|
||
<button class="btn btn-ok btn-sm" onclick="detectBotAdminId()" style="white-space:nowrap">🔍 Определить ID</button>
|
||
</div>
|
||
<div id="bot-detect-status" style="font-size:.75rem;color:var(--muted);margin-top:4px"></div>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="installBot()">
|
||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4"/></svg>
|
||
Установить / обновить
|
||
</button>
|
||
<div id="bot-install-result" style="margin-top:12px"></div>
|
||
</div>
|
||
|
||
<div class="card" id="bot-manage-card" style="display:none">
|
||
<div class="card-title">Управление</div>
|
||
<div class="btn-group">
|
||
<button class="btn btn-secondary" onclick="restartBot()">⟳ Перезапустить</button>
|
||
<button class="btn btn-danger" onclick="uninstallBot()">
|
||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||
Удалить бота
|
||
</button>
|
||
</div>
|
||
<div class="divider"></div>
|
||
<div class="card-title" style="margin-bottom:12px">Добавить админа</div>
|
||
<div class="form-row">
|
||
<div class="form-group" style="max-width:220px">
|
||
<label>Telegram ID</label>
|
||
<input type="text" id="bot-add-admin-id" placeholder="123456789">
|
||
</div>
|
||
</div>
|
||
<div class="btn-group">
|
||
<button class="btn btn-secondary" onclick="addBotAdmin()">Добавить</button>
|
||
</div>
|
||
<div id="bot-admin-result" style="margin-top:12px"></div>
|
||
</div>
|
||
</div>
|
||
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
// ──────────────── DASHBOARD ────────────────
|
||
async function loadDashboard(){
|
||
const d=await api('/api/dashboard');
|
||
if(!d)return;
|
||
document.getElementById('s-server-ip').textContent=d.server_ip||'?';
|
||
document.getElementById('s-warp-ip').textContent=d.warp_ip||'?';
|
||
const warpEl=document.getElementById('s-warp-status');
|
||
if(warpEl){
|
||
const warpOk=d.warp_ip&&d.warp_ip!=='недоступен'&&d.warp_ip!=='?';
|
||
warpEl.innerHTML=warpOk
|
||
?'<span class="badge badge-ok">● Активен</span>'
|
||
:'<span class="badge" style="background:rgba(255,95,109,.15);color:var(--danger)">● Недоступен</span>';
|
||
}
|
||
document.getElementById('s-week').textContent=d.week_gb??'0';
|
||
document.getElementById('s-month').textContent=d.month_gb??'0';
|
||
document.getElementById('s-port').textContent=d.mita_port||'?';
|
||
document.getElementById('s-users').textContent=d.users_count??'0';
|
||
const el=document.getElementById('s-mita-status');
|
||
if(el) el.innerHTML=d.mita_running
|
||
?'<span class="badge badge-ok">● Работает</span>'
|
||
:'<span class="badge" style="background:rgba(255,95,109,.15);color:var(--danger)">● Остановлен</span>';
|
||
}
|
||
loadDashboard();
|
||
|
||
let _statsTimer=null;
|
||
startStatsPolling();
|
||
|
||
function fmtBytes(b){
|
||
if(b>=1024**4)return(b/1024**4).toFixed(1)+' ТБ';
|
||
if(b>=1024**3)return(b/1024**3).toFixed(1)+' ГБ';
|
||
if(b>=1024**2)return(b/1024**2).toFixed(0)+' МБ';
|
||
if(b>=1024) return(b/1024).toFixed(0)+' КБ';
|
||
return b+' Б';
|
||
}
|
||
|
||
async function loadServerStats(){
|
||
const d=await api('/api/stats');
|
||
if(!d)return;
|
||
|
||
const cpuEl=document.getElementById('s-cpu');
|
||
if(cpuEl){
|
||
cpuEl.textContent=d.cpu.toFixed(1);
|
||
cpuEl.style.color=d.cpu>80?'var(--danger)':d.cpu>50?'var(--warn)':'var(--accent)';
|
||
}
|
||
|
||
const ramPctEl=document.getElementById('s-ram-pct');
|
||
if(ramPctEl){
|
||
ramPctEl.textContent=d.ram_pct.toFixed(1)+'%';
|
||
ramPctEl.style.color=d.ram_pct>90?'var(--danger)':d.ram_pct>70?'var(--warn)':'var(--text)';
|
||
}
|
||
const ramDetail=document.getElementById('s-ram-detail');
|
||
if(ramDetail)ramDetail.textContent=fmtBytes(d.ram_used)+' / '+fmtBytes(d.ram_total);
|
||
|
||
const diskPctEl=document.getElementById('s-disk-pct');
|
||
if(diskPctEl){
|
||
diskPctEl.textContent=d.disk_pct.toFixed(1)+'%';
|
||
diskPctEl.style.color=d.disk_pct>90?'var(--danger)':d.disk_pct>75?'var(--warn)':'var(--text)';
|
||
}
|
||
const diskDetail=document.getElementById('s-disk-detail');
|
||
if(diskDetail)diskDetail.textContent=fmtBytes(d.disk_used)+' / '+fmtBytes(d.disk_total);
|
||
}
|
||
|
||
function startStatsPolling(){
|
||
stopStatsPolling();
|
||
loadServerStats();
|
||
_statsTimer=setInterval(loadServerStats,2000);
|
||
}
|
||
|
||
function stopStatsPolling(){
|
||
if(_statsTimer){clearInterval(_statsTimer);_statsTimer=null;}
|
||
}
|
||
|
||
function showClients(){
|
||
const clients=[
|
||
{name:'mieru (официальный CLI)',url:'https://github.com/enfein/mieru',desc:'CLI-клиент для macOS, Linux, Windows, OpenWrt'},
|
||
{name:'Karing',url:'https://github.com/KaringX/karing',desc:'GUI клиент с поддержкой mieru, iOS/Android/Windows/macOS'},
|
||
{name:'NekoBox (NekoRay)',url:'https://github.com/MatsuriDayo/nekoray',desc:'GUI клиент для Windows/Linux, поддерживает mieru через sing-box'},
|
||
{name:'sing-box',url:'https://github.com/SagerNet/sing-box',desc:'Универсальное ядро, поддерживает mieru, все платформы'},
|
||
{name:'Hiddify',url:'https://github.com/hiddify/hiddify-app',desc:'Кросс-платформенный клиент на основе sing-box'},
|
||
{name:'FlClash',url:'https://github.com/chen08209/FlClash',desc:'Клиент на основе Clash, Android/Windows/Linux/macOS'},
|
||
];
|
||
const html=`
|
||
<p style="color:var(--muted);font-size:.85rem;margin-bottom:16px">
|
||
mieru — собственный протокол. Поддерживается в следующих клиентах:
|
||
</p>
|
||
${clients.map(c=>`
|
||
<div style="display:flex;justify-content:space-between;align-items:center;
|
||
padding:12px 0;border-bottom:1px solid var(--border)">
|
||
<div>
|
||
<div style="font-weight:600;margin-bottom:3px">${escH(c.name)}</div>
|
||
<div style="font-size:.8rem;color:var(--muted)">${escH(c.desc)}</div>
|
||
</div>
|
||
<a href="${escH(c.url)}" target="_blank" rel="noopener"
|
||
class="btn btn-secondary btn-sm" style="flex-shrink:0;margin-left:16px">
|
||
GitHub ↗
|
||
</a>
|
||
</div>`).join('')}
|
||
<p style="font-size:.75rem;color:var(--muted);margin-top:16px">
|
||
Для подключения используйте SOCKS5 127.0.0.1:1080 (mieru клиент) или встроенную поддержку протокола в Karing/sing-box.
|
||
</p>`;
|
||
openModal('Клиенты для mieru',html);
|
||
}
|
||
|
||
// ──────────────── USERS ────────────────
|
||
let warpUsers=new Set();
|
||
|
||
function fmtMB(mb){
|
||
if(mb==null||mb===0)return'—';
|
||
if(mb>=1024)return(mb/1024).toFixed(2)+' ГБ';
|
||
if(mb>=1)return mb.toFixed(1)+' МБ';
|
||
return Math.round(mb*1024)+' КБ';
|
||
}
|
||
|
||
async function loadUsers(){
|
||
const [ud,wd,sd]=await Promise.all([
|
||
api('/api/users'),
|
||
api('/api/users/warp_status'),
|
||
api('/api/users/stats'),
|
||
]);
|
||
if(!ud)return;
|
||
warpUsers=new Set(wd?.warp_users||[]);
|
||
const statsMap={};
|
||
(sd?.users||[]).forEach(s=>{statsMap[s.name]=s;});
|
||
|
||
const tbody=document.getElementById('users-tbody');
|
||
if(!ud.users.length){
|
||
tbody.innerHTML='<tr><td colspan="8" style="color:var(--muted);text-align:center;padding:20px">Нет пользователей</td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML=ud.users.map(u=>{
|
||
const wOn=warpUsers.has(u.name);
|
||
const s=statsMap[u.name]||{};
|
||
const statusBadge=s.online
|
||
?'<span class="badge badge-ok" style="margin-left:6px">● онлайн</span>'
|
||
:s.last_active&&s.last_active!=='никогда'
|
||
?`<span class="badge badge-off" style="margin-left:6px;white-space:nowrap">● ${escH(s.last_active)}</span>`
|
||
:'<span class="badge badge-off" style="margin-left:6px">● никогда</span>';
|
||
return `<tr>
|
||
<td>
|
||
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:4px">
|
||
<strong>${escH(u.name)}</strong>${statusBadge}
|
||
</div>
|
||
</td>
|
||
<td style="white-space:nowrap;font-size:.82rem;color:var(--muted)">${fmtMB(s.day_mb)}</td>
|
||
<td style="white-space:nowrap;font-size:.82rem;color:var(--muted)">${fmtMB(s.week_mb)}</td>
|
||
<td style="white-space:nowrap;font-size:.82rem;color:var(--muted)">${fmtMB(s.month_mb)}</td>
|
||
<td>
|
||
<label class="toggle" title="Весь WARP для этого пользователя">
|
||
<input type="checkbox" ${wOn?'checked':''} onchange="setWarp('${escH(u.name)}',this.checked)">
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</td>
|
||
<td>
|
||
<button class="btn btn-secondary btn-sm" onclick="openWarpRules('${escH(u.name)}')" ${wOn?'':'style="opacity:.4;cursor:not-allowed" disabled'}>
|
||
⚙ Настроить
|
||
</button>
|
||
</td>
|
||
<td><button class="btn btn-secondary btn-sm" onclick="showConfig('${escH(u.name)}')">Показать конфиг</button></td>
|
||
<td><button class="btn btn-danger btn-sm" onclick="deleteUser('${escH(u.name)}')">Удалить</button></td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function toggleCreateMode(){
|
||
const mode=document.getElementById('create-mode').value;
|
||
document.getElementById('count-group').style.display=mode==='manual'?'none':'';
|
||
document.getElementById('single-name-group').style.display=mode==='manual'?'':'none';
|
||
document.getElementById('bulk-names-group').style.display=mode==='bulk-manual'?'':'none';
|
||
}
|
||
toggleCreateMode();
|
||
|
||
async function createUsers(){
|
||
const mode=document.getElementById('create-mode').value;
|
||
const pwdMode=document.getElementById('create-pwd-mode').value;
|
||
let body={password_mode:pwdMode};
|
||
if(mode==='auto'){
|
||
body={...body,mode:'auto',count:parseInt(document.getElementById('create-count').value)||1};
|
||
}else if(mode==='manual'){
|
||
const n=document.getElementById('create-name').value.trim();
|
||
if(!n){showToast('Введите имя',true);return;}
|
||
body={...body,mode:'manual',names:[n],count:1};
|
||
}else{
|
||
const lines=document.getElementById('bulk-names').value.split('\n').map(s=>s.trim()).filter(Boolean);
|
||
if(!lines.length){showToast('Введите имена',true);return;}
|
||
body={...body,mode:'manual',names:lines,count:lines.length};
|
||
}
|
||
const d=await api('/api/users/create','POST',body);
|
||
if(!d||!d.created){showToast('Ошибка',true);return;}
|
||
showToast(`Создано: ${d.created.length}`);
|
||
showCreatedConfigs(d.created);
|
||
loadUsers();
|
||
}
|
||
|
||
function showCreatedConfigs(created){
|
||
if(!created.length)return;
|
||
let html='';
|
||
if(created.length===1){
|
||
const u=created[0];
|
||
html=`<p style="margin-bottom:12px;color:var(--muted);font-size:.875rem">
|
||
Пользователь <strong style="color:var(--text)">${escH(u.name)}</strong> создан.<br>
|
||
Пароль: <code style="font-family:var(--mono)">${escH(u.password)}</code></p>
|
||
${codeBlock(u.client_config)}`;
|
||
}else{
|
||
html=`<p style="margin-bottom:16px;color:var(--muted);font-size:.875rem">Создано ${created.length} пользователей. Выберите нужного:</p>`+
|
||
created.map(u=>`
|
||
<div class="accordion-item">
|
||
<div class="accordion-header" onclick="toggleAccordion(this)">
|
||
<span>${escH(u.name)}</span>
|
||
<span class="accordion-arrow">▼</span>
|
||
</div>
|
||
<div class="accordion-body">
|
||
<p style="font-size:.8rem;color:var(--muted);margin-bottom:8px">
|
||
Пароль: <code style="font-family:var(--mono)">${escH(u.password)}</code>
|
||
</p>
|
||
${codeBlock(u.client_config)}
|
||
</div>
|
||
</div>`).join('');
|
||
}
|
||
openModal('Конфиги созданных пользователей', html);
|
||
}
|
||
|
||
async function showConfig(name){
|
||
const d=await api('/api/users/config?name='+encodeURIComponent(name));
|
||
if(!d||!d.ok){
|
||
openModal('Конфиг: '+name,'<div class="alert alert-err">'+(d?.error||'Ошибка загрузки')+'</div>');
|
||
return;
|
||
}
|
||
const enc=encodeURIComponent(name);
|
||
const dlMieru =BASE+'/api/users/config/download?name='+enc+'&format=mieru';
|
||
const dlSingbox=BASE+'/api/users/config/download?name='+enc+'&format=singbox';
|
||
|
||
// Tab IDs
|
||
const tid='cfg_'+Math.random().toString(36).slice(2);
|
||
|
||
const html=`
|
||
<div style="margin-bottom:12px">
|
||
<span style="font-size:.85rem;color:var(--muted)">Пользователь: </span>
|
||
<strong>${escH(d.name)}</strong>
|
||
<span style="margin-left:12px;font-size:.85rem;color:var(--muted)">Пароль: </span>
|
||
<code style="font-family:var(--mono);font-size:.82rem;color:var(--ok)">${escH(d.password)}</code>
|
||
</div>
|
||
|
||
<!-- Tabs -->
|
||
<div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:16px">
|
||
<button id="${tid}_tab_mieru"
|
||
onclick="switchConfigTab('${tid}','mieru')"
|
||
style="padding:8px 18px;font-size:.85rem;font-weight:600;border:none;background:none;
|
||
cursor:pointer;border-bottom:2px solid var(--accent);color:var(--accent)">
|
||
mieru JSON
|
||
</button>
|
||
<button id="${tid}_tab_singbox"
|
||
onclick="switchConfigTab('${tid}','singbox')"
|
||
style="padding:8px 18px;font-size:.85rem;font-weight:600;border:none;background:none;
|
||
cursor:pointer;border-bottom:2px solid transparent;color:var(--muted)">
|
||
sing-box / Karing
|
||
</button>
|
||
<button id="${tid}_tab_qr"
|
||
onclick="switchConfigTab('${tid}','qr')"
|
||
style="padding:8px 18px;font-size:.85rem;font-weight:600;border:none;background:none;
|
||
cursor:pointer;border-bottom:2px solid transparent;color:var(--muted)">
|
||
QR-код
|
||
</button>
|
||
</div>
|
||
|
||
<!-- mieru tab -->
|
||
<div id="${tid}_pane_mieru">
|
||
<p style="font-size:.78rem;color:var(--muted);margin-bottom:8px">
|
||
Для официального клиента <strong style="color:var(--text)">mieru</strong> на macOS, Linux, Windows, OpenWrt.
|
||
</p>
|
||
${codeBlock(d.client_config)}
|
||
<a href="${dlMieru}" download="${escH(d.name)}_mieru.json"
|
||
class="btn btn-secondary btn-sm" style="margin-top:8px">
|
||
⬇ Скачать mieru.json
|
||
</a>
|
||
</div>
|
||
|
||
<!-- sing-box tab -->
|
||
<div id="${tid}_pane_singbox" style="display:none">
|
||
<p style="font-size:.78rem;color:var(--muted);margin-bottom:8px">
|
||
Для <strong style="color:var(--text)">Karing, Hiddify, NekoBox</strong> и других клиентов на базе sing-box.
|
||
Включает TUN-режим и правила для прямого выхода российских IP.
|
||
</p>
|
||
${codeBlock(d.singbox_config)}
|
||
<a href="${dlSingbox}" download="${escH(d.name)}_singbox.json"
|
||
class="btn btn-ok btn-sm" style="margin-top:8px">
|
||
⬇ Скачать singbox.json
|
||
</a>
|
||
|
||
<div class="divider"></div>
|
||
|
||
<div class="accordion-item">
|
||
<div class="accordion-header" onclick="toggleAccordion(this)">
|
||
<span>📋 Если импорт файла не работает — добавить вручную в Karing</span>
|
||
<span class="accordion-arrow">▼</span>
|
||
</div>
|
||
<div class="accordion-body">
|
||
${karingManualInstructions(d)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- QR tab -->
|
||
<div id="${tid}_pane_qr" style="display:none">
|
||
<p style="font-size:.78rem;color:var(--muted);margin-bottom:16px;line-height:1.6">
|
||
Отсканируйте QR-код на телефоне, чтобы скопировать конфиг без ручного набора.<br>
|
||
<strong style="color:var(--text)">mieru JSON</strong> — для официального клиента mieru.<br>
|
||
<strong style="color:var(--text)">sing-box JSON</strong> — для Karing, Hiddify, NekoBox (конфиг большой, качество ниже).
|
||
</p>
|
||
|
||
<div style="display:flex;gap:32px;flex-wrap:wrap;align-items:flex-start">
|
||
<div style="text-align:center">
|
||
<div style="font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.6px;
|
||
color:var(--muted);margin-bottom:10px">mieru JSON</div>
|
||
<div id="${tid}_qr_mieru" style="background:#fff;padding:10px;border-radius:10px;display:inline-block"></div>
|
||
</div>
|
||
<div style="text-align:center">
|
||
<div style="font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.6px;
|
||
color:var(--muted);margin-bottom:10px">sing-box JSON</div>
|
||
<div id="${tid}_qr_singbox" style="background:#fff;padding:10px;border-radius:10px;display:inline-block"></div>
|
||
<div id="${tid}_qr_singbox_warn" style="display:none;font-size:.75rem;color:var(--warn);margin-top:6px;max-width:220px">
|
||
⚠ Конфиг слишком большой для QR — используйте кнопку «Скачать singbox.json»
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
openModal('Конфиг: '+name, html);
|
||
setTimeout(() => _renderConfigQR(tid, d), 0);
|
||
}
|
||
|
||
function karingManualInstructions(d){
|
||
const ip = d.client_config.profiles[0].servers[0].ipAddress;
|
||
const pb = d.client_config.profiles[0].servers[0].portBindings[0];
|
||
const port = (pb.portRange||String(pb.port)).split('-')[0];
|
||
const user = escH(d.name);
|
||
const pass = escH(d.password);
|
||
|
||
const step = (n, html) => `
|
||
<div style="display:flex;gap:12px;margin-bottom:14px">
|
||
<div style="flex-shrink:0;width:24px;height:24px;border-radius:50%;background:var(--surface2);
|
||
border:1px solid var(--border);display:flex;align-items:center;justify-content:center;
|
||
font-size:.75rem;font-weight:700;color:var(--accent)">${n}</div>
|
||
<div style="font-size:.82rem;color:var(--text);line-height:1.6;padding-top:2px">${html}</div>
|
||
</div>`;
|
||
|
||
const field = (label, value) => `
|
||
<div style="margin-top:6px">
|
||
<span style="color:var(--muted)">${label}:</span>
|
||
<code style="font-family:var(--mono);color:var(--ok);background:var(--bg);
|
||
padding:2px 8px;border-radius:5px;margin-left:6px">${value}</code>
|
||
<button class="btn btn-secondary btn-sm" style="padding:2px 8px;margin-left:6px"
|
||
onclick="copyText('${value}')">копировать</button>
|
||
</div>`;
|
||
|
||
return `
|
||
<p style="font-size:.78rem;color:var(--muted);margin-bottom:16px">
|
||
Используйте если кнопка «Скачать singbox.json» → «Импорт» не подключается.
|
||
Karing требует ручного ввода полей mieru через интерфейс.
|
||
</p>
|
||
${step(1, `Откройте Karing → <strong>добавить профиль</strong>`)}
|
||
${step(2, `Выберите тип <strong>«Custom»</strong> и впишите любое имя профиля`)}
|
||
${step(3, `Перейдите в раздел <strong>«My profiles»</strong>`)}
|
||
${step(4, `Нажмите <strong>«+»</strong> в поле только что созданного профиля`)}
|
||
${step(5, `В выпадающем списке протоколов выберите <strong>«Mieru»</strong>`)}
|
||
${step(6, `В поле <strong>«tag»</strong> впишите любое название (например <code>proxy</code>)`)}
|
||
${step(7, `Заполните адрес сервера и порт:
|
||
${field('server', ip)}
|
||
${field('server port', port)}`)}
|
||
${step(8, `Заполните учётные данные:
|
||
${field('username', user)}
|
||
${field('password', pass)}`)}
|
||
${step(9, `В поле <strong>«multiplexing»</strong> выберите <strong>«multiplexing_low»</strong>`)}
|
||
${step(10, `В поле <strong>«transport»</strong> впишите заглавными буквами <code style="font-family:var(--mono);color:var(--ok)">TCP</code>,
|
||
затем нажмите ✓ в правом верхнем углу.<br>
|
||
Убедитесь что в «My profiles» тумблер профиля зелёный, затем нажмите кнопку подключения в главном меню Karing.`)}
|
||
<div class="divider"></div>
|
||
${step(11, `<strong>Diversion Rules (разделение трафика)</strong> — чтобы направить конкретные сайты/приложения мимо VPN или, наоборот, через него:
|
||
<ol style="margin-top:8px;padding-left:18px;color:var(--muted)">
|
||
<li>Нажмите карандаш (✏) в правом верхнем углу раздела «Diversion Rules»</li>
|
||
<li>Нажмите «⋮» (три точки) там же → <strong>«+ Add»</strong></li>
|
||
<li>Введите название правила, прокрутите список вниз и выберите созданное правило</li>
|
||
<li>Добавьте нужные адреса/приложения, нажмите ✓ в правом верхнем углу</li>
|
||
<li>Вернитесь в «Diversion Rules», найдите своё правило внизу списка</li>
|
||
<li>Нажмите на него и выберите <strong>«Direct»</strong> (трафик идёт мимо VPN)
|
||
или раскройте имя своего профиля и выберите имя VPN-конфига (трафик идёт через VPN)</li>
|
||
</ol>`)}
|
||
`;
|
||
}
|
||
|
||
function switchConfigTab(tid, tab) {
|
||
['mieru','singbox','qr'].forEach(t => {
|
||
const isActive = t === tab;
|
||
const btn = document.getElementById(tid+'_tab_'+t);
|
||
const pane = document.getElementById(tid+'_pane_'+t);
|
||
if (!btn || !pane) return;
|
||
btn.style.borderBottomColor = isActive ? 'var(--accent)' : 'transparent';
|
||
btn.style.color = isActive ? 'var(--accent)' : 'var(--muted)';
|
||
pane.style.display = isActive ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function _renderConfigQR(tid, d) {
|
||
const QR_MAX = 2953; // QR Version 40-L binary limit
|
||
|
||
function makeQR(elId, text, warnElId) {
|
||
const el = document.getElementById(elId);
|
||
if (!el) return;
|
||
if (text.length > QR_MAX) {
|
||
el.style.display = 'none';
|
||
const warn = document.getElementById(warnElId);
|
||
if (warn) warn.style.display = 'block';
|
||
return;
|
||
}
|
||
try {
|
||
new QRCode(el, {
|
||
text,
|
||
width: 220,
|
||
height: 220,
|
||
colorDark: '#000000',
|
||
colorLight: '#ffffff',
|
||
correctLevel: QRCode.CorrectLevel.L
|
||
});
|
||
} catch(e) {
|
||
el.style.display = 'none';
|
||
const warn = document.getElementById(warnElId);
|
||
if (warn) { warn.style.display = 'block'; warn.textContent = '⚠ Не удалось создать QR: ' + e.message; }
|
||
}
|
||
}
|
||
|
||
makeQR(tid+'_qr_mieru', JSON.stringify(d.client_config), null);
|
||
makeQR(tid+'_qr_singbox', JSON.stringify(d.singbox_config), tid+'_qr_singbox_warn');
|
||
}
|
||
|
||
async function deleteUser(name){
|
||
if(!confirm(`Удалить пользователя "${name}"?`))return;
|
||
await api('/api/users/delete','POST',{name});
|
||
showToast(`${name} удалён`);
|
||
loadUsers();
|
||
}
|
||
|
||
async function setWarp(name,enabled){
|
||
await api('/api/users/warp','POST',{name,enabled});
|
||
showToast(`WARP для ${name}: ${enabled?'включён':'выключен'}`);
|
||
loadUsers();
|
||
}
|
||
|
||
async function openWarpRules(name){
|
||
// Загрузить текущие правила
|
||
const d=await api('/api/users/warp_rules?name='+encodeURIComponent(name));
|
||
const r=d?.rules||{domains:[],ips:[],sources:[],full_warp:false};
|
||
|
||
const html=`
|
||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding:12px 16px;background:var(--surface2);border:1px solid var(--border);border-radius:8px">
|
||
<label class="toggle" style="flex-shrink:0">
|
||
<input type="checkbox" id="wr-full-warp" ${r.full_warp?'checked':''}
|
||
onchange="toggleFullWarp(this.checked)">
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
<div>
|
||
<div style="font-weight:600;font-size:.9rem">Весь трафик через WARP</div>
|
||
<div style="font-size:.75rem;color:var(--muted)">Если включено — правила ниже игнорируются, всё идёт через WARP</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="wr-rules-section">
|
||
<p style="color:var(--muted);font-size:.85rem;margin-bottom:18px;line-height:1.6">
|
||
Если «Весь трафик» выключен — через <strong style="color:var(--accent)">WARP</strong> пойдут только указанные домены и IP.<br>
|
||
Всё остальное идёт напрямую с IP вашего сервера.
|
||
</p>
|
||
|
||
<div style="margin-bottom:16px">
|
||
<label style="font-size:.8rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;display:block;margin-bottom:8px">
|
||
Домены (по одному на строку)
|
||
</label>
|
||
<textarea id="wr-domains" rows="5" style="width:100%;background:var(--bg);border:1px solid var(--border);
|
||
color:var(--text);padding:10px 12px;border-radius:8px;font-family:var(--mono);font-size:.82rem;
|
||
resize:vertical;outline:none;line-height:1.6" placeholder="openai.com chatgpt.com anthropic.com">${escH(r.domains.join('\n'))}</textarea>
|
||
<p style="font-size:.75rem;color:var(--muted);margin-top:4px">Субдомены включаются автоматически</p>
|
||
</div>
|
||
|
||
<div style="margin-bottom:16px">
|
||
<label style="font-size:.8rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;display:block;margin-bottom:8px">
|
||
IP-диапазоны / CIDR (по одному на строку)
|
||
</label>
|
||
<textarea id="wr-ips" rows="3" style="width:100%;background:var(--bg);border:1px solid var(--border);
|
||
color:var(--text);padding:10px 12px;border-radius:8px;font-family:var(--mono);font-size:.82rem;
|
||
resize:vertical;outline:none;line-height:1.6" placeholder="104.16.0.0/12 172.64.0.0/13">${escH(r.ips.join('\n'))}</textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:10px;margin-top:16px">
|
||
<button class="btn btn-primary" onclick="saveWarpRules('${escH(name)}')">
|
||
Сохранить и применить
|
||
</button>
|
||
<button class="btn btn-secondary" onclick="closeModal()">Отмена</button>
|
||
</div>
|
||
<div id="wr-result" style="margin-top:12px"></div>
|
||
`;
|
||
openModal('WARP-правила: '+name, html);
|
||
}
|
||
|
||
function toggleFullWarp(checked){
|
||
const el=document.getElementById('wr-rules-section');
|
||
if(el)el.style.display=checked?'none':'';
|
||
}
|
||
|
||
async function saveWarpRules(name){
|
||
const btn=document.querySelector('#modal-body .btn-primary');
|
||
btn.disabled=true;
|
||
btn.innerHTML='<span class="spinner"></span> Применяю…';
|
||
|
||
const fullWarp=document.getElementById('wr-full-warp')?.checked||false;
|
||
const domains=fullWarp?[]:document.getElementById('wr-domains').value.split('\n').map(s=>s.trim()).filter(Boolean);
|
||
const ips=fullWarp?[]:document.getElementById('wr-ips').value.split('\n').map(s=>s.trim()).filter(Boolean);
|
||
|
||
const d=await api('/api/users/warp_rules','POST',{name,domains,ips,sources:[],full_warp:fullWarp});
|
||
btn.disabled=false;
|
||
btn.textContent='Сохранить и применить';
|
||
|
||
const el=document.getElementById('wr-result');
|
||
if(d?.ok){
|
||
el.innerHTML=`<div class="alert alert-ok">✓ ${fullWarp?'Весь трафик через WARP':'Правила сохранены'} и применены в mita</div>`;
|
||
showToast(fullWarp?'WARP: весь трафик для '+name:'Правила WARP обновлены');
|
||
loadUsers();
|
||
}else{
|
||
el.innerHTML=`<div class="alert alert-err">Ошибка: ${escH(d?.error||'')}</div>`;
|
||
}
|
||
}
|
||
|
||
// ──────────────── SSL ────────────────
|
||
async function loadSSL(){
|
||
const d=await api('/api/ssl/status');
|
||
if(!d)return;
|
||
const el=document.getElementById('ssl-current');
|
||
if(d.type==='none'){
|
||
el.innerHTML='<div class="alert alert-warn">Сертификат не настроен. Панель работает по HTTP.</div>';
|
||
}else{
|
||
const icon=d.type==='letsencrypt'?'🔒':'🔑';
|
||
const label=d.type==='letsencrypt'?"Let's Encrypt":'Самоподписной';
|
||
el.innerHTML=`
|
||
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap">
|
||
<div style="font-size:2rem">${icon}</div>
|
||
<div>
|
||
<div style="font-weight:700;margin-bottom:4px">${label}</div>
|
||
${d.domain?`<div style="color:var(--muted);font-size:.85rem">Домен: <span style="color:var(--accent)">${escH(d.domain)}</span></div>`:''}
|
||
<div style="color:var(--muted);font-size:.85rem">Файл: <code style="font-family:var(--mono)">${escH(d.cert)}</code></div>
|
||
<div style="color:var(--muted);font-size:.85rem;margin-top:4px">${escH(d.expires)}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
loadPanelAccess();
|
||
}
|
||
|
||
async function loadPanelAccess() {
|
||
const d = await api('/api/panel/access');
|
||
if (!d) return;
|
||
const el = document.getElementById('ssh-tunnel-info');
|
||
if (!el) return;
|
||
const icon = d.mode === 'ssh' ? '🔒' : '🌐';
|
||
const label = d.mode === 'ssh' ? 'SSH-туннель (только 127.0.0.1)' : 'Доступ по IP (0.0.0.0)';
|
||
const sshCmd = `ssh -L ${d.panel_port}:127.0.0.1:${d.panel_port} -p ${d.ssh_port} root@${d.server_ip} -N`;
|
||
el.innerHTML = `
|
||
<div style="margin-bottom:8px">${icon} <strong>${label}</strong></div>
|
||
<div>Сервер: <code style="color:var(--accent)">${escH(d.server_ip)}</code></div>
|
||
<div>Порт: <code style="color:var(--accent)">${d.panel_port}</code></div>
|
||
<div style="margin-top:8px;font-size:.8rem;color:var(--ok)">Команда для туннеля:</div>
|
||
<div class="code-block" id="ssh-cmd-block">${escH(sshCmd)}</div>
|
||
<button class="btn btn-secondary btn-sm" style="margin-top:4px" onclick="copyText(document.getElementById('ssh-cmd-block').textContent)">Копировать</button>`;
|
||
}
|
||
|
||
async function switchToSSHTunnel() {
|
||
const btn = document.getElementById('ssh-tunnel-btn');
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner"></span> Переключение...';
|
||
|
||
const el = document.getElementById('ssh-result');
|
||
const d = await api('/api/panel/access', 'POST', {mode: 'ssh'});
|
||
|
||
btn.disabled = false;
|
||
btn.innerHTML = '<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"/></svg> Переключить на SSH-туннель';
|
||
|
||
if (d?.ok) {
|
||
el.innerHTML = '<div class="alert alert-ok">✓ Панель переключена на SSH-туннель. Используйте команду ниже для подключения.</div>';
|
||
showToast('SSH-туннель активирован');
|
||
loadPanelAccess();
|
||
} else {
|
||
el.innerHTML = `<div class="alert alert-err">Ошибка: ${escH(d?.error||'')}</div>`;
|
||
}
|
||
}
|
||
|
||
async function getLetsEncrypt(){
|
||
const domain=document.getElementById('le-domain').value.trim();
|
||
const email=document.getElementById('le-email').value.trim();
|
||
if(!domain){showToast('Укажите домен',true);return;}
|
||
const btn=event.target;
|
||
btn.disabled=true;btn.innerHTML='<span class="spinner"></span> Получение (может занять минуту)...';
|
||
const el=document.getElementById('le-result');
|
||
const d=await api('/api/ssl/letsencrypt','POST',{domain,email});
|
||
btn.disabled=false;btn.textContent="Получить сертификат";
|
||
if(!d)return;
|
||
if(d.ok){
|
||
el.innerHTML=`<div class="alert alert-ok">✓ Сертификат получен!<br><small>${escH(d.expires||'')}</small><br><small style="opacity:.7">${escH(d.note||'')}</small></div>`;
|
||
loadSSL();
|
||
}else{
|
||
el.innerHTML=`<div class="alert alert-err">Ошибка:<br><pre style="white-space:pre-wrap;font-size:.78rem;margin-top:6px">${escH(d.error||'')}</pre></div>`;
|
||
}
|
||
}
|
||
|
||
async function restartPanel(){
|
||
const btn=document.getElementById('restart-panel-btn');
|
||
btn.disabled=true;
|
||
btn.innerHTML='<span class="spinner"></span> Перезапуск...';
|
||
const el=document.getElementById('restart-result');
|
||
try{
|
||
const d=await api('/api/panel/restart','POST');
|
||
if(d?.ok){
|
||
el.innerHTML='<div class="alert alert-ok">✓ Панель перезапущена</div>';
|
||
showToast('Панель перезапущена');
|
||
}else{
|
||
el.innerHTML=`<div class="alert alert-err">Ошибка: ${escH(d?.error||'')}</div>`;
|
||
}
|
||
}catch(e){
|
||
el.innerHTML=`<div class="alert alert-err">Ошибка: ${escH(e.message)}</div>`;
|
||
}
|
||
btn.disabled=false;
|
||
btn.innerHTML='<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg> Перезапустить панель';
|
||
}
|
||
|
||
async function changePanelPort(){
|
||
const port=parseInt(document.getElementById('new-panel-port').value);
|
||
if(!port||port<1024||port>65535){showToast('Порт должен быть от 1024 до 65535',true);return;}
|
||
if(!confirm(`Сменить порт на ${port}? Панель перезапустится, страница попробует переподключиться.`))return;
|
||
|
||
const el=document.getElementById('change-port-result');
|
||
el.innerHTML='<span class="spinner"></span> Меняю порт...';
|
||
|
||
try{
|
||
const d=await api('/api/panel/change-port','POST',{port});
|
||
if(d?.ok){
|
||
el.innerHTML=`<div class="alert alert-ok">✓ Порт изменён на ${d.port}. Перенаправление...</div>`;
|
||
showToast(`Новый порт: ${d.port}`);
|
||
if(d.new_url){
|
||
// Ждём 3 секунды пока панель перезапустится и переходим на новый URL
|
||
setTimeout(()=>{window.location.href=d.new_url;},3000);
|
||
}
|
||
}else{
|
||
el.innerHTML=`<div class="alert alert-err">Ошибка: ${escH(d?.error||'')}</div>`;
|
||
}
|
||
}catch(e){
|
||
el.innerHTML=`<div class="alert alert-err">Ошибка: ${escH(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
// ──────────────── TELEGRAM BOT ────────────────
|
||
async function loadBotPage() {
|
||
const d = await api('/api/bot/status');
|
||
if (!d) return;
|
||
const statusEl = document.getElementById('bot-status-body');
|
||
const installEl = document.getElementById('bot-install-card');
|
||
const manageEl = document.getElementById('bot-manage-card');
|
||
|
||
if (d.installed) {
|
||
statusEl.innerHTML = `
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:10px">
|
||
<span class="badge badge-ok">Установлен</span>
|
||
${d.running
|
||
? '<span class="badge badge-ok">● Активен</span>'
|
||
: '<span class="badge" style="background:rgba(255,95,109,.15);color:var(--danger)">● Остановлен</span>'}
|
||
</div>
|
||
<div style="font-size:.85rem;color:var(--muted)">
|
||
Токен: <code style="color:var(--accent)">${escH(d.token||'—')}</code>
|
||
</div>
|
||
<div style="font-size:.85rem;color:var(--muted);margin-top:4px">
|
||
Админы (Telegram ID): <strong style="color:var(--text)">${escH((d.admin_ids||[]).join(', ')||'—')}</strong>
|
||
</div>`;
|
||
installEl.style.display = 'none';
|
||
manageEl.style.display = '';
|
||
} else {
|
||
statusEl.innerHTML = '<div style="color:var(--warn)">Бот не установлен</div>';
|
||
installEl.style.display = '';
|
||
manageEl.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function detectBotAdminId() {
|
||
const token = document.getElementById('bot-token').value.trim();
|
||
if (!token) { showToast('Введите токен бота', true); return; }
|
||
|
||
const el = document.getElementById('bot-detect-status');
|
||
const btn = document.querySelector('#bot-install-card .btn-ok');
|
||
|
||
el.textContent = 'Отправьте любое сообщение боту в Telegram. Ожидание (60 сек)...';
|
||
el.style.color = 'var(--warn)';
|
||
if (btn) { btn.disabled = true; btn.textContent = '⏳ Ожидание...'; }
|
||
|
||
try {
|
||
const d = await api('/api/bot/detect-id', 'POST', {token});
|
||
if (d?.ok) {
|
||
document.getElementById('bot-admin-id').value = d.admin_id;
|
||
el.textContent = '✓ ID определён: ' + d.admin_id;
|
||
el.style.color = 'var(--ok)';
|
||
showToast('Telegram ID определён');
|
||
} else {
|
||
el.textContent = '⚠ ' + (d?.error || 'Не удалось');
|
||
el.style.color = 'var(--danger)';
|
||
}
|
||
} catch(e) {
|
||
el.textContent = '⚠ Ошибка: ' + e.message;
|
||
el.style.color = 'var(--danger)';
|
||
}
|
||
if (btn) { btn.disabled = false; btn.textContent = '🔍 Определить ID'; }
|
||
}
|
||
|
||
async function installBot() {
|
||
const token = document.getElementById('bot-token').value.trim();
|
||
const adminId = document.getElementById('bot-admin-id').value.trim();
|
||
if (!token) { showToast('Введите токен', true); return; }
|
||
if (!adminId) { showToast('Введите Telegram ID', true); return; }
|
||
|
||
const btn = document.querySelector('#bot-install-card .btn-primary');
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner"></span> Установка...';
|
||
const d = await api('/api/bot/install', 'POST', {token, admin_id: adminId});
|
||
btn.disabled = false;
|
||
btn.innerHTML = '<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4"/></svg> Установить / обновить';
|
||
|
||
const el = document.getElementById('bot-install-result');
|
||
if (d?.ok) {
|
||
el.innerHTML = `<div class="alert alert-ok">✓ Бот установлен и ${d.running ? 'запущен' : 'установлен (проверьте логи)'}</div>`;
|
||
showToast('Бот установлен');
|
||
loadBotPage();
|
||
} else {
|
||
el.innerHTML = `<div class="alert alert-err">Ошибка: ${escH(d?.error||d?.output||'')}</div>`;
|
||
}
|
||
}
|
||
|
||
async function uninstallBot() {
|
||
if (!confirm('Удалить Telegram-бота? Это действие необратимо.')) return;
|
||
await api('/api/bot/uninstall', 'POST');
|
||
showToast('Бот удалён');
|
||
loadBotPage();
|
||
}
|
||
|
||
async function restartBot() {
|
||
const d = await api('/api/bot/config', 'POST', {});
|
||
if (d?.ok) {
|
||
showToast('Бот перезапущен');
|
||
setTimeout(loadBotPage, 1500);
|
||
} else {
|
||
showToast('Ошибка перезапуска', true);
|
||
}
|
||
}
|
||
|
||
async function addBotAdmin() {
|
||
const id = document.getElementById('bot-add-admin-id').value.trim();
|
||
if (!id) { showToast('Введите Telegram ID', true); return; }
|
||
const d = await api('/api/bot/config', 'POST', {admin_id: id});
|
||
const el = document.getElementById('bot-admin-result');
|
||
if (d?.ok) {
|
||
el.innerHTML = '<div class="alert alert-ok">✓ Админ добавлен</div>';
|
||
document.getElementById('bot-add-admin-id').value = '';
|
||
loadBotPage();
|
||
} else {
|
||
el.innerHTML = `<div class="alert alert-err">Ошибка: ${escH(d?.error||'')}</div>`;
|
||
}
|
||
}
|
||
async function loadSecurityPage() {
|
||
const d = await api('/api/fail2ban/status');
|
||
if (!d) return;
|
||
const el = document.getElementById('f2b-status-body');
|
||
const instBadge = d.installed
|
||
? '<span class="badge badge-ok">Установлен</span>'
|
||
: '<span class="badge badge-off">Не установлен</span>';
|
||
const activeBadge = d.active
|
||
? '<span class="badge badge-ok">Активен</span>'
|
||
: '<span class="badge badge-warn">Остановлен</span>';
|
||
const jailBadge = d.jail_active
|
||
? '<span class="badge badge-ok">Jail активен</span>'
|
||
: '<span class="badge badge-off">Jail не настроен</span>';
|
||
el.innerHTML = `
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px">
|
||
${instBadge} ${d.installed ? activeBadge : ''} ${d.active ? jailBadge : ''}
|
||
</div>
|
||
<div style="font-size:.85rem;color:var(--muted)">
|
||
Текущие лимиты:
|
||
<strong style="color:var(--text)">${d.max_retry} попыток</strong> /
|
||
<strong style="color:var(--text)">${fmtSeconds(d.ban_time)}</strong>
|
||
</div>`;
|
||
// Sync inputs
|
||
document.getElementById('f2b-maxretry').value = d.max_retry;
|
||
const sel = document.getElementById('f2b-bantime');
|
||
const opts = [...sel.options].map(o => o.value);
|
||
if (opts.includes(String(d.ban_time))) {
|
||
sel.value = d.ban_time;
|
||
} else {
|
||
sel.value = '0';
|
||
document.getElementById('f2b-bantime-custom').value = d.ban_time;
|
||
document.getElementById('custom-bantime-group').style.display = '';
|
||
}
|
||
}
|
||
|
||
function fmtSeconds(s) {
|
||
if (s >= 86400) return Math.round(s/86400) + ' дн.';
|
||
if (s >= 3600) return Math.round(s/3600) + ' ч.';
|
||
if (s >= 60) return Math.round(s/60) + ' мин.';
|
||
return s + ' сек.';
|
||
}
|
||
|
||
function toggleCustomBantime(val) {
|
||
document.getElementById('custom-bantime-group').style.display =
|
||
val === '0' ? '' : 'none';
|
||
}
|
||
|
||
function applyPreset(maxRetry, banTime) {
|
||
document.getElementById('f2b-maxretry').value = maxRetry;
|
||
const sel = document.getElementById('f2b-bantime');
|
||
const opts = [...sel.options].map(o => o.value);
|
||
if (opts.includes(String(banTime))) {
|
||
sel.value = banTime;
|
||
document.getElementById('custom-bantime-group').style.display = 'none';
|
||
} else {
|
||
sel.value = '0';
|
||
document.getElementById('f2b-bantime-custom').value = banTime;
|
||
document.getElementById('custom-bantime-group').style.display = '';
|
||
}
|
||
saveF2bConfig();
|
||
}
|
||
|
||
async function saveF2bConfig() {
|
||
const maxRetry = parseInt(document.getElementById('f2b-maxretry').value) || 5;
|
||
const selVal = document.getElementById('f2b-bantime').value;
|
||
const banTime = selVal === '0'
|
||
? parseInt(document.getElementById('f2b-bantime-custom').value) || 3600
|
||
: parseInt(selVal);
|
||
const install = document.getElementById('f2b-install').checked;
|
||
const btn = document.querySelector('#page-security .btn-primary');
|
||
const el = document.getElementById('f2b-result');
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner"></span> Применяю…';
|
||
const d = await api('/api/fail2ban/configure', 'POST', {max_retry: maxRetry, ban_time: banTime, install});
|
||
btn.disabled = false;
|
||
btn.textContent = 'Применить';
|
||
if (d?.ok) {
|
||
el.innerHTML = `<div class="alert alert-ok">✓ ${escH(d.message||'Применено')}</div>`;
|
||
loadSecurityPage();
|
||
} else {
|
||
el.innerHTML = `<div class="alert alert-err">Ошибка: ${escH(d?.error||'')}</div>`;
|
||
}
|
||
}
|
||
|
||
function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||
function codeBlock(json){
|
||
const txt=JSON.stringify(json,null,2);
|
||
const id='cb'+Math.random().toString(36).slice(2);
|
||
return `<div class="code-block" id="${id}">${escH(txt)}</div>
|
||
<button class="btn btn-secondary btn-sm" style="margin-top:8px" onclick="copyText(document.getElementById('${id}').textContent)">Копировать</button>`;
|
||
}
|
||
</script>
|
||
{% endblock %}
|