purgatory improvements

This commit is contained in:
KingKDot
2026-06-30 13:36:27 -05:00
parent bf99a5dca9
commit b5b50aa038
5 changed files with 183 additions and 53 deletions
+27 -19
View File
@@ -12,7 +12,7 @@ const searchInput = document.getElementById("search-input");
const bannedIpsSection = document.getElementById("banned-ips-section"); const bannedIpsSection = document.getElementById("banned-ips-section");
const bannedIpsBody = document.getElementById("banned-ips-body"); const bannedIpsBody = document.getElementById("banned-ips-body");
const bannedIpsEmpty = document.getElementById("banned-ips-empty"); const bannedIpsEmpty = document.getElementById("banned-ips-empty");
const clientsTable = document.getElementById("enrollment-table")?.closest(".bg-slate-900\\/50"); const clientsTable = document.getElementById("clients-table-panel");
const addBanBtn = document.getElementById("add-ban-btn"); const addBanBtn = document.getElementById("add-ban-btn");
const manualBanForm = document.getElementById("manual-ban-form"); const manualBanForm = document.getElementById("manual-ban-form");
const banIpInput = document.getElementById("ban-ip-input"); const banIpInput = document.getElementById("ban-ip-input");
@@ -80,6 +80,7 @@ async function loadStats() {
statPending.textContent = s.pending ?? 0; statPending.textContent = s.pending ?? 0;
statApproved.textContent = s.approved ?? 0; statApproved.textContent = s.approved ?? 0;
statDenied.textContent = s.denied ?? 0; statDenied.textContent = s.denied ?? 0;
if (statSuspicious) statSuspicious.textContent = s.suspicious ?? 0;
// Load banned IPs count // Load banned IPs count
try { try {
@@ -105,7 +106,7 @@ async function loadClients() {
loadingRow.classList.toggle("hidden", !!hasRows); loadingRow.classList.toggle("hidden", !!hasRows);
emptyEl.classList.add("hidden"); emptyEl.classList.add("hidden");
const fetchFilter = currentFilter === "suspicious" ? "all" : currentFilter; const fetchFilter = currentFilter;
try { try {
const data = await api(`/api/clients?page=1&pageSize=1000&enrollmentFilter=${fetchFilter}`); const data = await api(`/api/clients?page=1&pageSize=1000&enrollmentFilter=${fetchFilter}`);
clients = data.items || []; clients = data.items || [];
@@ -116,10 +117,6 @@ async function loadClients() {
// Sort by newest first (highest lastSeen = most recent) // Sort by newest first (highest lastSeen = most recent)
clients.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0)); clients.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0));
// Update suspicious stat from full loaded set
const suspiciousCount = clients.filter((c) => (c.suspiciousFlags || []).length > 0).length;
if (statSuspicious) statSuspicious.textContent = suspiciousCount;
// Apply suspicious tab filter // Apply suspicious tab filter
let base = clients; let base = clients;
if (currentFilter === "suspicious") { if (currentFilter === "suspicious") {
@@ -177,16 +174,27 @@ async function loadClients() {
for (const c of filtered) { for (const c of filtered) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.className = "hover:bg-slate-800/40 transition-colors"; tr.className = "group hover:bg-slate-800/45 transition-colors";
tr.dataset.id = c.id; tr.dataset.id = c.id;
const statusPill = statusBadgeWithReason(c.enrollmentStatus || "pending", c.denyReason); const statusPill = statusBadgeWithReason(c.enrollmentStatus || "pending", c.denyReason);
const fp = c.keyFingerprint ? c.keyFingerprint.substring(0, 16) + "..." : "-"; const fp = c.keyFingerprint ? c.keyFingerprint.substring(0, 16) + "..." : "-";
const lastSeen = c.lastSeen ? timeAgo(c.lastSeen) : "-"; const lastSeen = c.lastSeen ? timeAgo(c.lastSeen) : "-";
const riskBadges = suspiciousBadges(c.suspiciousFlags);
tr.innerHTML = ` 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"><input type="checkbox" class="row-check h-4 w-4 rounded border-slate-600 bg-slate-950/60" 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 font-medium text-slate-200">
<div class="flex items-center gap-3 min-w-[190px]">
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-slate-700 bg-slate-950/70 text-slate-400 group-hover:border-amber-400/40 group-hover:text-amber-300 transition-colors">
<i class="fa-solid fa-desktop text-xs"></i>
</span>
<span class="min-w-0">
<span class="block truncate">${esc(c.host || c.id)}</span>
${riskBadges ? `<span class="mt-1 flex flex-wrap gap-1">${riskBadges}</span>` : ""}
</span>
</div>
</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.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.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">${expandableCell(c.id, "cpu", c.cpu)}</td>
@@ -216,11 +224,11 @@ function tableDigest(items, fields) {
function statusBadge(status) { function statusBadge(status) {
const map = { const map = {
pending: 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>', '<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium bg-amber-500/15 text-amber-300 border border-amber-500/35"><i class="fa-solid fa-clock"></i>Pending</span>',
approved: 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>', '<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium bg-emerald-500/15 text-emerald-300 border border-emerald-500/35"><i class="fa-solid fa-check"></i>Approved</span>',
denied: 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>', '<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium bg-red-500/15 text-red-300 border border-red-500/35"><i class="fa-solid fa-ban"></i>Denied</span>',
}; };
return map[status] || map.pending; return map[status] || map.pending;
} }
@@ -239,7 +247,7 @@ function suspiciousBadges(flags) {
const label = SUSPICIOUS_FLAG_LABELS[f] || f; const label = SUSPICIOUS_FLAG_LABELS[f] || f;
const isFlood = f.endsWith("_flood"); const isFlood = f.endsWith("_flood");
const color = isFlood ? "bg-red-500/20 text-red-300 border-red-500/40" : "bg-amber-500/20 text-amber-300 border-amber-500/40"; const color = isFlood ? "bg-red-500/20 text-red-300 border-red-500/40" : "bg-amber-500/20 text-amber-300 border-amber-500/40";
return `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs ${color} border cursor-help" title="${esc(label)}"><i class="fa-solid fa-triangle-exclamation text-[9px]"></i>${esc(label.split(" ")[0])}</span>`; return `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-xs ${color} border cursor-help" title="${esc(label)}"><i class="fa-solid fa-triangle-exclamation text-[9px]"></i>${esc(label.split(" ")[0])}</span>`;
}).join(" "); }).join(" ");
} }
@@ -247,16 +255,16 @@ function actionButtons(c) {
const status = c.enrollmentStatus || "pending"; const status = c.enrollmentStatus || "pending";
let html = ""; let html = "";
if (status !== "approved") { if (status !== "approved") {
html += `<button class="act-approve whitespace-nowrap 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>`; html += `<button class="act-approve whitespace-nowrap px-2.5 py-1.5 rounded-md text-xs font-medium bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-950/30" data-id="${esc(c.id)}"><i class="fa-solid fa-check mr-1"></i>Approve</button>`;
} }
if (status !== "denied") { if (status !== "denied") {
html += `<button class="act-deny whitespace-nowrap 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>`; html += `<button class="act-deny whitespace-nowrap px-2.5 py-1.5 rounded-md text-xs font-medium bg-red-600 hover:bg-red-700 text-white shadow-sm shadow-red-950/30" data-id="${esc(c.id)}"><i class="fa-solid fa-ban mr-1"></i>Deny</button>`;
} }
if (status !== "pending") { if (status !== "pending") {
html += `<button class="act-reset whitespace-nowrap 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>`; html += `<button class="act-reset whitespace-nowrap px-2.5 py-1.5 rounded-md text-xs font-medium bg-slate-700 hover:bg-slate-600 text-white" data-id="${esc(c.id)}"><i class="fa-solid fa-rotate-left mr-1"></i>Reset</button>`;
} }
if (c.ip) { if (c.ip) {
html += `<button class="act-ban-ip whitespace-nowrap 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>`; html += `<button class="act-ban-ip whitespace-nowrap px-2.5 py-1.5 rounded-md 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; return html;
} }
@@ -363,7 +371,7 @@ document.querySelectorAll(".enrollment-tab").forEach((tab) => {
currentFilter = tab.dataset.filter; currentFilter = tab.dataset.filter;
document.querySelectorAll(".enrollment-tab").forEach((t) => { document.querySelectorAll(".enrollment-tab").forEach((t) => {
t.className = 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"; "enrollment-tab px-4 py-2 rounded-md text-sm font-medium bg-slate-800 text-slate-400 border border-slate-700 hover:bg-slate-700";
}); });
const colorMap = { const colorMap = {
pending: "bg-amber-500/20 text-amber-300 border-amber-500/40", pending: "bg-amber-500/20 text-amber-300 border-amber-500/40",
@@ -372,7 +380,7 @@ document.querySelectorAll(".enrollment-tab").forEach((tab) => {
"banned-ips": "bg-rose-500/20 text-rose-300 border-rose-500/40", "banned-ips": "bg-rose-500/20 text-rose-300 border-rose-500/40",
suspicious: "bg-orange-500/20 text-orange-300 border-orange-500/40", suspicious: "bg-orange-500/20 text-orange-300 border-orange-500/40",
}; };
tab.className = `enrollment-tab px-4 py-2 rounded-lg text-sm font-medium ${colorMap[currentFilter] || ""} border`; tab.className = `enrollment-tab px-4 py-2 rounded-md text-sm font-medium ${colorMap[currentFilter] || ""} border`;
if (currentFilter === "banned-ips") { if (currentFilter === "banned-ips") {
if (clientsTable) clientsTable.classList.add("hidden"); if (clientsTable) clientsTable.classList.add("hidden");
+62 -27
View File
@@ -20,26 +20,60 @@
<header id="top-nav"></header> <header id="top-nav"></header>
<div class="min-h-full flex flex-col"> <div class="min-h-full flex flex-col">
<main class="flex-1 max-w-7xl w-full mx-auto px-4 py-8"> <main class="flex-1 max-w-[1500px] w-full mx-auto px-4 sm:px-6 py-6">
<!-- Header --> <!-- Header -->
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div class="mb-5 rounded-lg border border-slate-800 bg-slate-900/60 px-5 py-4 shadow-[0_18px_55px_rgba(2,6,23,0.22)]">
<div> <div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-5">
<h2 class="text-2xl font-bold text-slate-100 mb-1"> <div class="flex items-center gap-3">
<i class="fa-solid fa-user-clock text-amber-400 mr-2"></i>Purgatory <div class="grid h-11 w-11 place-items-center rounded-lg border border-amber-400/30 bg-amber-400/10 text-amber-300">
</h2> <i class="fa-solid fa-user-clock"></i>
<p class="text-slate-400 text-sm">
Approve or deny clients before they can connect to the server.
</p>
</div>
<div class="flex items-center gap-3 flex-wrap justify-end">
<div id="stats-bar" class="flex items-center gap-3 text-sm">
<span class="text-amber-400"><i class="fa-solid fa-clock mr-1"></i><span id="stat-pending">0</span> pending</span>
<span class="text-emerald-400"><i class="fa-solid fa-check mr-1"></i><span id="stat-approved">0</span> approved</span>
<span class="text-red-400"><i class="fa-solid fa-ban mr-1"></i><span id="stat-denied">0</span> denied</span>
<span class="text-orange-400"><i class="fa-solid fa-triangle-exclamation mr-1"></i><span id="stat-suspicious">0</span> suspicious</span>
<span class="text-rose-400"><i class="fa-solid fa-shield-halved mr-1"></i><span id="stat-banned-ips">0</span> banned IPs</span>
</div> </div>
<div class="flex flex-col gap-1 border border-amber-500/30 rounded-lg px-3 py-2 bg-amber-500/5 text-sm"> <div>
<h2 class="text-2xl font-bold text-slate-100 leading-tight">Purgatory</h2>
<p class="text-slate-400 text-sm">
Approve or deny clients before they can connect to the server.
</p>
</div>
</div>
<div class="flex items-stretch gap-3 flex-wrap justify-start xl:justify-end">
<div id="stats-bar" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2 text-sm">
<span class="min-w-[120px] rounded-lg border border-amber-400/20 bg-amber-400/5 px-3 py-2 text-amber-300">
<span class="flex items-center gap-2">
<i class="fa-solid fa-clock text-amber-400"></i>
<span class="text-lg font-semibold leading-none" id="stat-pending">0</span>
</span>
<span class="mt-1 block text-xs text-slate-400">pending</span>
</span>
<span class="min-w-[120px] rounded-lg border border-emerald-400/20 bg-emerald-400/5 px-3 py-2 text-emerald-300">
<span class="flex items-center gap-2">
<i class="fa-solid fa-check text-emerald-400"></i>
<span class="text-lg font-semibold leading-none" id="stat-approved">0</span>
</span>
<span class="mt-1 block text-xs text-slate-400">approved</span>
</span>
<span class="min-w-[120px] rounded-lg border border-red-400/20 bg-red-400/5 px-3 py-2 text-red-300">
<span class="flex items-center gap-2">
<i class="fa-solid fa-ban text-red-400"></i>
<span class="text-lg font-semibold leading-none" id="stat-denied">0</span>
</span>
<span class="mt-1 block text-xs text-slate-400">denied</span>
</span>
<span class="min-w-[120px] rounded-lg border border-orange-400/20 bg-orange-400/5 px-3 py-2 text-orange-300">
<span class="flex items-center gap-2">
<i class="fa-solid fa-triangle-exclamation text-orange-400"></i>
<span class="text-lg font-semibold leading-none" id="stat-suspicious">0</span>
</span>
<span class="mt-1 block text-xs text-slate-400">suspicious</span>
</span>
<span class="min-w-[120px] rounded-lg border border-rose-400/20 bg-rose-400/5 px-3 py-2 text-rose-300">
<span class="flex items-center gap-2">
<i class="fa-solid fa-shield-halved text-rose-400"></i>
<span class="text-lg font-semibold leading-none" id="stat-banned-ips">0</span>
</span>
<span class="mt-1 block text-xs text-slate-400">banned IPs</span>
</span>
</div>
<div class="flex flex-col justify-center gap-1 border border-amber-500/30 rounded-lg px-3 py-2 bg-slate-950/50 text-sm shadow-inner shadow-amber-950/10">
<div class="flex items-center gap-2" title="When enabled, all connecting agents are approved automatically without review"> <div class="flex items-center gap-2" title="When enabled, all connecting agents are approved automatically without review">
<i class="fa-solid fa-door-open text-amber-400"></i> <i class="fa-solid fa-door-open text-amber-400"></i>
<span class="text-slate-300 font-medium">Always Allow</span> <span class="text-slate-300 font-medium">Always Allow</span>
@@ -60,28 +94,29 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
<!-- Filter Tabs --> <!-- Filter Tabs -->
<div class="mb-4 flex items-center gap-2 flex-wrap"> <div class="mb-4 flex items-center gap-2 flex-wrap rounded-lg border border-slate-800 bg-slate-900/45 p-2">
<button data-filter="pending" <button data-filter="pending"
class="enrollment-tab px-4 py-2 rounded-lg text-sm font-medium bg-amber-500/20 text-amber-300 border border-amber-500/40"> class="enrollment-tab px-4 py-2 rounded-md text-sm font-medium bg-amber-500/20 text-amber-300 border border-amber-500/40">
Pending Pending
</button> </button>
<button data-filter="denied" <button data-filter="denied"
class="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"> class="enrollment-tab px-4 py-2 rounded-md text-sm font-medium bg-slate-800 text-slate-400 border border-slate-700 hover:bg-slate-700">
Denied Denied
</button> </button>
<button data-filter="approved" <button data-filter="approved"
class="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"> class="enrollment-tab px-4 py-2 rounded-md text-sm font-medium bg-slate-800 text-slate-400 border border-slate-700 hover:bg-slate-700">
Approved Approved
</button> </button>
<button data-filter="banned-ips" <button data-filter="banned-ips"
class="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"> class="enrollment-tab px-4 py-2 rounded-md text-sm font-medium bg-slate-800 text-slate-400 border border-slate-700 hover:bg-slate-700">
<i class="fa-solid fa-shield-halved mr-1"></i>Banned IPs <i class="fa-solid fa-shield-halved mr-1"></i>Banned IPs
</button> </button>
<button data-filter="suspicious" <button data-filter="suspicious"
class="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"> class="enrollment-tab px-4 py-2 rounded-md text-sm font-medium bg-slate-800 text-slate-400 border border-slate-700 hover:bg-slate-700">
<i class="fa-solid fa-triangle-exclamation mr-1"></i>Suspicious <i class="fa-solid fa-triangle-exclamation mr-1"></i>Suspicious
</button> </button>
<div class="flex-1"></div> <div class="flex-1"></div>
@@ -96,7 +131,7 @@
<div class="relative"> <div class="relative">
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none"></i> <i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none"></i>
<input type="text" id="search-input" placeholder="Search host, user, IP..." <input type="text" id="search-input" placeholder="Search host, user, IP..."
class="w-56 pl-9 pr-3 py-2 rounded-lg text-sm bg-slate-800 border border-slate-700 text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent" /> class="w-56 pl-9 pr-3 py-2 rounded-md text-sm bg-slate-950/70 border border-slate-700 text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent" />
</div> </div>
<button id="bulk-approve-btn" <button id="bulk-approve-btn"
class="hidden px-3 py-2 rounded-lg text-sm font-medium bg-emerald-600 hover:bg-emerald-700 text-white transition-colors"> class="hidden px-3 py-2 rounded-lg text-sm font-medium bg-emerald-600 hover:bg-emerald-700 text-white transition-colors">
@@ -113,9 +148,9 @@
</div> </div>
<!-- Clients Table --> <!-- Clients Table -->
<div class="bg-slate-900/50 border border-slate-800 rounded-xl overflow-x-auto"> <div id="clients-table-panel" class="bg-slate-900/55 border border-slate-800 rounded-lg overflow-x-auto shadow-[0_18px_55px_rgba(2,6,23,0.18)]">
<table class="w-full min-w-[1200px]" id="enrollment-table"> <table class="w-full min-w-[1200px]" id="enrollment-table">
<thead class="bg-slate-800/50 border-b border-slate-700"> <thead class="bg-slate-950/55 border-b border-slate-700">
<tr> <tr>
<th class="w-10 px-4 py-3"> <th class="w-10 px-4 py-3">
<input type="checkbox" id="select-all" class="h-4 w-4 rounded border-slate-600" /> <input type="checkbox" id="select-all" class="h-4 w-4 rounded border-slate-600" />
+63 -1
View File
@@ -1,6 +1,6 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { db } from "./db/connection"; import { db } from "./db/connection";
import { deleteClientRow, getClientMetricsSummary, listClients, setClientBookmark, setClientTag, setClientWebcamInfo, upsertClientRow } from "./db"; import { deleteClientRow, getClientMetricsSummary, getEnrollmentStats, listClients, setClientBookmark, setClientTag, setClientWebcamInfo, upsertClientRow } from "./db";
const createdClientIds: string[] = []; const createdClientIds: string[] = [];
@@ -157,6 +157,68 @@ describe("client list ordering", () => {
}); });
describe("client metrics summary", () => { describe("client metrics summary", () => {
test("suspicious enrollment filter and stats scan the full matching set", () => {
try {
const prefix = `suspicious-filter-${Date.now().toString(36)}`;
const suspiciousId = `${prefix}-missing-identity`;
const normalId = `${prefix}-normal`;
const uniqueOs = `${prefix}-os`;
const beforeStats = getEnrollmentStats();
upsertClientRow({
id: suspiciousId,
hwid: suspiciousId,
role: "client",
host: "",
os: uniqueOs,
arch: "amd64",
version: "1.0.0",
user: "",
country: "US",
lastSeen: Date.now(),
online: 0,
enrollmentStatus: "pending",
});
createdClientIds.push(suspiciousId);
upsertClientRow({
id: normalId,
hwid: normalId,
role: "client",
host: "normal-host",
os: uniqueOs,
arch: "amd64",
version: "1.0.0",
user: "tester",
country: "US",
lastSeen: Date.now() - 1,
online: 0,
enrollmentStatus: "pending",
});
createdClientIds.push(normalId);
const result = listClients({
page: 1,
pageSize: 12,
search: "",
sort: "last_seen_desc",
statusFilter: "all",
osFilter: uniqueOs,
countryFilter: "all",
enrollmentFilter: "suspicious",
});
const afterStats = getEnrollmentStats();
expect(result.total).toBe(1);
expect(result.items.map((item) => item.id)).toEqual([suspiciousId]);
expect(result.items[0]?.suspiciousFlags).toContain("no_hostname");
expect(result.items[0]?.suspiciousFlags).toContain("no_user");
expect(afterStats.suspicious).toBe(beforeStats.suspicious + 1);
} finally {
cleanupCreatedClients();
}
});
test("operating system breakdown excludes purgatory clients", () => { test("operating system breakdown excludes purgatory clients", () => {
try { try {
const prefix = `metrics-purgatory-${Date.now().toString(36)}`; const prefix = `metrics-purgatory-${Date.now().toString(36)}`;
+28 -4
View File
@@ -969,6 +969,7 @@ export function listClients(filters: ListFilters): ListResult {
} = filters; } = filters;
const where: string[] = []; const where: string[] = [];
const params: any[] = []; const params: any[] = [];
const suspiciousEnrollmentFilter = enrollmentFilter === "suspicious";
if (statusFilter === "online") { if (statusFilter === "online") {
where.push("c.online=1"); where.push("c.online=1");
@@ -976,7 +977,7 @@ export function listClients(filters: ListFilters): ListResult {
where.push("c.online=0"); where.push("c.online=0");
} }
if (enrollmentFilter && enrollmentFilter !== "all") { if (enrollmentFilter && enrollmentFilter !== "all" && !suspiciousEnrollmentFilter) {
if (enrollmentFilter === "pending") { if (enrollmentFilter === "pending") {
where.push("(c.enrollment_status='pending' OR c.enrollment_status IS NULL)"); where.push("(c.enrollment_status='pending' OR c.enrollment_status IS NULL)");
} else { } else {
@@ -1108,7 +1109,22 @@ export function listClients(filters: ListFilters): ListResult {
let totalCount = 0; let totalCount = 0;
let onlineCount = 0; let onlineCount = 0;
if (search) { if (suspiciousEnrollmentFilter && !search) {
const candidates = db
.query<any>(
`SELECT ${clientFields}, g.name as groupName, g.color as groupColor
FROM clients c
LEFT JOIN client_groups g ON g.id = c.group_id
${whereSql}
${orderBy}`,
)
.all(...params);
const suspiciousSets = getFloodSetsWithCache();
const matches = candidates.filter((row: any) => computeClientSuspiciousFlags(row, suspiciousSets).length > 0);
totalCount = matches.length;
onlineCount = matches.filter((row: any) => row.online === 1).length;
rows = matches.slice(offset, offset + pageSize);
} else if (search) {
const searchWhere = [...where]; const searchWhere = [...where];
const searchParams = [...params]; const searchParams = [...params];
const candidateLimit = Math.min(5000, Math.max(1000, offset + pageSize * 8)); const candidateLimit = Math.min(5000, Math.max(1000, offset + pageSize * 8));
@@ -2183,13 +2199,14 @@ export function getEnrollmentStats(opts?: {
pending: number; pending: number;
approved: number; approved: number;
denied: number; denied: number;
suspicious: number;
} { } {
let sql = `SELECT COALESCE(enrollment_status,'pending') as status, COUNT(*) as c FROM clients`; let sql = `SELECT COALESCE(enrollment_status,'pending') as status, COUNT(*) as c FROM clients`;
const params: any[] = []; const params: any[] = [];
const where: string[] = []; const where: string[] = [];
if (opts?.allowedClientIds) { if (opts?.allowedClientIds) {
if (opts.allowedClientIds.length === 0) return { pending: 0, approved: 0, denied: 0 }; if (opts.allowedClientIds.length === 0) return { pending: 0, approved: 0, denied: 0, suspicious: 0 };
const placeholders = opts.allowedClientIds.map(() => "?").join(","); const placeholders = opts.allowedClientIds.map(() => "?").join(",");
where.push(`id IN (${placeholders})`); where.push(`id IN (${placeholders})`);
params.push(...opts.allowedClientIds); params.push(...opts.allowedClientIds);
@@ -2214,12 +2231,19 @@ export function getEnrollmentStats(opts?: {
sql += ` GROUP BY enrollment_status`; sql += ` GROUP BY enrollment_status`;
const rows = db.query<{ status: string; c: number }>(sql).all(...params); const rows = db.query<{ status: string; c: number }>(sql).all(...params);
const stats = { pending: 0, approved: 0, denied: 0 }; const stats = { pending: 0, approved: 0, denied: 0, suspicious: 0 };
for (const r of rows) { for (const r of rows) {
if (r.status === "approved") stats.approved = Number(r.c); if (r.status === "approved") stats.approved = Number(r.c);
else if (r.status === "denied") stats.denied = Number(r.c); else if (r.status === "denied") stats.denied = Number(r.c);
else stats.pending = Number(r.c); else stats.pending = Number(r.c);
} }
const suspiciousSql =
`SELECT id, hwid, cpu, gpu, ram, os, host, user, ip, monitors FROM clients${where.length > 0 ? ` WHERE ${where.join(" AND ")}` : ""}`;
const suspiciousSets = getFloodSetsWithCache();
const suspiciousRows = db.query<any>(suspiciousSql).all(...params);
stats.suspicious = suspiciousRows.filter((row: any) => computeClientSuspiciousFlags(row, suspiciousSets).length > 0).length;
return stats; return stats;
} }
@@ -101,7 +101,9 @@ export async function handleClientRoutes(
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
const page = Math.max(1, Number(url.searchParams.get("page") || 1)); const page = Math.max(1, Number(url.searchParams.get("page") || 1));
const pageSize = Math.min(200, Math.max(1, Number(url.searchParams.get("pageSize") || 12))); const isEnrollmentRequest = url.searchParams.has("enrollmentFilter");
const maxPageSize = isEnrollmentRequest ? 1000 : 200;
const pageSize = Math.min(maxPageSize, Math.max(1, Number(url.searchParams.get("pageSize") || 12)));
const search = (url.searchParams.get("q") || "").toLowerCase().trim(); const search = (url.searchParams.get("q") || "").toLowerCase().trim();
const sort = url.searchParams.get("sort") || "last_seen_desc"; const sort = url.searchParams.get("sort") || "last_seen_desc";
const statusFilter = url.searchParams.get("status") || "all"; const statusFilter = url.searchParams.get("status") || "all";
@@ -110,7 +112,6 @@ export async function handleClientRoutes(
const enrollmentFilter = url.searchParams.get("enrollmentFilter") || "approved"; const enrollmentFilter = url.searchParams.get("enrollmentFilter") || "approved";
const groupFilter = url.searchParams.get("group") || "all"; const groupFilter = url.searchParams.get("group") || "all";
const webcamFilter = url.searchParams.get("webcam") || "all"; const webcamFilter = url.searchParams.get("webcam") || "all";
const isEnrollmentRequest = url.searchParams.has("enrollmentFilter");
if (user.role === "admin") { if (user.role === "admin") {
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter, webcamFilter }); const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter, webcamFilter });