add grouping and make it obvious

This commit is contained in:
KingKDot
2026-04-14 19:38:27 -05:00
parent e94fe65dbf
commit 1807a94e9f
10 changed files with 460 additions and 34 deletions
+131 -27
View File
@@ -124,6 +124,20 @@ try {
db.run(`ALTER TABLE clients ADD COLUMN permissions TEXT`);
} catch {}
db.run(`
CREATE TABLE IF NOT EXISTS client_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
color TEXT NOT NULL DEFAULT '#3b82f6',
created_at INTEGER NOT NULL
);
`);
try {
db.run(`ALTER TABLE clients ADD COLUMN group_id INTEGER REFERENCES client_groups(id) ON DELETE SET NULL`);
} catch {}
db.run(`CREATE INDEX IF NOT EXISTS idx_clients_group_id ON clients(group_id);`);
db.run(`
CREATE TABLE IF NOT EXISTS banned_ips (
ip TEXT PRIMARY KEY,
@@ -589,6 +603,75 @@ export function setClientTag(
return ((result as any)?.changes || 0) > 0;
}
export interface ClientGroup {
id: number;
name: string;
color: string;
createdAt: number;
clientCount?: number;
}
export function listGroups(): ClientGroup[] {
const rows = db.query<any>(
`SELECT g.id, g.name, g.color, g.created_at as createdAt,
COUNT(c.id) as clientCount
FROM client_groups g
LEFT JOIN clients c ON c.group_id = g.id
GROUP BY g.id
ORDER BY g.name ASC`,
).all();
return rows.map((r: any) => ({
id: r.id,
name: r.name,
color: r.color,
createdAt: r.createdAt,
clientCount: r.clientCount ?? 0,
}));
}
export function getGroup(id: number): ClientGroup | null {
const row = db.query<any>(
`SELECT id, name, color, created_at as createdAt FROM client_groups WHERE id=?`,
).get(id);
return row ? { id: row.id, name: row.name, color: row.color, createdAt: row.createdAt } : null;
}
export function createGroup(name: string, color: string): ClientGroup {
const now = Date.now();
const result = db.run(
`INSERT INTO client_groups (name, color, created_at) VALUES (?, ?, ?)`,
name.trim(),
color,
now,
);
return { id: Number(result.lastInsertRowid), name: name.trim(), color, createdAt: now };
}
export function updateGroup(id: number, name: string, color: string): boolean {
const result = db.run(
`UPDATE client_groups SET name=?, color=? WHERE id=?`,
name.trim(),
color,
id,
);
return ((result as any)?.changes || 0) > 0;
}
export function deleteGroup(id: number): boolean {
db.run(`UPDATE clients SET group_id=NULL WHERE group_id=?`, id);
const result = db.run(`DELETE FROM client_groups WHERE id=?`, id);
return ((result as any)?.changes || 0) > 0;
}
export function setClientGroup(clientId: string, groupId: number | null): boolean {
const result = db.run(
`UPDATE clients SET group_id=? WHERE id=?`,
groupId,
clientId,
);
return ((result as any)?.changes || 0) > 0;
}
export function getClientIp(id: string): string | null {
const row = db.query<{ ip: string }>(`SELECT ip FROM clients WHERE id=?`).get(id);
return row?.ip || null;
@@ -798,107 +881,125 @@ export function listClients(filters: ListFilters): ListResult {
requireBuildOwner,
allowedClientIds,
deniedClientIds,
groupFilter,
} = filters;
const where: string[] = [];
const params: any[] = [];
if (search) {
where.push(
"(LOWER(COALESCE(host,'')) LIKE ? OR LOWER(COALESCE(user,'')) LIKE ? OR LOWER(COALESCE(nickname,'')) LIKE ? OR LOWER(COALESCE(custom_tag,'')) LIKE ? OR LOWER(COALESCE(custom_tag_note,'')) LIKE ? OR LOWER(id) LIKE ?)",
"(LOWER(COALESCE(c.host,'')) LIKE ? OR LOWER(COALESCE(c.user,'')) LIKE ? OR LOWER(COALESCE(c.nickname,'')) LIKE ? OR LOWER(COALESCE(c.custom_tag,'')) LIKE ? OR LOWER(COALESCE(c.custom_tag_note,'')) LIKE ? OR LOWER(c.id) LIKE ? OR LOWER(COALESCE(g.name,'')) LIKE ?)",
);
const needle = `%${search}%`;
params.push(needle, needle, needle, needle, needle, needle);
params.push(needle, needle, needle, needle, needle, needle, needle);
}
if (statusFilter === "online") {
where.push("online=1");
where.push("c.online=1");
} else if (statusFilter === "offline") {
where.push("online=0");
where.push("c.online=0");
}
if (enrollmentFilter && enrollmentFilter !== "all") {
if (enrollmentFilter === "pending") {
where.push("(enrollment_status='pending' OR enrollment_status IS NULL)");
where.push("(c.enrollment_status='pending' OR c.enrollment_status IS NULL)");
} else {
where.push("enrollment_status=?");
where.push("c.enrollment_status=?");
params.push(enrollmentFilter);
}
}
if (osFilter && osFilter !== "all") {
where.push("os=?");
where.push("c.os=?");
params.push(osFilter);
}
if (countryFilter && countryFilter !== "all") {
where.push("UPPER(COALESCE(country,'ZZ'))=?");
where.push("UPPER(COALESCE(c.country,'ZZ'))=?");
params.push(countryFilter.toUpperCase());
}
if (typeof builtByUserId === "number") {
where.push("built_by_user_id=?");
where.push("c.built_by_user_id=?");
params.push(builtByUserId);
}
if (requireBuildOwner) {
where.push("built_by_user_id IS NOT NULL");
where.push("c.built_by_user_id IS NOT NULL");
}
if (Array.isArray(allowedClientIds)) {
if (allowedClientIds.length === 0) {
where.push("1=0");
} else {
where.push(`id IN (${allowedClientIds.map(() => "?").join(",")})`);
where.push(`c.id IN (${allowedClientIds.map(() => "?").join(",")})`);
params.push(...allowedClientIds);
}
}
if (Array.isArray(deniedClientIds) && deniedClientIds.length > 0) {
where.push(`id NOT IN (${deniedClientIds.map(() => "?").join(",")})`);
where.push(`c.id NOT IN (${deniedClientIds.map(() => "?").join(",")})`);
params.push(...deniedClientIds);
}
if (groupFilter && groupFilter !== "all") {
if (groupFilter === "none") {
where.push("c.group_id IS NULL");
} else {
const gid = Number(groupFilter);
if (Number.isFinite(gid)) {
where.push("c.group_id=?");
params.push(gid);
}
}
}
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const orderBy = (() => {
const online = "online DESC";
const bookmark = "bookmarked DESC";
const online = "c.online DESC";
const bookmark = "c.bookmarked DESC";
switch (sort) {
case "stable":
return `ORDER BY ${online}, ${bookmark}, id ASC`;
return `ORDER BY ${online}, ${bookmark}, c.id ASC`;
case "ping_asc":
return `ORDER BY ${online}, ${bookmark}, ping_ms IS NULL, ping_ms ASC, id ASC`;
return `ORDER BY ${online}, ${bookmark}, c.ping_ms IS NULL, c.ping_ms ASC, c.id ASC`;
case "ping_desc":
return `ORDER BY ${online}, ${bookmark}, ping_ms IS NULL, ping_ms DESC, id ASC`;
return `ORDER BY ${online}, ${bookmark}, c.ping_ms IS NULL, c.ping_ms DESC, c.id ASC`;
case "host_asc":
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(nickname, host)) ASC, id ASC`;
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(c.nickname, c.host)) ASC, c.id ASC`;
case "host_desc":
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(nickname, host)) DESC, id ASC`;
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(c.nickname, c.host)) DESC, c.id ASC`;
case "country_asc":
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(country, 'zz')) ASC, id ASC`;
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(c.country, 'zz')) ASC, c.id ASC`;
case "country_desc":
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(country, 'zz')) DESC, id ASC`;
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(c.country, 'zz')) DESC, c.id ASC`;
case "admin_first":
return `ORDER BY ${online}, ${bookmark}, is_admin DESC, id ASC`;
return `ORDER BY ${online}, ${bookmark}, c.is_admin DESC, c.id ASC`;
case "group_asc":
return `ORDER BY ${online}, ${bookmark}, g.name IS NULL, LOWER(g.name) ASC, c.id ASC`;
case "group_desc":
return `ORDER BY ${online}, ${bookmark}, g.name IS NULL, LOWER(g.name) DESC, c.id ASC`;
default:
return `ORDER BY ${online}, ${bookmark}, last_seen DESC, id ASC`;
return `ORDER BY ${online}, ${bookmark}, c.last_seen DESC, c.id ASC`;
}
})();
const totalRow = db
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients ${whereSql}`)
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c LEFT JOIN client_groups g ON g.id = c.group_id ${whereSql}`)
.get(...params) ?? { c: 0 };
const onlineRow = db
.query<{ c: number }>(
`SELECT COUNT(*) as c FROM clients ${whereSql ? `${whereSql} AND online=1` : "WHERE online=1"}`,
`SELECT COUNT(*) as c FROM clients c LEFT JOIN client_groups g ON g.id = c.group_id ${whereSql ? `${whereSql} AND c.online=1` : "WHERE c.online=1"}`,
)
.get(...params) ?? { c: 0 };
const offset = (page - 1) * pageSize;
const rows = db
.query<any>(
`SELECT id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag as customTag, custom_tag_note as customTagNote, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs, bookmarked, build_tag as buildTag, built_by_user_id as builtByUserId, enrollment_status as enrollmentStatus, public_key as publicKey, key_fingerprint as keyFingerprint, cpu, gpu, ram, is_admin as isAdmin, elevation, permissions, disconnect_reason as disconnectReason, disconnect_detail as disconnectDetail
FROM clients
`SELECT c.id, c.hwid, c.role, c.ip, c.host, c.os, c.arch, c.version, c.user, c.nickname, c.custom_tag as customTag, c.custom_tag_note as customTagNote, c.monitors, c.country, c.last_seen as lastSeen, c.online, c.ping_ms as pingMs, c.bookmarked, c.build_tag as buildTag, c.built_by_user_id as builtByUserId, c.enrollment_status as enrollmentStatus, c.public_key as publicKey, c.key_fingerprint as keyFingerprint, c.cpu, c.gpu, c.ram, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, g.name as groupName, g.color as groupColor
FROM clients c
LEFT JOIN client_groups g ON g.id = c.group_id
${whereSql}
${orderBy}
LIMIT ? OFFSET ?`,
@@ -937,6 +1038,9 @@ export function listClients(filters: ListFilters): ListResult {
permissions: c.permissions ? (() => { try { return JSON.parse(c.permissions); } catch { return null; } })() : null,
disconnectReason: c.disconnectReason || null,
disconnectDetail: c.disconnectDetail || null,
groupId: typeof c.groupId === "number" ? c.groupId : null,
groupName: c.groupName || null,
groupColor: c.groupColor || null,
thumbnail: getThumbnail(c.id),
}));
@@ -18,6 +18,12 @@ import {
setClientTag,
setOnlineState,
unbanIp,
listGroups,
getGroup,
createGroup,
updateGroup,
deleteGroup,
setClientGroup,
} from "../../db";
import { metrics } from "../../metrics";
import { encodeMessage } from "../../protocol";
@@ -59,6 +65,7 @@ export async function handleClientRoutes(
): Promise<Response | null> {
if (
!url.pathname.startsWith("/api/clients") &&
!url.pathname.startsWith("/api/groups") &&
!url.pathname.match(/^\/api\/clients\/.+\/command$/)
) {
return null;
@@ -77,9 +84,10 @@ export async function handleClientRoutes(
const osFilter = url.searchParams.get("os") || "all";
const countryFilter = url.searchParams.get("country") || "all";
const enrollmentFilter = url.searchParams.get("enrollmentFilter") || "approved";
const groupFilter = url.searchParams.get("group") || "all";
const isEnrollmentRequest = url.searchParams.has("enrollmentFilter");
if (user.role === "admin") {
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter });
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter });
return Response.json(result, { headers: deps.CORS_HEADERS });
}
@@ -93,6 +101,7 @@ export async function handleClientRoutes(
osFilter,
countryFilter,
enrollmentFilter,
groupFilter,
builtByUserId: user.userId,
requireBuildOwner: true,
});
@@ -125,6 +134,7 @@ export async function handleClientRoutes(
osFilter,
countryFilter,
enrollmentFilter,
groupFilter,
allowedClientIds,
deniedClientIds,
});
@@ -758,5 +768,124 @@ export async function handleClientRoutes(
}
}
if (url.pathname === "/api/groups") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (req.method === "GET") {
return Response.json({ groups: listGroups() }, { headers: deps.CORS_HEADERS });
}
if (req.method === "POST") {
try { requirePermission(user, "clients:control"); } catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
try { body = await req.json(); } catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const name = typeof body?.name === "string" ? body.name.trim() : "";
const color = typeof body?.color === "string" ? body.color.trim() : "#3b82f6";
if (!name || name.length > 64) {
return Response.json({ error: "Name is required and must be 64 characters or fewer" }, { status: 400 });
}
if (!/^#[0-9a-fA-F]{6}$/.test(color)) {
return Response.json({ error: "Color must be a valid hex code (#RRGGBB)" }, { status: 400 });
}
const group = createGroup(name, color);
const ip = server.requestIP(req)?.address || "unknown";
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `create_group:${name}`, success: true });
return Response.json(group, { status: 201, headers: deps.CORS_HEADERS });
}
}
const groupIdMatch = url.pathname.match(/^\/api\/groups\/(\d+)$/);
if (groupIdMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "clients:control"); } catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const groupId = Number(groupIdMatch[1]);
if (req.method === "PATCH") {
let body: any = {};
try { body = await req.json(); } catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const existing = getGroup(groupId);
if (!existing) return Response.json({ error: "Group not found" }, { status: 404 });
const name = typeof body?.name === "string" ? body.name.trim() : existing.name;
const color = typeof body?.color === "string" ? body.color.trim() : existing.color;
if (name.length === 0 || name.length > 64) {
return Response.json({ error: "Name must be 1-64 characters" }, { status: 400 });
}
if (!/^#[0-9a-fA-F]{6}$/.test(color)) {
return Response.json({ error: "Color must be a valid hex code (#RRGGBB)" }, { status: 400 });
}
const updated = updateGroup(groupId, name, color);
if (!updated) return Response.json({ error: "Group not found" }, { status: 404 });
notifyDashboardViewers();
const ip = server.requestIP(req)?.address || "unknown";
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `update_group:${groupId}`, success: true });
return Response.json({ ok: true, id: groupId, name, color }, { headers: deps.CORS_HEADERS });
}
if (req.method === "DELETE") {
const deleted = deleteGroup(groupId);
if (!deleted) return Response.json({ error: "Group not found" }, { status: 404 });
notifyDashboardViewers();
const ip = server.requestIP(req)?.address || "unknown";
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `delete_group:${groupId}`, success: true });
return Response.json({ ok: true }, { headers: deps.CORS_HEADERS });
}
}
const clientGroupMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/group$/);
if (req.method === "PATCH" && clientGroupMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "clients:control"); } catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const targetId = clientGroupMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
if (!clientExists(targetId)) {
return Response.json({ error: "Client not found" }, { status: 404 });
}
let body: any = {};
try { body = await req.json(); } catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const groupId = body?.groupId === null ? null : Number(body?.groupId);
if (groupId !== null && (isNaN(groupId) || groupId < 1)) {
return Response.json({ error: "Invalid groupId" }, { status: 400 });
}
const updated = setClientGroup(targetId, groupId);
if (!updated) {
return Response.json({ error: "Client not found or group does not exist" }, { status: 404 });
}
notifyDashboardViewers();
const ip = server.requestIP(req)?.address || "unknown";
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, details: groupId ? `set_group:${groupId}` : "clear_group", success: true });
return Response.json({ ok: true, groupId }, { headers: deps.CORS_HEADERS });
}
return null;
}
+4
View File
@@ -38,6 +38,9 @@ export type ClientInfo = {
keyFingerprint?: string;
disconnectReason?: string;
disconnectDetail?: string;
groupId?: number | null;
groupName?: string | null;
groupColor?: string | null;
};
export type ListFilters = {
@@ -53,6 +56,7 @@ export type ListFilters = {
requireBuildOwner?: boolean;
allowedClientIds?: string[];
deniedClientIds?: string[];
groupFilter?: string;
};
export type ListItem = ClientInfo & {