version 0.3 Добавлена инструкция для Karing, добавлена установка необходимых зависимостей

This commit is contained in:
2026-06-19 14:30:41 +03:00
parent a0f6ad2895
commit 23cd9a2c92
9 changed files with 3871 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}mita panel{% endblock %}</title>
<style>
:root{--bg:#0d0f14;--surface:#141720;--surface2:#1c2030;--border:#252a3a;
--accent:#4f8fff;--accent2:#7b5fff;--ok:#3ecf8e;--warn:#f5a623;
--danger:#ff5f6d;--text:#e2e8f4;--muted:#6b7594;
--mono:'JetBrains Mono','Fira Mono',monospace;--sans:'Inter',system-ui,sans-serif;
--radius:10px;--radius-lg:16px}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{height:100%;font-size:15px}
body{font-family:var(--sans);background:var(--bg);color:var(--text);min-height:100%;display:flex}
.sidebar{width:220px;flex-shrink:0;background:var(--surface);border-right:1px solid var(--border);
display:flex;flex-direction:column;padding:28px 0 20px;position:fixed;top:0;left:0;height:100vh;z-index:100}
.sidebar-logo{font-size:1.2rem;font-weight:700;color:var(--text);padding:0 24px 28px;display:flex;align-items:center;gap:10px}
.logo-badge{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;
font-size:.75rem;font-weight:700;padding:4px 8px;border-radius:6px;letter-spacing:.5px}
.nav-item{display:flex;align-items:center;gap:12px;padding:10px 24px;color:var(--muted);
text-decoration:none;font-size:.875rem;font-weight:500;
border-top:none;border-right:none;border-bottom:none;border-left:3px solid transparent;
background:none;width:100%;text-align:left;transition:.15s;cursor:pointer}
.nav-item:hover{color:var(--text);background:var(--surface2)}
.nav-item.active{color:var(--accent);border-left-color:var(--accent);background:rgba(79,143,255,.08)}
.nav-icon{width:18px;height:18px;flex-shrink:0}
.sidebar-footer{margin-top:auto;padding:16px 24px;border-top:1px solid var(--border)}
.sidebar-footer a{color:var(--muted);font-size:.8rem;text-decoration:none}
.sidebar-footer a:hover{color:var(--danger)}
.main{margin-left:220px;flex:1;padding:36px 40px;min-height:100vh}
.page{display:none}.page.active{display:block}
h1{font-size:1.5rem;font-weight:700;margin-bottom:4px;letter-spacing:-.3px}
.subtitle{color:var(--muted);font-size:.875rem;margin-bottom:28px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;margin-bottom:20px}
.card-title{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.8px;color:var(--muted);margin-bottom:16px}
.grid-4{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:20px}
.grid-2{display:grid;grid-template-columns:repeat(2,1fr);gap:16px;margin-bottom:20px}
.stat-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px}
.stat-label{font-size:.75rem;color:var(--muted);font-weight:500;margin-bottom:8px;text-transform:uppercase;letter-spacing:.6px}
.stat-value{font-size:1.75rem;font-weight:700;font-family:var(--mono);color:var(--text);line-height:1}
.stat-value.accent{color:var(--accent)}.stat-value.ok{color:var(--ok)}
.stat-sub{font-size:.75rem;color:var(--muted);margin-top:6px}
.ip-badge{font-family:var(--mono);font-size:.8rem;background:var(--surface2);border:1px solid var(--border);padding:3px 8px;border-radius:6px;color:var(--accent)}
.btn{display:inline-flex;align-items:center;gap:8px;padding:9px 18px;border-radius:var(--radius);
font-size:.875rem;font-weight:600;border:1px solid transparent;cursor:pointer;transition:.15s;text-decoration:none;white-space:nowrap;font-family:var(--sans)}
.btn-primary{background:var(--accent);color:#fff;border-color:var(--accent)}.btn-primary:hover{background:#3d7aff}
.btn-secondary{background:var(--surface2);color:var(--text);border-color:var(--border)}.btn-secondary:hover{border-color:var(--accent);color:var(--accent)}
.btn-danger{background:transparent;color:var(--danger);border-color:var(--danger)}.btn-danger:hover{background:var(--danger);color:#fff}
.btn-ok{background:var(--ok);color:#0d0f14;border-color:var(--ok)}.btn-ok:hover{background:#2fb87a}
.btn-warn{background:var(--warn);color:#0d0f14;border-color:var(--warn)}
.btn-sm{padding:5px 12px;font-size:.8rem}.btn-group{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}
.form-row{display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;margin-bottom:16px}
.form-group{display:flex;flex-direction:column;gap:6px;flex:1;min-width:160px}
label{font-size:.8rem;font-weight:500;color:var(--muted)}
input,select{background:var(--surface2);border:1px solid var(--border);color:var(--text);
padding:9px 12px;border-radius:var(--radius);font-size:.875rem;font-family:var(--sans);width:100%;outline:none;transition:.15s}
input:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,143,255,.15)}
select option{background:var(--surface2)}
.table-wrap{overflow-x:auto}
table{width:100%;border-collapse:collapse;font-size:.875rem}
th{text-align:left;color:var(--muted);font-size:.75rem;font-weight:600;text-transform:uppercase;
letter-spacing:.6px;padding:10px 12px;border-bottom:1px solid var(--border)}
td{padding:10px 12px;border-bottom:1px solid rgba(37,42,58,.6);vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:rgba(255,255,255,.02)}
.badge{display:inline-block;padding:2px 8px;border-radius:20px;font-size:.75rem;font-weight:600}
.badge-ok{background:rgba(62,207,142,.15);color:var(--ok)}
.badge-warn{background:rgba(245,166,35,.15);color:var(--warn)}
.badge-off{background:var(--surface2);color:var(--muted)}
.toggle{position:relative;display:inline-block;width:38px;height:22px}
.toggle input{opacity:0;width:0;height:0;position:absolute}
.toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:22px;cursor:pointer;transition:.2s}
.toggle-slider::before{content:'';position:absolute;width:16px;height:16px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.2s}
.toggle input:checked+.toggle-slider{background:var(--accent)}
.toggle input:checked+.toggle-slider::before{transform:translateX(16px)}
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;align-items:center;justify-content:center;padding:20px}
.modal-overlay.open{display:flex}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);
padding:28px;max-width:700px;width:100%;max-height:90vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.6)}
.modal-title{font-size:1.1rem;font-weight:700;margin-bottom:20px;display:flex;justify-content:space-between;align-items:center}
.modal-close{background:none;border:none;color:var(--muted);font-size:1.3rem;cursor:pointer;padding:4px;line-height:1}
.modal-close:hover{color:var(--danger)}
.code-block{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);
padding:14px;font-family:var(--mono);font-size:.78rem;color:var(--ok);
overflow-x:auto;white-space:pre-wrap;word-break:break-all;max-height:260px;overflow-y:auto}
.accordion-item{border:1px solid var(--border);border-radius:var(--radius);margin-bottom:8px;overflow:hidden}
.accordion-header{padding:12px 16px;cursor:pointer;display:flex;justify-content:space-between;align-items:center;
background:var(--surface2);font-weight:600;font-size:.875rem;user-select:none}
.accordion-header:hover{background:var(--border)}
.accordion-body{display:none;padding:16px;background:var(--surface)}
.accordion-body.open{display:block}
.accordion-arrow{transition:.2s;color:var(--muted);display:inline-block}
.accordion-header.aopen .accordion-arrow{transform:rotate(180deg)}
.alert{padding:12px 16px;border-radius:var(--radius);font-size:.875rem;margin-bottom:16px}
.alert-ok{background:rgba(62,207,142,.12);border:1px solid rgba(62,207,142,.3);color:var(--ok)}
.alert-err{background:rgba(255,95,109,.12);border:1px solid rgba(255,95,109,.3);color:var(--danger)}
.alert-warn{background:rgba(245,166,35,.12);border:1px solid rgba(245,166,35,.3);color:var(--warn)}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.2);
border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle}
@keyframes spin{to{transform:rotate(360deg)}}
.divider{height:1px;background:var(--border);margin:24px 0}
.row-actions{display:flex;gap:8px;align-items:center}
.section-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}
</style>
</head>
<body>
<nav class="sidebar">
<div class="sidebar-logo">
<div class="logo-badge">MITA</div>Panel
</div>
<button class="nav-item active" onclick="showPage('dashboard',this)">
<svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
Дашборд
</button>
<button class="nav-item" onclick="showPage('users',this)">
<svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Пользователи
</button>
<button class="nav-item" onclick="showPage('ssl',this)">
<svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
SSL
</button>
<button class="nav-item" onclick="showPage('security',this)">
<svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
Безопасность
</button>
<div class="sidebar-footer"><a href="{{ base }}/logout">Выйти</a></div>
</nav>
<main class="main">{% block content %}{% endblock %}</main>
<div class="modal-overlay" id="modal">
<div class="modal">
<div class="modal-title">
<span id="modal-title-text"></span>
<button class="modal-close" onclick="closeModal()">&#x2715;</button>
</div>
<div id="modal-body"></div>
</div>
</div>
<div id="toast" style="position:fixed;bottom:24px;right:24px;z-index:999;display:none;
background:var(--surface2);border:1px solid var(--border);color:var(--ok);
padding:10px 18px;border-radius:8px;font-size:.85rem;transition:.2s"></div>
<script>
const BASE="{{ base }}";
function showPage(id,btn){
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
document.getElementById('page-'+id).classList.add('active');
if(btn)btn.classList.add('active');
if(id==='dashboard')loadDashboard();
if(id==='users')loadUsers();
if(id==='ssl')loadSSL();
}
function openModal(title,html){
document.getElementById('modal-title-text').textContent=title;
document.getElementById('modal-body').innerHTML=html;
document.getElementById('modal').classList.add('open');
}
function closeModal(){document.getElementById('modal').classList.remove('open');}
document.getElementById('modal').addEventListener('click',e=>{if(e.target.id==='modal')closeModal();});
async function api(path,method='GET',body=null){
const opts={method,headers:{'Content-Type':'application/json'}};
if(body)opts.body=JSON.stringify(body);
const r=await fetch(BASE+path,opts);
if(r.status===401){location.href=BASE+'/login';return null;}
return r.json();
}
function showToast(msg,isErr=false){
const t=document.getElementById('toast');
t.textContent=msg;t.style.color=isErr?'var(--danger)':'var(--ok)';
t.style.display='block';setTimeout(()=>t.style.display='none',2500);
}
function copyText(text){navigator.clipboard.writeText(text).then(()=>showToast('Скопировано ✓'));}
function toggleAccordion(hdr){
hdr.classList.toggle('aopen');
hdr.nextElementSibling.classList.toggle('open');
}
function codeBlock(json){
const txt=JSON.stringify(json,null,2);
return `<div class="code-block" id="cb_${Math.random().toString(36).slice(2)}">${escHtml(txt)}</div>
<button class="btn btn-secondary btn-sm" style="margin-top:8px" onclick="copyText(${escAttr(txt)})">Копировать</button>`;
}
function escHtml(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function escAttr(s){return "'"+s.replace(/\\/g,'\\\\').replace(/'/g,"\\'")+"'";}
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+750
View File
@@ -0,0 +1,750 @@
{% 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="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>WARP вкл</th><th>Правила WARP</th><th>Конфиг клиента</th><th>Действия</th></tr></thead>
<tbody id="users-tbody"><tr><td colspan="4" 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">Самоподписной сертификат</div>
<p style="color:var(--muted);font-size:.875rem;margin-bottom:16px;line-height:1.6">
Генерируется мгновенно, действует <strong style="color:var(--text)">10 лет</strong>.
Браузер покажет предупреждение — его можно принять.
Подходит для личного использования.
</p>
<button class="btn btn-secondary" onclick="genSelfSigned()">
<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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
Создать самоподписной
</button>
<div id="selfsigned-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>
<div class="alert alert-warn">
После смены сертификата необходимо перезапустить панель:<br>
<code style="font-family:var(--mono);font-size:.85rem">systemctl restart mita-panel</code>
</div>
</div>
</div>
{% endblock %}
<!-- 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()">
<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>
{% 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();
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();
async function loadUsers(){
const [ud,wd]=await Promise.all([api('/api/users'),api('/api/users/warp_status')]);
if(!ud)return;
warpUsers=new Set(wd?.warp_users||[]);
const tbody=document.getElementById('users-tbody');
if(!ud.users.length){
tbody.innerHTML='<tr><td colspan="4" style="color:var(--muted);text-align:center;padding:20px">Нет пользователей</td></tr>';
return;
}
tbody.innerHTML=ud.users.map(u=>{
const wOn=warpUsers.has(u.name);
return `<tr>
<td><strong>${escH(u.name)}</strong></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>
</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>
`;
openModal('Конфиг: '+name, html);
}
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'].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';
});
}
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>`;
}
}
async function genSelfSigned(){
const btn=event.target;
btn.disabled=true;btn.innerHTML='<span class="spinner"></span> Генерация...';
const el=document.getElementById('selfsigned-result');
const d=await api('/api/ssl/selfsigned','POST');
btn.disabled=false;btn.textContent='Создать самоподписной';
if(!d)return;
if(d.ok){
el.innerHTML=`<div class="alert alert-ok">✓ Сертификат создан (10 лет)<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">Ошибка: ${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>`;
}
}
// ──────────────── SECURITY / FAIL2BAN ────────────────
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 + ' сек.';
}
document.getElementById('f2b-bantime').addEventListener('change', function() {
document.getElementById('custom-bantime-group').style.display =
this.value === '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="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>showToast('Скопировано ✓'))">Копировать</button>`;
}
</script>
{% endblock %}
+93
View File
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>mita panel — вход</title>
<style>
:root{--bg:#0d0f14;--surface:#141720;--border:#252a3a;--accent:#4f8fff;--accent2:#7b5fff;
--ok:#3ecf8e;--danger:#ff5f6d;--text:#e2e8f4;--muted:#6b7594;--sans:'Inter',system-ui,sans-serif;--radius:10px}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--sans);background:var(--bg);color:var(--text);min-height:100vh;
display:flex;align-items:center;justify-content:center}
.wrap{width:100%;max-width:380px;padding:20px}
.logo{text-align:center;margin-bottom:32px}
.logo-badge{display:inline-block;background:linear-gradient(135deg,var(--accent),var(--accent2));
color:#fff;font-size:.85rem;font-weight:700;padding:6px 14px;border-radius:8px;letter-spacing:.5px;margin-bottom:12px}
.logo h1{font-size:1.4rem;font-weight:700;letter-spacing:-.3px}
.logo p{color:var(--muted);font-size:.85rem;margin-top:4px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:28px}
.form-group{margin-bottom:16px}
label{display:block;font-size:.8rem;font-weight:500;color:var(--muted);margin-bottom:6px}
input{width:100%;background:#1c2030;border:1px solid var(--border);color:var(--text);
padding:10px 14px;border-radius:var(--radius);font-size:.9rem;outline:none;transition:.15s;font-family:var(--sans)}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,143,255,.15)}
.btn{width:100%;padding:11px;background:var(--accent);color:#fff;border:none;
border-radius:var(--radius);font-size:.9rem;font-weight:600;cursor:pointer;transition:.15s;font-family:var(--sans)}
.btn:hover{background:#3d7aff}.btn:disabled{opacity:.6;cursor:not-allowed}
.err{color:var(--danger);font-size:.83rem;margin-top:12px;text-align:center;display:none}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.3);
border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<div class="wrap">
<div class="logo">
<div class="logo-badge">MITA</div>
<h1>Панель управления</h1>
<p>Войдите, чтобы продолжить</p>
</div>
<div class="card">
<div class="form-group">
<label>Логин</label>
<input type="text" id="u" autocomplete="username" placeholder="admin">
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" id="p" autocomplete="current-password" placeholder="••••••••">
</div>
<button class="btn" id="loginBtn" onclick="doLogin()">Войти</button>
<div class="err" id="err"></div>
<div id="attempts-wrap" style="display:none;margin-top:10px;text-align:center">
<span style="font-size:.8rem;color:var(--muted)">Осталось попыток: </span>
<span id="attempts-left" style="font-size:.8rem;font-weight:700;color:var(--warn)"></span>
</div>
</div>
</div>
<script>
const BASE="{{ base }}";
document.addEventListener('keydown',e=>{if(e.key==='Enter')doLogin();});
async function doLogin(){
const btn=document.getElementById('loginBtn');
const err=document.getElementById('err');
const attWrap=document.getElementById('attempts-wrap');
const attLeft=document.getElementById('attempts-left');
btn.disabled=true;btn.innerHTML='<span class="spinner"></span>Вхожу...';err.style.display='none';
try{
const r=await fetch(BASE+'/login',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({username:document.getElementById('u').value,password:document.getElementById('p').value})});
const d=await r.json();
if(d.ok){
location.href=BASE+'/';
return;
}
err.textContent=d.error||'Ошибка';
err.style.display='block';
if(d.rate_limited){
attWrap.style.display='block';
attLeft.textContent='0';
btn.disabled=true;
btn.textContent='Заблокировано';
return;
}
if(typeof d.remaining==='number'){
attWrap.style.display='block';
attLeft.textContent=d.remaining;
attLeft.style.color=d.remaining<=1?'var(--danger)':'var(--warn)';
}
}catch(e){err.textContent='Ошибка соединения';err.style.display='block';}
btn.disabled=false;btn.textContent='Войти';
}
</script>
</body>
</html>