UI improvements

This commit is contained in:
KingKDot
2026-05-30 22:44:55 -05:00
parent 5933297686
commit 2a90053eee
24 changed files with 1498 additions and 420 deletions
+118 -170
View File
@@ -1,7 +1,7 @@
import { TabulatorFull as Tabulator } from "/vendor/tabulator/tabulator_esm.min.js";
const enrollmentTableEl = document.getElementById("enrollment-table");
const body = document.getElementById("enrollment-body");
const loadingRow = document.getElementById("enrollment-loading");
const emptyEl = document.getElementById("enrollment-empty");
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");
@@ -10,9 +10,9 @@ 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 bannedIpsTableEl = document.getElementById("banned-ips-table");
const bannedIpsBody = document.getElementById("banned-ips-body");
const bannedIpsEmpty = document.getElementById("banned-ips-empty");
const clientsTable = document.getElementById("clients-table-wrap");
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");
@@ -44,10 +44,8 @@ const denyReasonModalBackdrop = document.getElementById("deny-reason-modal-backd
let currentFilter = "pending";
let searchQuery = "";
let clients = [];
let enrollmentTable = null;
let bannedIpsTable = null;
const selectedIds = new Set();
const expandedCells = new Set(); // tracks "clientId:field" keys
let lastEnrollmentDigest = "";
const SUSPICIOUS_FLAG_LABELS = {
hwid_flood: "HWID Flood (40+ same hardware ID)",
@@ -103,8 +101,9 @@ async function loadStats() {
}
async function loadClients() {
const hasRows = body.querySelector("tr[data-id]");
loadingRow.classList.toggle("hidden", !!hasRows);
emptyEl.classList.add("hidden");
if (enrollmentTable) enrollmentTable.replaceData([]);
const fetchFilter = currentFilter === "suspicious" ? "all" : currentFilter;
try {
@@ -136,26 +135,84 @@ async function loadClients() {
})
: base;
loadingRow.classList.add("hidden");
const showBulkActions = (currentFilter === "pending" || currentFilter === "suspicious") &&
filtered.some((c) => (c.enrollmentStatus || "pending") === "pending");
approveAllBtn.classList.toggle("hidden", !showBulkActions);
denyAllBtn.classList.toggle("hidden", !showBulkActions);
const visibleIds = new Set(filtered.map((c) => c.id));
for (const id of selectedIds) {
if (!visibleIds.has(id)) selectedIds.delete(id);
}
if (filtered.length === 0) {
lastEnrollmentDigest = `empty:${currentFilter}:${q}`;
body.querySelectorAll("tr:not(#enrollment-loading)").forEach((r) => r.remove());
emptyEl.classList.remove("hidden");
updateBulkButtons();
return;
}
if (enrollmentTable) enrollmentTable.replaceData(filtered);
const nextDigest = tableDigest(filtered, [
"id",
"host",
"user",
"os",
"cpu",
"gpu",
"ram",
"ip",
"country",
"keyFingerprint",
"lastSeen",
"enrollmentStatus",
"denyReason",
"suspiciousFlags",
]);
if (nextDigest === lastEnrollmentDigest) {
updateBulkButtons();
return;
}
lastEnrollmentDigest = nextDigest;
const selectedBeforeRender = new Set(getSelectedIds());
body.querySelectorAll("tr:not(#enrollment-loading)").forEach((r) => r.remove());
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 = statusBadgeWithReason(c.enrollmentStatus || "pending", c.denyReason);
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)}" ${selectedBeforeRender.has(c.id) ? "checked" : ""} /></td>
<td class="px-4 py-3 text-sm font-medium text-slate-200">${esc(c.host || c.id)}${suspiciousBadges(c.suspiciousFlags)}</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">${expandableCell(c.id, "cpu", c.cpu)}</td>
<td class="px-4 py-3 text-sm text-slate-400">${c.gpu ? dedupeGpu(c.gpu) : esc("-")}</td>
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.ram || "-")}</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"><div class="flex items-center justify-end gap-2 flex-nowrap">${actionButtons(c)}</div></td>
`;
tr.classList.add("cursor-pointer");
body.appendChild(tr);
}
updateBulkButtons();
}
function tableDigest(items, fields) {
return JSON.stringify(items.map((item) => {
const row = {};
for (const field of fields) row[field] = item[field] ?? null;
return row;
}));
}
function statusBadge(status) {
const map = {
pending:
@@ -232,129 +289,6 @@ function expandableCell(clientId, field, value) {
`</span>`;
}
function selectionHeaderFormatter() {
const rows = enrollmentTable?.getData() || [];
const allChecked = rows.length > 0 && rows.every((row) => selectedIds.has(row.id));
return `<input type="checkbox" class="enrollment-select-all h-4 w-4 rounded border-slate-600" ${allChecked ? "checked" : ""} />`;
}
function rowSelectionFormatter(cell) {
const id = cell.getRow().getData().id;
return `<input type="checkbox" class="row-check h-4 w-4 rounded border-slate-600" data-id="${esc(id)}" ${selectedIds.has(id) ? "checked" : ""} />`;
}
function initEnrollmentTable() {
if (!enrollmentTableEl) return;
enrollmentTable = new Tabulator(enrollmentTableEl, {
data: [],
height: "32rem",
layout: "fitDataStretch",
placeholder: "No clients in this category.",
index: "id",
virtualDom: true,
columns: [
{
title: "",
field: "id",
width: 54,
hozAlign: "center",
headerSort: false,
headerFormatter: selectionHeaderFormatter,
formatter: rowSelectionFormatter,
},
{
title: "Host",
field: "host",
width: 190,
formatter: (cell) => {
const c = cell.getRow().getData();
return `<span class="font-medium text-slate-200">${esc(c.host || c.id)}</span>${suspiciousBadges(c.suspiciousFlags)}`;
},
},
{ title: "User", field: "user", width: 140, formatter: (cell) => esc(cell.getValue() || "-") },
{ title: "OS", field: "os", width: 120, formatter: (cell) => esc(cell.getValue() || "-") },
{
title: "CPU",
field: "cpu",
width: 210,
formatter: (cell) => {
const c = cell.getRow().getData();
return expandableCell(c.id, "cpu", c.cpu);
},
},
{
title: "GPU",
field: "gpu",
width: 220,
formatter: (cell) => cell.getValue() ? dedupeGpu(cell.getValue()) : esc("-"),
},
{ title: "RAM", field: "ram", width: 100, formatter: (cell) => esc(cell.getValue() || "-") },
{ title: "IP", field: "ip", width: 140, formatter: (cell) => esc(cell.getValue() || "-") },
{ title: "Country", field: "country", width: 120, formatter: (cell) => esc(cell.getValue() || "-") },
{
title: "Key Fingerprint",
field: "keyFingerprint",
width: 170,
formatter: (cell) => `<span class="font-mono text-slate-500">${esc(cell.getValue() ? cell.getValue().substring(0, 16) + "..." : "-")}</span>`,
},
{
title: "Last Seen",
field: "lastSeen",
width: 120,
sorter: "number",
formatter: (cell) => `<span class="text-slate-500">${cell.getValue() ? timeAgo(cell.getValue()) : "-"}</span>`,
},
{
title: "Status",
field: "enrollmentStatus",
width: 150,
formatter: (cell) => {
const c = cell.getRow().getData();
return statusBadgeWithReason(c.enrollmentStatus || "pending", c.denyReason);
},
},
{
title: "Actions",
field: "id",
width: 300,
headerSort: false,
hozAlign: "right",
formatter: (cell) => `<div class="flex items-center justify-end gap-2 flex-nowrap">${actionButtons(cell.getRow().getData())}</div>`,
},
],
});
}
function initBannedIpsTable() {
if (!bannedIpsTableEl) return;
bannedIpsTable = new Tabulator(bannedIpsTableEl, {
data: [],
height: "24rem",
layout: "fitColumns",
placeholder: "No banned IPs.",
index: "ip",
columns: [
{ title: "IP Address", field: "ip", width: 180, formatter: (cell) => `<span class="font-mono text-slate-200">${esc(cell.getValue())}</span>` },
{ title: "Reason", field: "reason", formatter: (cell) => esc(cell.getValue() || "-") },
{
title: "Banned At",
field: "createdAt",
width: 150,
sorter: "number",
formatter: (cell) => `<span class="text-slate-500">${cell.getValue() ? timeAgo(cell.getValue()) : "-"}</span>`,
},
{
title: "Actions",
field: "ip",
width: 120,
headerSort: false,
hozAlign: "right",
formatter: (cell) => `<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(cell.getValue())}"><i class="fa-solid fa-unlock mr-1"></i>Unban</button>`,
},
],
});
}
function timeAgo(ts) {
const diff = Date.now() - ts;
if (diff < 60000) return "just now";
@@ -401,24 +335,26 @@ async function bulkAction(action) {
} catch (e) {
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
}
selectedIds.clear();
selectAllEl.checked = false;
await Promise.all([loadClients(), loadStats()]);
}
function getSelectedIds() {
return [...selectedIds];
return [...document.querySelectorAll(".row-check:checked")].map(
(el) => el.dataset.id,
);
}
function updateBulkButtons() {
const visibleIds = new Set((enrollmentTable?.getData() || []).map((row) => row.id));
for (const id of selectedIds) {
if (!visibleIds.has(id)) selectedIds.delete(id);
}
const count = selectedIds.size;
const rowChecks = [...document.querySelectorAll(".row-check")];
const selected = getSelectedIds();
const count = selected.length;
bulkApproveBtn.classList.toggle("hidden", count === 0);
bulkDenyBtn.classList.toggle("hidden", count === 0);
bulkBanBtn.classList.toggle("hidden", count === 0);
enrollmentTable?.redraw(true);
if (selectAllEl) {
selectAllEl.checked = rowChecks.length > 0 && rowChecks.every((cb) => cb.checked);
}
}
// ── Tab switching ──────────────────────────────────────────────────
@@ -454,7 +390,7 @@ document.querySelectorAll(".enrollment-tab").forEach((tab) => {
});
// ── Table delegation ───────────────────────────────────────────────
enrollmentTableEl?.addEventListener("click", (e) => {
body.addEventListener("click", (e) => {
// Expandable CPU/GPU cells
const expander = e.target.closest(".hw-expand");
if (expander) {
@@ -469,6 +405,19 @@ enrollmentTableEl?.addEventListener("click", (e) => {
return;
}
const row = e.target.closest("tr[data-id]");
if (
row &&
!e.target.closest("button, input, a, select, textarea")
) {
const checkbox = row.querySelector(".row-check");
if (checkbox) {
checkbox.checked = !checkbox.checked;
updateBulkButtons();
}
return;
}
const btn = e.target.closest("button");
if (!btn) return;
const id = btn.dataset.id;
@@ -479,24 +428,11 @@ enrollmentTableEl?.addEventListener("click", (e) => {
else if (btn.classList.contains("act-ban-ip")) banClientIp(id);
});
enrollmentTableEl?.addEventListener("change", (e) => {
const selectAll = e.target.closest(".enrollment-select-all");
if (selectAll) {
const rows = enrollmentTable?.getData() || [];
for (const row of rows) {
if (selectAll.checked) selectedIds.add(row.id);
else selectedIds.delete(row.id);
}
updateBulkButtons();
return;
}
const checkbox = e.target.closest(".row-check");
if (!checkbox) return;
const id = checkbox.dataset.id;
if (!id) return;
if (checkbox.checked) selectedIds.add(id);
else selectedIds.delete(id);
body.addEventListener("change", () => updateBulkButtons());
selectAllEl.addEventListener("change", () => {
document.querySelectorAll(".row-check").forEach((cb) => {
cb.checked = selectAllEl.checked;
});
updateBulkButtons();
});
@@ -575,8 +511,8 @@ async function banClientIp(clientId) {
}
async function loadBannedIps() {
bannedIpsBody.innerHTML = "";
bannedIpsEmpty.classList.add("hidden");
if (bannedIpsTable) bannedIpsTable.replaceData([]);
try {
const data = await api("/api/enrollment/banned-ips");
@@ -587,13 +523,27 @@ async function loadBannedIps() {
return;
}
if (bannedIpsTable) bannedIpsTable.replaceData(items);
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");
}
}
bannedIpsTableEl?.addEventListener("click", async (e) => {
bannedIpsBody.addEventListener("click", async (e) => {
const btn = e.target.closest("button");
if (!btn) return;
if (btn.classList.contains("act-unban")) {
@@ -773,8 +723,6 @@ denyReasonModalConfirm.addEventListener("click", async () => {
});
// ── Init ───────────────────────────────────────────────────────────
initEnrollmentTable();
initBannedIpsTable();
loadSettings();
loadStats();
loadClients();