416 lines
16 KiB
HTML
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>
|