diff --git a/Overlord-Server/public/assets/purgatory-ui.js b/Overlord-Server/public/assets/purgatory-ui.js
index 85efae0..fe13e43 100644
--- a/Overlord-Server/public/assets/purgatory-ui.js
+++ b/Overlord-Server/public/assets/purgatory-ui.js
@@ -12,7 +12,7 @@ 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 clientsTable = document.getElementById("clients-table-panel");
const addBanBtn = document.getElementById("add-ban-btn");
const manualBanForm = document.getElementById("manual-ban-form");
const banIpInput = document.getElementById("ban-ip-input");
@@ -80,6 +80,7 @@ async function loadStats() {
statPending.textContent = s.pending ?? 0;
statApproved.textContent = s.approved ?? 0;
statDenied.textContent = s.denied ?? 0;
+ if (statSuspicious) statSuspicious.textContent = s.suspicious ?? 0;
// Load banned IPs count
try {
@@ -105,7 +106,7 @@ async function loadClients() {
loadingRow.classList.toggle("hidden", !!hasRows);
emptyEl.classList.add("hidden");
- const fetchFilter = currentFilter === "suspicious" ? "all" : currentFilter;
+ const fetchFilter = currentFilter;
try {
const data = await api(`/api/clients?page=1&pageSize=1000&enrollmentFilter=${fetchFilter}`);
clients = data.items || [];
@@ -116,10 +117,6 @@ async function loadClients() {
// Sort by newest first (highest lastSeen = most recent)
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
let base = clients;
if (currentFilter === "suspicious") {
@@ -177,16 +174,27 @@ async function loadClients() {
for (const c of filtered) {
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;
const statusPill = statusBadgeWithReason(c.enrollmentStatus || "pending", c.denyReason);
const fp = c.keyFingerprint ? c.keyFingerprint.substring(0, 16) + "..." : "-";
const lastSeen = c.lastSeen ? timeAgo(c.lastSeen) : "-";
+ const riskBadges = suspiciousBadges(c.suspiciousFlags);
tr.innerHTML = `
-
|
- ${esc(c.host || c.id)}${suspiciousBadges(c.suspiciousFlags)} |
+ |
+
+
+
+
+
+
+ ${esc(c.host || c.id)}
+ ${riskBadges ? `${riskBadges}` : ""}
+
+
+ |
${esc(c.user || "-")} |
${esc(c.os || "-")} |
${expandableCell(c.id, "cpu", c.cpu)} |
@@ -216,11 +224,11 @@ function tableDigest(items, fields) {
function statusBadge(status) {
const map = {
pending:
- 'Pending',
+ 'Pending',
approved:
- 'Approved',
+ 'Approved',
denied:
- 'Denied',
+ 'Denied',
};
return map[status] || map.pending;
}
@@ -239,7 +247,7 @@ function suspiciousBadges(flags) {
const label = SUSPICIOUS_FLAG_LABELS[f] || f;
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";
- return `${esc(label.split(" ")[0])}`;
+ return `${esc(label.split(" ")[0])}`;
}).join(" ");
}
@@ -247,16 +255,16 @@ function actionButtons(c) {
const status = c.enrollmentStatus || "pending";
let html = "";
if (status !== "approved") {
- html += ``;
+ html += ``;
}
if (status !== "denied") {
- html += ``;
+ html += ``;
}
if (status !== "pending") {
- html += ``;
+ html += ``;
}
if (c.ip) {
- html += ``;
+ html += ``;
}
return html;
}
@@ -363,7 +371,7 @@ document.querySelectorAll(".enrollment-tab").forEach((tab) => {
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";
+ "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 = {
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",
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 (clientsTable) clientsTable.classList.add("hidden");
diff --git a/Overlord-Server/public/purgatory.html b/Overlord-Server/public/purgatory.html
index eec4570..39280e8 100644
--- a/Overlord-Server/public/purgatory.html
+++ b/Overlord-Server/public/purgatory.html
@@ -20,26 +20,60 @@
-
+
-
-
-
- Purgatory
-
-
- Approve or deny clients before they can connect to the server.
-
-
-
-
-
0 pending
-
0 approved
-
0 denied
-
0 suspicious
-
0 banned IPs
+
+
+
+
+
-
+
+
Purgatory
+
+ Approve or deny clients before they can connect to the server.
+
+
+
+
+
+
+
+
+ 0
+
+ pending
+
+
+
+
+ 0
+
+ approved
+
+
+
+
+ 0
+
+ denied
+
+
+
+
+ 0
+
+ suspicious
+
+
+
+
+ 0
+
+ banned IPs
+
+
+
Always Allow
@@ -60,28 +94,29 @@
+
-
+
@@ -96,7 +131,7 @@
+ 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" />
-
+
-
+
|
diff --git a/Overlord-Server/src/client-order.test.ts b/Overlord-Server/src/client-order.test.ts
index 2223e28..82cdf90 100644
--- a/Overlord-Server/src/client-order.test.ts
+++ b/Overlord-Server/src/client-order.test.ts
@@ -1,6 +1,6 @@
import { afterAll, describe, expect, test } from "bun:test";
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[] = [];
@@ -157,6 +157,68 @@ describe("client list ordering", () => {
});
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", () => {
try {
const prefix = `metrics-purgatory-${Date.now().toString(36)}`;
diff --git a/Overlord-Server/src/db/repositories.ts b/Overlord-Server/src/db/repositories.ts
index 2300d93..9f9ff3b 100644
--- a/Overlord-Server/src/db/repositories.ts
+++ b/Overlord-Server/src/db/repositories.ts
@@ -969,6 +969,7 @@ export function listClients(filters: ListFilters): ListResult {
} = filters;
const where: string[] = [];
const params: any[] = [];
+ const suspiciousEnrollmentFilter = enrollmentFilter === "suspicious";
if (statusFilter === "online") {
where.push("c.online=1");
@@ -976,7 +977,7 @@ export function listClients(filters: ListFilters): ListResult {
where.push("c.online=0");
}
- if (enrollmentFilter && enrollmentFilter !== "all") {
+ if (enrollmentFilter && enrollmentFilter !== "all" && !suspiciousEnrollmentFilter) {
if (enrollmentFilter === "pending") {
where.push("(c.enrollment_status='pending' OR c.enrollment_status IS NULL)");
} else {
@@ -1108,7 +1109,22 @@ export function listClients(filters: ListFilters): ListResult {
let totalCount = 0;
let onlineCount = 0;
- if (search) {
+ if (suspiciousEnrollmentFilter && !search) {
+ const candidates = db
+ .query(
+ `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 searchParams = [...params];
const candidateLimit = Math.min(5000, Math.max(1000, offset + pageSize * 8));
@@ -2183,13 +2199,14 @@ export function getEnrollmentStats(opts?: {
pending: number;
approved: number;
denied: number;
+ suspicious: number;
} {
let sql = `SELECT COALESCE(enrollment_status,'pending') as status, COUNT(*) as c FROM clients`;
const params: any[] = [];
const where: string[] = [];
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(",");
where.push(`id IN (${placeholders})`);
params.push(...opts.allowedClientIds);
@@ -2214,12 +2231,19 @@ export function getEnrollmentStats(opts?: {
sql += ` GROUP BY enrollment_status`;
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) {
if (r.status === "approved") stats.approved = Number(r.c);
else if (r.status === "denied") stats.denied = 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(suspiciousSql).all(...params);
+ stats.suspicious = suspiciousRows.filter((row: any) => computeClientSuspiciousFlags(row, suspiciousSets).length > 0).length;
+
return stats;
}
diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts
index 717e5a0..dc7991d 100644
--- a/Overlord-Server/src/server/routes/client-routes.ts
+++ b/Overlord-Server/src/server/routes/client-routes.ts
@@ -101,7 +101,9 @@ export async function handleClientRoutes(
return new Response("Unauthorized", { status: 401 });
}
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 sort = url.searchParams.get("sort") || "last_seen_desc";
const statusFilter = url.searchParams.get("status") || "all";
@@ -110,7 +112,6 @@ export async function handleClientRoutes(
const enrollmentFilter = url.searchParams.get("enrollmentFilter") || "approved";
const groupFilter = url.searchParams.get("group") || "all";
const webcamFilter = url.searchParams.get("webcam") || "all";
- const isEnrollmentRequest = url.searchParams.has("enrollmentFilter");
if (user.role === "admin") {
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter, webcamFilter });
|