Files
sdm-xray/panel/ui.go
T

380 lines
29 KiB
Go

package main
const ui = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>sdm-panel</title>
<style>
:root{--bg:#0d1117;--side:#0a0e13;--card:#151b23;--line:#1f2833;--txt:#dfe6ee;--mut:#8b98a5;--acc:#4f8ef7;--ok:#3fb97f;--err:#ff6b6b;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--txt);font-size:14px}
.hidden{display:none!important}
button{background:#1d2735;color:var(--txt);border:1px solid #2a3546;border-radius:8px;padding:8px 14px;cursor:pointer;font-size:13px;transition:background .15s}
button:hover{background:#273449}
button.primary{background:var(--acc);border-color:var(--acc);color:#fff}
button.primary:hover{background:#3d7ee6}
button.ghost{background:transparent;border-color:transparent;color:var(--mut);padding:6px 10px;font-size:12px}
button.ghost:hover{background:#1a222c;color:var(--txt)}
button.danger{background:#2a1820;border-color:#4a2330;color:#ff9d9d}
button.icon{font-size:14px;padding:6px 9px}
input[type=text],input[type=password],input[type=number],textarea{width:100%;background:#0b1016;border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:9px 11px;font-family:inherit;font-size:13px}
input:focus,textarea:focus{outline:none;border-color:var(--acc)}
label{display:block;color:var(--mut);font-size:12px;margin:0 0 5px}
/* login */
#login{min-height:100vh;display:flex;align-items:center;justify-content:center}
.login-card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:36px;width:340px}
.login-card h1{margin:0 0 4px;font-size:22px}
.login-card .sub{color:var(--mut);margin:0 0 24px;font-size:13px}
/* layout */
#app{display:flex;min-height:100vh}
aside{width:220px;background:var(--side);border-right:1px solid var(--line);padding:20px 12px;display:flex;flex-direction:column;position:sticky;top:0;height:100vh}
aside .brand{display:flex;align-items:center;gap:10px;padding:4px 10px 18px;font-weight:600;font-size:16px}
aside .brand .dot{width:10px;height:10px;border-radius:50%;background:var(--acc);box-shadow:0 0 10px var(--acc)}
aside nav{display:flex;flex-direction:column;gap:2px;flex:1}
aside nav button{text-align:left;background:transparent;border:1px solid transparent;border-radius:8px;padding:10px 12px;color:var(--mut);font-size:13px}
aside nav button:hover{background:#151b23;color:var(--txt)}
aside nav button.on{background:var(--card);color:var(--txt);border-color:var(--line)}
aside nav button .glyph{margin-right:8px;opacity:.7}
aside .logout{background:transparent;border:none;color:var(--mut);text-align:left;padding:10px 12px;cursor:pointer;font-size:13px}
aside .logout:hover{color:var(--err)}
main{flex:1;padding:28px 32px;max-width:1040px}
main h2{margin:0 0 4px;font-size:20px}
main .crumb{color:var(--mut);font-size:12px;margin:0 0 20px}
/* cards */
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:14px;margin-bottom:20px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px}
.stat .k{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.6px}
.stat .v{font-size:15px;margin-top:6px;word-break:break-all;line-height:1.4}
.badge{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:20px;font-size:12px;font-weight:500}
.badge::before{content:"";width:8px;height:8px;border-radius:50%}
.badge.ok{background:rgba(63,185,127,.12);color:var(--ok)}.badge.ok::before{background:var(--ok);box-shadow:0 0 8px var(--ok)}
.badge.bad{background:rgba(255,107,107,.12);color:var(--err)}.badge.bad::before{background:var(--err)}
.section{margin:22px 0 8px;color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:1px}
/* clients */
.cli{display:flex;align-items:center;gap:14px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:13px 16px;margin-bottom:10px}
.cli .nm{font-weight:500;min-width:120px}
.cli .uid{font-family:var(--mono);font-size:12px;color:var(--mut);flex:1;word-break:break-all}
.cli .acts{display:flex;gap:6px;flex-shrink:0}
/* forms */
.row{display:flex;gap:12px;flex-wrap:wrap}
.row > div{flex:1;min-width:150px}
textarea{min-height:110px;font-family:var(--mono);font-size:12px;line-height:1.5}
.mut{color:var(--mut);font-size:12px}
pre{background:#0b1016;border:1px solid var(--line);border-radius:12px;padding:14px;overflow:auto;max-height:440px;font-family:var(--mono);font-size:12px;white-space:pre-wrap;line-height:1.5}
.actions{display:flex;gap:10px;align-items:center;margin-top:16px}
#toast{position:fixed;top:18px;right:18px;background:#1d2735;border:1px solid var(--line);border-radius:10px;padding:12px 18px;opacity:0;transition:opacity .25s;pointer-events:none;z-index:50;max-width:380px;font-size:13px}
#toast.show{opacity:1}
#toast.ok{border-color:var(--ok)}#toast.err{border-color:var(--err)}
</style>
</head>
<body>
<div id="login"><div class="login-card">
<h1>sdm-panel</h1>
<p class="sub">stealth tunnel · управление сервером</p>
<label>Логин</label>
<input type="text" id="login" value="admin" onkeydown="if(event.key==='Enter')document.getElementById('pw').focus()">
<label>Пароль</label>
<input type="password" id="pw" onkeydown="if(event.key==='Enter')login()" autofocus>
<br><br>
<button class="primary" style="width:100%" onclick="login()">Войти</button>
</div></div>
<div id="app" class="hidden">
<aside>
<div class="brand"><span class="dot"></span>sdm-panel</div>
<nav>
<button class="on" data-t="status" onclick="tab('status')"><span class="glyph">▤</span>Статус</button>
<button data-t="clients" onclick="tab('clients')"><span class="glyph">👥</span>Клиенты</button>
<button data-t="tunnel" onclick="tab('tunnel')"><span class="glyph">⚙</span>Туннель</button>
<button data-t="panel" onclick="tab('panel')"><span class="glyph">🛠</span>Панель</button>
<button data-t="logs" onclick="tab('logs')"><span class="glyph">≡</span>Логи</button>
</nav>
<button class="logout" onclick="logout()">⏻ Выйти</button>
</aside>
<main>
<div id="t-status">
<h2>Статус</h2><p class="crumb">Общее состояние сервера и туннеля</p>
<div class="grid" id="statusGrid"></div>
<div class="card actions">
<button class="primary" onclick="restart()">Перезапустить xray</button>
<button onclick="loadStatus()">Обновить</button>
</div>
</div>
<div id="t-clients" class="hidden">
<h2>Клиенты</h2><p class="crumb">Устройства, которым разрешено подключаться</p>
<div class="card" style="margin-bottom:16px">
<div class="row">
<div style="flex:3"><label>Имя клиента</label><input type="text" id="newName" placeholder="например: iphone, ноутбук, роутер"></div>
<div style="flex:1;display:flex;align-items:flex-end"><button class="primary" style="width:100%" onclick="addClient()">Добавить</button></div>
</div>
</div>
<div class="card" id="statsCard" style="margin-bottom:16px">
<div class="section">Трафик и онлайн (xray stats)</div>
<div id="statsBody" class="mut">загрузка…</div>
</div>
<div id="clientsList"></div>
</div>
<div id="t-tunnel" class="hidden">
<h2>Туннель</h2><p class="crumb">Зеркало и параметры транспорта</p>
<div class="card">
<div class="section">Безопасность (TLS / REALITY)</div>
<div class="row">
<div style="flex:1"><label>Security</label>
<select id="secType" style="width:100%;background:#0b1016;border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:9px 11px;font-size:13px">
<option value="tls">TLS 1.3 (uTLS)</option>
<option value="reality">REALITY</option>
</select>
</div>
<div style="flex:2"><label>REALITY dest (сайт-цель)</label><input type="text" id="secDest" placeholder="www.apple.com"></div>
<div style="flex:1"><label>shortId (hex)</label><input type="text" id="secShort"></div>
</div>
<div style="margin-top:10px" class="mut" id="secPub"></div>
<div class="actions" style="margin-top:12px">
<button class="primary" onclick="saveSecurity()">Применить security</button>
<button onclick="genSecurityKey()">Сменить REALITY-ключ</button>
<span class="mut">При переключении на REALITY генерируется ключевая пара; publicKey нужно раздать клиентам.</span>
</div>
</div>
<div class="card">
<div class="section">Зеркало (сайт, который видит зонд/браузер)</div>
<div class="row">
<div style="flex:2"><label>target — выбери или впиши свой</label>
<input type="text" id="target" list="targetList" placeholder="https://www.iana.org">
<datalist id="targetList">
<option value="https://www.iana.org">IANA (нейтральный, рекомендуется)</option>
<option value="https://www.apple.com">Apple</option>
<option value="https://www.openbsd.org">OpenBSD</option>
<option value="https://www.debian.org">Debian</option>
<option value="https://www.gnu.org">GNU</option>
<option value="https://www.mozilla.org">Mozilla</option>
<option value="https://www.kernel.org">kernel.org</option>
<option value="https://www.cloudflare.com">Cloudflare</option>
<option value="">— без зеркала (встроенная страница) —</option>
</datalist>
</div>
</div>
</div>
<div class="card">
<div class="section">Маскировка (опции вкл/выкл)</div>
<label style="display:flex;align-items:center;gap:8px;margin:6px 0"><input type="checkbox" id="padOn"> Padding кадров — случайный мусор в потоке (маскирует размеры, чуть медленнее)</label>
<label style="display:flex;align-items:center;gap:8px;margin:6px 0"><input type="checkbox" id="decoyOn"> Decoy-трафик — фоновые запросы к CDN</label>
<label style="display:flex;align-items:center;gap:8px;margin:6px 0"><input type="checkbox" id="paceOn"> Pacing — интервалы между кадрами (сглаживает поток)</label>
</div>
<details class="card" style="padding:14px 16px">
<summary style="cursor:pointer;font-weight:500">Экспертные настройки</summary>
<div class="section">Пути туннеля (по одному на строку; клиенты и сервер должны совпадать)</div>
<textarea id="paths"></textarea>
<div class="section">Транспорт</div>
<div class="row">
<div><label>Порты (через запятую)</label><input type="text" id="ports"></div>
<div><label>padMax</label><input type="number" id="padMax"></div>
<div><label>padProb (%)</label><input type="number" id="padProb"></div>
<div><label>paceMs</label><input type="number" id="paceMs"></div>
<div><label>paceJitterMs</label><input type="number" id="paceJitterMs"></div>
<div><label>bps (0 = выкл)</label><input type="number" id="bps"></div>
<div><label>retries</label><input type="number" id="retries"></div>
<div><label>padProfile</label><input type="text" id="padProfile"></div>
</div>
<div class="section">Decoy (фоновый трафик)</div>
<div class="row">
<div><label>decoy (true/false)</label><input type="text" id="decoy"></div>
<div><label>decoyEverySecs</label><input type="number" id="decoyEverySecs"></div>
<div><label>decoyTarget</label><input type="text" id="decoyTarget"></div>
</div>
</details>
<div class="card actions">
<button class="primary" onclick="saveStealth()">Сохранить и перезапустить</button>
<button onclick="rotStealthKey()">Сменить stealth-ключ</button>
<span class="mut">После сохранения xray перезапустится; клиентам понадобятся обновлённые конфиги.</span>
</div>
</div>
<div id="t-panel" class="hidden">
<h2>Панель</h2><p class="crumb">Настройки самой панели</p>
<div class="card">
<div class="row">
<div><label>serverName (домен для клиентских конфигов)</label><input type="text" id="pServer"></div>
<div><label>bind</label><input type="text" id="pBind"></div>
<div><label>TLS fingerprint (отпечаток браузера)</label>
<select id="pFp" style="width:100%;background:#0b1016;border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:9px 11px;font-size:13px">
<option value="chrome">Chrome</option>
<option value="firefox">Firefox</option>
<option value="edge">Edge</option>
<option value="safari">Safari</option>
<option value="ios">iOS</option>
<option value="360">360</option>
<option value="qq">QQ</option>
<option value="random">Random (случайный)</option>
</select>
</div>
</div>
<div style="margin-top:12px"><label>config path</label><input type="text" id="pConfig"></div>
<div style="margin-top:12px"><label>Логин</label><input type="text" id="pLogin"></div>
<div style="margin-top:12px"><label>Новый пароль (пусто = не менять)</label><input type="password" id="pPass"></div>
<div class="actions">
<button class="primary" onclick="savePanel()">Сохранить</button>
<span class="mut">Смена bind/пароля вступает в силу после перезапуска панели: systemctl restart sdm-panel</span>
</div>
</div>
<div class="card">
<div class="section">Логи xray</div>
<div class="row">
<div style="flex:1"><label>Уровень логирования</label>
<select id="lvLevel" style="width:100%;background:#0b1016;border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:9px 11px;font-size:13px">
<option value="error">error</option>
<option value="warning">warning</option>
<option value="info">info</option>
<option value="debug">debug</option>
</select>
</div>
<div style="display:flex;align-items:flex-end"><button onclick="saveLoglevel()">Сохранить</button></div>
</div>
</div>
<div class="card">
<div class="section">Версия и сертификат</div>
<div class="mut" id="verInfo">—</div>
<div class="actions" style="margin-top:10px"><button onclick="loadVersion()">Обновить</button></div>
</div>
<div class="card">
<div class="section">Бэкап / восстановление config.json</div>
<div class="actions" style="margin-top:10px">
<button onclick="backup()">Скачать config.json</button>
<label style="display:inline-flex;align-items:center;cursor:pointer;border:1px solid #2a3546;border-radius:8px;padding:8px 14px;color:var(--mut);font-size:13px;background:#1d2735">Загрузить конфиг<input type="file" id="restoreFile" accept=".json,application/json" style="display:none" onchange="restoreCfg(this)"></label>
</div>
</div>
</div>
<div id="t-logs" class="hidden">
<h2>Логи</h2><p class="crumb">Последние записи xray</p>
<div class="card">
<button onclick="loadLogs()">Обновить</button>
<pre id="logs" style="margin-top:12px"></pre>
</div>
</div>
</main>
</div>
<div id="toast"></div>
<script>
let TOK = localStorage.getItem('tok') || '';
function api(p, opt){ opt=opt||{};
opt.headers = Object.assign({'Authorization':'Bearer '+TOK}, opt.headers||{});
return fetch(p,opt).then(async r=>{const j=await r.json().catch(()=>({}));
if(r.status===401){logout();throw new Error('unauthorized')}
if(!r.ok) throw new Error(j.error||('HTTP '+r.status));
return j;});
}
function toast(msg,ok){const t=document.getElementById('toast');t.textContent=msg;t.className='show '+(ok?'ok':'err');setTimeout(()=>t.className='',2600)}
function esc(s){return (s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
function login(){fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({login:document.getElementById('login').value,password:document.getElementById('pw').value})})
.then(r=>r.json()).then(j=>{if(!j.token)throw new Error('неверные данные');TOK=j.token;localStorage.setItem('tok',j.token);showApp()}).catch(e=>toast(e.message,false))}
function logout(){localStorage.removeItem('tok');TOK='';location.reload()}
function showApp(){document.getElementById('login').classList.add('hidden');document.getElementById('app').classList.remove('hidden');tab('status')}
function tab(n){document.querySelectorAll('#app main > div').forEach(t=>t.classList.toggle('hidden',t.id!=='t-'+n));document.querySelectorAll('aside nav button').forEach(b=>b.classList.toggle('on',b.dataset.t===n));if(n==='status')loadStatus();if(n==='clients'){loadClients();loadStats()}if(n==='tunnel'){loadStealth();loadSecurity()}if(n==='panel'){loadPanel();loadLoglevel();loadVersion()}if(n==='logs')loadLogs()}
async function loadStatus(){try{const s=await api('api/status');
const badge=s.service==='active'?'<span class="badge ok">active</span>':'<span class="badge bad">'+esc(s.service)+'</span>';
const g=document.getElementById('statusGrid');g.innerHTML='';
[['Сервис',badge],['Порты',(s.ports||[]).join(', ')||'-'],['Пути',(s.paths||[]).join(', ')||'-'],['Зеркало',esc(s.target||'-')],['Клиентов',s.clientCount||0]].forEach(([k,v])=>{
const d=document.createElement('div');d.className='card stat';d.innerHTML='<div class="k">'+k+'</div><div class="v">'+v+'</div>';g.appendChild(d)})}catch(e){toast(e.message,false)}}
async function loadClients(){try{const j=await api('api/clients');const L=document.getElementById('clientsList');L.innerHTML='';
if(!j.clients.length){L.innerHTML='<div class="card mut">Клиентов пока нет — добавьте первого.</div>';return}
j.clients.forEach(cl=>{const d=document.createElement('div');d.className='cli';
d.innerHTML='<div class="nm">'+esc(cl.name||'—')+'</div><div class="uid">'+esc(cl.id||'')+'</div>'+
'<div class="acts">'+
'<button class="ghost" title="Скопировать vless:// ссылку" onclick="copyLink(\''+esc(cl.id)+'\')">⧉ ссылка</button>'+
'<button class="ghost" title="Скопировать конфиг" onclick="copyConfig(\''+esc(cl.id)+'\')">⧉ конфиг</button>'+
'<button class="ghost" title="Скопировать UUID" onclick="copyText(\''+esc(cl.id)+'\',\'UUID скопирован\')">⧉ uuid</button>'+
'<button class="ghost" title="Переименовать" onclick="renameClient(\''+esc(cl.id)+'\')">✎</button>'+
'<button class="ghost" title="Новый UUID" onclick="rotClient(\''+esc(cl.id)+'\')">⟳</button>'+
'<button class="ghost" title="Удалить" onclick="delClient(\''+esc(cl.id)+'\')">✕</button>'+
'</div>';
L.appendChild(d)})}catch(e){toast(e.message,false)}}
async function loadStats(){try{const j=await api('api/stats');
const el=document.getElementById('statsBody');
const u=j.users||[];
if(!u.length){el.innerHTML='<div class="mut">Соединений с момента включения статистики ещё нет.</div>';return}
const h=u.map(s=>{
const on=s.ips&&s.ips.length?'<span class="badge ok">онлайн '+s.ips.length+'</span>':'<span class="badge">офлайн</span>';
return '<div style="display:flex;align-items:center;gap:12px;padding:8px 0;border-bottom:1px solid var(--line)">'+
'<div style="flex:1">'+esc(s.email||'—')+'</div>'+on+
'<div style="min-width:120px;text-align:right">↑ '+fmtB(s.uplink)+'</div>'+
'<div style="min-width:120px;text-align:right">↓ '+fmtB(s.downlink)+'</div></div>'}).join('');
el.innerHTML=h}catch(e){toast(e.message,false)}}
function fmtB(n){if(!n)return'0 B';if(n<1024)return n+' B';if(n<1048576)return(n/1024).toFixed(1)+' KB';if(n<1073741824)return(n/1048576).toFixed(1)+' MB';return(n/1073741824).toFixed(2)+' GB'}
async function addClient(){try{const n=document.getElementById('newName').value.trim();const j=await api('api/clients',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n||'client'})});toast('клиент добавлен',true);document.getElementById('newName').value='';loadClients()}catch(e){toast(e.message,false)}}
async function delClient(id){if(!confirm('Удалить клиента?'))return;try{await api('api/clients/'+id,{method:'DELETE'});toast('клиент удалён',true);loadClients()}catch(e){toast(e.message,false)}}
async function rotClient(id){try{const j=await api('api/clients/'+id+'/rotate',{method:'POST'});toast('новый UUID выдан',true);loadClients()}catch(e){toast(e.message,false)}}
async function copyConfig(id){try{const r=await fetch('api/clients/'+id+'/config',{headers:{'Authorization':'Bearer '+TOK}});const txt=await r.text();await navigator.clipboard.writeText(txt);toast('конфиг client-stealth.json скопирован',true)}catch(e){toast('скопировать не удалось: '+e.message,false)}}
async function copyLink(id){try{const r=await fetch('api/clients/'+id+'/link',{headers:{'Authorization':'Bearer '+TOK}});const j=await r.json();await navigator.clipboard.writeText(j.link);toast('vless:// ссылка скопирована',true)}catch(e){toast('ошибка: '+e.message,false)}}
function copyText(t,msg){navigator.clipboard.writeText(t).then(()=>toast(msg||'скопировано',true)).catch(e=>toast(e.message,false))}
async function loadSecurity(){try{const s=await api('api/security');
document.getElementById('secType').value=s.security||'tls';
document.getElementById('secDest').value=s.dest||'';
document.getElementById('secShort').value=s.shortId||'';
document.getElementById('secPub').textContent=s.publicKey?('REALITY publicKey (для клиентов): '+s.publicKey):'';
}catch(e){toast(e.message,false)}}
async function saveSecurity(){try{const j=await api('api/security',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({security:document.getElementById('secType').value,dest:document.getElementById('secDest').value.trim(),shortId:document.getElementById('secShort').value.trim()})});
toast('security применён, xray: '+(j.restart||'ok'),true);
if(j.publicKey){document.getElementById('secPub').textContent='REALITY publicKey (для клиентов): '+j.publicKey;navigator.clipboard.writeText(j.publicKey);toast('publicKey скопирован',true)}
loadSecurity()}catch(e){toast(e.message,false)}}
async function genSecurityKey(){if(!confirm('Сменить REALITY-ключ? Клиентские конфиги потеряют связь до обновления publicKey.'))return;try{const j=await api('api/security/key',{method:'POST'});toast('новый publicKey: '+j.publicKey,true);navigator.clipboard.writeText(j.publicKey);loadSecurity()}catch(e){toast(e.message,false)}}
async function loadLoglevel(){try{const j=await api('api/loglevel');document.getElementById('lvLevel').value=j.loglevel||'warning'}catch(e){toast(e.message,false)}}
async function saveLoglevel(){try{const j=await api('api/loglevel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({loglevel:document.getElementById('lvLevel').value})});toast('сохранено, xray: '+(j.restart||'ok'),true)}catch(e){toast(e.message,false)}}
async function loadVersion(){try{const j=await api('api/version');document.getElementById('verInfo').innerHTML='<div>'+esc(j.xray)+'</div><div>cert: '+(j.certFile?esc(j.certFile):'—')+'</div><div>expires: '+(j.certExpiry?esc(j.certExpiry):'—')+'</div>'}catch(e){toast(e.message,false)}}
function backup(){fetch('api/backup',{headers:{'Authorization':'Bearer '+TOK}}).then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);return r.blob()}).then(b=>{const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='config.json';a.click();URL.revokeObjectURL(a.href);toast('конфиг скачан',true)}).catch(e=>toast(e.message,false))}
function restoreCfg(inp){const f=inp.files[0];if(!f)return;if(!confirm('Перезаписать config.json и перезапустить xray?')){inp.value='';return}const rd=new FileReader();rd.onload=async()=>{try{const j=await api('api/restore',{method:'POST',headers:{'Content-Type':'application/json'},body:rd.result});toast('восстановлено, xray: '+(j.restart||'ok'),true);loadStatus()}catch(e){toast(e.message,false)}};rd.readAsText(f);inp.value=''}
async function renameClient(id){const nm=prompt('Новое имя клиента:', '');if(!nm||!nm.trim())return;try{await api('api/clients/'+id+'/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:nm.trim()})});toast('переименовано',true);loadClients()}catch(e){toast(e.message,false)}}
async function loadStealth(){try{const s=await api('api/stealth');
document.getElementById('paths').value=(s.paths||[]).join('\n');
const set=(id,v)=>document.getElementById(id).value=(v===undefined?'':v);
set('ports',(s.ports||[]).join(', '));set('padMax',s.padMax);set('padProb',s.padProb);set('paceMs',s.paceMs);set('paceJitterMs',s.paceJitterMs);set('bps',s.bps);set('retries',s.retries);set('padProfile',s.padProfile);set('target',s.target);set('decoy',s.decoy);set('decoyEverySecs',s.decoyEverySecs);set('decoyTarget',s.decoyTarget);
document.getElementById('padOn').checked = (s.padMax||0)>0;
document.getElementById('decoyOn').checked = s.decoy===true;
document.getElementById('paceOn').checked = (s.paceMs||0)>0;}catch(e){toast(e.message,false)}}
async function saveStealth(){try{const num=v=>{const n=Number(v);return isNaN(n)?0:n};
let padMax=num(document.getElementById('padMax').value), padProb=num(document.getElementById('padProb').value);
if(document.getElementById('padOn').checked){if(padMax<=0)padMax=2048;if(padProb<=0)padProb=50}else{padMax=0;padProb=0}
let decoy=document.getElementById('decoy').value==='true';
if(document.getElementById('decoyOn').checked)decoy=true;else decoy=false;
let paceMs=num(document.getElementById('paceMs').value);
if(document.getElementById('paceOn').checked){if(paceMs<=0)paceMs=5}else{paceMs=0}
const body={paths:document.getElementById('paths').value.split('\n').map(s=>s.trim()).filter(Boolean),
ports:document.getElementById('ports').value.split(',').map(s=>Number(s.trim())).filter(Boolean),
padMax:padMax,padProb:padProb,
paceMs:paceMs,paceJitterMs:num(document.getElementById('paceJitterMs').value),
bps:num(document.getElementById('bps').value),retries:num(document.getElementById('retries').value),
padProfile:document.getElementById('padProfile').value,target:document.getElementById('target').value,
decoy:decoy,decoyEverySecs:num(document.getElementById('decoyEverySecs').value),
decoyTarget:document.getElementById('decoyTarget').value};
const j=await api('api/stealth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('сохранено, xray: '+(j.restart||'ok'),true)}catch(e){toast(e.message,false)}}
async function rotStealthKey(){try{const j=await api('api/stealth/key',{method:'POST'});toast('новый stealth-ключ: '+j.key,true)}catch(e){toast(e.message,false)}}
async function loadPanel(){try{const p=await api('api/panel');document.getElementById('pServer').value=p.serverName||'';document.getElementById('pBind').value=p.bind;document.getElementById('pConfig').value=p.config;document.getElementById('pLogin').value=p.login||'admin';document.getElementById('pFp').value=p.fingerprint||'chrome'}catch(e){toast(e.message,false)}}
async function savePanel(){try{await api('api/panel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({serverName:document.getElementById('pServer').value,bind:document.getElementById('pBind').value,config:document.getElementById('pConfig').value,login:document.getElementById('pLogin').value,password:document.getElementById('pPass').value,fingerprint:document.getElementById('pFp').value})});toast('сохранено',true);document.getElementById('pPass').value=''}catch(e){toast(e.message,false)}}
async function restart(){try{const j=await api('api/restart',{method:'POST'});toast('xray: '+(j.restart||'ok'),true);loadStatus()}catch(e){toast(e.message,false)}}
async function loadLogs(){try{const j=await api('api/logs?n=150');document.getElementById('logs').textContent=j.logs||''}catch(e){toast(e.message,false)}}
setInterval(()=>{if(document.querySelector('aside nav button[data-t=clients].on'))loadStats()},5000);
if(TOK){showApp()}else{document.getElementById('pw').focus()}
</script>
</body></html>
`