Files
keenetic-dns-routes/templates/index.html
Андрей Бобырев adcf95ff4a fix(ui): author links only in bottom bar (match strip style)
Made-with: Cursor
2026-04-25 13:05:32 +03:00

412 lines
26 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.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Keenetic DNS Routes</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0a0a0b;--card:#161618;--bd:#2a2a2e;--tx:#f4f4f5;--mu:#71717a;--ac:#3b82f6;--ok:#22c55e;--er:#ef4444}
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--tx);min-height:100vh;padding-bottom:46px}
.hdr{padding:14px 20px;border-bottom:1px solid var(--bd);display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.hdr h1{font-size:17px;font-weight:800}
.hdr small{color:var(--mu);font-weight:500}
.wrap{max-width:1100px;margin:0 auto;padding:20px}
.tabs{display:flex;gap:6px;margin-bottom:16px}
.tab{padding:8px 18px;border-radius:10px;border:1px solid var(--bd);background:var(--card);color:var(--mu);font-weight:600;cursor:pointer;font-size:13px}
.tab.on{background:var(--ac);color:#fff;border-color:var(--ac)}
.card{background:var(--card);border:1px solid var(--bd);border-radius:14px;padding:18px;margin-bottom:16px}
.card h2{font-size:14px;margin-bottom:10px}
label{display:block;font-size:11px;color:var(--mu);margin-bottom:4px;font-weight:600}
input[type=text],input[type=url],input[type=password],textarea{width:100%;background:#111;border:1px solid var(--bd);border-radius:10px;padding:10px 12px;color:var(--tx);font-size:13px;font-family:inherit}
textarea{min-height:220px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.45;resize:vertical}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:12px}
.row > div{flex:1;min-width:200px}
.btn{padding:9px 18px;border-radius:10px;border:none;font-weight:700;font-size:13px;cursor:pointer;font-family:inherit}
.btn-b{background:var(--ac);color:#fff}
.btn-g{background:var(--ok);color:#0a0a0b}
.btn-d{background:#3f3f46;color:#fff}
.btn-o{background:#ea580c;color:#fff}
.tbl{width:100%;border-collapse:collapse;font-size:13px}
.tbl th,.tbl td{padding:8px 6px;border-bottom:1px solid var(--bd);text-align:left}
.tbl th{color:var(--mu);font-size:11px;text-transform:uppercase}
.log{margin-top:10px;padding:12px;background:#111;border-radius:10px;font-size:12px;white-space:pre-wrap;max-height:240px;overflow-y:auto;display:none}
.log.show{display:block}
.pill{display:inline-block;padding:2px 8px;border-radius:6px;font-size:11px;font-weight:700}
.pill.ok{background:rgba(34,197,94,.15);color:var(--ok)}
.pill.bad{background:rgba(239,68,68,.15);color:var(--er)}
#auth-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:100;align-items:center;justify-content:center}
#auth-bg.on{display:flex}
#auth-box{background:var(--card);border:1px solid var(--bd);padding:28px;border-radius:16px;width:min(360px,92vw)}
#ifscan-modal,#edit-router-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.88);z-index:200;align-items:center;justify-content:center;padding:16px}
#ifscan-modal.on,#edit-router-modal.on{display:flex}
#ifscan-box{background:var(--card);border:1px solid var(--bd);border-radius:16px;max-width:900px;width:100%;max-height:88vh;overflow:auto;padding:18px}
#edit-router-box{background:var(--card);border:1px solid var(--bd);border-radius:16px;max-width:520px;width:100%;padding:18px}
.if-row{cursor:pointer}
.if-row:hover{background:rgba(59,130,246,.12)}
.kdns-foot{position:fixed;bottom:0;left:0;right:0;z-index:50;font-size:11px;font-weight:600;color:var(--mu);
padding:10px 20px;border-top:1px solid var(--bd);background:rgba(59,130,246,.1);
display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;justify-content:center;backdrop-filter:blur(10px)}
.kdns-foot a{color:var(--ac);text-decoration:none;border-bottom:1px solid rgba(59,130,246,.45);font-weight:700}
.kdns-foot a:hover{color:#93c5fd;border-bottom-color:#93c5fd}
.kdns-foot .kdns-am{color:var(--tx);font-weight:700;opacity:.95}
</style>
</head>
<body>
<div id="auth-bg" class="on"><div id="auth-box">
<h2 style="margin-bottom:12px;font-size:18px">Keenetic DNS Routes</h2>
<p style="font-size:11px;color:var(--mu);margin-bottom:10px;line-height:1.45">Сюда только <b>ADMIN_PASSWORD</b> из <code>.env</code> (вход на эту страницу). Для API роутеров: поля ниже или <code>KEENETIC_*</code> в <code>.env</code>, или <code>логин:пароль@</code> в URL.</p>
<label>Пароль веб-интерфейса (ADMIN_PASSWORD)</label>
<input type="password" id="apw" autocomplete="current-password" style="margin-bottom:12px" onkeydown="if(event.key==='Enter')doLogin()"/>
<div id="aerr" style="color:var(--er);font-size:12px;margin-bottom:8px;display:none"></div>
<button class="btn btn-b" style="width:100%" onclick="doLogin()">Войти</button>
</div></div>
<div class="hdr">
<div><h1>Keenetic DNS Routes</h1><small>Списки DNS-маршрутизации (без Neo) · порт 8001</small></div>
<button class="btn btn-d" onclick="logout()">Выйти</button>
</div>
<div class="wrap">
<div class="card" id="cred-card" style="display:none">
<h2>Учётные данные Keenetic (API)</h2>
<p style="font-size:11px;color:var(--mu);margin-bottom:8px;line-height:1.5">Два варианта по умолчанию: <b>KEENETIC_LOGIN / KEENETIC_PASSWORD</b> в <code>.env</code> на сервере <b>или</b> свои логин/пароль (или <code>http(s)://логин:пароль@хост:порт</code>) у каждого роутера. Пароль входа на эту страницу — только <code>ADMIN_PASSWORD</code>.</p>
<p id="cred-line" style="font-size:14px;margin-bottom:6px"></p>
<p id="cred-hint" style="font-size:11px;color:var(--mu);line-height:1.5"></p>
<p style="font-size:11px;color:var(--mu);margin-top:8px">В URL можно указать <code>логин:пароль@</code> перед хостом — при сохранении они переносятся в поля, в адресе остаётся только <code>http(s)://хост:порт</code>. Пути вида <code>/rci/...</code> в base URL не указывай — их добавляет сервис.</p>
</div>
<div class="tabs">
<button class="tab on" data-t="US" onclick="setTab('US')">Список US</button>
<button class="tab" data-t="RU" onclick="setTab('RU')">Список RU</button>
</div>
<div class="card" id="panel-US">
<h2>US — домены и IP/CIDR (одна строка = одна запись)</h2>
<div class="row" style="align-items:flex-end">
<div style="flex:1"><label>Interface ID (как в RCI: Wireguard0, PPPoE0…)</label>
<input type="text" id="if-US" placeholder="Wireguard0" oninput="ST.groups=readForm()"/></div>
<div><label>&nbsp;</label><button type="button" class="btn btn-d" onclick="openIfScan('US')">Сканировать…</button></div>
</div>
<textarea id="tx-US" placeholder="youtube.com&#10;1.2.3.0/24" oninput="ST.groups=readForm()"></textarea>
</div>
<div class="card" id="panel-RU" style="display:none">
<h2>RU — домены и IP/CIDR</h2>
<div class="row" style="align-items:flex-end">
<div style="flex:1"><label>Interface ID</label>
<input type="text" id="if-RU" placeholder="GigabitEthernet0" oninput="ST.groups=readForm()"/></div>
<div><label>&nbsp;</label><button type="button" class="btn btn-d" onclick="openIfScan('RU')">Сканировать…</button></div>
</div>
<textarea id="tx-RU" placeholder="yandex.ru" oninput="ST.groups=readForm()"></textarea>
</div>
<div class="row" style="align-items:center;margin-top:4px">
<input type="text" id="one-line" placeholder="Добавить одну строку в активную вкладку (US/RU)…" style="flex:1"/>
<button class="btn btn-d" type="button" onclick="appendCurrentTab()">+ В текст</button>
</div>
<div class="row">
<button class="btn btn-g" onclick="saveServer()">Сохранить на сервер</button>
<button class="btn btn-o" onclick="applyAll()">Применить на всех legacy</button>
<button class="btn btn-b" onclick="applySel()">Только на выбранных</button>
</div>
<div id="sv" style="font-size:12px;color:var(--mu);margin-bottom:8px"></div>
<div class="log" id="log"></div>
<div class="card" style="margin-top:24px">
<h2>Роутеры (KeenDNS → base URL прокси)</h2>
<p style="font-size:12px;color:var(--mu);margin-bottom:12px">У каждого роутера: <b>base URL</b> + при необходимости <b>логин/пароль</b>, либо сразу URL <code>http(s)://логин:пароль@хост:порт</code>. Если поля логина/пароля пустые — берутся <code>KEENETIC_*</code> из <code>.env</code> на сервере.</p>
<p style="font-size:11px;color:var(--mu);margin-bottom:10px;line-height:1.45">Укажи тот же <b>хост и порт</b>, что в KeenDNS для HTTP Proxy (часто не стандартный порт). Для <code>http</code> без порта запрос уйдёт на <b>80</b> и может быть <code>/auth 404</code>. <code>/auth 403</code> — проверь разрешённые IP для API на роутере.</p>
<div class="row">
<div style="min-width:120px"><label>Имя</label><input type="text" id="rn" placeholder="Дача"/></div>
<div style="flex:2;min-width:220px"><label>Base URL прокси</label><input type="url" id="ru" placeholder="https://homesmart.netcraze.pro:81"/></div>
<div style="min-width:100px"><label>Логин (если не .env)</label><input type="text" id="rl" placeholder="пусто = из .env"/></div>
<div style="min-width:120px"><label>Пароль (если не .env)</label><input type="password" id="rp" placeholder="пусто = из .env" autocomplete="new-password"/></div>
<div><label>&nbsp;</label><button class="btn btn-b" onclick="addR()">+ Добавить</button></div>
</div>
<div style="overflow-x:auto">
<table class="tbl" id="rtbl"><thead><tr>
<th style="width:36px" title="Выбор для кнопки «Только на выбранных»"></th><th>Имя</th><th>Base URL</th><th>Логин</th><th>Пароль</th><th title="Включить/выключить роутер для «Применить на всех»">Вкл</th><th></th>
</tr></thead><tbody></tbody></table>
</div>
</div>
</div>
<div id="edit-router-modal"><div id="edit-router-box">
<h3 style="margin-bottom:12px;font-size:16px">Роутер</h3>
<input type="hidden" id="er-id"/>
<div class="row"><div style="flex:1"><label>Имя</label><input type="text" id="er-name"/></div></div>
<div class="row"><div style="flex:1"><label>Base URL прокси</label><input type="url" id="er-url" placeholder="https://хост:порт"/></div></div>
<div class="row"><div style="flex:1"><label>Keenetic логин</label><input type="text" id="er-login" placeholder="логин RCI"/></div></div>
<div class="row"><div style="flex:1"><label>Keenetic пароль</label><input type="password" id="er-pass" placeholder="пусто = не менять" autocomplete="new-password"/></div></div>
<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--mu);cursor:pointer;margin-bottom:12px">
<input type="checkbox" id="er-clear-pass"/> Очистить пароль (потом задай заново или в URL user:pass@)
</label>
<div class="row" style="margin-bottom:0">
<button type="button" class="btn btn-b" onclick="saveEditRouter()">Сохранить</button>
<button type="button" class="btn btn-d" onclick="closeEditRouter()">Отмена</button>
</div>
</div></div>
<div id="ifscan-modal"><div id="ifscan-box">
<h3 style="margin-bottom:12px;font-size:16px">Интерфейсы с роутера (RCI)</h3>
<p style="font-size:12px;color:var(--mu);margin-bottom:10px">Выбери роутер → «Загрузить». Строка = то, что вписывается в <b>Interface ID</b> для текущей вкладки (US или RU). Обычно для VPN — <code>Wireguard0</code> / <code>OpenVPN0</code>, для провайдера — <code>PPPoE0</code> / <code>GigabitEthernet0</code> и т.п.</p>
<div class="row" style="align-items:flex-end;margin-bottom:10px">
<div style="flex:1"><label>Роутер</label>
<select id="ifscan-router" style="width:100%;background:#111;border:1px solid var(--bd);border-radius:10px;padding:10px;color:var(--tx);font-size:13px"></select></div>
<div><label>&nbsp;</label><button type="button" class="btn btn-b" onclick="runIfScan()">Загрузить</button></div>
</div>
<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--mu);cursor:pointer;margin-bottom:10px">
<input type="checkbox" id="ifscan-wg-only"/> Только WireGuard (удобно выбрать туннель US/RU)
</label>
<div id="ifscan-list" style="font-size:12px"></div>
<button type="button" class="btn btn-d" style="margin-top:14px" onclick="closeIfScan()">Закрыть</button>
</div></div>
<script>
let ST={groups:{US:{interface_id:'',lines:[]},RU:{interface_id:'',lines:[]}},routers:[]};
function hdr(){return{'Content-Type':'application/json','X-Admin-Password':sessionStorage.getItem('kdns_pw')||''};}
async function doLogin(){
const p=document.getElementById('apw').value;
const e=document.getElementById('aerr');
e.style.display='none';
const r=await fetch('/api/auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:p})});
if(r.ok){sessionStorage.setItem('kdns_pw',p);document.getElementById('auth-bg').classList.remove('on');await load();}
else{e.textContent='Неверный пароль';e.style.display='block';}
}
function logout(){sessionStorage.removeItem('kdns_pw');document.getElementById('auth-bg').classList.add('on');}
async function loadCred(){
try{
const r=await fetch('/api/keenetic-env',{headers:hdr()});
if(!r.ok)return;
const j=await r.json();
document.getElementById('cred-card').style.display='block';
const pw=j.password_configured?'<span class="pill ok">KEENETIC_PASSWORD в .env задан</span>':'<span class="pill bad">KEENETIC_PASSWORD в .env пустой</span>';
document.getElementById('cred-line').innerHTML='Дефолт из .env: логин <b>'+esc(j.login||'')+'</b> &nbsp; '+pw;
document.getElementById('cred-hint').textContent=j.hint||'';
}catch(_){}
}
let _ifTab='US';
function openIfScan(tab){
_ifTab=tab;
const s=document.getElementById('ifscan-router');
s.innerHTML='<option value="">— выбери роутер —</option>'+(ST.routers||[]).map(r=>'<option value="'+esc(r.id)+'">'+esc(r.name)+'</option>').join('');
document.getElementById('ifscan-list').innerHTML='';
document.getElementById('ifscan-modal').classList.add('on');
}
function closeIfScan(){document.getElementById('ifscan-modal').classList.remove('on');}
function pickIf(id){
document.getElementById('if-'+_ifTab).value=id;
closeIfScan();
}
async function runIfScan(){
const id=document.getElementById('ifscan-router').value;
const L=document.getElementById('ifscan-list');
if(!id){L.innerHTML='<span style="color:var(--er)">Выбери роутера</span>';return;}
L.innerHTML='Загрузка…';
const wg=document.getElementById('ifscan-wg-only').checked;
const q=wg?'?wireguard_only=1':'';
const r=await fetch('/api/routers/'+encodeURIComponent(id)+'/interfaces'+q,{headers:hdr()});
if(r.status===401){logout();return;}
if(!r.ok){L.innerHTML='<span style="color:var(--er)">'+(await r.text())+'</span>';return;}
const j=await r.json();
const rows=(j.interfaces||[]).map(it=>{
const iid=String(it.id||'');
return '<tr class="if-row" data-ifid="'+esc(iid).replace(/"/g,'&quot;')+'"><td><code>'+esc(iid)+'</code></td><td>'+esc(it.type)+'</td><td>'+esc(it.description)+'</td><td style="font-size:11px;color:var(--mu)">'+esc([it.state,it.link,it.connected].filter(Boolean).join(' · '))+'</td><td><button type="button" class="btn btn-b" style="padding:4px 10px;font-size:11px">Вставить</button></td></tr>';
}).join('');
L.innerHTML='<table class="tbl"><thead><tr><th>ID</th><th>Тип</th><th>Описание</th><th>Состояние</th><th></th></tr></thead><tbody>'+rows+'</tbody></table>';
L.querySelector('tbody').onclick=function(ev){
const tr=ev.target.closest('tr[data-ifid]');
if(!tr)return;
pickIf(tr.getAttribute('data-ifid'));
};
}
async function load(){
const r=await fetch('/api/data',{headers:hdr()});
if(r.status===401){logout();return;}
ST=await r.json();
paint();
await loadCred();
}
function paint(){
for(const k of['US','RU']){
const g=ST.groups[k]||{};
document.getElementById('if-'+k).value=g.interface_id||'';
document.getElementById('tx-'+k).value=(g.lines||[]).join('\n');
}
const tb=document.querySelector('#rtbl tbody');
tb.innerHTML=(ST.routers||[]).map(ro=>{
const lo=String(ro.keenetic_login||'').trim();
const hasPw=String(ro.keenetic_password||'').length>0;
const fromEnv=!lo&&!hasPw;
const iid=String(ro.id||'');
return `<tr>
<td><input type="checkbox" class="kdns-sel" data-id="${esc(iid)}" title="Выбор для «Только на выбранных»"/></td>
<td>${esc(ro.name)}</td>
<td style="font-size:11px;word-break:break-all">${esc(ro.rci_base_url)}</td>
<td style="font-size:11px">${fromEnv?'<span style="color:var(--mu)">из .env</span>':esc(lo)}</td>
<td style="font-size:11px">${fromEnv?'<span class="pill ok">из .env</span>':(hasPw?'<span class="pill ok">есть</span>':'<span class="pill bad">нет</span>')}</td>
<td><input type="checkbox" ${ro.enabled?'checked':''} onchange='toggleEn(${JSON.stringify(iid)},this.checked)'/></td>
<td><button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick='openEditRouter(${JSON.stringify(iid)})'>Изм.</button>
<button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick='testR(${JSON.stringify(iid)})'>Тест</button>
<button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick='delR(${JSON.stringify(iid)})'>✕</button></td>
</tr>`;
}).join('')||'<tr><td colspan="7" style="color:var(--mu)">Нет роутеров</td></tr>';
}
function openEditRouter(id){
const ro=(ST.routers||[]).find(x=>x.id===id);if(!ro)return;
document.getElementById('er-id').value=ro.id;
document.getElementById('er-name').value=ro.name||'';
document.getElementById('er-url').value=ro.rci_base_url||'';
document.getElementById('er-login').value=ro.keenetic_login||'';
document.getElementById('er-pass').value='';
document.getElementById('er-clear-pass').checked=false;
document.getElementById('edit-router-modal').classList.add('on');
}
function closeEditRouter(){document.getElementById('edit-router-modal').classList.remove('on');}
async function saveEditRouter(){
const id=document.getElementById('er-id').value;
const body={name:document.getElementById('er-name').value.trim(),rci_base_url:document.getElementById('er-url').value.trim(),keenetic_login:document.getElementById('er-login').value.trim()};
if(document.getElementById('er-clear-pass').checked)body.keenetic_password='';
else{const pw=document.getElementById('er-pass').value;if(pw)body.keenetic_password=pw;}
const r=await fetch('/api/routers/'+encodeURIComponent(id),{method:'PATCH',headers:hdr(),body:JSON.stringify(body)});
if(r.status===401){logout();return;}
if(!r.ok){alert(await r.text());return;}
closeEditRouter();
await load();
}
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');}
function setTab(t){
document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on',x.dataset.t===t));
document.getElementById('panel-US').style.display=t==='US'?'block':'none';
document.getElementById('panel-RU').style.display=t==='RU'?'block':'none';
}
function activeGroup(){return(document.querySelector('.tab.on')||{}).dataset?.t||'US';}
function appendCurrentTab(){
const k=activeGroup();
const line=document.getElementById('one-line').value.trim();
if(!line)return;
const ta=document.getElementById('tx-'+k);
const v=ta.value.replace(/\s+$/,'');
ta.value=(v?v+'\n':'')+line;
document.getElementById('one-line').value='';
ST.groups=readForm();
}
function readForm(){
const groups={};
for(const k of['US','RU']){
const raw=document.getElementById('tx-'+k).value||'';
const lines=raw.split(/\r?\n/).map(s=>s.trim()).filter(Boolean);
groups[k]={interface_id:document.getElementById('if-'+k).value.trim(),lines};
}
return groups;
}
/**
* @param {{skipLoad?:boolean}} opts — skipLoad: не перезагружать страницу (нужно перед «Только на выбранных»,
* иначе load() сбрасывает чекбоксы выбора; или перед добавлением роутера, чтобы сначала сохранить US/RU).
*/
async function saveServer(opts){
const skipLoad=opts&&opts.skipLoad;
const groups=readForm();
const r=await fetch('/api/data',{method:'PUT',headers:hdr(),body:JSON.stringify({groups,routers:ST.routers})});
if(r.status===401){logout();return;}
if(!r.ok){alert(await r.text());return;}
ST.groups=groups;
document.getElementById('sv').textContent='Сохранено '+new Date().toLocaleTimeString('ru');
if(!skipLoad) await load();
}
async function addR(){
const name=document.getElementById('rn').value.trim();
const rci_base_url=document.getElementById('ru').value.trim();
const keenetic_login=document.getElementById('rl').value.trim();
const keenetic_password=document.getElementById('rp').value;
if(!name||!rci_base_url){alert('Имя и URL');return;}
// Сначала сохраняем US/RU с сервера в store, иначе после load() пропадут несохранённые домены
await saveServer({skipLoad:true});
const r=await fetch('/api/routers',{method:'POST',headers:hdr(),body:JSON.stringify({name,rci_base_url,keenetic_login,keenetic_password})});
if(r.status===401){logout();return;}
if(!r.ok){alert(await r.text());return;}
document.getElementById('rn').value='';document.getElementById('ru').value='';
document.getElementById('rl').value='';document.getElementById('rp').value='';
await load();
}
async function delR(id){
if(!confirm('Удалить?'))return;
const r=await fetch('/api/routers/'+encodeURIComponent(id),{method:'DELETE',headers:hdr()});
if(r.status===401){logout();return;}
if(!r.ok){alert(await r.text());return;}
await load();
}
async function toggleEn(id,en){
ST.routers=(ST.routers||[]).map(x=>x.id===id?{...x,enabled:en}:x);
const groups=readForm();
ST.groups=groups;
await fetch('/api/data',{method:'PUT',headers:hdr(),body:JSON.stringify({groups,routers:ST.routers})});
}
async function testR(id){
const r=await fetch('/api/test-router/'+encodeURIComponent(id),{method:'POST',headers:hdr()});
if(r.status===401){logout();return;}
const raw=await r.text();
let j=null; try{j=JSON.parse(raw);}catch(_){}
if(!r.ok){
const d=j&&j.detail;
alert('Ошибка: '+(typeof d==='string'?d:Array.isArray(d)?d.map(x=>x.msg||x).join('; '):raw||r.status));
return;
}
if(!j){alert('Ошибка: '+raw);return;}
alert(j.ok?'OK: '+j.message:'Ошибка: '+j.message);
}
function showLog(t){const L=document.getElementById('log');L.textContent=t;L.classList.add('show');}
async function applyAll(){
await saveServer({skipLoad:true});
const r=await fetch('/api/apply',{method:'POST',headers:hdr(),body:JSON.stringify({mode:'all'})});
if(r.status===401){logout();return;}
const j=await r.json();
showLog((j.results||[]).map(x=>x.ok?`${x.router}\n${(x.log||[]).join('\n')}`:`${x.router}: ${x.error}`).join('\n\n'));
await load();
}
async function applySel(){
const ids=[...document.querySelectorAll('#rtbl input.kdns-sel:checked')].map(c=>c.getAttribute('data-id')).filter(Boolean);
if(!ids.length){
alert('Отметьте чекбоксы в первой колонке таблицы «Роутеры» (слева), не переключатель «Вкл». Обнови страницу (Ctrl+F5), если не помогло.');
return;
}
await saveServer({skipLoad:true});
const r=await fetch('/api/apply',{method:'POST',headers:hdr(),body:JSON.stringify({mode:'selected',router_ids:ids})});
if(r.status===401){logout();return;}
const j=await r.json();
showLog((j.results||[]).map(x=>x.ok?`${x.router}\n${(x.log||[]).join('\n')}`:`${x.router}: ${x.error}`).join('\n\n'));
await load();
}
(async()=>{
const p=sessionStorage.getItem('kdns_pw');
if(p){
const r=await fetch('/api/auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:p})});
if(r.ok){document.getElementById('auth-bg').classList.remove('on');await load();}
else{sessionStorage.removeItem('kdns_pw');}
}
})();
</script>
<div class="kdns-foot" lang="ru" id="kdns-foot-strip" aria-label="Автор и ссылки">
<span class="kdns-am">автор ·</span>
<a href="https://github.com/andrey271192" target="_blank" rel="noopener noreferrer" title="Репозитории на GitHub">GitHub andrey271192</a>
<span>·</span>
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Boosty</a>
<span>·</span>
<a href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank" rel="noopener noreferrer" title="Поддержка проекта (Ozon Bank, СБП)">Поддержка</a>
<span>·</span>
<a href="https://t.me/Iot_andrey" target="_blank" rel="noopener noreferrer">Telegram @Iot_andrey</a>
</div>
</body>
</html>