stealth-browser/static/index.html

227 lines
13 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Stealth Browser Admin</title>
<style>
:root{--bg:#0f1115;--panel:#171a21;--panel2:#1d212b;--border:#2a2f3a;--fg:#e6e9ef;--dim:#8a91a0;--acc:#4f8cff;--ok:#37c269;--warn:#e5a13a;--err:#e5484d}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--fg);font:14px/1.45 system-ui,Segoe UI,sans-serif;padding:16px}
h1{font-size:18px;margin-bottom:4px} h1 span{color:var(--dim);font-weight:400;font-size:13px}
.tabs{display:flex;gap:8px;margin:14px 0;border-bottom:1px solid var(--border);padding-bottom:8px}
.tab{background:var(--panel);border:1px solid var(--border);color:var(--dim);padding:6px 14px;border-radius:6px;cursor:pointer}
.tab.active{background:var(--acc);border-color:var(--acc);color:#fff}
.section{display:none}.section.active{display:block}
.card{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:10px}
.card.pending{border-color:var(--warn)}
.row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.url{flex:1;min-width:200px;font-family:ui-monospace,monospace;font-size:12px;color:var(--dim);word-break:break-all}
.badge{font-size:11px;padding:2px 8px;border-radius:10px;background:var(--panel2);color:var(--dim);border:1px solid var(--border)}
.badge.captcha{background:#3a2c14;color:var(--warn);border-color:#5c4520}
.badge.ok{background:#14281c;color:var(--ok);border-color:#1f4d2e}
.badge.fail{background:#33181a;color:var(--err);border-color:#5c2024}
button{background:var(--panel2);border:1px solid var(--border);color:var(--fg);padding:5px 12px;border-radius:6px;cursor:pointer;font-size:13px}
button:hover{border-color:var(--acc)}
button.primary{background:var(--acc);border-color:var(--acc);color:#fff}
button.danger{background:#3a1d1f;border-color:#5c2024;color:var(--err)}
button:disabled{opacity:.4;cursor:not-allowed}
input{background:var(--panel2);border:1px solid var(--border);color:var(--fg);padding:6px 8px;border-radius:6px;font-size:13px}
input:focus{outline:none;border-color:var(--acc)}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:10px 0}
.probe{border:1px solid var(--border);border-radius:8px;overflow:hidden;background:#000;max-height:70vh;overflow-y:auto;position:relative;cursor:crosshair}
.probe img{display:block;width:100%;height:auto}
#crosshair{position:absolute;border:1px solid var(--err);width:4px;height:4px;border-radius:50%;pointer-events:none;display:none}
.result{white-space:pre-wrap;max-height:300px;overflow-y:auto;background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:10px;font-family:ui-monospace,monospace;font-size:12px;color:var(--fg);margin-top:10px}
table{width:100%;border-collapse:collapse;font-size:12px}
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--border);vertical-align:top}
th{color:var(--dim);font-weight:600}
.dim{color:var(--dim)}
.login{position:fixed;inset:0;background:var(--bg);display:none;align-items:center;justify-content:center;z-index:10}
.login .box{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:24px;width:320px}
.login h2{font-size:16px;margin-bottom:12px}
.login input{width:100%;margin-bottom:10px}
.err{color:var(--err);font-size:12px;margin-top:8px;display:none}
.hint{font-size:12px;color:var(--dim);margin-top:12px}
a{color:var(--acc)}
.view{display:none;margin-top:12px}
</style>
</head>
<body>
<h1>🛡️ Stealth Browser Admin <span id="hdr"></span></h1>
<div class="tabs">
<button class="tab active" data-t="challenges">Challenges <span id="cnt" class="dim"></span></button>
<button class="tab" data-t="logs">Logs</button>
</div>
<div id="sec-challenges" class="section active">
<div id="pending"></div>
<div id="done"></div>
</div>
<div id="sec-logs" class="section">
<table><thead><tr><th>Time</th><th>Job</th><th>Event</th><th>URL / Detail</th><th>Dur</th></tr></thead>
<tbody id="logrows"><tr><td colspan="5" class="dim">loading…</td></tr></tbody></table>
</div>
<div id="view" class="view">
<div class="card" id="viewhead"></div>
<div class="toolbar">
<button id="b-click" class="primary">Click</button>
<button id="b-reload">Reload</button>
<button id="b-back">Back</button>
<button id="b-up">↑ Scroll</button>
<button id="b-down">↓ Scroll</button>
<input id="in-url" style="flex:1" placeholder="goto URL…">
<button id="b-goto">Go</button>
<input id="in-type" style="flex:1" placeholder="type text into focused field…">
<button id="b-type">Type</button>
<button id="b-enter">Enter</button>
<button id="b-continue" class="primary">✔ Done — extract page</button>
<button id="b-cancel" class="danger">✖ Cancel job</button>
</div>
<div class="probe"><img id="shot" alt=""><div id="crosshair"></div></div>
<div id="stage" class="dim hint"></div>
<div id="result" class="result" style="display:none"></div>
</div>
<div class="login" id="login"><div class="box">
<h2>Login</h2>
<input id="li-user" placeholder="username" autocomplete="username">
<input id="li-pass" type="password" placeholder="password" autocomplete="current-password">
<button id="li-go" class="primary" style="width:100%">Sign in</button>
<div class="err" id="li-err">Wrong credentials</div>
<div class="hint">Stored in this browser only. Set ADMIN_USER/ADMIN_PASS on the server to require login.</div>
</div></div>
<script>
const $=s=>document.querySelector(s);
let auth=null, jobId=null, pollTimer=null;
function hdrs(){const h={}; if(auth) h['Authorization']='Basic '+auth; return h;}
async function api(url,opts={}){const r=await fetch(url,{...opts,headers:{...(hdrs()||{}),...(opts.headers||{})}}); if(r.status===401){showLogin();throw new Error('auth');} return r;}
function showLogin(){document.getElementById('login').style.display='flex';}
function hideLogin(){document.getElementById('login').style.display='none';}
function b64(s){return btoa(unescape(encodeURIComponent(s)));}
function startLogin(){const u=$('#li-user').value,p=$('#li-pass').value; if(!u&&!p){hideLogin();auth=null;refresh();return;} auth=b64(u+':'+p); api('/api/jobs').then(()=>{hideLogin();refresh();}).catch(()=>{$('#li-err').style.display='block';auth=null;});}
$('#li-go').onclick=startLogin;
$('#li-pass').addEventListener('keydown',e=>{if(e.key==='Enter')startLogin();});
function badge(j){const m={captcha:'captcha',done:j.outcome==='ok'?'ok':'captcha',ok:'ok',failed:'fail',cancelled:'',timeout:'fail'}; const c=m[j.status.replace('solving','')]; return `<span class="badge ${c||''}">${j.status}${j.outcome&&j.outcome!=='ok'?' · '+j.outcome:''}</span>`;}
function renderPending(items){
const p=items.filter(j=>j.status==='captcha');
$('#cnt').textContent=p.length?`(${p.length})`:'';
$('#pending').innerHTML=p.map(j=>`
<div class="card pending"><div class="row">
<span class="badge captcha">⚠ captcha</span>
<span class="url">${j.url}</span>
<button data-open="${j.id}" class="primary">Open & solve</button>
<button data-cancel2="${j.id}" class="danger">Cancel</button>
</div></div>`).join('') || '<div class="card"><span class="dim">No pending challenges — fetching normally.</span></div>';
document.querySelectorAll('[data-open]').forEach(b=>b.onclick=()=>openView(b.dataset.open));
document.querySelectorAll('[data-cancel2]').forEach(b=>b.onclick=async()=>{await api('/api/jobs/'+b.dataset.cancel2+'/cancel',{method:'POST'});refresh();});
}
function renderDone(items){
const d=items.filter(j=>['done','failed','cancelled','timeout'].includes(j.status)).slice(0,15);
$('#done').innerHTML=d.map(j=>`
<div class="card"><div class="row">
<span>${badge(j)}</span>
<span class="url">${j.url}</span>
${j.title?`<span class="dim">${j.title}</span>`:''}
<a href="javascript:void 0" data-res="${j.id}">result</a>
</div></div>`).join('')||'';
document.querySelectorAll('[data-res]').forEach(a=>a.onclick=async()=>{const r=await api('/api/jobs/'+a.dataset.res);openView(a.dataset.res,true);});
}
function renderJobs(items){renderPending(items);renderDone(items);}
document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>{
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
document.querySelectorAll('.section').forEach(x=>x.classList.remove('active'));
b.classList.add('active'); $('#sec-'+b.dataset.t).classList.add('active');
});
async function refresh(){
try{
const jr=await api('/api/jobs');
const jobs=await jr.json();
renderJobs(jobs);
const h=jobs.length?'('+jobs.length+' jobs)':'—';
$('#hdr').textContent=h;
}catch(e){ if(e.message!=='auth') console.error(e); }
}
async function refreshLogs(){
try{
const r=await api('/api/logs'); const logs=await r.json();
$('#logrows').innerHTML=logs.map(l=>`<tr>
<td class="dim">${l.ts}</td><td>${l.job||''}</td><td>${l.event}</td>
<td class="url">${l.url||(l.error?l.error.slice(0,120):'')}</td>
<td>${l.duration_ms!=null?l.duration_ms+'ms':''}</td></tr>`).join('') ||
'<tr><td colspan="5" class="dim">no logs yet</td></tr>';
}catch(e){}
}
let shotTimer=null, cursorEl=null;
function openView(id, alreadyDone=false){
jobId=id; $('#view').style.display='block';
$('#result').style.display='none'; $('#stage').textContent='Loading…';
const jobs=document.querySelectorAll('#pending .card');
const probe=document.querySelector('.probe');
const img=$('#shot');
img.onclick=async e=>{
const rect=img.getBoundingClientRect();
const x=(e.clientX-rect.left)/rect.width*img.naturalWidth;
const y=(e.clientY-rect.top)/rect.height*img.naturalHeight;
await api('/api/jobs/'+jobId+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'click',x,y,img_w:img.naturalWidth})});
shot();
};
cursorEl=cursorEl||document.getElementById('crosshair');
probe.addEventListener('mousemove',e=>{if(!cursorEl)return;const r=img.getBoundingClientRect();cursorEl.style.display='block';cursorEl.style.left=(e.clientX-r.left-2)+'px';cursorEl.style.top=(e.clientY-r.top-2)+'px';});
const shot=async()=>{
try{
const r=await api('/api/jobs/'+jobId+'/screenshot');
const blob=await r.blob(); img.src=URL.createObjectURL(blob);
$('#stage').textContent=(new Date()).toLocaleTimeString()+' · live view (click to interact)';
}catch(e){ $('#stage').textContent='job closed or done'; }
};
shot(); shotTimer=setInterval(shot,2000);
pollDetail();
}
async function pollDetail(){
const t=setInterval(async()=>{
if(!jobId){clearInterval(t);return;}
try{
const r=await api('/api/jobs/'+jobId); const j=await r.json();
if(['done','failed','cancelled','timeout'].includes(j.status)){
clearInterval(t); clearInterval(shotTimer);
$('#stage').textContent='';
$('#result').style.display='block';
$('#result').textContent=`[${j.status}${j.outcome?' · '+j.outcome:''}] ${j.url}\n——\n${(j.text||j.error||'').slice(0,15000)}`;
refresh();
}
}catch(e){}
},1500);
}
$('#b-continue').onclick=async()=>{await api('/api/jobs/'+jobId+'/continue',{method:'POST'});};
$('#b-cancel').onclick=async()=>{await api('/api/jobs/'+jobId+'/cancel',{method:'POST'});refresh();};
$('#b-type').onclick=async()=>{await api('/api/jobs/'+jobId+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'type',text:$('#in-type').value})});};
$('#b-enter').onclick=async()=>{await api('/api/jobs/'+jobId+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'key',key:'Enter'})});};
$('#b-reload').onclick=r1('reload'); $('#b-back').onclick=r1('back');
function r1(type){return async()=>{await api('/api/jobs/'+jobId+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type})});};}
$('#b-up').onclick=async()=>{await api('/api/jobs/'+jobId+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'scroll',delta:-400})});};
$('#b-down').onclick=async()=>{await api('/api/jobs/'+jobId+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'scroll',delta:400})});};
$('#b-goto').onclick=async()=>{await api('/api/jobs/'+jobId+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'goto',url:$('#in-url').value})});};
setInterval(()=>{refresh();const t=document.querySelector('.tab.active');if(t&&t.dataset.t==='logs')refreshLogs();},2000);
(async function init(){try{const r=await api('/api/jobs');if(r.ok){hideLogin();refresh();} }catch(e){showLogin();}} )();
refreshLogs();
</script>
</body>
</html>