1.5.0 update
This commit is contained in:
@@ -8,6 +8,7 @@ export function applyUserRoleUI(user, refs) {
|
||||
scriptsLink,
|
||||
logsLink,
|
||||
notificationsLink,
|
||||
enrollmentLink,
|
||||
} = refs;
|
||||
|
||||
if (!user || !usernameDisplay || !roleBadge) return;
|
||||
@@ -70,6 +71,7 @@ export function applyUserRoleUI(user, refs) {
|
||||
if (user.role === "admin" || user.role === "operator") {
|
||||
buildLink?.classList.remove("hidden");
|
||||
notificationsLink?.classList.remove("hidden");
|
||||
enrollmentLink?.classList.remove("hidden");
|
||||
}
|
||||
if (user.role !== "viewer") {
|
||||
scriptsLink?.classList.remove("hidden");
|
||||
|
||||
@@ -77,6 +77,12 @@ export function mountNav(host) {
|
||||
class="hidden inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 hover:bg-slate-800 text-slate-300 transition-colors"
|
||||
><i class="fa-solid fa-users text-indigo-400"></i> Users</a
|
||||
>
|
||||
<a
|
||||
href="/purgatory"
|
||||
id="enrollment-link"
|
||||
class="hidden inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 hover:bg-slate-800 text-slate-300 transition-colors"
|
||||
><i class="fa-solid fa-user-clock text-amber-400"></i> Purgatory<span id="enrollment-badge" class="hidden min-w-[20px] h-5 px-1 rounded-full bg-amber-500 text-white text-xs flex items-center justify-center"></span></a
|
||||
>
|
||||
</nav>
|
||||
<div id="nav-utility" class="flex flex-wrap items-center gap-2 md:w-auto md:justify-end md:shrink-0">
|
||||
<button
|
||||
@@ -135,5 +141,7 @@ export function mountNav(host) {
|
||||
scriptsLink: document.getElementById("scripts-link"),
|
||||
logsLink: document.getElementById("logs-link"),
|
||||
notificationsLink: document.getElementById("notifications-link"),
|
||||
enrollmentLink: document.getElementById("enrollment-link"),
|
||||
enrollmentBadge: document.getElementById("enrollment-badge"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
const body = document.getElementById("enrollment-body");
|
||||
const loadingRow = document.getElementById("enrollment-loading");
|
||||
const emptyEl = document.getElementById("enrollment-empty");
|
||||
const toggleEl = document.getElementById("require-approval-toggle");
|
||||
const selectAllEl = document.getElementById("select-all");
|
||||
const bulkApproveBtn = document.getElementById("bulk-approve-btn");
|
||||
const bulkDenyBtn = document.getElementById("bulk-deny-btn");
|
||||
const statPending = document.getElementById("stat-pending");
|
||||
const statApproved = document.getElementById("stat-approved");
|
||||
const statDenied = document.getElementById("stat-denied");
|
||||
const statBannedIps = document.getElementById("stat-banned-ips");
|
||||
const searchInput = document.getElementById("search-input");
|
||||
const bannedIpsSection = document.getElementById("banned-ips-section");
|
||||
const bannedIpsBody = document.getElementById("banned-ips-body");
|
||||
const bannedIpsEmpty = document.getElementById("banned-ips-empty");
|
||||
const clientsTable = document.getElementById("enrollment-table")?.closest(".bg-slate-900\\/50");
|
||||
const addBanBtn = document.getElementById("add-ban-btn");
|
||||
const manualBanForm = document.getElementById("manual-ban-form");
|
||||
const banIpInput = document.getElementById("ban-ip-input");
|
||||
const banReasonInput = document.getElementById("ban-reason-input");
|
||||
const confirmBanBtn = document.getElementById("confirm-ban-btn");
|
||||
const cancelBanBtn = document.getElementById("cancel-ban-btn");
|
||||
const bulkBanBtn = document.getElementById("bulk-ban-btn");
|
||||
const banModal = document.getElementById("ban-confirm-modal");
|
||||
const banModalBody = document.getElementById("ban-modal-body");
|
||||
const banModalConfirm = document.getElementById("ban-modal-confirm");
|
||||
const banModalCancel = document.getElementById("ban-modal-cancel");
|
||||
const banModalBackdrop = document.getElementById("ban-modal-backdrop");
|
||||
|
||||
let currentFilter = "pending";
|
||||
let searchQuery = "";
|
||||
let clients = [];
|
||||
|
||||
// ── API helpers ────────────────────────────────────────────────────
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(path, { credentials: "include", ...opts });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const s = await api("/api/enrollment/stats");
|
||||
statPending.textContent = s.pending ?? 0;
|
||||
statApproved.textContent = s.approved ?? 0;
|
||||
statDenied.textContent = s.denied ?? 0;
|
||||
|
||||
// Load banned IPs count
|
||||
try {
|
||||
const b = await api("/api/enrollment/banned-ips");
|
||||
statBannedIps.textContent = (b.items || []).length;
|
||||
} catch { statBannedIps.textContent = 0; }
|
||||
|
||||
// Update nav badge
|
||||
const badge = document.getElementById("enrollment-badge");
|
||||
if (badge) {
|
||||
if (s.pending > 0) {
|
||||
badge.textContent = s.pending;
|
||||
badge.classList.remove("hidden");
|
||||
} else {
|
||||
badge.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const s = await api("/api/enrollment/settings");
|
||||
toggleEl.checked = s.requireApproval;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadClients() {
|
||||
loadingRow.classList.remove("hidden");
|
||||
emptyEl.classList.add("hidden");
|
||||
body.querySelectorAll("tr:not(#enrollment-loading)").forEach((r) => r.remove());
|
||||
|
||||
try {
|
||||
const data = await api(`/api/clients?page=1&pageSize=500&enrollmentFilter=${currentFilter}`);
|
||||
clients = data.items || [];
|
||||
} catch {
|
||||
clients = [];
|
||||
}
|
||||
|
||||
// Sort by newest first (highest lastSeen = most recent)
|
||||
clients.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0));
|
||||
|
||||
// Apply search filter
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
const filtered = q
|
||||
? clients.filter((c) => {
|
||||
const fields = [c.host, c.user, c.ip, c.id, c.os, c.country, c.keyFingerprint];
|
||||
return fields.some((f) => f && String(f).toLowerCase().includes(q));
|
||||
})
|
||||
: clients;
|
||||
|
||||
loadingRow.classList.add("hidden");
|
||||
|
||||
if (filtered.length === 0) {
|
||||
emptyEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const c of filtered) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "hover:bg-slate-800/40 transition-colors";
|
||||
tr.dataset.id = c.id;
|
||||
|
||||
const statusPill = statusBadge(c.enrollmentStatus || "pending");
|
||||
const fp = c.keyFingerprint ? c.keyFingerprint.substring(0, 16) + "..." : "-";
|
||||
const lastSeen = c.lastSeen ? timeAgo(c.lastSeen) : "-";
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="px-4 py-3"><input type="checkbox" class="row-check h-4 w-4 rounded border-slate-600" data-id="${esc(c.id)}" /></td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-slate-200">${esc(c.host || c.id)}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.user || "-")}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.os || "-")}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.ip || "-")}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.country || "-")}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-500 font-mono">${esc(fp)}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-500">${lastSeen}</td>
|
||||
<td class="px-4 py-3">${statusPill}</td>
|
||||
<td class="px-4 py-3 text-right space-x-2">${actionButtons(c)}</td>
|
||||
`;
|
||||
body.appendChild(tr);
|
||||
}
|
||||
updateBulkButtons();
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const map = {
|
||||
pending:
|
||||
'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-500/20 text-amber-300 border border-amber-500/40"><i class="fa-solid fa-clock"></i>Pending</span>',
|
||||
approved:
|
||||
'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-500/20 text-emerald-300 border border-emerald-500/40"><i class="fa-solid fa-check"></i>Approved</span>',
|
||||
denied:
|
||||
'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-500/20 text-red-300 border border-red-500/40"><i class="fa-solid fa-ban"></i>Denied</span>',
|
||||
};
|
||||
return map[status] || map.pending;
|
||||
}
|
||||
|
||||
function actionButtons(c) {
|
||||
const status = c.enrollmentStatus || "pending";
|
||||
let html = "";
|
||||
if (status !== "approved") {
|
||||
html += `<button class="act-approve px-2 py-1 rounded text-xs font-medium bg-emerald-600 hover:bg-emerald-700 text-white" data-id="${esc(c.id)}"><i class="fa-solid fa-check mr-1"></i>Approve</button>`;
|
||||
}
|
||||
if (status !== "denied") {
|
||||
html += `<button class="act-deny px-2 py-1 rounded text-xs font-medium bg-red-600 hover:bg-red-700 text-white" data-id="${esc(c.id)}"><i class="fa-solid fa-ban mr-1"></i>Deny</button>`;
|
||||
}
|
||||
if (status !== "pending") {
|
||||
html += `<button class="act-reset px-2 py-1 rounded text-xs font-medium bg-slate-600 hover:bg-slate-700 text-white" data-id="${esc(c.id)}"><i class="fa-solid fa-rotate-left mr-1"></i>Reset</button>`;
|
||||
}
|
||||
if (c.ip) {
|
||||
html += `<button class="act-ban-ip px-2 py-1 rounded text-xs font-medium bg-rose-700 hover:bg-rose-800 text-white" data-id="${esc(c.id)}" title="Ban IP ${esc(c.ip)}"><i class="fa-solid fa-shield-halved mr-1"></i>Ban IP</button>`;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = String(s ?? "");
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function timeAgo(ts) {
|
||||
const diff = Date.now() - ts;
|
||||
if (diff < 60000) return "just now";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
return `${Math.floor(diff / 86400000)}d ago`;
|
||||
}
|
||||
|
||||
// ── Event handlers ─────────────────────────────────────────────────
|
||||
async function setStatus(clientId, action) {
|
||||
try {
|
||||
await api(`/api/enrollment/${encodeURIComponent(clientId)}/${action}`, { method: "POST" });
|
||||
if (window.showToast) window.showToast(`Client ${action}`, "success");
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
|
||||
}
|
||||
await Promise.all([loadClients(), loadStats()]);
|
||||
}
|
||||
|
||||
async function bulkAction(action) {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await api("/api/enrollment/bulk", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids, action }),
|
||||
});
|
||||
if (window.showToast) window.showToast(`${ids.length} clients ${action}`, "success");
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
|
||||
}
|
||||
selectAllEl.checked = false;
|
||||
await Promise.all([loadClients(), loadStats()]);
|
||||
}
|
||||
|
||||
function getSelectedIds() {
|
||||
return [...document.querySelectorAll(".row-check:checked")].map(
|
||||
(el) => el.dataset.id,
|
||||
);
|
||||
}
|
||||
|
||||
function updateBulkButtons() {
|
||||
const count = getSelectedIds().length;
|
||||
bulkApproveBtn.classList.toggle("hidden", count === 0);
|
||||
bulkDenyBtn.classList.toggle("hidden", count === 0);
|
||||
bulkBanBtn.classList.toggle("hidden", count === 0);
|
||||
}
|
||||
|
||||
// ── Tab switching ──────────────────────────────────────────────────
|
||||
document.querySelectorAll(".enrollment-tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
currentFilter = tab.dataset.filter;
|
||||
document.querySelectorAll(".enrollment-tab").forEach((t) => {
|
||||
t.className =
|
||||
"enrollment-tab px-4 py-2 rounded-lg text-sm font-medium bg-slate-800 text-slate-400 border border-slate-700 hover:bg-slate-700";
|
||||
});
|
||||
const colorMap = {
|
||||
pending: "bg-amber-500/20 text-amber-300 border-amber-500/40",
|
||||
denied: "bg-red-500/20 text-red-300 border-red-500/40",
|
||||
approved: "bg-emerald-500/20 text-emerald-300 border-emerald-500/40",
|
||||
"banned-ips": "bg-rose-500/20 text-rose-300 border-rose-500/40",
|
||||
};
|
||||
tab.className = `enrollment-tab px-4 py-2 rounded-lg text-sm font-medium ${colorMap[currentFilter] || ""} border`;
|
||||
|
||||
if (currentFilter === "banned-ips") {
|
||||
if (clientsTable) clientsTable.classList.add("hidden");
|
||||
emptyEl.classList.add("hidden");
|
||||
bannedIpsSection.classList.remove("hidden");
|
||||
loadBannedIps();
|
||||
} else {
|
||||
bannedIpsSection.classList.add("hidden");
|
||||
if (clientsTable) clientsTable.classList.remove("hidden");
|
||||
loadClients();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Toggle require approval ────────────────────────────────────────
|
||||
toggleEl.addEventListener("change", async () => {
|
||||
try {
|
||||
await api("/api/enrollment/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requireApproval: toggleEl.checked }),
|
||||
});
|
||||
if (window.showToast)
|
||||
window.showToast(
|
||||
toggleEl.checked ? "Approval required" : "Auto-approve enabled",
|
||||
"success",
|
||||
);
|
||||
} catch (e) {
|
||||
toggleEl.checked = !toggleEl.checked;
|
||||
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Table delegation ───────────────────────────────────────────────
|
||||
body.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
const id = btn.dataset.id;
|
||||
if (!id) return;
|
||||
if (btn.classList.contains("act-approve")) setStatus(id, "approve");
|
||||
else if (btn.classList.contains("act-deny")) setStatus(id, "deny");
|
||||
else if (btn.classList.contains("act-reset")) setStatus(id, "reset");
|
||||
else if (btn.classList.contains("act-ban-ip")) banClientIp(id);
|
||||
});
|
||||
|
||||
body.addEventListener("change", () => updateBulkButtons());
|
||||
selectAllEl.addEventListener("change", () => {
|
||||
document.querySelectorAll(".row-check").forEach((cb) => {
|
||||
cb.checked = selectAllEl.checked;
|
||||
});
|
||||
updateBulkButtons();
|
||||
});
|
||||
|
||||
bulkApproveBtn.addEventListener("click", () => bulkAction("approve"));
|
||||
bulkDenyBtn.addEventListener("click", () => bulkAction("deny"));
|
||||
bulkBanBtn.addEventListener("click", () => {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) return;
|
||||
// Find IP info for selected clients
|
||||
const selected = clients.filter((c) => ids.includes(c.id) && c.ip);
|
||||
const ips = [...new Set(selected.map((c) => c.ip))];
|
||||
showBanModal(
|
||||
`You are about to <strong>ban ${ips.length} IP address${ips.length !== 1 ? "es" : ""}</strong> affecting <strong>${ids.length} client${ids.length !== 1 ? "s" : ""}</strong>. Banned IPs will be blocked from all future connections.`,
|
||||
ips,
|
||||
async () => {
|
||||
await bulkAction("ban-ip");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── Search ─────────────────────────────────────────────────────────
|
||||
let searchDebounce;
|
||||
searchInput.addEventListener("input", () => {
|
||||
clearTimeout(searchDebounce);
|
||||
searchDebounce = setTimeout(() => {
|
||||
searchQuery = searchInput.value;
|
||||
loadClients();
|
||||
}, 250);
|
||||
});
|
||||
|
||||
// ── Ban confirmation modal ──────────────────────────────────────────
|
||||
let banModalResolve = null;
|
||||
|
||||
function showBanModal(message, ips, onConfirm) {
|
||||
let html = `<p class="mb-3">${message}</p>`;
|
||||
if (ips && ips.length > 0 && ips.length <= 10) {
|
||||
html += `<div class="bg-slate-800/60 border border-slate-700 rounded-lg p-3 font-mono text-xs text-rose-300 space-y-1">`;
|
||||
for (const ip of ips) html += `<div>${esc(ip)}</div>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
banModalBody.innerHTML = html;
|
||||
banModal.classList.remove("hidden");
|
||||
banModalResolve = onConfirm;
|
||||
}
|
||||
|
||||
function closeBanModal() {
|
||||
banModal.classList.add("hidden");
|
||||
banModalResolve = null;
|
||||
}
|
||||
|
||||
banModalConfirm.addEventListener("click", async () => {
|
||||
const fn = banModalResolve;
|
||||
closeBanModal();
|
||||
if (fn) await fn();
|
||||
});
|
||||
banModalCancel.addEventListener("click", closeBanModal);
|
||||
banModalBackdrop.addEventListener("click", closeBanModal);
|
||||
|
||||
// ── Banned IPs ─────────────────────────────────────────────────────
|
||||
async function banClientIp(clientId) {
|
||||
const client = clients.find((c) => c.id === clientId);
|
||||
const ipText = client?.ip ? ` (${client.ip})` : "";
|
||||
showBanModal(
|
||||
`You are about to <strong>ban the IP address</strong> of client <strong>${esc(client?.host || clientId)}</strong>${esc(ipText)}. This will block all connections from this IP.`,
|
||||
client?.ip ? [client.ip] : [],
|
||||
async () => {
|
||||
try {
|
||||
await api(`/api/enrollment/${encodeURIComponent(clientId)}/ban-ip`, { method: "POST" });
|
||||
if (window.showToast) window.showToast("IP banned", "success");
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
|
||||
}
|
||||
await Promise.all([loadClients(), loadStats()]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function loadBannedIps() {
|
||||
bannedIpsBody.innerHTML = "";
|
||||
bannedIpsEmpty.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const data = await api("/api/enrollment/banned-ips");
|
||||
const items = data.items || [];
|
||||
|
||||
if (items.length === 0) {
|
||||
bannedIpsEmpty.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of items) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "hover:bg-slate-800/40 transition-colors";
|
||||
tr.innerHTML = `
|
||||
<td class="px-4 py-3 text-sm font-mono text-slate-200">${esc(entry.ip)}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-400">${esc(entry.reason || "-")}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-500">${entry.createdAt ? timeAgo(entry.createdAt) : "-"}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button class="act-unban px-2 py-1 rounded text-xs font-medium bg-slate-600 hover:bg-slate-700 text-white" data-ip="${esc(entry.ip)}">
|
||||
<i class="fa-solid fa-unlock mr-1"></i>Unban
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
bannedIpsBody.appendChild(tr);
|
||||
}
|
||||
} catch {
|
||||
bannedIpsEmpty.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
bannedIpsBody.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
if (btn.classList.contains("act-unban")) {
|
||||
const ip = btn.dataset.ip;
|
||||
if (!ip) return;
|
||||
try {
|
||||
await api(`/api/enrollment/banned-ips?ip=${encodeURIComponent(ip)}`, { method: "DELETE" });
|
||||
if (window.showToast) window.showToast(`Unbanned ${ip}`, "success");
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
|
||||
}
|
||||
await Promise.all([loadBannedIps(), loadStats()]);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Manual IP ban form ─────────────────────────────────────────────
|
||||
addBanBtn.addEventListener("click", () => {
|
||||
manualBanForm.classList.toggle("hidden");
|
||||
banIpInput.value = "";
|
||||
banReasonInput.value = "";
|
||||
if (!manualBanForm.classList.contains("hidden")) banIpInput.focus();
|
||||
});
|
||||
|
||||
cancelBanBtn.addEventListener("click", () => {
|
||||
manualBanForm.classList.add("hidden");
|
||||
});
|
||||
|
||||
confirmBanBtn.addEventListener("click", async () => {
|
||||
const ip = banIpInput.value.trim();
|
||||
if (!ip) { banIpInput.focus(); return; }
|
||||
try {
|
||||
await api("/api/enrollment/ban-ip", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ip, reason: banReasonInput.value.trim() || undefined }),
|
||||
});
|
||||
if (window.showToast) window.showToast(`Banned ${ip}`, "success");
|
||||
manualBanForm.classList.add("hidden");
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
|
||||
}
|
||||
await Promise.all([loadBannedIps(), loadStats()]);
|
||||
});
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────
|
||||
loadSettings();
|
||||
loadStats();
|
||||
loadClients();
|
||||
|
||||
// Auto-refresh every 15 seconds
|
||||
setInterval(() => {
|
||||
loadStats();
|
||||
loadClients();
|
||||
}, 15000);
|
||||
Reference in New Issue
Block a user