227 lines
13 KiB
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> |