admin / static /js /dashboard.js
CORVO-AI's picture
Upload 6 files
30a02c3 verified
/* =============================================================
ServerClass Admin — Dashboard Client
Socket.IO driven; no client-side polling.
============================================================= */
(() => {
const socket = io({ transports: ["websocket", "polling"] });
// State
let allUsers = [];
let lastStatus = null;
let lastTotal = 0;
// DOM refs
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");
// -----------------------------------------------------------
// Helpers
// -----------------------------------------------------------
const escapeHtml = (s) => {
if (s === null || s === undefined) return "";
return String(s)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};
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");
// force reflow to restart animation
void el.offsetWidth;
el.classList.add("pulse");
};
// -----------------------------------------------------------
// Render: errors
// -----------------------------------------------------------
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("");
};
// -----------------------------------------------------------
// Render: stats
// -----------------------------------------------------------
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);
};
// -----------------------------------------------------------
// Render: servers
// -----------------------------------------------------------
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);
};
// -----------------------------------------------------------
// Render: users
// -----------------------------------------------------------
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);
};
// -----------------------------------------------------------
// Filter
// -----------------------------------------------------------
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);
};
// -----------------------------------------------------------
// Form events
// -----------------------------------------------------------
$("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.IO lifecycle
// -----------------------------------------------------------
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" });
});
// -----------------------------------------------------------
// Main payload handler
// -----------------------------------------------------------
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");
});
})();