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();
+ }
}
];