Files

1201 lines
58 KiB
HTML
Raw Permalink Normal View History

{% 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&#10;bob&#10;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)">Строгий &mdash; 3 / 30 min</button>
<button class="btn btn-secondary" onclick="applyPreset(5,3600)">Стандарт &mdash; 5 / 1 chas</button>
<button class="btn btn-secondary" onclick="applyPreset(10,900)">Мягкий &mdash; 10 / 15 min</button>
<button class="btn btn-secondary" onclick="applyPreset(3,86400)">Жёсткий &mdash; 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">&#x25BC;</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&#10;chatgpt.com&#10;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&#10;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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
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 %}