🚧 wip: user devices endpoint

This commit is contained in:
Fred KISSIE
2026-02-06 05:37:44 +01:00
parent 67b63d3084
commit 9f2fd34e99
10 changed files with 374 additions and 74 deletions

View File

@@ -6,6 +6,7 @@ export * from "./unarchiveClient";
export * from "./blockClient"; export * from "./blockClient";
export * from "./unblockClient"; export * from "./unblockClient";
export * from "./listClients"; export * from "./listClients";
export * from "./listUserDevices";
export * from "./updateClient"; export * from "./updateClient";
export * from "./getClient"; export * from "./getClient";
export * from "./createUserClient"; export * from "./createUserClient";

View File

@@ -105,11 +105,7 @@ const listClientsSchema = z.object({
.catch(1) .catch(1)
.default(1), .default(1),
query: z.string().optional(), query: z.string().optional(),
sort_by: z sort_by: z.enum(["megabytesIn", "megabytesOut"]).optional().catch(undefined)
.enum(["megabytesIn", "megabytesOut"])
.optional()
.catch(undefined),
filter: z.enum(["user", "machine"]).optional()
}); });
function queryClientsBase() { function queryClientsBase() {
@@ -134,15 +130,7 @@ function queryClientsBase() {
approvalState: clients.approvalState, approvalState: clients.approvalState,
olmArchived: olms.archived, olmArchived: olms.archived,
archived: clients.archived, archived: clients.archived,
blocked: clients.blocked, blocked: clients.blocked
deviceModel: currentFingerprint.deviceModel,
fingerprintPlatform: currentFingerprint.platform,
fingerprintOsVersion: currentFingerprint.osVersion,
fingerprintKernelVersion: currentFingerprint.kernelVersion,
fingerprintArch: currentFingerprint.arch,
fingerprintSerialNumber: currentFingerprint.serialNumber,
fingerprintUsername: currentFingerprint.username,
fingerprintHostname: currentFingerprint.hostname
}) })
.from(clients) .from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId)) .leftJoin(orgs, eq(clients.orgId, orgs.orgId))
@@ -208,7 +196,7 @@ export async function listClients(
) )
); );
} }
const { page, pageSize, query, filter } = parsedQuery.data; const { page, pageSize, query } = parsedQuery.data;
const parsedParams = listClientsParamsSchema.safeParse(req.params); const parsedParams = listClientsParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -262,15 +250,10 @@ export async function listClients(
// Get client count with filter // Get client count with filter
const conditions = [ const conditions = [
inArray(clients.clientId, accessibleClientIds), inArray(clients.clientId, accessibleClientIds),
eq(clients.orgId, orgId) eq(clients.orgId, orgId),
isNull(clients.userId)
]; ];
if (filter === "user") {
conditions.push(isNotNull(clients.userId));
} else if (filter === "machine") {
conditions.push(isNull(clients.userId));
}
const countQuery = db.$count( const countQuery = db.$count(
queryClientsBase().where(and(...conditions)) queryClientsBase().where(and(...conditions))
); );
@@ -312,11 +295,8 @@ export async function listClients(
// Merge clients with their site associations and replace name with device name // Merge clients with their site associations and replace name with device name
const clientsWithSites = clientsList.map((client) => { const clientsWithSites = clientsList.map((client) => {
const model = client.deviceModel || null;
const newName = getUserDeviceName(model, client.name);
return { return {
...client, ...client,
name: newName,
sites: sitesByClient[client.clientId] || [] sites: sitesByClient[client.clientId] || []
}; };
}); });

View File

@@ -0,0 +1,302 @@
import {
clients,
currentFingerprint,
db,
olms,
orgs,
roleClients,
userClients,
users
} from "@server/db";
import { getUserDeviceName } from "@server/db/names";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import type { PaginatedResponse } from "@server/types/Pagination";
import { and, eq, inArray, isNotNull, or, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import NodeCache from "node-cache";
import semver from "semver";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
async function getLatestOlmVersion(): Promise<string | null> {
try {
const cachedVersion = olmVersionCache.get<string>("latestOlmVersion");
if (cachedVersion) {
return cachedVersion;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500);
const response = await fetch(
"https://api.github.com/repos/fosrl/olm/tags",
{
signal: controller.signal
}
);
clearTimeout(timeoutId);
if (!response.ok) {
logger.warn(
`Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}`
);
return null;
}
let tags = await response.json();
if (!Array.isArray(tags) || tags.length === 0) {
logger.warn("No tags found for Olm repository");
return null;
}
tags = tags.filter((version) => !version.name.includes("rc"));
const latestVersion = tags[0].name;
olmVersionCache.set("latestOlmVersion", latestVersion);
return latestVersion;
} catch (error: any) {
if (error.name === "AbortError") {
logger.warn("Request to fetch latest Olm version timed out (1.5s)");
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
logger.warn("Connection timeout while fetching latest Olm version");
} else {
logger.warn(
"Error fetching latest Olm version:",
error.message || error
);
}
return null;
}
}
const listUserDevicesParamsSchema = z.strictObject({
orgId: z.string()
});
const listUserDevicesSchema = z.object({
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()
.positive()
.optional()
.catch(20)
.default(20),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional()
.catch(1)
.default(1),
query: z.string().optional(),
sort_by: z.enum(["megabytesIn", "megabytesOut"]).optional().catch(undefined)
});
function queryUserDevicesBase() {
return db
.select({
clientId: clients.clientId,
orgId: clients.orgId,
name: clients.name,
pubKey: clients.pubKey,
subnet: clients.subnet,
megabytesIn: clients.megabytesIn,
megabytesOut: clients.megabytesOut,
orgName: orgs.name,
type: clients.type,
online: clients.online,
olmVersion: olms.version,
userId: clients.userId,
username: users.username,
userEmail: users.email,
niceId: clients.niceId,
agent: olms.agent,
approvalState: clients.approvalState,
olmArchived: olms.archived,
archived: clients.archived,
blocked: clients.blocked,
deviceModel: currentFingerprint.deviceModel,
fingerprintPlatform: currentFingerprint.platform,
fingerprintOsVersion: currentFingerprint.osVersion,
fingerprintKernelVersion: currentFingerprint.kernelVersion,
fingerprintArch: currentFingerprint.arch,
fingerprintSerialNumber: currentFingerprint.serialNumber,
fingerprintUsername: currentFingerprint.username,
fingerprintHostname: currentFingerprint.hostname
})
.from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
.leftJoin(olms, eq(clients.clientId, olms.clientId))
.leftJoin(users, eq(clients.userId, users.userId))
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId));
}
type OlmWithUpdateAvailable = Awaited<
ReturnType<typeof queryUserDevicesBase>
>[0] & {
olmUpdateAvailable?: boolean;
};
export type ListUserDevicesResponse = PaginatedResponse<{
devices: Array<OlmWithUpdateAvailable>;
}>;
registry.registerPath({
method: "get",
path: "/org/{orgId}/user-devices",
description: "List all user devices for an organization.",
tags: [OpenAPITags.Client, OpenAPITags.Org],
request: {
query: listUserDevicesSchema,
params: listUserDevicesParamsSchema
},
responses: {}
});
export async function listUserDevices(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listUserDevicesSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const { page, pageSize, query } = parsedQuery.data;
const parsedParams = listUserDevicesParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
let accessibleClients;
if (req.user) {
accessibleClients = await db
.select({
clientId: sql<number>`COALESCE(${userClients.clientId}, ${roleClients.clientId})`
})
.from(userClients)
.fullJoin(
roleClients,
eq(userClients.clientId, roleClients.clientId)
)
.where(
or(
eq(userClients.userId, req.user!.userId),
eq(roleClients.roleId, req.userOrgRoleId!)
)
);
} else {
accessibleClients = await db
.select({ clientId: clients.clientId })
.from(clients)
.where(eq(clients.orgId, orgId));
}
const accessibleClientIds = accessibleClients.map(
(client) => client.clientId
);
// Get client count with filter
const conditions = [
inArray(clients.clientId, accessibleClientIds),
eq(clients.orgId, orgId),
isNotNull(clients.userId)
];
const baseQuery = queryUserDevicesBase().where(and(...conditions));
const countQuery = db.$count(baseQuery.as("filtered_clients"));
const [clientsList, totalCount] = await Promise.all([
baseQuery.limit(pageSize).offset(pageSize * (page - 1)),
countQuery
]);
// Merge clients with their site associations and replace name with device name
const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsList.map(
(client) => {
const model = client.deviceModel || null;
const newName = getUserDeviceName(model, client.name);
const OlmWithUpdate: OlmWithUpdateAvailable = {
...client,
name: newName
};
// Initially set to false, will be updated if version check succeeds
OlmWithUpdate.olmUpdateAvailable = false;
return OlmWithUpdate;
}
);
// Try to get the latest version, but don't block if it fails
try {
const latestOlmVersion = await getLatestOlmVersion();
if (latestOlmVersion) {
olmsWithUpdates.forEach((client) => {
try {
client.olmUpdateAvailable = semver.lt(
client.olmVersion ? client.olmVersion : "",
latestOlmVersion
);
} catch (error) {
client.olmUpdateAvailable = false;
}
});
}
} catch (error) {
// Log the error but don't let it block the response
logger.warn(
"Failed to check for OLM updates, continuing without update info:",
error
);
}
return response<ListUserDevicesResponse>(res, {
data: {
devices: olmsWithUpdates,
pagination: {
total: totalCount,
page,
pageSize
}
},
success: true,
error: false,
message: "Clients retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -143,6 +143,13 @@ authenticated.get(
client.listClients client.listClients
); );
authenticated.get(
"/org/:orgId/user-devices",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listClients),
client.listUserDevices
);
authenticated.get( authenticated.get(
"/client/:clientId", "/client/:clientId",
verifyClientAccess, verifyClientAccess,

View File

@@ -818,6 +818,13 @@ authenticated.get(
client.listClients client.listClients
); );
authenticated.get(
"/org/:orgId/user-devices",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.listClients),
client.listUserDevices
);
authenticated.get( authenticated.get(
"/client/:clientId", "/client/:clientId",
verifyApiKeyClientAccess, verifyApiKeyClientAccess,

View File

@@ -380,7 +380,7 @@ export async function listResources(
hcEnabled: targetHealthCheck.hcEnabled hcEnabled: targetHealthCheck.hcEnabled
}) })
.from(targets) .from(targets)
.where(sql`${targets.resourceId} in ${resourceIdList}`) .where(inArray(targets.resourceId, resourceIdList))
.leftJoin( .leftJoin(
targetHealthCheck, targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId) eq(targetHealthCheck.targetId, targets.targetId)

View File

@@ -1,11 +1,12 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListClientsResponse } from "@server/routers/client";
import { getTranslations } from "next-intl/server";
import type { ClientRow } from "@app/components/UserDevicesTable"; import type { ClientRow } from "@app/components/UserDevicesTable";
import UserDevicesTable from "@app/components/UserDevicesTable"; import UserDevicesTable from "@app/components/UserDevicesTable";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { type ListUserDevicesResponse } from "@server/routers/client";
import type { Pagination } from "@server/types/Pagination";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
type ClientsPageProps = { type ClientsPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@@ -18,14 +19,21 @@ export default async function ClientsPage(props: ClientsPageProps) {
const params = await props.params; const params = await props.params;
let userClients: ListClientsResponse["clients"] = []; let userClients: ListUserDevicesResponse["devices"] = [];
let pagination: Pagination = {
page: 1,
total: 0,
pageSize: 20
};
try { try {
const userRes = await internal.get<AxiosResponse<ListClientsResponse>>( const userRes = await internal.get<
`/org/${params.orgId}/clients?filter=user`, AxiosResponse<ListUserDevicesResponse>
await authCookieHeader() >(`/org/${params.orgId}/user-devices`, await authCookieHeader());
); const responseData = userRes.data.data;
userClients = userRes.data.data.clients; userClients = responseData.devices;
pagination = responseData.pagination;
} catch (e) {} } catch (e) {}
function formatSize(mb: number): string { function formatSize(mb: number): string {
@@ -39,31 +47,29 @@ export default async function ClientsPage(props: ClientsPageProps) {
} }
const mapClientToRow = ( const mapClientToRow = (
client: ListClientsResponse["clients"][0] client: ListUserDevicesResponse["devices"][number]
): ClientRow => { ): ClientRow => {
// Build fingerprint object if any fingerprint data exists // Build fingerprint object if any fingerprint data exists
const hasFingerprintData = const hasFingerprintData =
(client as any).fingerprintPlatform || client.fingerprintPlatform ||
(client as any).fingerprintOsVersion || client.fingerprintOsVersion ||
(client as any).fingerprintKernelVersion || client.fingerprintKernelVersion ||
(client as any).fingerprintArch || client.fingerprintArch ||
(client as any).fingerprintSerialNumber || client.fingerprintSerialNumber ||
(client as any).fingerprintUsername || client.fingerprintUsername ||
(client as any).fingerprintHostname || client.fingerprintHostname ||
(client as any).deviceModel; client.deviceModel;
const fingerprint = hasFingerprintData const fingerprint = hasFingerprintData
? { ? {
platform: (client as any).fingerprintPlatform || null, platform: client.fingerprintPlatform,
osVersion: (client as any).fingerprintOsVersion || null, osVersion: client.fingerprintOsVersion,
kernelVersion: kernelVersion: client.fingerprintKernelVersion,
(client as any).fingerprintKernelVersion || null, arch: client.fingerprintArch,
arch: (client as any).fingerprintArch || null, deviceModel: client.deviceModel,
deviceModel: (client as any).deviceModel || null, serialNumber: client.fingerprintSerialNumber,
serialNumber: username: client.fingerprintUsername,
(client as any).fingerprintSerialNumber || null, hostname: client.fingerprintHostname
username: (client as any).fingerprintUsername || null,
hostname: (client as any).fingerprintHostname || null
} }
: null; : null;
@@ -71,19 +77,19 @@ export default async function ClientsPage(props: ClientsPageProps) {
name: client.name, name: client.name,
id: client.clientId, id: client.clientId,
subnet: client.subnet.split("/")[0], subnet: client.subnet.split("/")[0],
mbIn: formatSize(client.megabytesIn || 0), mbIn: formatSize(client.megabytesIn ?? 0),
mbOut: formatSize(client.megabytesOut || 0), mbOut: formatSize(client.megabytesOut ?? 0),
orgId: params.orgId, orgId: params.orgId,
online: client.online, online: client.online,
olmVersion: client.olmVersion || undefined, olmVersion: client.olmVersion || undefined,
olmUpdateAvailable: client.olmUpdateAvailable || false, olmUpdateAvailable: Boolean(client.olmUpdateAvailable),
userId: client.userId, userId: client.userId,
username: client.username, username: client.username,
userEmail: client.userEmail, userEmail: client.userEmail,
niceId: client.niceId, niceId: client.niceId,
agent: client.agent, agent: client.agent,
archived: client.archived || false, archived: Boolean(client.archived),
blocked: client.blocked || false, blocked: Boolean(client.blocked),
approvalState: client.approvalState, approvalState: client.approvalState,
fingerprint fingerprint
}; };
@@ -91,6 +97,11 @@ export default async function ClientsPage(props: ClientsPageProps) {
const userClientRows: ClientRow[] = userClients.map(mapClientToRow); const userClientRows: ClientRow[] = userClients.map(mapClientToRow);
console.log({
userClientRows,
pagination
});
return ( return (
<> <>
<SettingsSectionTitle <SettingsSectionTitle

View File

@@ -255,10 +255,7 @@ export default function CreateInternalResourceDialog({
const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId })); const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId }));
const { data: clientsResponse = [] } = useQuery( const { data: clientsResponse = [] } = useQuery(
orgQueries.clients({ orgQueries.clients({
orgId, orgId
filters: {
filter: "machine"
}
}) })
); );

View File

@@ -277,10 +277,7 @@ export default function EditInternalResourceDialog({
orgQueries.roles({ orgId }), orgQueries.roles({ orgId }),
orgQueries.users({ orgId }), orgQueries.users({ orgId }),
orgQueries.clients({ orgQueries.clients({
orgId, orgId
filters: {
filter: "machine"
}
}), }),
resourceQueries.siteResourceUsers({ siteResourceId: resource.id }), resourceQueries.siteResourceUsers({ siteResourceId: resource.id }),
resourceQueries.siteResourceRoles({ siteResourceId: resource.id }), resourceQueries.siteResourceRoles({ siteResourceId: resource.id }),

View File

@@ -86,8 +86,7 @@ export const productUpdatesQueries = {
}; };
export const clientFilterSchema = z.object({ export const clientFilterSchema = z.object({
filter: z.enum(["machine", "user"]), pageSize: z.int().prefault(1000).optional()
limit: z.int().prefault(1000).optional()
}); });
export const orgQueries = { export const orgQueries = {
@@ -96,14 +95,13 @@ export const orgQueries = {
filters filters
}: { }: {
orgId: string; orgId: string;
filters: z.infer<typeof clientFilterSchema>; filters?: z.infer<typeof clientFilterSchema>;
}) => }) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "CLIENTS", filters] as const, queryKey: ["ORG", orgId, "CLIENTS", filters] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({ const sp = new URLSearchParams({
...filters, pageSize: (filters?.pageSize ?? 1000).toString()
limit: (filters.limit ?? 1000).toString()
}); });
const res = await meta!.api.get< const res = await meta!.api.get<