wswg/web/index.html

26 lines
8.2 KiB
HTML

<!doctype html><html lang="ru"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>wswg</title>
<style>
:root{font:14px system-ui,sans-serif;color-scheme:light dark;background:#eee;color:#222}*{box-sizing:border-box}body{margin:0}button,input,textarea{font:inherit}button{padding:5px 12px;cursor:pointer}input[type=text],input[type=number],textarea{padding:6px;border:1px solid #999;background:#fff;color:#222;border-radius:2px}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid #1769c2;outline-offset:1px}main{display:grid;grid-template-columns:210px 1fr;height:calc(100dvh - 45px);min-height:420px;max-width:1200px;margin:auto}aside{padding:14px 10px;border-right:1px solid #aaa;display:flex;flex-direction:column;gap:12px}h1,h2{font-size:14px;font-weight:600;margin:0}#profiles{flex:1;overflow:auto;display:flex;flex-direction:column;gap:3px;align-items:stretch}.profile{text-align:left;border:1px solid transparent;background:transparent;padding:8px;overflow-wrap:anywhere}.profile[aria-pressed=true]{background:#cfe2fa;border-color:#7ba8da}article{padding:18px;overflow:auto}.row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap}#name{font-size:16px;overflow-wrap:anywhere}textarea{display:block;width:100%;height:240px;resize:vertical;font:13px ui-monospace,monospace;margin:10px 0}textarea[readonly]{background:#f7f7f7}fieldset{padding:12px;border:1px solid #aaa;margin:16px 0}fieldset .row:last-child{margin-bottom:0}fieldset label{min-width:85px}#host{width:min(300px,100%)}#port{width:85px}footer{min-height:45px;display:flex;justify-content:space-between;align-items:center;gap:10px;border-top:1px solid #aaa;padding:7px 12px;flex-wrap:wrap;font-size:12px}footer label{white-space:nowrap}#error{color:#a00012;white-space:pre-wrap;margin-top:12px}#empty{color:#666}button:disabled{cursor:default;opacity:.55}[hidden]{display:none!important}@media(prefers-color-scheme:dark){:root{background:#202020;color:#eee}input[type=text],input[type=number],textarea{background:#292929;color:#eee}textarea[readonly]{background:#252525}.profile[aria-pressed=true]{background:#254568}#empty{color:#bbb}#error{color:#ff9b9b}}@media(max-width:560px){main{grid-template-columns:130px 1fr}aside{padding:10px 5px}article{padding:12px}button{padding:6px 8px}textarea{height:220px}footer{font-size:11px}}
</style>
<main><aside><h1>Configs</h1><nav id="profiles" aria-label="Конфигурации"></nav><button id="new">New</button></aside><article><p id="empty">Выберите конфигурацию или нажмите New.</p><div id="editor" hidden><div class="row"><strong id="name"></strong><button id="rename">Rename</button><button id="delete">Delete</button></div><div class="row"><h2>WireGuard</h2><button id="edit">Edit</button></div><textarea id="wg" aria-label="WireGuard config" spellcheck="false" readonly></textarea><label><input id="dns" type="checkbox"> Use DNS from config</label><fieldset><legend><label><input id="enabled" type="checkbox"> Wstunnel</label></legend><div class="row"><label for="host">IP / host</label><input id="host" type="text" placeholder="From WireGuard"></div><div class="row"><label for="port">Port</label><input id="port" type="number" min="1" max="65535" value="443"></div><label><input id="https" type="checkbox" checked> Use HTTPS</label></fieldset><div class="row"><button id="save">Save</button><button id="connect">Connect</button><button id="disconnect">Disconnect</button><span id="saved" role="status"></span></div></div><div id="error" role="alert"></div></article></main><footer><span id="status" role="status">Disconnected</span><span><label><input id="autorun" type="checkbox"> Autorun</label> | <label><input id="boot" type="checkbox"> On boot</label></span></footer>
<script>
'use strict';const $=id=>document.getElementById(id),token=location.hash.slice(1)||sessionStorage.getItem('wswg-token')||'';sessionStorage.setItem('wswg-token',token);history.replaceState(null,'',location.pathname);let state,selected='',draft=null,editing=false,busy=false,dirty=false;
async function api(path,data){const r=await fetch('/api/'+path,{method:data===undefined?'GET':'POST',headers:{Authorization:'Bearer '+token,'Content-Type':'application/json'},body:data===undefined?undefined:JSON.stringify(data)});const j=await r.json();if(!r.ok)throw Error(j.error||r.statusText);return j}
function fail(e){$('error').textContent=e.message||String(e)}
async function action(fn){if(busy)return;busy=true;$('error').textContent='';try{await fn()}catch(e){fail(e)}finally{busy=false}}
function select(p){if(dirty&&!confirm('Discard unsaved changes?'))return;draft=structuredClone(p);selected=p.id;dirty=false;editing=!p.id;draw()}
function draw(){const p=draft;$('editor').hidden=!p;$('empty').hidden=!!p;list();if(!p)return;$('name').textContent=p.name;$('wg').value=p.wireguard;$('wg').readOnly=!editing;$('edit').textContent=editing?'Read only':'Edit';$('dns').checked=p.dns;$('enabled').checked=p.tunnel.enabled;$('host').value=p.tunnel.host;$('port').value=p.tunnel.port;$('https').checked=p.tunnel.https;$('delete').disabled=!p.id;$('connect').disabled=!p.id||dirty;$('saved').textContent=dirty?'Unsaved':'';locks()}
function locks(){const active=state&&state.status.connected&&selected&&state.settings.profiles.find(p=>p.id===selected)?.name===state.status.config;for(const id of ['wg','edit','dns','enabled','host','port','https','save','delete','rename'])$(id).disabled=!!active;$('disconnect').disabled=!state?.status.connected;if(active)$('connect').disabled=true}
function list(){if(!state)return;$('profiles').replaceChildren();for(const p of state.settings.profiles){const b=document.createElement('button');b.className='profile';b.textContent=p.name+(state.status.connected&&state.status.config===p.name?' [✓]':'');b.setAttribute('aria-pressed',p.id===selected);b.onclick=()=>select(p);$('profiles').append(b)}}
function read(){if(!draft)return;draft.wireguard=$('wg').value;draft.dns=$('dns').checked;draft.tunnel={enabled:$('enabled').checked,host:$('host').value.trim(),port:Number($('port').value),https:$('https').checked}}
async function refresh(){state=await api('state');const s=state.status;$('status').textContent=s.connected?`Connected to ${s.ip} | Last handshake ${s.handshake}s | Ping ${s.ping}ms | Packet loss: ${s.packetLoss}%`:'Disconnected'+(s.error?' | '+s.error:'');$('autorun').checked=state.settings.autorun;$('boot').checked=state.settings.onBoot;list();locks()}
for(const id of ['wg','dns','enabled','host','port','https'])$(id).addEventListener('input',()=>{dirty=true;read();$('saved').textContent='Unsaved';$('connect').disabled=true});
$('new').onclick=()=>select({id:'',name:'New config',wireguard:'[Interface]\nPrivateKey = \nAddress = \n\n[Peer]\nPublicKey = \nAllowedIPs = 0.0.0.0/0\nEndpoint = \nPersistentKeepalive = 25\n',dns:true,tunnel:{enabled:false,host:'',port:443,https:true}});
$('rename').onclick=()=>{const n=prompt('Config name',draft.name);if(n?.trim()){draft.name=n.trim();dirty=true;read();draw()}};
$('edit').onclick=()=>{editing=!editing;$('wg').readOnly=!editing;$('edit').textContent=editing?'Read only':'Edit';if(editing)$('wg').focus()};
$('save').onclick=()=>action(async()=>{read();await api('save',draft);const name=draft.name;await refresh();draft=structuredClone(state.settings.profiles.find(p=>p.name===name));selected=draft.id;dirty=false;editing=false;draw();$('saved').textContent='Saved'});
$('delete').onclick=()=>action(async()=>{if(!confirm('Delete '+draft.name+'?'))return;await api('delete',{id:selected});draft=null;selected='';dirty=false;await refresh();draw()});
$('connect').onclick=()=>action(async()=>{await api('connect',{id:selected});await refresh()});$('disconnect').onclick=()=>action(async()=>{await api('disconnect',{});await refresh()});
for(const id of ['autorun','boot'])$(id).onchange=()=>action(async()=>{try{await api('options',{autorun:$('autorun').checked,onBoot:$('boot').checked})}finally{await refresh()}});
window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue=''}});(async()=>{try{await refresh();if(state.settings.profiles.length)select(state.settings.profiles.find(p=>p.id===state.settings.last)||state.settings.profiles[0])}catch(e){fail(e)}setInterval(()=>{if(!busy)refresh().catch(fail)},1000)})();
</script></html>