Files
android-fleet-godhead/static/index.html

458 lines
25 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ANDROID FLEET — GODHEAD</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.19.0/css/xterm.css">
<script src="https://cdn.jsdelivr.net/npm/xterm@4.19.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.7.0/lib/xterm-addon-fit.min.js"></script>
<style>
body { background:#0a0a0f; color:#e4e4e7; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
background-image: radial-gradient(circle, #17171f 1px, transparent 1px); background-size: 26px 26px; }
.panel { background:#12121a; border:1px solid #23232e; border-radius: 0.75rem; }
.card:hover { box-shadow: 0 0 24px rgba(99,102,241,.18); border-color:#3b3b52; }
.grad { background: linear-gradient(90deg,#6366f1,#d946ef); -webkit-background-clip:text; background-clip:text; color:transparent; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.pulse { animation: pulse 2s cubic-bezier(.4,0,.6,1) infinite; }
@keyframes pulse { 50% { opacity:.35; } }
.bar { height:6px; border-radius:99px; background:#23232e; overflow:hidden; }
.bar > div { height:100%; border-radius:99px; transition: width .8s; }
::-webkit-scrollbar { width:8px; height:8px; } ::-webkit-scrollbar-thumb { background:#23232e; border-radius:8px; }
.tile-num { font-size: 1.6rem; font-weight: 700; font-family: ui-monospace, Menlo, monospace; }
button { transition: all .15s; } button:active { transform: scale(.96); }
</style>
</head>
<body class="min-h-screen p-4 md:p-6">
<div class="max-w-[1600px] mx-auto space-y-4">
<!-- header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-black tracking-widest grad">ANDROID FLEET — GODHEAD</h1>
<p class="text-[11px] text-zinc-500 mono">autonomous android spawner · proxy switchboard · live telemetry</p>
</div>
<div class="flex items-center gap-3">
<span class="mono text-zinc-400 text-sm" id="clock">--:--:--</span>
<span id="wsdot" class="flex items-center gap-1.5 text-xs mono text-zinc-500">
<span id="wsdot-led" class="w-2.5 h-2.5 rounded-full bg-zinc-600 inline-block"></span>WS</span>
</div>
</div>
<!-- stats strip -->
<div class="grid grid-cols-2 md:grid-cols-6 gap-3" id="stats"></div>
<!-- controls -->
<div class="panel p-3 flex flex-wrap items-center gap-3">
<span class="text-xs font-bold tracking-wider text-zinc-400">SPAWN</span>
<input id="spawnCount" type="number" min="1" max="10" value="1"
class="w-16 bg-black/40 border border-zinc-700 rounded px-2 py-1 text-sm mono">
<input id="spawnPrefix" placeholder="name prefix (android)" value="android"
class="w-40 bg-black/40 border border-zinc-700 rounded px-2 py-1 text-sm mono">
<button onclick="spawn()" class="px-4 py-1.5 rounded bg-gradient-to-r from-indigo-500 to-fuchsia-500 text-white text-sm font-bold">
⚡ SPAWN
</button>
<div id="spawnStatus" class="text-xs mono text-zinc-400"></div>
<div class="ml-auto text-[11px] mono text-zinc-600">discovery: LAN :5555 scan + ARP · every 10s</div>
</div>
<!-- charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div class="panel p-3"><div class="text-xs font-bold text-zinc-400 mb-1">FLEET BANDWIDTH (KB/s)</div><div class="relative h-44"><canvas id="chBw"></canvas></div></div>
<div class="panel p-3"><div class="text-xs font-bold text-zinc-400 mb-1">CPU % / MEM % (fleet avg)</div><div class="relative h-44"><canvas id="chCpu"></canvas></div></div>
<div class="panel p-3"><div class="text-xs font-bold text-zinc-400 mb-1">ONLINE DEVICES</div><div class="relative h-44"><canvas id="chOn"></canvas></div></div>
<div class="panel p-3"><div class="text-xs font-bold text-zinc-400 mb-1">PROXY LATENCY (ms)</div><div class="relative h-44"><canvas id="chPx"></canvas></div></div>
</div>
<!-- fleet grid -->
<div id="fleet" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3"></div>
<div id="empty" class="panel p-10 text-center">
<div class="text-5xl mb-3 pulse">📱</div>
<div class="text-lg font-bold text-zinc-400">NO DEVICES — awaiting spawn</div>
<div class="text-xs mono text-zinc-600 mt-1">spawn a fleet member and it will auto-register here within seconds</div>
</div>
<!-- proxies + log -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div class="panel p-3">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-bold tracking-wider text-zinc-400">PROXY BANK</span>
<button onclick="showProxyAdd()" class="text-xs px-2 py-1 rounded bg-indigo-500/20 text-indigo-300 hover:bg-indigo-500/40">+ ADD</button>
</div>
<div id="proxies" class="space-y-1.5 text-xs"></div>
</div>
<div class="panel p-3 text-xs">
<span class="font-bold tracking-wider text-zinc-400">PROXYFLY ENGINE</span>
<div class="text-zinc-500 mt-1">auto-harvest · test · clean-check (ip-api + Spamhaus) · assign — every 45 min, zero touch</div>
</div>
<div class="panel p-3">
<span class="text-xs font-bold tracking-wider text-zinc-400 mb-2 inline-block">IG ACCOUNTS</span>
<div id="accts" class="space-y-1.5 text-xs"></div>
</div>
<div class="panel p-3 flex flex-col">
<span class="text-xs font-bold tracking-wider text-zinc-400 mb-2">EVENT LOG</span>
<div id="log" class="mono text-[11px] space-y-1 max-h-64 overflow-y-auto"></div>
</div>
</div>
</div>
<!-- modals -->
<div id="modal" class="hidden fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div class="panel p-4 w-full max-w-2xl max-h-[85vh] overflow-y-auto">
<div class="flex justify-between items-center mb-2">
<span id="modalTitle" class="font-bold text-sm"></span>
<button onclick="closeModal()" class="text-zinc-400 hover:text-white text-lg">✕</button>
</div>
<div id="modalBody"></div>
</div>
</div>
<!-- live screen hover popup -->
<div id="hoverShot" class="hidden fixed z-50 pointer-events-none panel p-1" style="width:264px">
<img id="hoverImg" class="w-full rounded" src="" alt="live">
<div class="text-[9px] mono text-zinc-400 text-center mt-1">◉ live screen</div>
</div>
<script>
const WS_URL = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws";
let ws, devices = [], metrics = {}, proxies = [];
let bwHist = [], cpuHist = [], memHist = [], onHist = [];
const MAXPTS = 60;
let charts = {};
function mkChart(id, labels, datasets) {
return new Chart(document.getElementById(id), {
type: "line",
data: { labels, datasets },
options: {
animation: false, responsive: true, maintainAspectRatio: false,
plugins: { legend: { labels: { color: "#a1a1aa", boxWidth: 10, font: { size: 10 } } } },
scales: {
x: { ticks: { color: "#52525b", maxTicksLimit: 8 }, grid: { color: "#1c1c26" } },
y: { beginAtZero: true, ticks: { color: "#52525b" }, grid: { color: "#1c1c26" } }
}
}
});
}
function initCharts() {
// NOTE: every chart canvas MUST sit in a fixed-height wrapper (h-44) —
// maintainAspectRatio:false without one makes Chart.js grow the page forever.
const c = (id, lab, col, fill) => mkChart(id, [], [{ label: lab, data: [], borderColor: col, backgroundColor: fill, borderWidth: 1.5, pointRadius: 0, tension: .3 }]);
charts.bw = mkChart("chBw", [], [
{ label: "rx", data: [], borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,.15)", borderWidth: 1.5, pointRadius: 0, tension: .3, fill: true },
{ label: "tx", data: [], borderColor: "#d946ef", backgroundColor: "rgba(217,70,239,.15)", borderWidth: 1.5, pointRadius: 0, tension: .3, fill: true }]);
charts.cpu = mkChart("chCpu", [], [
{ label: "cpu%", data: [], borderColor: "#22c55e", backgroundColor: "rgba(34,197,94,.15)", borderWidth: 1.5, pointRadius: 0, tension: .3, fill: true },
{ label: "mem%", data: [], borderColor: "#f59e0b", backgroundColor: "rgba(245,158,11,.15)", borderWidth: 1.5, pointRadius: 0, tension: .3, fill: true }]);
charts.on = mkChart("chOn", [], [{ label: "online", data: [], borderColor: "#22c55e", backgroundColor: "rgba(34,197,94,.15)", borderWidth: 1.5, pointRadius: 0, tension: .3, fill: true }]);
charts.px = mkChart("chPx", [], [{ label: "latency ms", data: [], borderColor: "#f59e0b", backgroundColor: "rgba(245,158,11,.2)", borderWidth: 1.5, pointRadius: 0, tension: .3, fill: true }]);
}
function connect() {
ws = new WebSocket(WS_URL);
ws.onopen = () => { document.getElementById("wsdot-led").className = "w-2.5 h-2.5 rounded-full bg-green-500 inline-block pulse"; };
ws.onclose = () => { document.getElementById("wsdot-led").className = "w-2.5 h-2.5 rounded-full bg-red-500 inline-block"; setTimeout(connect, 3000); };
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.type === "devices") { devices = m.data; renderFleet(); renderStats(); }
if (m.type === "metrics") { metrics = m.data; renderMetrics(); tickCharts(); }
if (m.type === "log") addLog(m.data);
};
}
function tickCharts() {
const t = new Date().toLocaleTimeString("en-US", { hour12: false }).slice(0, 8);
let rx = 0, tx = 0, cpu = 0, mem = 0, n = 0;
for (const k in metrics) {
const d = metrics[k];
rx += d.rx_kb || 0; tx += d.tx_kb || 0;
if (d.cpu_pct != null) { cpu += d.cpu_pct; n++; }
if (d.mem_total_mb) mem += (d.mem_used_mb / d.mem_total_mb) * 100;
}
if (n) cpu /= n;
push(charts.bw, t, [rx, tx]);
push(charts.cpu, t, [cpu, n ? mem / n : 0]);
push(charts.on, t, [devices.filter(d => d.adb_state === "online").length]);
const lat = proxies.map(p => p.latency_ms || 0);
if (lat.length) charts.px.data.labels.push(t), charts.px.data.datasets[0].data.push(lat.reduce((a, b) => a + b, 0) / lat.length);
for (const k in charts) {
while (charts[k].data.labels.length > MAXPTS) { charts[k].data.labels.shift(); charts[k].data.datasets.forEach(ds => ds.data.shift()); }
charts[k].update("none");
}
}
function push(ch, label, vals) {
ch.data.labels.push(label);
ch.data.datasets.forEach((ds, i) => ds.data.push(vals[i]));
}
function renderStats() {
const online = devices.filter(d => d.adb_state === "online");
const ph = proxies.filter(p => p.health === "healthy");
const cpu = online.length ? (online.reduce((a, d) => a + (d.cpu_pct || 0), 0) / online.length) : 0;
const mem = online.length ? (online.reduce((a, d) => a + ((d.mem_total_mb ? d.mem_used_mb / d.mem_total_mb : 0)), 0) / online.length * 100) : 0;
let bw = 0; for (const k in metrics) bw += (metrics[k].rx_kb || 0) + (metrics[k].tx_kb || 0);
const tiles = [
["DEVICES ONLINE", `${online.length}/${devices.length}`, "text-green-400"],
["KNOWN DEVICES", devices.length, "text-indigo-300"],
["PROXIES HEALTHY", `${ph.length}/${proxies.length}`, "text-amber-300"],
["AVG CPU", cpu.toFixed(0) + "%", "text-fuchsia-300"],
["AVG MEM", mem.toFixed(0) + "%", "text-orange-300"],
["NET THROUGHPUT", (bw / 1024).toFixed(2) + " MB/s", "text-cyan-300"],
];
document.getElementById("stats").innerHTML = tiles.map(([l, v, c]) =>
`<div class="panel p-3"><div class="text-[10px] font-bold tracking-widest text-zinc-500">${l}</div>
<div class="tile-num ${c}">${v}</div></div>`).join("");
}
function renderFleet() {
const el = document.getElementById("fleet");
document.getElementById("empty").style.display = devices.length ? "none" : "";
el.innerHTML = devices.map(d => {
const online = d.adb_state === "online";
const badge = online ? '<span class="px-2 py-0.5 rounded bg-green-500/20 text-green-400 text-[10px] font-bold">ONLINE</span>'
: d.adb_state === "unknown" ? '<span class="px-2 py-0.5 rounded bg-zinc-500/20 text-zinc-400 text-[10px] font-bold">UNKNOWN</span>'
: '<span class="px-2 py-0.5 rounded bg-red-500/20 text-red-400 text-[10px] font-bold">OFFLINE</span>';
const mt = d.mem_total_mb ? Math.round(d.mem_used_mb / d.mem_total_mb * 100) : 0;
const m = metrics[d.mac] || {};
const flag = d.public_ip && d.proxy_name ? "🌍" : "";
const up = d.first_seen ? humanize(Date.now() / 1000 - d.first_seen) : "—";
return `<div class="panel card p-3 space-y-2" data-mac="${d.mac}" onmouseenter="hoverOn('${d.mac}',event)" onmouseleave="hoverOff()">
<div class="flex items-center justify-between">
<div class="font-bold text-sm">${esc(d.label || d.model || "android")}</div>${badge}
</div>
<div class="mono text-[10px] text-zinc-500 grid grid-cols-2 gap-x-2 gap-y-0.5">
<span>IP <span class="text-zinc-300">${d.ip || "—"}</span></span>
<span>VMID <span class="text-zinc-300">${d.vmid || "?"}</span></span>
<span>MAC <span class="text-zinc-300">${(d.mac || "").slice(0, 14)}</span></span>
<span>AND <span class="text-zinc-300">${d.android || "?"}</span></span>
<span>EGRESS <span class="text-zinc-300">${d.public_ip || "none"}</span> ${flag}</span>
<span>PROXY <span class="text-amber-300">${d.proxy_name || "—"}</span></span>
<span>UP <span class="text-zinc-300">${up}</span></span>
<span>BAT <span class="text-zinc-300">${d.battery != null ? d.battery + "%" : "—"}</span></span>
</div>
<div class="space-y-1">
<div class="flex justify-between text-[10px] mono text-zinc-500"><span>CPU</span><span>${Math.round(m.cpu_pct || d.cpu_pct || 0)}%</span></div>
<div class="bar"><div style="width:${Math.min(100, m.cpu_pct || d.cpu_pct || 0)}%; background:linear-gradient(90deg,#6366f1,#d946ef)"></div></div>
<div class="flex justify-between text-[10px] mono text-zinc-500"><span>MEM</span><span>${mt}%</span></div>
<div class="bar"><div style="width:${mt}%; background:linear-gradient(90deg,#22c55e,#84cc16)"></div></div>
</div>
<div class="flex gap-1.5 flex-wrap">
<button onclick="doTerm('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/40">TERMINAL</button>
<button onclick="doOnboard('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-fuchsia-500/20 text-fuchsia-300 hover:bg-fuchsia-500/40">ONBOARD</button>
<button onclick="doShell('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-zinc-700/50 hover:bg-zinc-600">SHELL</button>
<button onclick="doShot('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-zinc-700/50 hover:bg-zinc-600">SCREENSHOT</button>
<button onclick="doReboot('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-zinc-700/50 hover:bg-zinc-600">REBOOT</button>
<button onclick="doLabel('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-zinc-700/50 hover:bg-zinc-600">LABEL</button>
<button onclick="doKill('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-red-500/20 text-red-400 hover:bg-red-500/40">KILL</button>
</div>
</div>`;
}).join("");
}
function renderMetrics() {}
function renderProxies() {
document.getElementById("proxies").innerHTML = proxies.map(p => {
const h = p.health === "healthy" ? "text-green-400" : p.health === "dead" ? "text-red-400" : "text-zinc-400";
return `<div class="flex items-center justify-between bg-black/30 rounded px-2 py-1.5">
<div><span class="font-bold">${esc(p.name)}</span> <span class="mono text-zinc-500">${esc(p.country || "")} · ${p.host}:${p.port}</span>
${p.clean ? '<span class="text-[10px] text-green-400 font-bold"> ✔ clean</span>' : '<span class="text-[10px] text-zinc-600"> · unchecked</span>'}
${p.assigned_mac ? `<span class="text-[10px] text-indigo-300 mono">→ ${p.assigned_mac.slice(0, 14)}</span>` : ""}</div>
<div class="flex items-center gap-2">
<span class="${h}">● ${p.health} ${p.latency_ms ? p.latency_ms + "ms" : ""}</span>
<button onclick="doDelProxy(${p.id})" class="text-zinc-600 hover:text-red-400">✕</button>
</div></div>`;
}).join("");
}
function addLog(e) {
const el = document.getElementById("log");
const colors = { DEVICE_JOINED: "text-green-400", SPAWN: "text-indigo-300", SPAWN_DONE: "text-green-400", SPAWN_ERROR: "text-red-400", ERROR: "text-red-400", DEVICE_OFFLINE: "text-orange-300", DEVICE_DISCONNECTED: "text-orange-300", PROXY_ASSIGNED: "text-amber-300", KILL: "text-red-400", STARTUP: "text-cyan-300", WARN: "text-amber-300" };
const c = colors[e.type] || "text-zinc-400";
el.insertAdjacentHTML("afterbegin", `<div class="${c}">${new Date(e.ts * 1000).toLocaleTimeString("en-US", { hour12: false })} [${e.type}] ${esc(e.msg)}</div>`);
while (el.children.length > 40) el.removeChild(el.lastChild);
}
async function api(path, opts = {}) {
const r = await fetch(path, { headers: { "Content-Type": "application/json" }, ...opts });
return r.json();
}
async function spawn() {
const count = parseInt(document.getElementById("spawnCount").value) || 1;
const prefix = document.getElementById("spawnPrefix").value || "android";
const s = document.getElementById("spawnStatus");
s.textContent = "spawning…";
const r = await api("/api/spawn", { method: "POST", body: JSON.stringify({ count, name_prefix: prefix }) });
s.textContent = r.error || `job #${r.job_id} queued — watching…`;
const poll = setInterval(async () => {
const jobs = await api("/api/spawn/jobs");
const j = jobs.find(x => x.id === r.job_id);
if (!j || ["done", "error"].includes(j.status)) {
clearInterval(poll);
s.textContent = j && j.status === "done" ? `✅ ${j.done} devices spawned: ${JSON.parse(j.vmid_list || "[]").join(", ")}`
: `❌ ${(j && j.error) || "failed"}`;
} else {
s.textContent = `job #${r.job_id}: ${j.done}/${j.count} spawned…`;
}
}, 3000);
}
async function doShell(mac) {
const cmd = prompt("whitelisted cmd — getprop | dumpsys battery | cat /proc/meminfo | pm list packages | settings list | ip addr | wm size | logcat -d | ls | df", "getprop ro.build.fingerprint");
if (!cmd) return;
openModal("SHELL — " + mac.slice(0, 14), "<pre class='mono text-xs text-zinc-300 whitespace-pre-wrap'>running…</pre>");
try {
const r = await api(`/api/device/${mac}/shell`, { method: "POST", body: JSON.stringify({ cmd }) });
document.getElementById("modalBody").innerHTML = `<pre class='mono text-xs text-zinc-300 whitespace-pre-wrap'>${esc(r.output || r.error)}</pre>`;
} catch (e) { document.getElementById("modalBody").innerHTML = "error"; }
}
async function doShot(mac) {
openModal("SCREENSHOT — " + mac.slice(0, 14), "<div class='text-xs text-zinc-400'>capturing…</div>");
await api(`/api/device/${mac}/screenshot`, { method: "POST" }).catch(() => {});
document.getElementById("modalBody").innerHTML =
`<img src="/api/device/${mac}/screenshot/latest?t=${Date.now()}" class="w-full rounded border border-zinc-700">
<button onclick="doShot('${mac}')" class="mt-2 text-xs px-3 py-1 rounded bg-indigo-500/30 text-indigo-300">↻ refresh</button>`;
}
async function doReboot(mac) { await api(`/api/device/${mac}/reboot`, { method: "POST" }); addLog({ ts: Date.now() / 1000, type: "WARN", msg: `reboot sent to ${mac.slice(0, 14)}` }); }
async function doLabel(mac) {
const label = prompt("device label", "");
if (!label) return;
await api("/api/devices", { method: "PATCH", body: JSON.stringify({ mac, label }) });
const r = await api("/api/devices"); devices = r; renderFleet();
}
async function doKill(mac) {
if (!confirm("DESTROY this VM? This deletes the Proxmox VM permanently.")) return;
await api(`/api/device/${mac}/kill`, { method: "POST" });
addLog({ ts: Date.now() / 1000, type: "KILL", msg: `kill sent to ${mac.slice(0, 14)}` });
}
function showProxyAdd() {
openModal("ADD PROXY", `
<div class="space-y-2 text-sm">
<input id="pxName" placeholder="name (nord-london)" class="w-full bg-black/40 border border-zinc-700 rounded px-2 py-1.5 mono">
<input id="pxHost" placeholder="host (10.30.20.xx)" class="w-full bg-black/40 border border-zinc-700 rounded px-2 py-1.5 mono">
<input id="pxPort" placeholder="port (1080)" type="number" class="w-full bg-black/40 border border-zinc-700 rounded px-2 py-1.5 mono">
<input id="pxUser" placeholder="user (optional)" class="w-full bg-black/40 border border-zinc-700 rounded px-2 py-1.5 mono">
<input id="pxPass" placeholder="pass (optional)" class="w-full bg-black/40 border border-zinc-700 rounded px-2 py-1.5 mono">
<button onclick="addProxy()" class="w-full px-3 py-1.5 rounded bg-gradient-to-r from-indigo-500 to-fuchsia-500 text-white text-sm font-bold">SAVE</button>
</div>`);
}
async function addProxy() {
const body = {
name: document.getElementById("pxName").value,
host: document.getElementById("pxHost").value,
port: parseInt(document.getElementById("pxPort").value),
user: document.getElementById("pxUser").value,
pass: document.getElementById("pxPass").value,
proto: "socks5",
};
await api("/api/proxies", { method: "POST", body: JSON.stringify(body) });
closeModal(); loadProxies();
}
async function doDelProxy(id) {
if (!confirm("delete proxy?")) return;
await api(`/api/proxies/${id}`, { method: "DELETE" }); loadProxies();
}
async function loadProxies() { proxies = await api("/api/proxies"); renderProxies(); }
function openModal(title, body) {
document.getElementById("modalTitle").textContent = title;
document.getElementById("modalBody").innerHTML = body;
document.getElementById("modal").classList.remove("hidden");
}
function closeModal() {
document.getElementById("modal").classList.add("hidden");
if (window.termWs) { try { window.termWs.close(); } catch (e) {} window.termWs = null; }
if (window.term) { try { window.term.dispose(); } catch (e) {} window.term = null; }
}
// ---------- live screen hover popup ----------
let hoverTimer = null;
function hoverOn(mac, e) {
const hs = document.getElementById("hoverShot");
hs.classList.remove("hidden");
const refresh = () => {
document.getElementById("hoverImg").src = `/api/device/${mac}/screenshot/latest?t=${Date.now()}`;
};
refresh();
hoverTimer = setInterval(refresh, 2000);
const w = 264, h = 320;
let x = e.clientX + 14, y = e.clientY + 14;
if (x + w > innerWidth) x = e.clientX - w - 14;
if (y + h > innerHeight) y = innerHeight - h - 10;
hs.style.left = x + "px";
hs.style.top = y + "px";
}
function hoverOff() {
document.getElementById("hoverShot").classList.add("hidden");
if (hoverTimer) { clearInterval(hoverTimer); hoverTimer = null; }
}
// ---------- interactive terminal ----------
function doTerm(mac) {
openModal("TERMINAL — " + mac.slice(0, 14),
`<div id="termHost" class="bg-black rounded border border-zinc-700 p-2" style="height:420px"></div>
<div class="text-[10px] mono text-zinc-500 mt-1">interactive root shell · click and type</div>`);
const term = new Terminal({ cursorBlink: true, fontSize: 13, theme: { background: "#000000" } });
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(document.getElementById("termHost"));
fit.fit();
const ws = new WebSocket(`${WS_URL.replace(/\/ws$/, "")}/ws/shell/${mac}`);
window.term = term; window.termWs = ws;
term.onData((d) => { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "input", data: d })); });
ws.onmessage = (m) => {
const d = JSON.parse(m.data);
if (d.type === "out") term.write(d.data);
if (d.type === "error") term.write("\r\n\x1b[31m" + d.data + "\x1b[0m\r\n");
};
window.addEventListener("resize", () => { try { fit.fit(); } catch (e) {} });
}
// ---------- onboarding ----------
async function doOnboard(mac) {
addLog({ ts: Date.now() / 1000, type: "SPAWN", msg: `onboarding started for ${mac.slice(0, 14)}` });
await api(`/api/device/${mac}/onboard`, { method: "POST" });
if (!window._acctPoll) {
window._acctPoll = setInterval(loadAccounts, 5000);
}
}
async function loadAccounts() {
const accts = await api("/api/accounts");
const el = document.getElementById("accts");
if (!el) return;
el.innerHTML = accts.length ? accts.map(a =>
`<div class="flex justify-between bg-black/30 rounded px-2 py-1">
<span class="font-bold">${esc(a.username || "—")}</span>
<span class="mono text-zinc-500">${esc(a.email || "")}</span>
<span class="${a.status === "created" ? "text-green-400" : "text-amber-300"}">${esc(a.status)}</span>
</div>`).join("") : '<div class="text-zinc-600 text-center py-2">no accounts yet — hit ONBOARD on a device</div>';
}
function esc(s) { return String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); }
function humanize(sec) {
sec = Math.max(0, Math.floor(sec));
if (sec < 60) return sec + "s";
if (sec < 3600) return Math.floor(sec / 60) + "m";
if (sec < 86400) return Math.floor(sec / 3600) + "h";
return Math.floor(sec / 86400) + "d";
}
document.getElementById("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); });
setInterval(() => { document.getElementById("clock").textContent = new Date().toLocaleTimeString(); }, 1000);
setInterval(loadProxies, 15000);
initCharts();
connect();
loadProxies();
loadAccounts();
</script>
</body>
</html>