2026-08-18 16:55:42 +03:00
|
|
|
|
(() => {
|
|
|
|
|
|
const errorEl = document.getElementById("error");
|
|
|
|
|
|
const healthEl = document.getElementById("health");
|
|
|
|
|
|
const statusFields = document.getElementById("status-fields");
|
|
|
|
|
|
const pathInput = document.getElementById("file-path");
|
|
|
|
|
|
const fileMeta = document.getElementById("file-meta");
|
|
|
|
|
|
const fileRows = document.getElementById("file-rows");
|
|
|
|
|
|
const shotGallery = document.getElementById("shot-gallery");
|
|
|
|
|
|
const execOut = document.getElementById("exec-out");
|
|
|
|
|
|
const execMeta = document.getElementById("exec-meta");
|
2026-08-18 17:06:20 +03:00
|
|
|
|
const startupState = document.getElementById("startup-state");
|
|
|
|
|
|
const startupAdd = document.getElementById("startup-add");
|
|
|
|
|
|
const startupRemove = document.getElementById("startup-remove");
|
2026-08-18 19:12:25 +03:00
|
|
|
|
const videoCanvas = document.getElementById("video-canvas");
|
|
|
|
|
|
const videoMeta = document.getElementById("video-meta");
|
|
|
|
|
|
const videoStart = document.getElementById("video-start");
|
|
|
|
|
|
const videoStop = document.getElementById("video-stop");
|
2026-08-18 16:55:42 +03:00
|
|
|
|
|
|
|
|
|
|
let objectUrls = [];
|
2026-08-18 19:12:25 +03:00
|
|
|
|
let videoRunning = false;
|
|
|
|
|
|
let lastMonitor = { left: 0, top: 0, width: 0, height: 0 };
|
2026-08-18 16:55:42 +03:00
|
|
|
|
|
|
|
|
|
|
function showError(message) {
|
|
|
|
|
|
errorEl.textContent = message || "";
|
|
|
|
|
|
errorEl.classList.toggle("show", Boolean(message));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function readError(res) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
return data.error || res.statusText;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return res.statusText || "request failed";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function api(url, options) {
|
|
|
|
|
|
showError("");
|
|
|
|
|
|
const res = await fetch(url, options);
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
throw new Error(await readError(res));
|
|
|
|
|
|
}
|
|
|
|
|
|
return res;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatBytes(n) {
|
|
|
|
|
|
if (n < 1024) return `${n} B`;
|
|
|
|
|
|
const units = ["KB", "MB", "GB", "TB"];
|
|
|
|
|
|
let value = n / 1024;
|
|
|
|
|
|
let i = 0;
|
|
|
|
|
|
while (value >= 1024 && i < units.length - 1) {
|
|
|
|
|
|
value /= 1024;
|
|
|
|
|
|
i += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[i]}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function joinPath(base, name) {
|
|
|
|
|
|
if (!base) return name;
|
|
|
|
|
|
if (/[\\/]$/.test(base)) return base + name;
|
|
|
|
|
|
return `${base}\\${name}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parentPath(path) {
|
|
|
|
|
|
const trimmed = String(path || "").replace(/[\\/]+$/, "");
|
|
|
|
|
|
const idx = Math.max(trimmed.lastIndexOf("\\"), trimmed.lastIndexOf("/"));
|
|
|
|
|
|
if (idx <= 2) return trimmed.slice(0, 3);
|
|
|
|
|
|
return trimmed.slice(0, idx);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function revokeShots() {
|
|
|
|
|
|
for (const url of objectUrls) URL.revokeObjectURL(url);
|
|
|
|
|
|
objectUrls = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadHealth() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await api("/health");
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
healthEl.textContent = data.status === "ok" ? "online" : data.status;
|
|
|
|
|
|
healthEl.className = "badge ok";
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
healthEl.textContent = "offline";
|
|
|
|
|
|
healthEl.className = "badge bad";
|
|
|
|
|
|
showError(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadStatus() {
|
|
|
|
|
|
const res = await api("/api/v1/status");
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
const fields = [
|
|
|
|
|
|
["Hostname", data.hostname],
|
|
|
|
|
|
["User", data.user],
|
|
|
|
|
|
["OS", data.os],
|
|
|
|
|
|
["Architecture", data.architecture],
|
|
|
|
|
|
["Version", data.agent_version],
|
|
|
|
|
|
["Listen", data.listen_address],
|
|
|
|
|
|
["Uptime", `${data.uptime_seconds}s`],
|
|
|
|
|
|
["Local IPs", (data.local_ips || []).join(", ") || "—"],
|
2026-08-18 17:06:20 +03:00
|
|
|
|
["Startup", data.startup_enabled ? "enabled" : "disabled"],
|
2026-08-18 16:55:42 +03:00
|
|
|
|
];
|
2026-08-18 17:06:20 +03:00
|
|
|
|
setStartup(Boolean(data.startup_enabled));
|
2026-08-18 16:55:42 +03:00
|
|
|
|
statusFields.replaceChildren(
|
|
|
|
|
|
...fields.flatMap(([label, value]) => {
|
|
|
|
|
|
const dt = document.createElement("dt");
|
|
|
|
|
|
dt.textContent = label;
|
|
|
|
|
|
const dd = document.createElement("dd");
|
|
|
|
|
|
dd.textContent = value || "—";
|
|
|
|
|
|
return [dt, dd];
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function listFiles(path) {
|
|
|
|
|
|
const query = new URLSearchParams();
|
|
|
|
|
|
if (path) query.set("path", path);
|
|
|
|
|
|
query.set("depth", "0");
|
|
|
|
|
|
const res = await api(`/api/v1/files?${query}`);
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
pathInput.value = data.path || "";
|
|
|
|
|
|
const entries = data.entries || [];
|
|
|
|
|
|
fileMeta.textContent = `${entries.length} entries`;
|
|
|
|
|
|
fileRows.replaceChildren();
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
|
const tr = document.createElement("tr");
|
|
|
|
|
|
const name = document.createElement("td");
|
|
|
|
|
|
name.className = entry.type === "dir" ? "name" : "name file";
|
|
|
|
|
|
name.textContent = entry.name;
|
|
|
|
|
|
name.addEventListener("click", () => {
|
|
|
|
|
|
const full = joinPath(data.path, entry.name);
|
|
|
|
|
|
if (entry.type === "dir") {
|
|
|
|
|
|
listFiles(full).catch((err) => showError(err.message));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const link = document.createElement("a");
|
|
|
|
|
|
link.href = `/api/v1/download?path=${encodeURIComponent(full)}`;
|
|
|
|
|
|
link.download = entry.name;
|
|
|
|
|
|
document.body.append(link);
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
link.remove();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
const type = document.createElement("td");
|
|
|
|
|
|
type.textContent = entry.type;
|
|
|
|
|
|
const size = document.createElement("td");
|
|
|
|
|
|
size.textContent = entry.type === "dir" ? "—" : formatBytes(entry.size || 0);
|
|
|
|
|
|
const modified = document.createElement("td");
|
|
|
|
|
|
modified.textContent = entry.modified_time ? new Date(entry.modified_time).toLocaleString() : "";
|
|
|
|
|
|
tr.append(name, type, size, modified);
|
|
|
|
|
|
fileRows.append(tr);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function captureScreen() {
|
|
|
|
|
|
revokeShots();
|
|
|
|
|
|
shotGallery.replaceChildren();
|
|
|
|
|
|
const format = document.getElementById("shot-format").value;
|
|
|
|
|
|
const quality = document.getElementById("shot-quality").value;
|
|
|
|
|
|
const res = await api(`/api/v1/screenshot?format=${encodeURIComponent(format)}&quality=${encodeURIComponent(quality)}`);
|
|
|
|
|
|
const contentType = res.headers.get("content-type") || "";
|
|
|
|
|
|
if (contentType.includes("application/json")) {
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
for (const image of data.images || []) {
|
|
|
|
|
|
addShot(base64Blob(image.data_base64, image.content_type), `Monitor ${image.monitor}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
addShot(await res.blob(), "Display");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function base64Blob(data, contentType) {
|
|
|
|
|
|
const binary = atob(data);
|
|
|
|
|
|
const bytes = new Uint8Array(binary.length);
|
|
|
|
|
|
for (let i = 0; i < binary.length; i += 1) {
|
|
|
|
|
|
bytes[i] = binary.charCodeAt(i);
|
|
|
|
|
|
}
|
|
|
|
|
|
return new Blob([bytes], { type: contentType || "application/octet-stream" });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function addShot(blob, caption) {
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
objectUrls.push(url);
|
|
|
|
|
|
const figure = document.createElement("figure");
|
|
|
|
|
|
const img = document.createElement("img");
|
|
|
|
|
|
img.src = url;
|
|
|
|
|
|
img.alt = caption;
|
|
|
|
|
|
const figcaption = document.createElement("figcaption");
|
|
|
|
|
|
figcaption.className = "meta";
|
|
|
|
|
|
figcaption.textContent = caption;
|
|
|
|
|
|
figure.append(img, figcaption);
|
|
|
|
|
|
shotGallery.append(figure);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 17:06:20 +03:00
|
|
|
|
function setStartup(enabled) {
|
|
|
|
|
|
startupState.textContent = enabled ? "Startup: enabled" : "Startup: disabled";
|
|
|
|
|
|
startupAdd.disabled = enabled;
|
|
|
|
|
|
startupRemove.disabled = !enabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function setStartupEnabled(enabled) {
|
|
|
|
|
|
const res = await api("/api/v1/startup", { method: enabled ? "POST" : "DELETE" });
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
setStartup(Boolean(data.startup_enabled));
|
|
|
|
|
|
loadStatus().catch((err) => showError(err.message));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 19:12:25 +03:00
|
|
|
|
function clamp(value, min, max) {
|
|
|
|
|
|
const n = Number(value);
|
|
|
|
|
|
if (!Number.isFinite(n)) return min;
|
|
|
|
|
|
return Math.min(max, Math.max(min, Math.round(n)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sleep(ms) {
|
|
|
|
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function startVideo() {
|
|
|
|
|
|
if (videoRunning) return;
|
|
|
|
|
|
videoRunning = true;
|
|
|
|
|
|
videoStart.disabled = true;
|
|
|
|
|
|
videoStop.disabled = false;
|
|
|
|
|
|
while (videoRunning) {
|
|
|
|
|
|
const started = Date.now();
|
|
|
|
|
|
try {
|
|
|
|
|
|
await pullVideoFrame();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showError(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!videoRunning) break;
|
|
|
|
|
|
const fps = clamp(document.getElementById("video-fps").value, 1, 15);
|
|
|
|
|
|
await sleep(Math.max(0, 1000 / fps - (Date.now() - started)));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopVideo() {
|
|
|
|
|
|
videoRunning = false;
|
|
|
|
|
|
videoStart.disabled = false;
|
|
|
|
|
|
videoStop.disabled = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function pullVideoFrame() {
|
|
|
|
|
|
const quality = clamp(document.getElementById("video-quality").value, 1, 100);
|
|
|
|
|
|
const monitor = clamp(document.getElementById("video-monitor").value, 0, 64);
|
|
|
|
|
|
const res = await api(`/api/v1/screenshot?format=jpeg&quality=${quality}&monitor=${monitor}`);
|
|
|
|
|
|
lastMonitor = {
|
|
|
|
|
|
left: Number(res.headers.get("X-Monitor-Left") || 0),
|
|
|
|
|
|
top: Number(res.headers.get("X-Monitor-Top") || 0),
|
|
|
|
|
|
width: Number(res.headers.get("X-Monitor-Width") || 0),
|
|
|
|
|
|
height: Number(res.headers.get("X-Monitor-Height") || 0),
|
|
|
|
|
|
};
|
|
|
|
|
|
const blob = await res.blob();
|
|
|
|
|
|
const bitmap = await createImageBitmap(blob);
|
|
|
|
|
|
if (videoCanvas.width !== bitmap.width || videoCanvas.height !== bitmap.height) {
|
|
|
|
|
|
videoCanvas.width = bitmap.width;
|
|
|
|
|
|
videoCanvas.height = bitmap.height;
|
|
|
|
|
|
}
|
|
|
|
|
|
const ctx = videoCanvas.getContext("2d");
|
|
|
|
|
|
ctx.drawImage(bitmap, 0, 0);
|
|
|
|
|
|
const width = bitmap.width;
|
|
|
|
|
|
const height = bitmap.height;
|
|
|
|
|
|
bitmap.close();
|
|
|
|
|
|
videoMeta.textContent = `${width}×${height} · monitor ${monitor} @ ${lastMonitor.left},${lastMonitor.top}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function sendClick(event, button) {
|
|
|
|
|
|
if (!lastMonitor.width || !lastMonitor.height) return;
|
|
|
|
|
|
const rect = videoCanvas.getBoundingClientRect();
|
|
|
|
|
|
if (!rect.width || !rect.height) return;
|
|
|
|
|
|
const bitmapX = Math.floor((event.clientX - rect.left) * (videoCanvas.width / rect.width));
|
|
|
|
|
|
const bitmapY = Math.floor((event.clientY - rect.top) * (videoCanvas.height / rect.height));
|
|
|
|
|
|
const x = lastMonitor.left + bitmapX;
|
|
|
|
|
|
const y = lastMonitor.top + bitmapY;
|
|
|
|
|
|
const monitor = clamp(document.getElementById("video-monitor").value, 0, 64);
|
|
|
|
|
|
await api("/api/v1/input/click", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ x, y, button, monitor }),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function sendVideoText() {
|
|
|
|
|
|
const field = document.getElementById("video-text");
|
|
|
|
|
|
const text = field.value;
|
|
|
|
|
|
if (!text) return;
|
|
|
|
|
|
await api("/api/v1/input/text", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ text }),
|
|
|
|
|
|
});
|
|
|
|
|
|
field.value = "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 16:55:42 +03:00
|
|
|
|
async function runCommand() {
|
|
|
|
|
|
const command = document.getElementById("exec-command").value.trim();
|
|
|
|
|
|
const timeout = Number(document.getElementById("exec-timeout").value);
|
|
|
|
|
|
const res = await api("/api/v1/exec", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ command, timeout_sec: timeout }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
execMeta.textContent = `exit code ${data.exit_code}`;
|
|
|
|
|
|
const stdout = data.stdout || "";
|
|
|
|
|
|
const stderr = data.stderr || "";
|
|
|
|
|
|
execOut.textContent = [stdout, stderr && `STDERR:\n${stderr}`].filter(Boolean).join("\n\n");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll("nav button").forEach((button) => {
|
|
|
|
|
|
button.addEventListener("click", () => {
|
|
|
|
|
|
document.querySelectorAll("nav button").forEach((item) => item.classList.remove("active"));
|
|
|
|
|
|
document.querySelectorAll(".panel").forEach((panel) => panel.classList.remove("active"));
|
|
|
|
|
|
button.classList.add("active");
|
|
|
|
|
|
document.getElementById(button.dataset.tab).classList.add("active");
|
2026-08-18 19:12:25 +03:00
|
|
|
|
if (button.dataset.tab !== "video") {
|
|
|
|
|
|
stopVideo();
|
|
|
|
|
|
}
|
2026-08-18 16:55:42 +03:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById("refresh-status").addEventListener("click", () => {
|
|
|
|
|
|
Promise.all([loadHealth(), loadStatus()]).catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
2026-08-18 17:06:20 +03:00
|
|
|
|
startupAdd.addEventListener("click", () => {
|
|
|
|
|
|
setStartupEnabled(true).catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
startupRemove.addEventListener("click", () => {
|
|
|
|
|
|
setStartupEnabled(false).catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
2026-08-18 16:55:42 +03:00
|
|
|
|
document.getElementById("file-list").addEventListener("click", () => {
|
|
|
|
|
|
listFiles(pathInput.value.trim()).catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
document.getElementById("file-up").addEventListener("click", () => {
|
|
|
|
|
|
listFiles(parentPath(pathInput.value.trim())).catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
document.getElementById("shot-capture").addEventListener("click", () => {
|
|
|
|
|
|
captureScreen().catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
2026-08-18 19:12:25 +03:00
|
|
|
|
videoStart.addEventListener("click", () => {
|
|
|
|
|
|
startVideo().catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
videoStop.addEventListener("click", () => stopVideo());
|
|
|
|
|
|
videoCanvas.addEventListener("click", (event) => {
|
|
|
|
|
|
sendClick(event, "left").catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
videoCanvas.addEventListener("contextmenu", (event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
sendClick(event, "right").catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
document.getElementById("video-send").addEventListener("click", () => {
|
|
|
|
|
|
sendVideoText().catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
document.getElementById("video-text").addEventListener("keydown", (event) => {
|
|
|
|
|
|
if (event.key === "Enter") {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
sendVideoText().catch((err) => showError(err.message));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-08-18 16:55:42 +03:00
|
|
|
|
document.getElementById("exec-run").addEventListener("click", () => {
|
|
|
|
|
|
runCommand().catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
Promise.all([loadHealth(), loadStatus(), listFiles("")]).catch((err) => showError(err.message));
|
|
|
|
|
|
})();
|