458 lines
25 KiB
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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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>
|