| |
| |
| |
|
|
|
|
| (() => {
|
| const socket = io({ transports: ["websocket", "polling"] });
|
|
|
|
|
| let allUsers = [];
|
| let lastStatus = null;
|
| let lastTotal = 0;
|
|
|
|
|
| const $ = (id) => document.getElementById(id);
|
| const connDot = $("connDot");
|
| const connLabel = $("connLabel");
|
| const lastSync = $("lastSync");
|
| const errorBox = $("errorBox");
|
| const statsGrid = $("statsGrid");
|
| const serversC = $("serversContent");
|
| const usersC = $("usersContent");
|
| const userCount = $("userCount");
|
|
|
|
|
|
|
|
|
| const escapeHtml = (s) => {
|
| if (s === null || s === undefined) return "";
|
| return String(s)
|
| .replace(/&/g, "&")
|
| .replace(/</g, "<")
|
| .replace(/>/g, ">")
|
| .replace(/"/g, """)
|
| .replace(/'/g, "'");
|
| };
|
|
|
| const fmtDate = (iso) => {
|
| if (!iso) return "—";
|
| try {
|
| const d = new Date(iso);
|
| if (isNaN(d)) return "—";
|
| return d.toLocaleString(undefined, {
|
| year: "numeric", month: "short", day: "2-digit",
|
| hour: "2-digit", minute: "2-digit"
|
| });
|
| } catch { return "—"; }
|
| };
|
|
|
| const fmtTime = (ts) => {
|
| const d = ts ? new Date(ts * 1000) : new Date();
|
| return d.toLocaleTimeString(undefined, { hour12: false });
|
| };
|
|
|
| const setConn = (state, label) => {
|
| connDot.dataset.state = state;
|
| connLabel.textContent = label;
|
| };
|
|
|
| const pulse = (el) => {
|
| if (!el) return;
|
| el.classList.remove("pulse");
|
|
|
| void el.offsetWidth;
|
| el.classList.add("pulse");
|
| };
|
|
|
|
|
|
|
|
|
| const renderErrors = (errors) => {
|
| if (!errors || Object.keys(errors).length === 0) {
|
| errorBox.innerHTML = "";
|
| return;
|
| }
|
| errorBox.innerHTML = Object.entries(errors).map(([k, v]) => `
|
| <div class="error">
|
| <span class="error__tag">${escapeHtml(k)}</span>
|
| <span class="error__msg">${escapeHtml(v)}</span>
|
| </div>
|
| `).join("");
|
| };
|
|
|
|
|
|
|
|
|
| const renderStats = (status, total) => {
|
| const cards = [];
|
|
|
| cards.push(`
|
| <div class="stat">
|
| <div class="stat__label mono">Total Users</div>
|
| <div class="stat__value">${total ?? "—"}</div>
|
| <div class="stat__sub mono">Registered accounts</div>
|
| </div>
|
| `);
|
|
|
| if (status) {
|
| const cap = status.total_servers * status.max_per_server;
|
| const pct = cap > 0 ? Math.round((total / cap) * 100) : 0;
|
| cards.push(`
|
| <div class="stat">
|
| <div class="stat__label mono">Servers</div>
|
| <div class="stat__value">${status.total_servers}</div>
|
| <div class="stat__sub mono">Max ${status.max_per_server} ea.</div>
|
| </div>
|
| <div class="stat">
|
| <div class="stat__label mono">Reservations</div>
|
| <div class="stat__value">${status.total_reservations}</div>
|
| <div class="stat__sub mono">Pending registrations</div>
|
| </div>
|
| <div class="stat">
|
| <div class="stat__label mono">Capacity</div>
|
| <div class="stat__value">${pct}<span style="font-size:.5em">%</span></div>
|
| <div class="stat__sub mono">${total} / ${cap}</div>
|
| </div>
|
| `);
|
| } else {
|
| cards.push(`
|
| <div class="stat stat--placeholder">
|
| <div class="stat__label mono">Servers</div>
|
| <div class="stat__value">—</div>
|
| <div class="stat__sub mono">Awaiting API key</div>
|
| </div>
|
| <div class="stat stat--placeholder">
|
| <div class="stat__label mono">Reservations</div>
|
| <div class="stat__value">—</div>
|
| <div class="stat__sub mono">Awaiting API key</div>
|
| </div>
|
| <div class="stat stat--placeholder">
|
| <div class="stat__label mono">Capacity</div>
|
| <div class="stat__value">—</div>
|
| <div class="stat__sub mono">Awaiting API key</div>
|
| </div>
|
| `);
|
| }
|
|
|
| statsGrid.innerHTML = cards.join("");
|
| pulse(statsGrid);
|
| };
|
|
|
|
|
|
|
|
|
| const renderServers = (servers) => {
|
| if (!servers || !servers.length) {
|
| serversC.innerHTML = `<div class="placeholder mono">No server data available.</div>`;
|
| return;
|
| }
|
|
|
| const rows = servers.map((s) => {
|
| const pct = s.max > 0 ? (s.effective / s.max) * 100 : 0;
|
| const state = pct >= 100 ? "full" : pct >= 75 ? "warn" : "ok";
|
| const statusBadge = s.available
|
| ? `<span class="badge">Open</span>`
|
| : `<span class="badge badge--solid">Full</span>`;
|
|
|
| return `
|
| <tr>
|
| <td data-label="No." class="idx">№ ${escapeHtml(String(s.server_num).padStart(2, "0"))}</td>
|
| <td data-label="Users"><span class="capacity-text">${s.users}</span></td>
|
| <td data-label="Reserved"><span class="capacity-text">${s.reserved}</span></td>
|
| <td data-label="Capacity">
|
| <span class="capacity-text">${s.effective} / ${s.max}</span>
|
| <div class="bar"><div class="bar__fill" data-state="${state}" style="width:${Math.min(pct, 100)}%"></div></div>
|
| </td>
|
| <td data-label="Status">${statusBadge}</td>
|
| <td data-label="URL"><span class="url" title="${escapeHtml(s.url || "")}">${escapeHtml(s.url || "—")}</span></td>
|
| </tr>
|
| `;
|
| }).join("");
|
|
|
| serversC.innerHTML = `
|
| <div class="table-wrap">
|
| <table class="dataset">
|
| <thead>
|
| <tr>
|
| <th>№</th>
|
| <th>Users</th>
|
| <th>Reserved</th>
|
| <th>Capacity</th>
|
| <th>Status</th>
|
| <th>Endpoint</th>
|
| </tr>
|
| </thead>
|
| <tbody>${rows}</tbody>
|
| </table>
|
| </div>
|
| `;
|
| pulse(serversC);
|
| };
|
|
|
|
|
|
|
|
|
| const renderUsers = (users) => {
|
| userCount.textContent = `${users.length} record${users.length === 1 ? "" : "s"}`;
|
|
|
| if (!users.length) {
|
| usersC.innerHTML = `<div class="placeholder mono">No users match the current filter.</div>`;
|
| return;
|
| }
|
|
|
| const rows = users.map((u, i) => {
|
| const tokens = u.tokens_count || 0;
|
| const tokensBadge = tokens > 0
|
| ? `<span class="badge badge--solid">${tokens} token${tokens > 1 ? "s" : ""}</span>`
|
| : `<span class="badge badge--empty">none</span>`;
|
|
|
| return `
|
| <tr>
|
| <td data-label="#" class="idx">${String(i + 1).padStart(3, "0")}</td>
|
| <td data-label="Username"><span class="headline">${escapeHtml(u.username || "—")}</span></td>
|
| <td data-label="Telegram ID"><span class="meta">${escapeHtml(u.telegram_id || "—")}</span></td>
|
| <td data-label="Server"><span class="badge">Server ${escapeHtml(String(u.server_num ?? "?"))}</span></td>
|
| <td data-label="Tokens">${tokensBadge}</td>
|
| <td data-label="Created"><span class="meta">${escapeHtml(fmtDate(u.created_at))}</span></td>
|
| <td data-label="Last login"><span class="meta">${escapeHtml(fmtDate(u.last_login))}</span></td>
|
| </tr>
|
| `;
|
| }).join("");
|
|
|
| usersC.innerHTML = `
|
| <div class="table-wrap">
|
| <table class="dataset">
|
| <thead>
|
| <tr>
|
| <th>#</th>
|
| <th>Username</th>
|
| <th>Telegram ID</th>
|
| <th>Server</th>
|
| <th>Tokens</th>
|
| <th>Created</th>
|
| <th>Last login</th>
|
| </tr>
|
| </thead>
|
| <tbody>${rows}</tbody>
|
| </table>
|
| </div>
|
| `;
|
| pulse(usersC);
|
| };
|
|
|
|
|
|
|
|
|
| window.filterUsers = () => {
|
| const q = ($("userSearch").value || "").trim().toLowerCase();
|
| if (!q) return renderUsers(allUsers);
|
| const filtered = allUsers.filter((u) =>
|
| (u.username || "").toLowerCase().includes(q) ||
|
| (u.telegram_id || "").toLowerCase().includes(q) ||
|
| String(u.server_num || "").includes(q)
|
| );
|
| renderUsers(filtered);
|
| };
|
|
|
|
|
|
|
|
|
| $("authForm").addEventListener("submit", (e) => {
|
| e.preventDefault();
|
| const admin_secret = $("adminSecret").value.trim();
|
| const api_key = $("apiKey").value.trim();
|
| if (!admin_secret && !api_key) {
|
| renderErrors({ auth: "Provide at least an Admin Secret or API Key." });
|
| return;
|
| }
|
| setConn("warn", "AUTHENTICATING…");
|
| socket.emit("authenticate", { admin_secret, api_key });
|
| });
|
|
|
| $("refreshBtn").addEventListener("click", () => {
|
| setConn("warn", "REFRESHING…");
|
| socket.emit("refresh");
|
| });
|
|
|
|
|
|
|
|
|
| socket.on("connect", () => {
|
| setConn("on", "LIVE");
|
| });
|
|
|
| socket.on("connected", (data) => {
|
| if (data && data.poll_interval) {
|
| const el = $("pollInterval");
|
| if (el) el.textContent = data.poll_interval;
|
| }
|
| });
|
|
|
| socket.on("disconnect", () => {
|
| setConn("off", "DISCONNECTED");
|
| });
|
|
|
| socket.on("connect_error", () => {
|
| setConn("off", "CONNECTION ERROR");
|
| });
|
|
|
| socket.on("error", (data) => {
|
| renderErrors({ socket: (data && data.message) || "Unknown error" });
|
| });
|
|
|
|
|
|
|
|
|
| socket.on("data_update", (payload) => {
|
| if (!payload) return;
|
|
|
| if (payload.errors) renderErrors(payload.errors);
|
|
|
| allUsers = payload.users || [];
|
| lastTotal = payload.total ?? allUsers.length;
|
| lastStatus = payload.status || null;
|
|
|
| renderStats(lastStatus, lastTotal);
|
| renderServers(lastStatus ? lastStatus.servers : null);
|
| renderUsers(allUsers);
|
|
|
| lastSync.textContent = fmtTime(payload.timestamp);
|
| setConn("on", payload.source === "auto" ? "LIVE · AUTO" : "LIVE");
|
| });
|
| })(); |