Files
MSWP/templates/index.html
T
grendervill deee59ddba version 1.2.3: SSH-туннель в веб-панели, авто-ID бота, фиксы
- templates/index.html: вкладка SSL — замена самоподписного сертификата на SSH-туннель, авто-ID при установке бота
- app.py: API /api/panel/access (get/set ip/ssh), /api/bot/detect-id, правка парсинга трафика
- bot.py: позиционный парсинг mita get users (8 колонок)
- install.sh, mita-ctl.sh: автоопределение Telegram ID через getUpdates API, исправлен unbound variable BOLD, SCRIPT_DIR
- install-panel.sh: копирование bot.py и install-bot.sh в /opt/mita-panel/
- README: обновлена дорожная карта
2026-06-27 00:05:09 +03:00

1148 lines
56 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 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>
<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" 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" 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>
</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;
let body={};
if(mode==='auto'){
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={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={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:[]};
const html=`
<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 style="margin-bottom:20px">
<label style="font-size:.8rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;display:block;margin-bottom:8px">
Источники — geosite / geoip / URL (по одному на строку)
</label>
<textarea id="wr-sources" rows="4" 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="geosite:openai&#10;geosite:netflix&#10;geoip:us&#10;https://example.com/domains.txt">${escH(r.sources.join('\n'))}</textarea>
<div style="font-size:.75rem;color:var(--muted);margin-top:6px;line-height:1.7">
<strong style="color:var(--text)">geosite:категория</strong> — из базы v2fly (openai, google, netflix, category-ru…)<br>
<strong style="color:var(--text)">geoip:страна</strong> — IP-диапазоны страны (us, gb, de…)<br>
<strong style="color:var(--text)">https://…</strong> — прямая ссылка на текстовый файл с доменами или CIDR
</div>
</div>
<div style="display:flex;gap:10px">
<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);
}
async function saveWarpRules(name){
const btn=document.querySelector('#modal-body .btn-primary');
btn.disabled=true;
btn.innerHTML='<span class="spinner"></span> Применяю…';
const domains=document.getElementById('wr-domains').value.split('\n').map(s=>s.trim()).filter(Boolean);
const ips=document.getElementById('wr-ips').value.split('\n').map(s=>s.trim()).filter(Boolean);
const sources=document.getElementById('wr-sources').value.split('\n').map(s=>s.trim()).filter(Boolean);
const d=await api('/api/users/warp_rules','POST',{name,domains,ips,sources});
btn.disabled=false;
btn.textContent='Сохранить и применить';
const el=document.getElementById('wr-result');
if(d?.ok){
el.innerHTML='<div class="alert alert-ok">✓ Правила сохранены и применены в mita</div>';
showToast('Правила WARP обновлены');
}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.querySelector('#page-ssl .btn-warn');
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.querySelector('#page-ssl .btn-warn');
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> Перезапустить панель';
}
// ──────────────── 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 %}