skl-webui/webui.html
2025-12-22 18:33:55 +02:00

416 lines
16 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Skylink Console</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap"
rel="stylesheet"
/>
<style>
:root,
html,
body {
background: #05070b;
color: #e5e7eb;
height: 100%;
}
textarea {
background: #05070b !important;
color: #e5e7eb !important;
}
pre {
background: #05070b !important;
}
#paneGrid {
height: calc(100vh - 96px);
}
</style>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
mono: ["JetBrains Mono", "ui-monospace", "SFMono-Regular", "Menlo", "monospace"],
},
colors: {
base: "#05070b",
panel: "#0c1019",
line: "#1a2233",
accent: "#3bef8b",
accent2: "#4dd5ff",
},
boxShadow: {
frame: "0 0 0 1px rgba(59, 239, 139, 0.15)",
},
},
},
};
</script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-base text-slate-100 font-mono">
<div class="min-h-screen flex flex-col bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:24px_24px]">
<header class="border-b border-line bg-panel/95 sticky top-0 z-10">
<div class="w-full max-w-none px-4 py-3 flex items-center gap-3 overflow-x-auto scrollbar-thin scrollbar-thumb-slate-700/50">
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Clients</span>
<div id="clientTabs" class="flex items-center gap-2">
<div class="text-sm text-slate-500">No clients yet.</div>
</div>
<div class="flex items-center gap-2 ml-auto">
<button
id="refreshClients"
class="px-3 py-1 text-xs border border-line bg-base/70 rounded-lg hover:border-accent/40 transition"
>
sync
</button>
<button
id="reconnectBtn"
class="px-3 py-1 text-xs border border-line bg-base/70 rounded-lg hover:border-accent/40 transition"
>
stream
</button>
</div>
</div>
</header>
<main class="w-full max-w-none px-4 py-4 flex-1 min-h-0">
<div id="paneGrid" class="grid grid-cols-2 gap-4 w-full h-full min-h-0">
<section class="rounded-xl border border-line bg-panel shadow-frame flex flex-col h-full min-h-0">
<div class="flex items-center justify-between px-4 py-3 border-b border-line/70">
<div class="flex flex-col">
<span class="text-sm text-slate-200">Send JSON</span>
<span id="selectedLabel" class="text-xs text-slate-500">No client selected</span>
</div>
<div class="flex items-center gap-2">
<button
id="formatBtn"
class="px-3 py-1 text-xs border border-line rounded-lg bg-base/70 hover:border-accent/40 transition"
>
format
</button>
<button
id="sendBtn"
class="px-4 py-1 text-xs font-semibold rounded-lg border border-accent/60 text-base bg-accent/10 hover:bg-accent/20 transition"
>
send
</button>
</div>
</div>
<textarea
id="payloadInput"
class="flex-1 min-h-0 w-full h-full bg-base/70 text-slate-100 text-sm p-4 resize-none focus:outline-none focus:ring-1 focus:ring-accent/50 border-b border-line/70"
spellcheck="false"
></textarea>
<div class="px-4 py-2 text-xs text-slate-500 border-t border-line/70" id="statusText">
waiting for clients...
</div>
</section>
<section class="rounded-xl border border-line bg-panel shadow-frame flex flex-col h-full min-h-0">
<div class="flex items-center justify-between px-4 py-3 border-b border-line/70">
<div class="flex flex-col">
<span class="text-sm text-slate-200">Live stream</span>
<span class="text-xs text-slate-500">All inbound/outbound + connect/disconnect</span>
</div>
<button
id="clearConsole"
class="px-3 py-1 text-xs border border-line rounded-lg bg-base/70 hover:border-accent/40 transition"
>
clear
</button>
</div>
<div
id="console"
class="flex-1 min-h-0 h-full overflow-y-auto p-4 space-y-3 bg-base/70"
>
<div class="text-sm text-slate-500">Streaming events will appear here.</div>
</div>
<div class="px-4 py-2 text-xs text-slate-500 border-t border-line/70 h-1/4">
<h2 class="text-sm text-slate-200">Client API reference</h2>
<ul class="text-xs text-slate-500">
<li >ClientInfo ➜ no arguments</li>
<li>RunCMD ➜ command: string, args: vector of strings</li>
<li>URunCMD ➜ command: string</li>
<li>URunExe ➜ path: string, args: string</li>
<li>Dnx ➜ params: subset containing</li>
<li>⤷ url: string</li>
<li>⤷ name: string</li>
<li>⤷ args: vec of strings</li>
<li>⤷ run_as_system: boolean</li>
<li>⤷ file_type: string which is one of Executable, Powershell or Python</li>
</ul>
</div>
</section>
</div>
</main>
</div>
<script>
const clientTabsEl = document.getElementById("clientTabs");
const statusTextEl = document.getElementById("statusText");
const selectedLabelEl = document.getElementById("selectedLabel");
const payloadInput = document.getElementById("payloadInput");
const sendBtn = document.getElementById("sendBtn");
const formatBtn = document.getElementById("formatBtn");
const refreshBtn = document.getElementById("refreshClients");
const consoleEl = document.getElementById("console");
const clearConsoleBtn = document.getElementById("clearConsole");
const reconnectBtn = document.getElementById("reconnectBtn");
let clients = [];
let selectedClient = null;
let eventSource = null;
const maxLogs = 400;
const setStatus = (text, tone = "muted") => {
const colors = {
muted: "text-slate-500",
ok: "text-emerald-300",
warn: "text-amber-300",
error: "text-rose-300",
};
statusTextEl.className = `px-4 py-2 text-xs border-t border-line/70 ${colors[tone] || colors.muted}`;
statusTextEl.textContent = text;
};
const renderTabs = () => {
clientTabsEl.innerHTML = "";
if (!clients.length) {
const placeholder = document.createElement("div");
placeholder.className = "text-sm text-slate-500";
placeholder.textContent = "No clients yet.";
clientTabsEl.appendChild(placeholder);
selectedLabelEl.textContent = "No client selected";
return;
}
if (!selectedClient) {
selectedClient = clients[0];
}
selectedLabelEl.textContent = `Sending to client ${selectedClient}`;
clients.forEach((id) => {
const tab = document.createElement("button");
const isSelected = id === selectedClient;
tab.className =
"px-3 py-1 text-xs rounded-lg border transition whitespace-nowrap " +
(isSelected
? "border-accent/70 bg-accent/15 text-accent shadow-frame"
: "border-line bg-base/70 text-slate-200 hover:border-accent/40");
tab.textContent = `client ${id}`;
tab.onclick = () => {
selectedClient = id;
renderTabs();
};
clientTabsEl.appendChild(tab);
});
};
const formatPayload = () => {
try {
const parsed = JSON.parse(payloadInput.value);
payloadInput.value = JSON.stringify(parsed, null, 2);
setStatus("formatted JSON", "ok");
} catch (err) {
setStatus(`cannot format: ${err.message}`, "warn");
}
};
const fetchClients = async () => {
try {
const res = await fetch("/clients");
const data = await res.json();
clients = data.clients || [];
if (selectedClient && !clients.includes(selectedClient)) {
selectedClient = clients[0] ?? null;
}
renderTabs();
} catch (err) {
setStatus(`failed to load clients: ${err.message}`, "error");
}
};
const safeStringify = (value) => {
try {
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
} catch (err) {
return String(value);
}
};
const trimConsole = () => {
while (consoleEl.children.length > maxLogs) {
consoleEl.removeChild(consoleEl.firstChild);
}
};
const appendLog = ({ type, clientId, payload, timestamp }) => {
const wrapper = document.createElement("div");
wrapper.className =
"border border-line rounded-lg bg-panel/80 px-3 py-2 space-y-2 shadow-frame";
const header = document.createElement("div");
header.className = "flex items-center justify-between text-[11px] text-slate-400 uppercase tracking-wide";
const badge = document.createElement("span");
badge.className =
"px-2 py-1 rounded border border-line bg-base/60 text-slate-200";
const labels = {
inbound: ["inbound", "border-emerald-400/60 text-emerald-200"],
outbound: ["outbound", "border-accent2/60 text-accent2"],
client_connected: ["connected", "border-emerald-400/60 text-emerald-200"],
client_disconnected: ["disconnected", "border-amber-400/60 text-amber-200"],
};
const [label, cls] = labels[type] || ["event", "border-line text-slate-300"];
badge.textContent = label;
badge.className += " " + cls;
const meta = document.createElement("div");
meta.textContent = `${clientId ? "client " + clientId : "—"}${
timestamp ? new Date(timestamp).toLocaleTimeString() : new Date().toLocaleTimeString()
}`;
header.append(badge, meta);
wrapper.appendChild(header);
const pre = document.createElement("pre");
pre.className =
"bg-base/60 border border-line rounded-md p-2 text-xs text-slate-100 overflow-x-auto";
pre.textContent = safeStringify(payload ?? {});
wrapper.appendChild(pre);
// Use firstElementChild to ignore whitespace/text nodes
if (consoleEl.firstElementChild && consoleEl.firstElementChild.classList.contains("text-sm")) {
consoleEl.firstElementChild.remove();
}
consoleEl.appendChild(wrapper);
trimConsole();
consoleEl.scrollTop = consoleEl.scrollHeight;
};
const handleSseEvent = (event) => {
if (!event?.data) return;
try {
const data = JSON.parse(event.data);
switch (event.type) {
case "inbound":
case "outbound":
appendLog({
type: event.type,
clientId: data.client_id,
payload: data.payload,
timestamp: data.timestamp,
});
break;
case "client_connected":
if (!clients.includes(data.client_id)) {
clients.push(data.client_id);
clients.sort((a, b) => a - b);
}
appendLog({
type: event.type,
clientId: data.client_id,
payload: { status: "connected" },
timestamp: data.timestamp,
});
if (!selectedClient) {
selectedClient = data.client_id;
}
renderTabs();
break;
case "client_disconnected":
clients = clients.filter((id) => id !== data.client_id);
appendLog({
type: event.type,
clientId: data.client_id,
payload: { status: "disconnected" },
timestamp: data.timestamp,
});
if (selectedClient === data.client_id) {
selectedClient = clients[0] ?? null;
}
renderTabs();
break;
default:
break;
}
} catch (err) {
setStatus(`stream parse error: ${err.message}`, "warn");
}
};
const connectStream = () => {
if (eventSource) {
eventSource.close();
}
setStatus("connecting stream...", "muted");
eventSource = new EventSource("/events");
eventSource.addEventListener("open", () => setStatus("stream live", "ok"));
eventSource.addEventListener("error", () => setStatus("stream error", "error"));
["inbound", "outbound", "client_connected", "client_disconnected"].forEach((evt) => {
eventSource.addEventListener(evt, handleSseEvent);
});
};
const sendPayload = async () => {
if (!selectedClient) {
setStatus("select a client first", "warn");
return;
}
let parsed;
try {
parsed = JSON.parse(payloadInput.value);
} catch (err) {
setStatus(`payload must be valid JSON: ${err.message}`, "warn");
return;
}
try {
const res = await fetch(`/clients/${selectedClient}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payload: parsed }),
});
if (!res.ok) {
const detail = await res.json().catch(() => ({}));
throw new Error(detail.detail || "send failed");
}
setStatus(`sent to client ${selectedClient}`, "ok");
} catch (err) {
setStatus(err.message, "error");
}
};
const clearConsole = () => {
consoleEl.innerHTML = '<div class="text-sm text-slate-500">Stream cleared.</div>';
};
sendBtn.addEventListener("click", sendPayload);
formatBtn.addEventListener("click", formatPayload);
refreshBtn.addEventListener("click", fetchClients);
clearConsoleBtn.addEventListener("click", clearConsole);
reconnectBtn.addEventListener("click", connectStream);
payloadInput.addEventListener("keydown", (e) => {
if (e.key === "Tab") {
e.preventDefault();
const start = e.target.selectionStart;
const end = e.target.selectionEnd;
const value = e.target.value;
e.target.value = value.substring(0, start) + " " + value.substring(end);
e.target.selectionStart = e.target.selectionEnd = start + 2;
}
});
connectStream();
fetchClients();
</script>
</body>
</html>