init: add code
This commit is contained in:
commit
c2f9a6e600
4 changed files with 593 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/.vscode
|
||||
/frp_0.65.0_linux_amd64
|
||||
/__pycache__
|
||||
172
main.py
Normal file
172
main.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import asyncio
|
||||
import itertools
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Set
|
||||
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OutboundPayload(BaseModel):
|
||||
payload: Any
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# In-memory registries
|
||||
clients: Dict[int, WebSocket] = {}
|
||||
client_lock = asyncio.Lock()
|
||||
client_id_seq = itertools.count(1)
|
||||
|
||||
sse_subscribers: Set[asyncio.Queue[str]] = set()
|
||||
sse_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def format_sse_event(data: Dict[str, Any]) -> str:
|
||||
"""Build a Server-Sent Event payload with an optional event name."""
|
||||
event_name = data.get("event")
|
||||
payload = json.dumps(data, separators=(",", ":"))
|
||||
lines: List[str] = []
|
||||
if event_name:
|
||||
lines.append(f"event: {event_name}")
|
||||
for line in payload.splitlines():
|
||||
lines.append(f"data: {line}")
|
||||
lines.append("")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
async def broadcast_event(event: Dict[str, Any]) -> None:
|
||||
"""Send an event to all SSE subscribers without blocking the websocket loop."""
|
||||
message = format_sse_event(event)
|
||||
async with sse_lock:
|
||||
for queue in list(sse_subscribers):
|
||||
try:
|
||||
queue.put_nowait(message)
|
||||
except asyncio.QueueFull:
|
||||
# Should not happen with the default unbounded queues, but guard anyway.
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index() -> HTMLResponse:
|
||||
html_path = Path(__file__).parent / "webui.html"
|
||||
return HTMLResponse(html_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@app.get("/clients")
|
||||
async def list_clients() -> Dict[str, List[int]]:
|
||||
async with client_lock:
|
||||
return {"clients": sorted(clients.keys())}
|
||||
|
||||
|
||||
@app.post("/clients/{client_id}/send")
|
||||
async def send_to_client(client_id: int, body: OutboundPayload) -> Dict[str, Any]:
|
||||
async with client_lock:
|
||||
websocket = clients.get(client_id)
|
||||
if websocket is None:
|
||||
raise HTTPException(status_code=404, detail="Client not connected")
|
||||
|
||||
try:
|
||||
payload_text = json.dumps(body.payload)
|
||||
except TypeError as exc: # noqa: BLE001
|
||||
raise HTTPException(
|
||||
status_code=422, detail="Payload must be JSON serializable"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
await websocket.send_text(payload_text)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to send to client {client_id}"
|
||||
) from exc
|
||||
|
||||
await broadcast_event(
|
||||
{
|
||||
"event": "outbound",
|
||||
"timestamp": now_iso(),
|
||||
"client_id": client_id,
|
||||
"payload": body.payload,
|
||||
}
|
||||
)
|
||||
return {"status": "sent"}
|
||||
|
||||
|
||||
@app.get("/events")
|
||||
async def sse_events() -> StreamingResponse:
|
||||
queue: asyncio.Queue[str] = asyncio.Queue()
|
||||
async with sse_lock:
|
||||
sse_subscribers.add(queue)
|
||||
|
||||
async def event_stream():
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
message = await asyncio.wait_for(queue.get(), timeout=15)
|
||||
yield message
|
||||
except asyncio.TimeoutError:
|
||||
# Keep connection alive
|
||||
yield "event: keepalive\ndata: {}\n\n"
|
||||
except asyncio.CancelledError:
|
||||
# Client disconnected
|
||||
pass
|
||||
finally:
|
||||
async with sse_lock:
|
||||
sse_subscribers.discard(queue)
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_handler(websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
client_id = next(client_id_seq)
|
||||
async with client_lock:
|
||||
clients[client_id] = websocket
|
||||
|
||||
await broadcast_event(
|
||||
{"event": "client_connected", "timestamp": now_iso(), "client_id": client_id}
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
message = await websocket.receive_text()
|
||||
try:
|
||||
parsed = json.loads(message)
|
||||
except json.JSONDecodeError:
|
||||
print("json decode error")
|
||||
print(message)
|
||||
parsed = {"raw": message}
|
||||
|
||||
await broadcast_event(
|
||||
{
|
||||
"event": "inbound",
|
||||
"timestamp": now_iso(),
|
||||
"client_id": client_id,
|
||||
"payload": parsed,
|
||||
}
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
async with client_lock:
|
||||
clients.pop(client_id, None)
|
||||
await broadcast_event(
|
||||
{
|
||||
"event": "client_disconnected",
|
||||
"timestamp": now_iso(),
|
||||
"client_id": client_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fastapi==0.110.2
|
||||
uvicorn[standard]==0.30.1
|
||||
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