diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index fb3004921..cc006565f 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -67,12 +67,12 @@ const listUserDevicesSchema = z.strictObject({ }), query: z.string().optional(), sort_by: z - .enum(["megabytesIn", "megabytesOut"]) + .enum(["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"]) .optional() .catch(undefined) .openapi({ type: "string", - enum: ["megabytesIn", "megabytesOut"], + enum: ["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"], description: "Field to sort by" }), order: z @@ -183,7 +183,9 @@ function queryUserDevicesBase() { fingerprintArch: currentFingerprint.arch, fingerprintSerialNumber: currentFingerprint.serialNumber, fingerprintUsername: currentFingerprint.username, - fingerprintHostname: currentFingerprint.hostname + fingerprintHostname: currentFingerprint.hostname, + firstSeen: currentFingerprint.firstSeen, + lastSeen: currentFingerprint.lastSeen }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) @@ -389,14 +391,23 @@ export async function listUserDevices( const countQuery = db.$count(baseQuery.as("filtered_clients")); + const sortColumn = + sort_by === "firstSeen" + ? currentFingerprint.firstSeen + : sort_by === "lastSeen" + ? currentFingerprint.lastSeen + : sort_by + ? clients[sort_by] + : undefined; + const listDevicesQuery = baseQuery .limit(pageSize) .offset(pageSize * (page - 1)) .orderBy( - sort_by + sortColumn ? order === "asc" - ? asc(clients[sort_by]) - : desc(clients[sort_by]) + ? asc(sortColumn) + : desc(sortColumn) : asc(clients.clientId) ); diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index 880019177..5cc904fee 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -104,7 +104,9 @@ export default async function ClientsPage(props: ClientsPageProps) { archived: Boolean(client.archived), blocked: Boolean(client.blocked), approvalState: client.approvalState, - fingerprint + fingerprint, + firstSeen: client.firstSeen ?? null, + lastSeen: client.lastSeen ?? null }; }; diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 6b881f4e9..957df9218 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -77,6 +77,8 @@ export type ClientRow = { username: string | null; hostname: string | null; } | null; + firstSeen: number | null; + lastSeen: number | null; }; type ClientTableProps = { @@ -112,7 +114,9 @@ export default function UserDevicesTable({ const defaultUserColumnVisibility = { subnet: false, - niceId: false + niceId: false, + firstSeen: false, + lastSeen: false }; const refreshData = () => { @@ -621,6 +625,68 @@ export default function UserDevicesTable({ accessorKey: "subnet", friendlyName: t("address"), header: () => {t("address")} + }, + { + accessorKey: "firstSeen", + friendlyName: t("firstSeen"), + header: () => { + const firstSeenOrder = getSortDirection( + "firstSeen", + searchParams + ); + + const Icon = + firstSeenOrder === "asc" + ? ArrowDown01Icon + : firstSeenOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + }, + cell: ({ row }) => { + const firstSeen = row.original.firstSeen; + if (!firstSeen) return "-"; + return new Date(firstSeen * 1000).toLocaleString(); + } + }, + { + accessorKey: "lastSeen", + friendlyName: t("lastSeen"), + header: () => { + const lastSeenOrder = getSortDirection( + "lastSeen", + searchParams + ); + + const Icon = + lastSeenOrder === "asc" + ? ArrowDown01Icon + : lastSeenOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + }, + cell: ({ row }) => { + const lastSeen = row.original.lastSeen; + if (!lastSeen) return "-"; + return new Date(lastSeen * 1000).toLocaleString(); + } } ];