init: add code
This commit is contained in:
commit
c2f9a6e600
4 changed files with 593 additions and 0 deletions
416
webui.html
Normal file
416
webui.html
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
<!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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue