diff --git a/.vscode/settings.json b/.vscode/settings.json index 767e57b5e..5092cb6c1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,7 +10,7 @@ "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescript]": { - "editor.defaultFormatter": "vscode.typescript-language-features" + "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" diff --git a/messages/en-US.json b/messages/en-US.json index d311976a6..4fec9cf6c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -461,6 +461,8 @@ "filterByApprovalState": "Filter By Approval State", "approvalListEmpty": "No approvals", "approvalState": "Approval State", + "approvalLoadMore": "Load more", + "loadingApprovals": "Loading Approvals", "approve": "Approve", "approved": "Approved", "denied": "Denied", @@ -1169,7 +1171,8 @@ "actionViewLogs": "View Logs", "noneSelected": "None selected", "orgNotFound2": "No organizations found.", - "searchProgress": "Search...", + "searchPlaceholder": "Search...", + "emptySearchOptions": "No options found", "create": "Create", "orgs": "Organizations", "loginError": "An unexpected error occurred. Please try again.", diff --git a/package-lock.json b/package-lock.json index d5f7c924b..c5f91840f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,6 +97,7 @@ "tailwind-merge": "3.4.0", "topojson-client": "3.1.0", "tw-animate-css": "1.4.0", + "use-debounce": "^10.1.0", "uuid": "13.0.0", "vaul": "1.1.2", "visionscarto-world-atlas": "1.0.0", @@ -13918,7 +13919,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -21122,6 +21122,18 @@ } } }, + "node_modules/use-debounce": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.0.tgz", + "integrity": "sha512-lu87Za35V3n/MyMoEpD5zJv0k7hCn0p+V/fK2kWD+3k2u3kOCwO593UArbczg1fhfs2rqPEnHpULJ3KmGdDzvg==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-intl": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.7.0.tgz", diff --git a/package.json b/package.json index 5cb205ceb..d622a1a5a 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "tailwind-merge": "3.4.0", "topojson-client": "3.1.0", "tw-animate-css": "1.4.0", + "use-debounce": "^10.1.0", "uuid": "13.0.0", "vaul": "1.1.2", "visionscarto-world-atlas": "1.0.0", @@ -144,6 +145,7 @@ "@types/express": "5.0.6", "@types/express-session": "1.18.2", "@types/jmespath": "0.15.2", + "@types/js-yaml": "4.0.9", "@types/jsonwebtoken": "9.0.10", "@types/node": "24.10.2", "@types/nodemailer": "7.0.4", @@ -156,7 +158,6 @@ "@types/topojson-client": "3.1.5", "@types/ws": "8.18.1", "@types/yargs": "17.0.35", - "@types/js-yaml": "4.0.9", "babel-plugin-react-compiler": "1.0.0", "drizzle-kit": "0.31.8", "esbuild": "0.27.2", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index b3b6534e7..41647ba16 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -190,7 +190,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", { hcFollowRedirects: boolean("hcFollowRedirects").default(true), hcMethod: varchar("hcMethod").default("GET"), hcStatus: integer("hcStatus"), // http code - hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy" + hcHealth: text("hcHealth") + .$type<"unknown" | "healthy" | "unhealthy">() + .default("unknown"), // "unknown", "healthy", "unhealthy" hcTlsServerName: text("hcTlsServerName") }); @@ -220,7 +222,7 @@ export const siteResources = pgTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), - mode: varchar("mode").notNull(), // "host" | "cidr" | "port" + mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" protocol: varchar("protocol"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index df188213c..aad18f51b 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -216,7 +216,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { }).default(true), hcMethod: text("hcMethod").default("GET"), hcStatus: integer("hcStatus"), // http code - hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy" + hcHealth: text("hcHealth") + .$type<"unknown" | "healthy" | "unhealthy">() + .default("unknown"), // "unknown", "healthy", "unhealthy" hcTlsServerName: text("hcTlsServerName") }); @@ -248,7 +250,7 @@ export const siteResources = sqliteTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: text("niceId").notNull(), name: text("name").notNull(), - mode: text("mode").notNull(), // "host" | "cidr" | "port" + mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" protocol: text("protocol"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/private/routers/approvals/countApprovals.ts b/server/private/routers/approvals/countApprovals.ts index c68e422ac..0885c7e88 100644 --- a/server/private/routers/approvals/countApprovals.ts +++ b/server/private/routers/approvals/countApprovals.ts @@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error"; import type { Request, Response, NextFunction } from "express"; import { approvals, db, type Approval } from "@server/db"; -import { eq, sql, and } from "drizzle-orm"; +import { eq, sql, and, inArray } from "drizzle-orm"; import response from "@server/lib/response"; const paramsSchema = z.strictObject({ @@ -88,7 +88,7 @@ export async function countApprovals( .where( and( eq(approvals.orgId, orgId), - sql`${approvals.decision} in ${state}` + inArray(approvals.decision, state) ) ); diff --git a/server/private/routers/approvals/listApprovals.ts b/server/private/routers/approvals/listApprovals.ts index 753a2f1a9..fcac27f92 100644 --- a/server/private/routers/approvals/listApprovals.ts +++ b/server/private/routers/approvals/listApprovals.ts @@ -28,7 +28,7 @@ import { currentFingerprint, type Approval } from "@server/db"; -import { eq, isNull, sql, not, and, desc } from "drizzle-orm"; +import { eq, isNull, sql, not, and, desc, gte, lte } from "drizzle-orm"; import response from "@server/lib/response"; import { getUserDeviceName } from "@server/db/names"; @@ -37,18 +37,26 @@ const paramsSchema = z.strictObject({ }); const querySchema = z.strictObject({ - limit: z - .string() + limit: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().nonnegative()), - offset: z - .string() + .catch(20) + .default(20), + cursorPending: z.coerce // pending cursor + .number() + .int() + .max(1) // 0 means non pending + .min(0) // 1 means pending .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()), + .catch(undefined), + cursorTimestamp: z.coerce + .number() + .int() + .positive() + .optional() + .catch(undefined), approvalState: z .enum(["pending", "approved", "denied", "all"]) .optional() @@ -61,13 +69,21 @@ const querySchema = z.strictObject({ .pipe(z.number().int().positive().optional()) }); -async function queryApprovals( - orgId: string, - limit: number, - offset: number, - approvalState: z.infer["approvalState"], - clientId?: number -) { +async function queryApprovals({ + orgId, + limit, + approvalState, + cursorPending, + cursorTimestamp, + clientId +}: { + orgId: string; + limit: number; + approvalState: z.infer["approvalState"]; + cursorPending?: number; + cursorTimestamp?: number; + clientId?: number; +}) { let state: Array = []; switch (approvalState) { case "pending": @@ -83,6 +99,26 @@ async function queryApprovals( state = ["approved", "denied", "pending"]; } + const conditions = [ + eq(approvals.orgId, orgId), + sql`${approvals.decision} in ${state}` + ]; + + if (clientId) { + conditions.push(eq(approvals.clientId, clientId)); + } + + const pendingSortKey = sql`CASE ${approvals.decision} WHEN 'pending' THEN 1 ELSE 0 END`; + + if (cursorPending != null && cursorTimestamp != null) { + // https://stackoverflow.com/a/79720298/10322846 + // composite cursor, next data means (pending, timestamp) <= cursor + conditions.push( + lte(pendingSortKey, cursorPending), + lte(approvals.timestamp, cursorTimestamp) + ); + } + const res = await db .select({ approvalId: approvals.approvalId, @@ -105,7 +141,8 @@ async function queryApprovals( fingerprintArch: currentFingerprint.arch, fingerprintSerialNumber: currentFingerprint.serialNumber, fingerprintUsername: currentFingerprint.username, - fingerprintHostname: currentFingerprint.hostname + fingerprintHostname: currentFingerprint.hostname, + timestamp: approvals.timestamp }) .from(approvals) .innerJoin(users, and(eq(approvals.userId, users.userId))) @@ -118,22 +155,12 @@ async function queryApprovals( ) .leftJoin(olms, eq(clients.clientId, olms.clientId)) .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)) - .where( - and( - eq(approvals.orgId, orgId), - sql`${approvals.decision} in ${state}`, - ...(clientId ? [eq(approvals.clientId, clientId)] : []) - ) - ) - .orderBy( - sql`CASE ${approvals.decision} WHEN 'pending' THEN 0 ELSE 1 END`, - desc(approvals.timestamp) - ) - .limit(limit) - .offset(offset); + .where(and(...conditions)) + .orderBy(desc(pendingSortKey), desc(approvals.timestamp)) + .limit(limit + 1); // the `+1` is used for the cursor // Process results to format device names and build fingerprint objects - return res.map((approval) => { + const approvalsList = res.slice(0, limit).map((approval) => { const model = approval.deviceModel || null; const deviceName = approval.clientName ? getUserDeviceName(model, approval.clientName) @@ -152,15 +179,15 @@ async function queryApprovals( const fingerprint = hasFingerprintData ? { - platform: approval.fingerprintPlatform || null, - osVersion: approval.fingerprintOsVersion || null, - kernelVersion: approval.fingerprintKernelVersion || null, - arch: approval.fingerprintArch || null, - deviceModel: approval.deviceModel || null, - serialNumber: approval.fingerprintSerialNumber || null, - username: approval.fingerprintUsername || null, - hostname: approval.fingerprintHostname || null - } + platform: approval.fingerprintPlatform ?? null, + osVersion: approval.fingerprintOsVersion ?? null, + kernelVersion: approval.fingerprintKernelVersion ?? null, + arch: approval.fingerprintArch ?? null, + deviceModel: approval.deviceModel ?? null, + serialNumber: approval.fingerprintSerialNumber ?? null, + username: approval.fingerprintUsername ?? null, + hostname: approval.fingerprintHostname ?? null + } : null; const { @@ -183,11 +210,30 @@ async function queryApprovals( niceId: approval.niceId || null }; }); + let nextCursorPending: number | null = null; + let nextCursorTimestamp: number | null = null; + if (res.length > limit) { + const lastItem = res[limit]; + nextCursorPending = lastItem.decision === "pending" ? 1 : 0; + nextCursorTimestamp = lastItem.timestamp; + } + return { + approvalsList, + nextCursorPending, + nextCursorTimestamp + }; } export type ListApprovalsResponse = { - approvals: NonNullable>>; - pagination: { total: number; limit: number; offset: number }; + approvals: NonNullable< + Awaited> + >["approvalsList"]; + pagination: { + total: number; + limit: number; + cursorPending: number | null; + cursorTimestamp: number | null; + }; }; export async function listApprovals( @@ -215,17 +261,25 @@ export async function listApprovals( ) ); } - const { limit, offset, approvalState, clientId } = parsedQuery.data; + const { + limit, + cursorPending, + cursorTimestamp, + approvalState, + clientId + } = parsedQuery.data; const { orgId } = parsedParams.data; - const approvalsList = await queryApprovals( - orgId.toString(), - limit, - offset, - approvalState, - clientId - ); + const { approvalsList, nextCursorPending, nextCursorTimestamp } = + await queryApprovals({ + orgId: orgId.toString(), + limit, + cursorPending, + cursorTimestamp, + approvalState, + clientId + }); const [{ count }] = await db .select({ count: sql`count(*)` }) @@ -237,7 +291,8 @@ export async function listApprovals( pagination: { total: count, limit, - offset + cursorPending: nextCursorPending, + cursorTimestamp: nextCursorTimestamp } }, success: true, diff --git a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts index 422544170..50248f1f9 100644 --- a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts +++ b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts @@ -37,8 +37,9 @@ export async function generateNewEnterpriseLicense( next: NextFunction ): Promise { try { - - const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(req.params); + const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse( + req.params + ); if (!parsedParams.success) { return next( createHttpError( @@ -63,7 +64,10 @@ export async function generateNewEnterpriseLicense( const licenseData = req.body; - if (licenseData.tier != "big_license" && licenseData.tier != "small_license") { + if ( + licenseData.tier != "big_license" && + licenseData.tier != "small_license" + ) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -79,7 +83,8 @@ export async function generateNewEnterpriseLicense( return next( createHttpError( apiResponse.status || HttpCode.BAD_REQUEST, - apiResponse.message || "Failed to create license from Fossorial API" + apiResponse.message || + "Failed to create license from Fossorial API" ) ); } @@ -112,7 +117,10 @@ export async function generateNewEnterpriseLicense( ); } - const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE; + const tier = + licenseData.tier === "big_license" + ? LicenseId.BIG_LICENSE + : LicenseId.SMALL_LICENSE; const tierPrice = getLicensePriceSet()[tier]; const session = await stripe!.checkout.sessions.create({ @@ -122,7 +130,7 @@ export async function generateNewEnterpriseLicense( { price: tierPrice, // Use the standard tier quantity: 1 - }, + } ], // Start with the standard feature set that matches the free limits customer: customer.customerId, mode: "subscription", diff --git a/server/routers/client/index.ts b/server/routers/client/index.ts index 34614cc8f..e195d1c52 100644 --- a/server/routers/client/index.ts +++ b/server/routers/client/index.ts @@ -6,6 +6,7 @@ export * from "./unarchiveClient"; export * from "./blockClient"; export * from "./unblockClient"; export * from "./listClients"; +export * from "./listUserDevices"; export * from "./updateClient"; export * from "./getClient"; export * from "./createUserClient"; diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 09560c6df..9ba7c6843 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -1,34 +1,38 @@ -import { db, olms, users } from "@server/db"; import { clients, + clientSitesAssociationsCache, + currentFingerprint, + db, + olms, orgs, roleClients, sites, userClients, - clientSitesAssociationsCache, - currentFingerprint + users } from "@server/db"; -import logger from "@server/logger"; -import HttpCode from "@server/types/HttpCode"; 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, - count, + asc, + desc, eq, + ilike, inArray, - isNotNull, isNull, or, - sql + sql, + type SQL } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; -import { z } from "zod"; -import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; import NodeCache from "node-cache"; import semver from "semver"; -import { getUserDeviceName } from "@server/db/names"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; const olmVersionCache = new NodeCache({ stdTTL: 3600 }); @@ -89,38 +93,47 @@ const listClientsParamsSchema = z.strictObject({ }); const listClientsSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().positive()), - offset: z - .string() + .catch(20) + .default(20), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()), - filter: z.enum(["user", "machine"]).optional() + .catch(1) + .default(1), + query: z.string().optional(), + sort_by: z + .enum(["megabytesIn", "megabytesOut"]) + .optional() + .catch(undefined), + order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc"), + online: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined), + status: z.preprocess( + (val: string | undefined) => { + if (val) { + return val.split(","); // the search query array is an array joined by commas + } + return undefined; + }, + z + .array(z.enum(["active", "blocked", "archived"])) + .optional() + .default(["active"]) + .catch(["active"]) + ) }); -function queryClients( - orgId: string, - accessibleClientIds: number[], - filter?: "user" | "machine" -) { - const conditions = [ - inArray(clients.clientId, accessibleClientIds), - eq(clients.orgId, orgId) - ]; - - // Add filter condition based on filter type - if (filter === "user") { - conditions.push(isNotNull(clients.userId)); - } else if (filter === "machine") { - conditions.push(isNull(clients.userId)); - } - +function queryClientsBase() { return db .select({ clientId: clients.clientId, @@ -142,22 +155,13 @@ function queryClients( 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 + blocked: clients.blocked }) .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)) - .where(and(...conditions)); + .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)); } async function getSiteAssociations(clientIds: number[]) { @@ -175,7 +179,7 @@ async function getSiteAssociations(clientIds: number[]) { .where(inArray(clientSitesAssociationsCache.clientId, clientIds)); } -type ClientWithSites = Awaited>[0] & { +type ClientWithSites = Awaited>[0] & { sites: Array<{ siteId: number; siteName: string | null; @@ -186,10 +190,9 @@ type ClientWithSites = Awaited>[0] & { type OlmWithUpdateAvailable = ClientWithSites; -export type ListClientsResponse = { +export type ListClientsResponse = PaginatedResponse<{ clients: Array; - pagination: { total: number; limit: number; offset: number }; -}; +}>; registry.registerPath({ method: "get", @@ -218,7 +221,8 @@ export async function listClients( ) ); } - const { limit, offset, filter } = parsedQuery.data; + const { page, pageSize, online, query, status, sort_by, order } = + parsedQuery.data; const parsedParams = listClientsParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -267,28 +271,62 @@ export async function listClients( const accessibleClientIds = accessibleClients.map( (client) => client.clientId ); - const baseQuery = queryClients(orgId, accessibleClientIds, filter); // Get client count with filter - const countConditions = [ - inArray(clients.clientId, accessibleClientIds), - eq(clients.orgId, orgId) + const conditions = [ + and( + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId), + isNull(clients.userId) + ) ]; - if (filter === "user") { - countConditions.push(isNotNull(clients.userId)); - } else if (filter === "machine") { - countConditions.push(isNull(clients.userId)); + if (typeof online !== "undefined") { + conditions.push(eq(clients.online, online)); } - const countQuery = db - .select({ count: count() }) - .from(clients) - .where(and(...countConditions)); + if (status.length > 0) { + const filterAggregates: (SQL | undefined)[] = []; - const clientsList = await baseQuery.limit(limit).offset(offset); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0].count; + if (status.includes("active")) { + filterAggregates.push( + and(eq(clients.archived, false), eq(clients.blocked, false)) + ); + } + + if (status.includes("archived")) { + filterAggregates.push(eq(clients.archived, true)); + } + if (status.includes("blocked")) { + filterAggregates.push(eq(clients.blocked, true)); + } + + conditions.push(or(...filterAggregates)); + } + + if (query) { + conditions.push(or(ilike(clients.name, "%" + query + "%"))); + } + + const baseQuery = queryClientsBase().where(and(...conditions)); + + const countQuery = db.$count(baseQuery.as("filtered_clients")); + + const listMachinesQuery = baseQuery + .limit(page) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(clients[sort_by]) + : desc(clients[sort_by]) + : asc(clients.clientId) + ); + + const [clientsList, totalCount] = await Promise.all([ + listMachinesQuery, + countQuery + ]); // Get associated sites for all clients const clientIds = clientsList.map((client) => client.clientId); @@ -319,14 +357,8 @@ export async function listClients( // Merge clients with their site associations and replace name with device name const clientsWithSites = clientsList.map((client) => { - const model = client.deviceModel || null; - let newName = client.name; - if (filter === "user") { - newName = getUserDeviceName(model, client.name); - } return { ...client, - name: newName, sites: sitesByClient[client.clientId] || [] }; }); @@ -371,8 +403,8 @@ export async function listClients( clients: olmsWithUpdates, pagination: { total: totalCount, - limit, - offset + page, + pageSize } }, success: true, diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts new file mode 100644 index 000000000..65dba7e6c --- /dev/null +++ b/server/routers/client/listUserDevices.ts @@ -0,0 +1,436 @@ +import { build } from "@server/build"; +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, + asc, + desc, + eq, + ilike, + inArray, + isNotNull, + isNull, + or, + sql, + type 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 { + try { + const cachedVersion = olmVersionCache.get("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() // for prettier formatting + .int() + .positive() + .optional() + .catch(20) + .default(20), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) + .optional() + .catch(1) + .default(1), + query: z.string().optional(), + sort_by: z + .enum(["megabytesIn", "megabytesOut"]) + .optional() + .catch(undefined), + order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc"), + online: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined), + agent: z + .enum([ + "windows", + "android", + "cli", + "olm", + "macos", + "ios", + "ipados", + "unknown" + ]) + .optional() + .catch(undefined), + status: z.preprocess( + (val: string | undefined) => { + if (val) { + return val.split(","); // the search query array is an array joined by commas + } + return undefined; + }, + z + .array( + z.enum(["active", "pending", "denied", "blocked", "archived"]) + ) + .optional() + .default(["active", "pending"]) + .catch(["active", "pending"]) + ) +}); + +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 +>[0] & { + olmUpdateAvailable?: boolean; +}; + +export type ListUserDevicesResponse = PaginatedResponse<{ + devices: Array; +}>; + +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 { + try { + const parsedQuery = listUserDevicesSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { page, pageSize, query, sort_by, online, status, agent, order } = + 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`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 = [ + and( + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId), + isNotNull(clients.userId) + ) + ]; + + if (query) { + conditions.push( + or( + ilike(clients.name, "%" + query + "%"), + ilike(users.email, "%" + query + "%") + ) + ); + } + + if (typeof online !== "undefined") { + conditions.push(eq(clients.online, online)); + } + + const agentValueMap = { + windows: "Pangolin Windows", + android: "Pangolin Android", + ios: "Pangolin iOS", + ipados: "Pangolin iPadOS", + macos: "Pangolin macOS", + cli: "Pangolin CLI", + olm: "Olm CLI" + } satisfies Record< + Exclude, + string + >; + if (typeof agent !== "undefined") { + if (agent === "unknown") { + conditions.push(isNull(olms.agent)); + } else { + conditions.push(eq(olms.agent, agentValueMap[agent])); + } + } + + if (status.length > 0) { + const filterAggregates: (SQL | undefined)[] = []; + + if (status.includes("active")) { + filterAggregates.push( + and( + eq(clients.archived, false), + eq(clients.blocked, false), + build !== "oss" + ? or( + eq(clients.approvalState, "approved"), + isNull(clients.approvalState) // approval state of `NULL` means approved by default + ) + : undefined // undefined are automatically ignored by `drizzle-orm` + ) + ); + } + + if (status.includes("archived")) { + filterAggregates.push(eq(clients.archived, true)); + } + if (status.includes("blocked")) { + filterAggregates.push(eq(clients.blocked, true)); + } + + if (build !== "oss") { + if (status.includes("pending")) { + filterAggregates.push(eq(clients.approvalState, "pending")); + } + if (status.includes("denied")) { + filterAggregates.push(eq(clients.approvalState, "denied")); + } + } + + conditions.push(or(...filterAggregates)); + } + + const baseQuery = queryUserDevicesBase().where(and(...conditions)); + + const countQuery = db.$count(baseQuery.as("filtered_clients")); + + const listDevicesQuery = baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(clients[sort_by]) + : desc(clients[sort_by]) + : asc(clients.clientId) + ); + + const [clientsList, totalCount] = await Promise.all([ + listDevicesQuery, + 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(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") + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 1a04b55ec..52aaa81e9 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -145,6 +145,13 @@ authenticated.get( client.listClients ); +authenticated.get( + "/org/:orgId/user-devices", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listClients), + client.listUserDevices +); + authenticated.get( "/client/:clientId", verifyClientAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 9ece5ddd0..6c39fe983 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -866,6 +866,13 @@ authenticated.get( client.listClients ); +authenticated.get( + "/org/:orgId/user-devices", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listClients), + client.listUserDevices +); + authenticated.get( "/client/:clientId", verifyApiKeyClientAccess, diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index c17e65a40..090ea9713 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -17,58 +17,59 @@ import { import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql, eq, or, inArray, and, count } from "drizzle-orm"; +import { + sql, + eq, + or, + inArray, + and, + count, + ilike, + asc, + not, + isNull, + type SQL +} from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import type { PaginatedResponse } from "@server/types/Pagination"; const listResourcesParamsSchema = z.strictObject({ orgId: z.string() }); const listResourcesSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().nonnegative()), - - offset: z - .string() + .catch(20) + .default(20), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1), + query: z.string().optional(), + enabled: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined), + authState: z + .enum(["protected", "not_protected", "none"]) + .optional() + .catch(undefined), + healthStatus: z + .enum(["no_targets", "healthy", "degraded", "offline", "unknown"]) + .optional() + .catch(undefined) }); -// (resource fields + a single joined target) -type JoinedRow = { - resourceId: number; - niceId: string; - name: string; - ssl: boolean; - fullDomain: string | null; - passwordId: number | null; - sso: boolean; - pincodeId: number | null; - whitelist: boolean; - http: boolean; - protocol: string; - proxyPort: number | null; - enabled: boolean; - domainId: string | null; - headerAuthId: number | null; - - targetId: number | null; - targetIp: string | null; - targetPort: number | null; - targetEnabled: boolean | null; - - hcHealth: string | null; - hcEnabled: boolean | null; -}; - // grouped by resource with targets[]) export type ResourceWithTargets = { resourceId: number; @@ -91,11 +92,32 @@ export type ResourceWithTargets = { ip: string; port: number; enabled: boolean; - healthStatus?: "healthy" | "unhealthy" | "unknown"; + healthStatus: "healthy" | "unhealthy" | "unknown" | null; }>; }; -function queryResources(accessibleResourceIds: number[], orgId: string) { +// Aggregate filters +const total_targets = count(targets.targetId); +const healthy_targets = sql`SUM( + CASE + WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1 + ELSE 0 + END + ) `; +const unknown_targets = sql`SUM( + CASE + WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1 + ELSE 0 + END + ) `; +const unhealthy_targets = sql`SUM( + CASE + WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1 + ELSE 0 + END + ) `; + +function queryResourcesBase() { return db .select({ resourceId: resources.resourceId, @@ -114,14 +136,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { niceId: resources.niceId, headerAuthId: resourceHeaderAuth.headerAuthId, headerAuthExtendedCompatibilityId: - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId, - targetId: targets.targetId, - targetIp: targets.ip, - targetPort: targets.port, - targetEnabled: targets.enabled, - - hcHealth: targetHealthCheck.hcHealth, - hcEnabled: targetHealthCheck.hcEnabled + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId }) .from(resources) .leftJoin( @@ -148,18 +163,18 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) ) - .where( - and( - inArray(resources.resourceId, accessibleResourceIds), - eq(resources.orgId, orgId) - ) + .groupBy( + resources.resourceId, + resourcePassword.passwordId, + resourcePincode.pincodeId, + resourceHeaderAuth.headerAuthId, + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId ); } -export type ListResourcesResponse = { +export type ListResourcesResponse = PaginatedResponse<{ resources: ResourceWithTargets[]; - pagination: { total: number; limit: number; offset: number }; -}; +}>; registry.registerPath({ method: "get", @@ -190,7 +205,8 @@ export async function listResources( ) ); } - const { limit, offset } = parsedQuery.data; + const { page, pageSize, authState, enabled, query, healthStatus } = + parsedQuery.data; const parsedParams = listResourcesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -252,14 +268,123 @@ export async function listResources( (resource) => resource.resourceId ); - const countQuery: any = db - .select({ count: count() }) - .from(resources) - .where(inArray(resources.resourceId, accessibleResourceIds)); + const conditions = [ + and( + inArray(resources.resourceId, accessibleResourceIds), + eq(resources.orgId, orgId) + ) + ]; - const baseQuery = queryResources(accessibleResourceIds, orgId); + if (query) { + conditions.push( + or( + ilike(resources.name, "%" + query + "%"), + ilike(resources.fullDomain, "%" + query + "%") + ) + ); + } + if (typeof enabled !== "undefined") { + conditions.push(eq(resources.enabled, enabled)); + } - const rows: JoinedRow[] = await baseQuery.limit(limit).offset(offset); + if (typeof authState !== "undefined") { + switch (authState) { + case "none": + conditions.push(eq(resources.http, false)); + break; + case "protected": + conditions.push( + or( + eq(resources.sso, true), + eq(resources.emailWhitelistEnabled, true), + not(isNull(resourceHeaderAuth.headerAuthId)), + not(isNull(resourcePincode.pincodeId)), + not(isNull(resourcePassword.passwordId)) + ) + ); + break; + case "not_protected": + conditions.push( + not(eq(resources.sso, true)), + not(eq(resources.emailWhitelistEnabled, true)), + isNull(resourceHeaderAuth.headerAuthId), + isNull(resourcePincode.pincodeId), + isNull(resourcePassword.passwordId) + ); + break; + } + } + + let aggregateFilters: SQL | undefined = sql`1 = 1`; + + if (typeof healthStatus !== "undefined") { + switch (healthStatus) { + case "healthy": + aggregateFilters = and( + sql`${total_targets} > 0`, + sql`${healthy_targets} = ${total_targets}` + ); + break; + case "degraded": + aggregateFilters = and( + sql`${total_targets} > 0`, + sql`${unhealthy_targets} > 0` + ); + break; + case "no_targets": + aggregateFilters = sql`${total_targets} = 0`; + break; + case "offline": + aggregateFilters = and( + sql`${total_targets} > 0`, + sql`${healthy_targets} = 0`, + sql`${unhealthy_targets} = ${total_targets}` + ); + break; + case "unknown": + aggregateFilters = and( + sql`${total_targets} > 0`, + sql`${unknown_targets} = ${total_targets}` + ); + break; + } + } + + const baseQuery = queryResourcesBase() + .where(and(...conditions)) + .having(aggregateFilters); + + // we need to add `as` so that drizzle filters the result as a subquery + const countQuery = db.$count(baseQuery.as("filtered_resources")); + + const [rows, totalCount] = await Promise.all([ + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(asc(resources.resourceId)), + countQuery + ]); + + const resourceIdList = rows.map((row) => row.resourceId); + const allResourceTargets = + resourceIdList.length === 0 + ? [] + : await db + .select({ + targetId: targets.targetId, + resourceId: targets.resourceId, + ip: targets.ip, + port: targets.port, + enabled: targets.enabled, + healthStatus: targetHealthCheck.hcHealth, + hcEnabled: targetHealthCheck.hcEnabled + }) + .from(targets) + .where(inArray(targets.resourceId, resourceIdList)) + .leftJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ); // avoids TS issues with reduce/never[] const map = new Map(); @@ -288,44 +413,20 @@ export async function listResources( map.set(row.resourceId, entry); } - if ( - row.targetId != null && - row.targetIp && - row.targetPort != null && - row.targetEnabled != null - ) { - let healthStatus: "healthy" | "unhealthy" | "unknown" = - "unknown"; - - if (row.hcEnabled && row.hcHealth) { - healthStatus = row.hcHealth as - | "healthy" - | "unhealthy" - | "unknown"; - } - - entry.targets.push({ - targetId: row.targetId, - ip: row.targetIp, - port: row.targetPort, - enabled: row.targetEnabled, - healthStatus: healthStatus - }); - } + entry.targets = allResourceTargets.filter( + (t) => t.resourceId === entry.resourceId + ); } const resourcesList: ResourceWithTargets[] = Array.from(map.values()); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0]?.count ?? 0; - return response(res, { data: { resources: resourcesList, pagination: { total: totalCount, - limit, - offset + pageSize, + page } }, success: true, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 4fe05c265..cc2924995 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -4,7 +4,17 @@ import { remoteExitNodes } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; -import { and, count, eq, inArray, or, sql } from "drizzle-orm"; +import { + and, + asc, + count, + desc, + eq, + ilike, + inArray, + or, + sql +} from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -12,6 +22,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import semver from "semver"; import cache from "@server/lib/cache"; +import type { PaginatedResponse } from "@server/types/Pagination"; async function getLatestNewtVersion(): Promise { try { @@ -74,21 +85,34 @@ const listSitesParamsSchema = z.strictObject({ }); const listSitesSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().positive()), - offset: z - .string() + .catch(20) + .default(20), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1), + query: z.string().optional(), + sort_by: z + .enum(["megabytesIn", "megabytesOut"]) + .optional() + .catch(undefined), + order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc"), + online: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined) }); -function querySites(orgId: string, accessibleSiteIds: number[]) { +function querySitesBase() { return db .select({ siteId: sites.siteId, @@ -115,23 +139,16 @@ function querySites(orgId: string, accessibleSiteIds: number[]) { .leftJoin( remoteExitNodes, eq(remoteExitNodes.exitNodeId, sites.exitNodeId) - ) - .where( - and( - inArray(sites.siteId, accessibleSiteIds), - eq(sites.orgId, orgId) - ) ); } -type SiteWithUpdateAvailable = Awaited>[0] & { +type SiteWithUpdateAvailable = Awaited>[0] & { newtUpdateAvailable?: boolean; }; -export type ListSitesResponse = { +export type ListSitesResponse = PaginatedResponse<{ sites: SiteWithUpdateAvailable[]; - pagination: { total: number; limit: number; offset: number }; -}; +}>; registry.registerPath({ method: "get", @@ -160,7 +177,6 @@ export async function listSites( ) ); } - const { limit, offset } = parsedQuery.data; const parsedParams = listSitesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -203,34 +219,61 @@ export async function listSites( .where(eq(sites.orgId, orgId)); } - const accessibleSiteIds = accessibleSites.map((site) => site.siteId); - const baseQuery = querySites(orgId, accessibleSiteIds); + const { pageSize, page, query, sort_by, order, online } = + parsedQuery.data; - const countQuery = db - .select({ count: count() }) - .from(sites) - .where( - and( - inArray(sites.siteId, accessibleSiteIds), - eq(sites.orgId, orgId) + const accessibleSiteIds = accessibleSites.map((site) => site.siteId); + + const conditions = [ + and( + inArray(sites.siteId, accessibleSiteIds), + eq(sites.orgId, orgId) + ) + ]; + if (query) { + conditions.push( + or( + ilike(sites.name, "%" + query + "%"), + ilike(sites.niceId, "%" + query + "%") ) ); + } + if (typeof online !== "undefined") { + conditions.push(eq(sites.online, online)); + } - const sitesList = await baseQuery.limit(limit).offset(offset); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0].count; + const baseQuery = querySitesBase().where(and(...conditions)); + + // we need to add `as` so that drizzle filters the result as a subquery + const countQuery = db.$count( + querySitesBase().where(and(...conditions)) + ); + + const siteListQuery = baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(sites[sort_by]) + : desc(sites[sort_by]) + : asc(sites.siteId) + ); + + const [totalCount, rows] = await Promise.all([ + countQuery, + siteListQuery + ]); // Get latest version asynchronously without blocking the response const latestNewtVersionPromise = getLatestNewtVersion(); - const sitesWithUpdates: SiteWithUpdateAvailable[] = sitesList.map( - (site) => { - const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; - // Initially set to false, will be updated if version check succeeds - siteWithUpdate.newtUpdateAvailable = false; - return siteWithUpdate; - } - ); + const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => { + const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; + // Initially set to false, will be updated if version check succeeds + siteWithUpdate.newtUpdateAvailable = false; + return siteWithUpdate; + }); // Try to get the latest version, but don't block if it fails try { @@ -267,8 +310,8 @@ export async function listSites( sites: sitesWithUpdates, pagination: { total: totalCount, - limit, - offset + pageSize, + page } }, success: true, diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index b6140c275..48c298d32 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -284,7 +284,7 @@ export async function createSiteResource( niceId, orgId, name, - mode, + mode: mode as "host" | "cidr", // protocol: mode === "port" ? protocol : null, // proxyPort: mode === "port" ? proxyPort : null, // destinationPort: mode === "port" ? destinationPort : null, diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index dee1eebc9..f15d2eccb 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -1,41 +1,73 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, resources } from "@server/db"; import { siteResources, sites, SiteResource } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { eq, and } from "drizzle-orm"; +import { eq, and, asc, ilike, or } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; +import type { PaginatedResponse } from "@server/types/Pagination"; const listAllSiteResourcesByOrgParamsSchema = z.strictObject({ orgId: z.string() }); const listAllSiteResourcesByOrgQuerySchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().positive()), - offset: z - .string() + .catch(20) + .default(20), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1), + query: z.string().optional(), + mode: z.enum(["host", "cidr"]).optional().catch(undefined) }); -export type ListAllSiteResourcesByOrgResponse = { +export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ siteResources: (SiteResource & { siteName: string; siteNiceId: string; siteAddress: string | null; })[]; -}; +}>; + +function querySiteResourcesBase() { + return db + .select({ + siteResourceId: siteResources.siteResourceId, + siteId: siteResources.siteId, + orgId: siteResources.orgId, + niceId: siteResources.niceId, + name: siteResources.name, + mode: siteResources.mode, + protocol: siteResources.protocol, + proxyPort: siteResources.proxyPort, + destinationPort: siteResources.destinationPort, + destination: siteResources.destination, + enabled: siteResources.enabled, + alias: siteResources.alias, + aliasAddress: siteResources.aliasAddress, + tcpPortRangeString: siteResources.tcpPortRangeString, + udpPortRangeString: siteResources.udpPortRangeString, + disableIcmp: siteResources.disableIcmp, + siteName: sites.name, + siteNiceId: sites.niceId, + siteAddress: sites.address + }) + .from(siteResources) + .innerJoin(sites, eq(siteResources.siteId, sites.siteId)); +} registry.registerPath({ method: "get", @@ -80,39 +112,48 @@ export async function listAllSiteResourcesByOrg( } const { orgId } = parsedParams.data; - const { limit, offset } = parsedQuery.data; + const { page, pageSize, query, mode } = parsedQuery.data; - // Get all site resources for the org with site names - const siteResourcesList = await db - .select({ - siteResourceId: siteResources.siteResourceId, - siteId: siteResources.siteId, - orgId: siteResources.orgId, - niceId: siteResources.niceId, - name: siteResources.name, - mode: siteResources.mode, - protocol: siteResources.protocol, - proxyPort: siteResources.proxyPort, - destinationPort: siteResources.destinationPort, - destination: siteResources.destination, - enabled: siteResources.enabled, - alias: siteResources.alias, - aliasAddress: siteResources.aliasAddress, - tcpPortRangeString: siteResources.tcpPortRangeString, - udpPortRangeString: siteResources.udpPortRangeString, - disableIcmp: siteResources.disableIcmp, - siteName: sites.name, - siteNiceId: sites.niceId, - siteAddress: sites.address - }) - .from(siteResources) - .innerJoin(sites, eq(siteResources.siteId, sites.siteId)) - .where(eq(siteResources.orgId, orgId)) - .limit(limit) - .offset(offset); + const conditions = [and(eq(siteResources.orgId, orgId))]; + if (query) { + conditions.push( + or( + ilike(siteResources.name, "%" + query + "%"), + ilike(siteResources.destination, "%" + query + "%"), + ilike(siteResources.alias, "%" + query + "%"), + ilike(siteResources.aliasAddress, "%" + query + "%"), + ilike(sites.name, "%" + query + "%") + ) + ); + } - return response(res, { - data: { siteResources: siteResourcesList }, + if (mode) { + conditions.push(eq(siteResources.mode, mode)); + } + + const baseQuery = querySiteResourcesBase().where(and(...conditions)); + + const countQuery = db.$count( + querySiteResourcesBase().where(and(...conditions)) + ); + + const [siteResourcesList, totalCount] = await Promise.all([ + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(asc(siteResources.siteResourceId)), + countQuery + ]); + + return response(res, { + data: { + siteResources: siteResourcesList, + pagination: { + total: totalCount, + pageSize, + page + } + }, success: true, error: false, message: "Site resources retrieved successfully", diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index 2bfcff190..01cbdea81 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -105,7 +105,10 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( await db .update(targetHealthCheck) .set({ - hcHealth: healthStatus.status + hcHealth: healthStatus.status as + | "unknown" + | "healthy" + | "unhealthy" }) .where(eq(targetHealthCheck.targetId, targetIdNum)) .execute(); diff --git a/server/types/Pagination.ts b/server/types/Pagination.ts new file mode 100644 index 000000000..b0f5edfe2 --- /dev/null +++ b/server/types/Pagination.ts @@ -0,0 +1,5 @@ +export type Pagination = { total: number; pageSize: number; page: number }; + +export type PaginatedResponse = T & { + pagination: Pagination; +}; diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx index b3e731e85..4b40c906c 100644 --- a/src/app/[orgId]/settings/clients/machine/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -7,10 +7,11 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { ListClientsResponse } from "@server/routers/client"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; +import type { Pagination } from "@server/types/Pagination"; type ClientsPageProps = { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; @@ -19,17 +20,25 @@ export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); let machineClients: ListClientsResponse["clients"] = []; + let pagination: Pagination = { + page: 1, + total: 0, + pageSize: 20 + }; try { const machineRes = await internal.get< AxiosResponse >( - `/org/${params.orgId}/clients?filter=machine`, + `/org/${params.orgId}/clients?${searchParams.toString()}`, await authCookieHeader() ); - machineClients = machineRes.data.data.clients; + const responseData = machineRes.data.data; + machineClients = responseData.clients; + pagination = responseData.pagination; } catch (e) {} function formatSize(mb: number): string { @@ -80,6 +89,11 @@ export default async function ClientsPage(props: ClientsPageProps) { ); diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx index 7acf52b29..c08551865 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -602,7 +602,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .biometricsEnabled + .biometricsEnabled === + true ) : "-"} @@ -622,7 +623,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .diskEncrypted + .diskEncrypted === + true ) : "-"} @@ -642,7 +644,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .firewallEnabled + .firewallEnabled === + true ) : "-"} @@ -663,7 +666,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .autoUpdatesEnabled + .autoUpdatesEnabled === + true ) : "-"} @@ -683,7 +687,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .tpmAvailable + .tpmAvailable === + true ) : "-"} @@ -707,7 +712,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .windowsAntivirusEnabled + .windowsAntivirusEnabled === + true ) : "-"} @@ -727,7 +733,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .macosSipEnabled + .macosSipEnabled === + true ) : "-"} @@ -751,7 +758,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .macosGatekeeperEnabled + .macosGatekeeperEnabled === + true ) : "-"} @@ -775,7 +783,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .macosFirewallStealthMode + .macosFirewallStealthMode === + true ) : "-"} @@ -796,7 +805,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .linuxAppArmorEnabled + .linuxAppArmorEnabled === + true ) : "-"} @@ -817,7 +827,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .linuxSELinuxEnabled + .linuxSELinuxEnabled === + true ) : "-"} diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index 35a2b2e31..fcb24e4e3 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -1,14 +1,16 @@ -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import { AxiosResponse } from "axios"; 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 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 = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; @@ -17,15 +19,26 @@ export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); - let userClients: ListClientsResponse["clients"] = []; + let userClients: ListUserDevicesResponse["devices"] = []; + + let pagination: Pagination = { + page: 1, + total: 0, + pageSize: 20 + }; try { - const userRes = await internal.get>( - `/org/${params.orgId}/clients?filter=user`, + const userRes = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/user-devices?${searchParams.toString()}`, await authCookieHeader() ); - userClients = userRes.data.data.clients; + const responseData = userRes.data.data; + userClients = responseData.devices; + pagination = responseData.pagination; } catch (e) {} function formatSize(mb: number): string { @@ -39,31 +52,29 @@ export default async function ClientsPage(props: ClientsPageProps) { } const mapClientToRow = ( - client: ListClientsResponse["clients"][0] + client: ListUserDevicesResponse["devices"][number] ): ClientRow => { // Build fingerprint object if any fingerprint data exists const hasFingerprintData = - (client as any).fingerprintPlatform || - (client as any).fingerprintOsVersion || - (client as any).fingerprintKernelVersion || - (client as any).fingerprintArch || - (client as any).fingerprintSerialNumber || - (client as any).fingerprintUsername || - (client as any).fingerprintHostname || - (client as any).deviceModel; + client.fingerprintPlatform || + client.fingerprintOsVersion || + client.fingerprintKernelVersion || + client.fingerprintArch || + client.fingerprintSerialNumber || + client.fingerprintUsername || + client.fingerprintHostname || + client.deviceModel; const fingerprint = hasFingerprintData ? { - platform: (client as any).fingerprintPlatform || null, - osVersion: (client as any).fingerprintOsVersion || null, - kernelVersion: - (client as any).fingerprintKernelVersion || null, - arch: (client as any).fingerprintArch || null, - deviceModel: (client as any).deviceModel || null, - serialNumber: - (client as any).fingerprintSerialNumber || null, - username: (client as any).fingerprintUsername || null, - hostname: (client as any).fingerprintHostname || null + platform: client.fingerprintPlatform, + osVersion: client.fingerprintOsVersion, + kernelVersion: client.fingerprintKernelVersion, + arch: client.fingerprintArch, + deviceModel: client.deviceModel, + serialNumber: client.fingerprintSerialNumber, + username: client.fingerprintUsername, + hostname: client.fingerprintHostname } : null; @@ -71,19 +82,19 @@ export default async function ClientsPage(props: ClientsPageProps) { name: client.name, id: client.clientId, subnet: client.subnet.split("/")[0], - mbIn: formatSize(client.megabytesIn || 0), - mbOut: formatSize(client.megabytesOut || 0), + mbIn: formatSize(client.megabytesIn ?? 0), + mbOut: formatSize(client.megabytesOut ?? 0), orgId: params.orgId, online: client.online, olmVersion: client.olmVersion || undefined, - olmUpdateAvailable: client.olmUpdateAvailable || false, + olmUpdateAvailable: Boolean(client.olmUpdateAvailable), userId: client.userId, username: client.username, userEmail: client.userEmail, niceId: client.niceId, agent: client.agent, - archived: client.archived || false, - blocked: client.blocked || false, + archived: Boolean(client.archived), + blocked: Boolean(client.blocked), approvalState: client.approvalState, fingerprint }; @@ -101,6 +112,11 @@ export default async function ClientsPage(props: ClientsPageProps) { ); diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index ac85520e9..f5e1a701d 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -14,7 +14,7 @@ import { redirect } from "next/navigation"; export interface ClientResourcesPageProps { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; + searchParams: Promise>; } export default async function ClientResourcesPage( @@ -22,22 +22,24 @@ export default async function ClientResourcesPage( ) { const params = await props.params; const t = await getTranslations(); - - let resources: ListResourcesResponse["resources"] = []; - try { - const res = await internal.get>( - `/org/${params.orgId}/resources`, - await authCookieHeader() - ); - resources = res.data.data.resources; - } catch (e) {} + const searchParams = new URLSearchParams(await props.searchParams); let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; + let pagination: ListResourcesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; try { const res = await internal.get< AxiosResponse - >(`/org/${params.orgId}/site-resources`, await authCookieHeader()); - siteResources = res.data.data.siteResources; + >( + `/org/${params.orgId}/site-resources?${searchParams.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + siteResources = responseData.siteResources; + pagination = responseData.pagination; } catch (e) {} let org = null; @@ -89,9 +91,10 @@ export default async function ClientResourcesPage( diff --git a/src/app/[orgId]/settings/resources/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/page.tsx index 408a9352c..57505c53c 100644 --- a/src/app/[orgId]/settings/resources/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/page.tsx @@ -16,7 +16,7 @@ import { cache } from "react"; export interface ProxyResourcesPageProps { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; + searchParams: Promise>; } export default async function ProxyResourcesPage( @@ -24,14 +24,22 @@ export default async function ProxyResourcesPage( ) { const params = await props.params; const t = await getTranslations(); + const searchParams = new URLSearchParams(await props.searchParams); let resources: ListResourcesResponse["resources"] = []; + let pagination: ListResourcesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; try { const res = await internal.get>( - `/org/${params.orgId}/resources`, + `/org/${params.orgId}/resources?${searchParams.toString()}`, await authCookieHeader() ); - resources = res.data.data.resources; + const responseData = res.data.data; + resources = responseData.resources; + pagination = responseData.pagination; } catch (e) {} let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; @@ -104,9 +112,10 @@ export default async function ProxyResourcesPage( diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 85f0e2b1a..161c757f6 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -9,19 +9,30 @@ import { getTranslations } from "next-intl/server"; type SitesPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; export default async function SitesPage(props: SitesPageProps) { const params = await props.params; + + const searchParams = new URLSearchParams(await props.searchParams); + let sites: ListSitesResponse["sites"] = []; + let pagination: ListSitesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; try { const res = await internal.get>( - `/org/${params.orgId}/sites`, + `/org/${params.orgId}/sites?${searchParams.toString()}`, await authCookieHeader() ); - sites = res.data.data.sites; + const responseData = res.data.data; + sites = responseData.sites; + pagination = responseData.pagination; } catch (e) {} const t = await getTranslations(); @@ -60,8 +71,6 @@ export default async function SitesPage(props: SitesPageProps) { return ( <> - {/* */} - - + ); } diff --git a/src/components/ApprovalFeed.tsx b/src/components/ApprovalFeed.tsx index 87a9d11a4..14465fe33 100644 --- a/src/components/ApprovalFeed.tsx +++ b/src/components/ApprovalFeed.tsx @@ -2,16 +2,16 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { cn } from "@app/lib/cn"; import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { approvalFiltersSchema, approvalQueries, type ApprovalItem } from "@app/lib/queries"; -import { useQuery } from "@tanstack/react-query"; -import { ArrowRight, Ban, Check, LaptopMinimal, RefreshCw } from "lucide-react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Ban, Check, Loader, RefreshCw } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; @@ -54,12 +54,20 @@ export function ApprovalFeed({ const { isPaidUser } = usePaidStatus(); - const { data, isFetching, refetch } = useQuery({ + const { + data, + isFetching, + isLoading, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage + } = useInfiniteQuery({ ...approvalQueries.listApprovals(orgId, filters), enabled: isPaidUser(tierMatrix.deviceApprovals) }); - const approvals = data?.approvals ?? []; + const approvals = data?.pages.flatMap((data) => data.approvals) ?? []; // Show empty state if no approvals are enabled for any role if (!hasApprovalsEnabled) { @@ -115,13 +123,13 @@ export function ApprovalFeed({ onClick={() => { refetch(); }} - disabled={isFetching} + disabled={isFetching || isLoading} className="lg:static gap-2" > {t("refresh")} @@ -145,13 +153,30 @@ export function ApprovalFeed({ ))} {approvals.length === 0 && ( -
  • - {t("approvalListEmpty")} +
  • + {isLoading + ? t("loadingApprovals") + : t("approvalListEmpty")} + + {isLoading && ( + + )}
  • )} + {hasNextPage && ( + + )} ); } diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index c49cde8d2..126eb2421 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -25,6 +25,11 @@ import CreateInternalResourceDialog from "@app/components/CreateInternalResource import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; +import type { PaginationState } from "@tanstack/react-table"; +import { ControlledDataTable } from "./ui/controlled-data-table"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useDebouncedCallback } from "use-debounce"; +import { ColumnFilterButton } from "./ColumnFilterButton"; export type InternalResourceRow = { id: number; @@ -51,18 +56,22 @@ export type InternalResourceRow = { type ClientResourcesTableProps = { internalResources: InternalResourceRow[]; orgId: string; - defaultSort?: { - id: string; - desc: boolean; - }; + pagination: PaginationState; + rowCount: number; }; export default function ClientResourcesTable({ internalResources, orgId, - defaultSort + pagination, + rowCount }: ClientResourcesTableProps) { const router = useRouter(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const t = useTranslations(); const { env } = useEnvContext(); @@ -122,19 +131,7 @@ export default function ClientResourcesTable({ accessorKey: "name", enableHiding: false, friendlyName: t("name"), - header: ({ column }) => { - return ( - - ); - } + header: () => {t("name")} }, { id: "niceId", @@ -180,9 +177,24 @@ export default function ClientResourcesTable({ accessorKey: "mode", friendlyName: t("editInternalResourceDialogMode"), header: () => ( - - {t("editInternalResourceDialogMode")} - + handleFilterChange("mode", value)} + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("editInternalResourceDialogMode")} + className="p-3" + /> ), cell: ({ row }) => { const resourceRow = row.original; @@ -300,6 +312,37 @@ export default function ClientResourcesTable({ } ]; + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + searchParams.delete(column); + searchParams.delete("page"); + + if (value) { + searchParams.set(column, value); + } + filter({ + searchParams + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + return ( <> {selectedInternalResource && ( @@ -327,19 +370,20 @@ export default function ClientResourcesTable({ /> )} - setIsCreateDialogOpen(true)} addButtonText={t("resourceAdd")} + onSearch={handleSearchChange} onRefresh={refreshData} - isRefreshing={isRefreshing} - defaultSort={defaultSort} - enableColumnVisibility={true} - persistColumnVisibility="internal-resources" + onPaginationChange={handlePaginationChange} + pagination={pagination} + rowCount={rowCount} + isRefreshing={isRefreshing || isFiltering} + enableColumnVisibility columnVisibility={{ niceId: false, aliasAddress: false diff --git a/src/components/ColumnFilter.tsx b/src/components/ColumnFilter.tsx index a856984eb..3e7b585b8 100644 --- a/src/components/ColumnFilter.tsx +++ b/src/components/ColumnFilter.tsx @@ -15,6 +15,7 @@ import { } from "@app/components/ui/command"; import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react"; import { cn } from "@app/lib/cn"; +import { Badge } from "./ui/badge"; interface FilterOption { value: string; @@ -61,16 +62,19 @@ export function ColumnFilter({ >
    - - {selectedOption - ? selectedOption.label - : placeholder} - + + {selectedOption && ( + + {selectedOption + ? selectedOption.label + : placeholder} + + )}
    - + diff --git a/src/components/ColumnFilterButton.tsx b/src/components/ColumnFilterButton.tsx new file mode 100644 index 000000000..7d17066cb --- /dev/null +++ b/src/components/ColumnFilterButton.tsx @@ -0,0 +1,126 @@ +import { useState } from "react"; +import { Button } from "@app/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react"; +import { cn } from "@app/lib/cn"; +import { Badge } from "./ui/badge"; + +interface FilterOption { + value: string; + label: string; +} + +interface ColumnFilterButtonProps { + options: FilterOption[]; + selectedValue?: string; + onValueChange: (value: string | undefined) => void; + placeholder?: string; + searchPlaceholder?: string; + emptyMessage?: string; + className?: string; + label: string; +} + +export function ColumnFilterButton({ + options, + selectedValue, + onValueChange, + placeholder, + searchPlaceholder = "Search...", + emptyMessage = "No options found", + className, + label +}: ColumnFilterButtonProps) { + const [open, setOpen] = useState(false); + + const selectedOption = options.find( + (option) => option.value === selectedValue + ); + + return ( + + + + + + + + + {emptyMessage} + + {/* Clear filter option */} + {selectedValue && ( + { + onValueChange(undefined); + setOpen(false); + }} + className="text-muted-foreground" + > + Clear filter + + )} + {options.map((option) => ( + { + onValueChange( + selectedValue === option.value + ? undefined + : option.value + ); + setOpen(false); + }} + > + + {option.label} + + ))} + + + + + + ); +} diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 34e8f55e2..25e5a7213 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -255,10 +255,7 @@ export default function CreateInternalResourceDialog({ const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId })); const { data: clientsResponse = [] } = useQuery( orgQueries.clients({ - orgId, - filters: { - filter: "machine" - } + orgId }) ); diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 4c1176e5c..d6078052a 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -277,10 +277,7 @@ export default function EditInternalResourceDialog({ orgQueries.roles({ orgId }), orgQueries.users({ orgId }), orgQueries.clients({ - orgId, - filters: { - filter: "machine" - } + orgId }), resourceQueries.siteResourceUsers({ siteResourceId: resource.id }), resourceQueries.siteResourceRoles({ siteResourceId: resource.id }), diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index ad01c40fa..97de41130 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -16,13 +16,23 @@ import { ArrowRight, ArrowUpDown, MoreHorizontal, - CircleSlash + CircleSlash, + ArrowDown01Icon, + ArrowUp10Icon, + ChevronsUpDownIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; import { Badge } from "./ui/badge"; +import type { PaginationState } from "@tanstack/react-table"; +import { ControlledDataTable } from "./ui/controlled-data-table"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { ColumnFilterButton } from "./ColumnFilterButton"; export type ClientRow = { id: number; @@ -48,14 +58,24 @@ export type ClientRow = { type ClientTableProps = { machineClients: ClientRow[]; orgId: string; + pagination: PaginationState; + rowCount: number; }; export default function MachineClientsTable({ machineClients, - orgId + orgId, + pagination, + rowCount }: ClientTableProps) { const router = useRouter(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + const t = useTranslations(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -65,6 +85,7 @@ export default function MachineClientsTable({ const api = createApiClient(useEnvContext()); const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); const defaultMachineColumnVisibility = { subnet: false, @@ -182,22 +203,8 @@ export default function MachineClientsTable({ { accessorKey: "name", enableHiding: false, - friendlyName: "Name", - header: ({ column }) => { - return ( - - ); - }, + friendlyName: t("name"), + header: () => {t("name")}, cell: ({ row }) => { const r = row.original; return ( @@ -224,38 +231,35 @@ export default function MachineClientsTable({ { accessorKey: "niceId", friendlyName: "Identifier", - header: ({ column }) => { - return ( - - ); - } + header: () => {t("identifier")} }, { accessorKey: "online", - friendlyName: "Connectivity", - header: ({ column }) => { + friendlyName: t("online"), + header: () => { return ( - + onValueChange={(value) => + handleFilterChange("online", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("online")} + className="p-3" + /> ); }, cell: ({ row }) => { @@ -279,38 +283,52 @@ export default function MachineClientsTable({ }, { accessorKey: "mbIn", - friendlyName: "Data In", - header: ({ column }) => { + friendlyName: t("dataIn"), + header: () => { + const dataInOrder = getSortDirection( + "megabytesIn", + searchParams + ); + + const Icon = + dataInOrder === "asc" + ? ArrowDown01Icon + : dataInOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } }, { accessorKey: "mbOut", - friendlyName: "Data Out", - header: ({ column }) => { + friendlyName: t("dataOut"), + header: () => { + const dataOutOrder = getSortDirection( + "megabytesOut", + searchParams + ); + + const Icon = + dataOutOrder === "asc" + ? ArrowDown01Icon + : dataOutOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -318,21 +336,7 @@ export default function MachineClientsTable({ { accessorKey: "client", friendlyName: t("agent"), - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("agent")}, cell: ({ row }) => { const originalRow = row.original; @@ -356,22 +360,8 @@ export default function MachineClientsTable({ }, { accessorKey: "subnet", - friendlyName: "Address", - header: ({ column }) => { - return ( - - ); - } + friendlyName: t("address"), + header: () => {t("address")} } ]; @@ -455,7 +445,56 @@ export default function MachineClientsTable({ } return baseColumns; - }, [hasRowsWithoutUserId, t]); + }, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]); + + const booleanSearchFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + + function handleFilterChange( + column: string, + value: string | null | undefined | string[] + ) { + searchParams.delete(column); + searchParams.delete("page"); + + if (typeof value === "string") { + searchParams.set(column, value); + } else if (value) { + for (const val of value) { + searchParams.append(column, val); + } + } + + filter({ + searchParams + }); + } + + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); return ( <> @@ -478,20 +517,25 @@ export default function MachineClientsTable({ title="Delete Client" /> )} - - router.push(`/${orgId}/settings/clients/machine/create`) + startNavigation(() => + router.push(`/${orgId}/settings/clients/machine/create`) + ) } + pagination={pagination} + rowCount={rowCount} addButtonText={t("createClient")} onRefresh={refreshData} - isRefreshing={isRefreshing} - enableColumnVisibility={true} - persistColumnVisibility="machine-clients" + isRefreshing={isRefreshing || isFiltering} + onSearch={handleSearchChange} + onPaginationChange={handlePaginationChange} + isNavigatingToAddPage={isNavigatingToAddPage} + enableColumnVisibility columnVisibility={defaultMachineColumnVisibility} stickyLeftColumn="name" stickyRightColumn="actions" @@ -518,30 +562,10 @@ export default function MachineClientsTable({ value: "blocked" } ], - filterFn: ( - row: ClientRow, - selectedValues: (string | number | boolean)[] - ) => { - if (selectedValues.length === 0) return true; - const rowArchived = row.archived || false; - const rowBlocked = row.blocked || false; - const isActive = !rowArchived && !rowBlocked; - - if (selectedValues.includes("active") && isActive) - return true; - if ( - selectedValues.includes("archived") && - rowArchived - ) - return true; - if ( - selectedValues.includes("blocked") && - rowBlocked - ) - return true; - return false; + onValueChange(selectedValues: string[]) { + handleFilterChange("status", selectedValues); }, - defaultValues: ["active"] // Default to showing active clients + values: searchParams.getAll("status") } ]} /> diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx index b2939a90c..e139e43af 100644 --- a/src/components/OrgSelector.tsx +++ b/src/components/OrgSelector.tsx @@ -83,7 +83,7 @@ export function OrgSelector({ diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 69b180c47..490904c71 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -2,9 +2,8 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; -import { DataTable } from "@app/components/ui/data-table"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { Button } from "@app/components/ui/button"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -14,13 +13,14 @@ import { import { InfoPopup } from "@app/components/ui/info-popup"; import { Switch } from "@app/components/ui/switch"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { UpdateResourceResponse } from "@server/routers/resource"; +import type { PaginationState } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; import { ArrowRight, - ArrowUpDown, CheckCircle2, ChevronDown, Clock, @@ -32,14 +32,24 @@ import { import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; +import { + useOptimistic, + useRef, + useState, + useTransition, + type ComponentRef +} from "react"; +import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; +import { ColumnFilterButton } from "./ColumnFilterButton"; +import { ControlledDataTable } from "./ui/controlled-data-table"; export type TargetHealth = { targetId: number; ip: string; port: number; enabled: boolean; - healthStatus?: "healthy" | "unhealthy" | "unknown"; + healthStatus: "healthy" | "unhealthy" | "unknown" | null; }; export type ResourceRow = { @@ -117,18 +127,22 @@ function StatusIcon({ type ProxyResourcesTableProps = { resources: ResourceRow[]; orgId: string; - defaultSort?: { - id: string; - desc: boolean; - }; + pagination: PaginationState; + rowCount: number; }; export default function ProxyResourcesTable({ resources, orgId, - defaultSort + pagination, + rowCount }: ProxyResourcesTableProps) { const router = useRouter(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const t = useTranslations(); const { env } = useEnvContext(); @@ -140,6 +154,7 @@ export default function ProxyResourcesTable({ useState(); const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); const refreshData = () => { startTransition(() => { @@ -174,23 +189,24 @@ export default function ProxyResourcesTable({ }; async function toggleResourceEnabled(val: boolean, resourceId: number) { - await api - .post>( + try { + await api.post>( `resource/${resourceId}`, { enabled: val } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourcesErrorUpdate"), - description: formatAxiosError( - e, - t("resourcesErrorUpdateDescription") - ) - }); + ); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("resourcesErrorUpdate"), + description: formatAxiosError( + e, + t("resourcesErrorUpdateDescription") + ) }); + } } function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) { @@ -236,7 +252,7 @@ export default function ProxyResourcesTable({ - + {monitoredTargets.length > 0 && ( <> {monitoredTargets.map((target) => ( @@ -302,38 +318,14 @@ export default function ProxyResourcesTable({ accessorKey: "name", enableHiding: false, friendlyName: t("name"), - header: ({ column }) => { - return ( - - ); - } + header: () => {t("name")} }, { id: "niceId", accessorKey: "nice", friendlyName: t("identifier"), enableHiding: true, - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("identifier")}, cell: ({ row }) => { return {row.original.nice || "-"}; } @@ -359,19 +351,33 @@ export default function ProxyResourcesTable({ id: "status", accessorKey: "status", friendlyName: t("status"), - header: ({ column }) => { - return ( - - ); - }, + header: () => ( + + handleFilterChange("healthStatus", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("status")} + className="p-3" + /> + ), cell: ({ row }) => { const resourceRow = row.original; return ; @@ -419,19 +425,23 @@ export default function ProxyResourcesTable({ { accessorKey: "authState", friendlyName: t("authentication"), - header: ({ column }) => { - return ( - - ); - }, + header: () => ( + + handleFilterChange("authState", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("authentication")} + className="p-3" + /> + ), cell: ({ row }) => { const resourceRow = row.original; return ( @@ -456,20 +466,28 @@ export default function ProxyResourcesTable({ { accessorKey: "enabled", friendlyName: t("enabled"), - header: () => {t("enabled")}, + header: () => ( + + handleFilterChange("enabled", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("enabled")} + className="p-3" + /> + ), cell: ({ row }) => ( - - toggleResourceEnabled(val, row.original.id) - } + ) }, @@ -525,6 +543,42 @@ export default function ProxyResourcesTable({ } ]; + const booleanSearchFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + searchParams.delete(column); + searchParams.delete("page"); + + if (value) { + searchParams.set(column, value); + } + filter({ + searchParams + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + return ( <> {selectedResource && ( @@ -547,21 +601,25 @@ export default function ProxyResourcesTable({ /> )} - - router.push(`/${orgId}/settings/resources/proxy/create`) + startNavigation(() => + router.push(`/${orgId}/settings/resources/proxy/create`) + ) } addButtonText={t("resourceAdd")} onRefresh={refreshData} - isRefreshing={isRefreshing} - defaultSort={defaultSort} - enableColumnVisibility={true} - persistColumnVisibility="proxy-resources" + isRefreshing={isRefreshing || isFiltering} + isNavigatingToAddPage={isNavigatingToAddPage} + enableColumnVisibility columnVisibility={{ niceId: false }} stickyLeftColumn="name" stickyRightColumn="actions" @@ -569,3 +627,43 @@ export default function ProxyResourcesTable({ ); } + +type ResourceEnabledFormProps = { + resource: ResourceRow; + onToggleResourceEnabled: ( + val: boolean, + resourceId: number + ) => Promise; +}; + +function ResourceEnabledForm({ + resource, + onToggleResourceEnabled +}: ResourceEnabledFormProps) { + const enabled = resource.http + ? !!resource.domainId && resource.enabled + : resource.enabled; + const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled); + + const formRef = useRef>(null); + + async function submitAction(formData: FormData) { + const newEnabled = !(formData.get("enabled") === "on"); + setOptimisticEnabled(newEnabled); + await onToggleResourceEnabled(newEnabled, resource.id); + } + + return ( +
    + formRef.current?.requestSubmit()} + /> + + ); +} diff --git a/src/components/SitesDataTable.tsx b/src/components/SitesDataTable.tsx deleted file mode 100644 index 125f4d59a..000000000 --- a/src/components/SitesDataTable.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { ColumnDef } from "@tanstack/react-table"; -import { DataTable } from "@app/components/ui/data-table"; -import { useTranslations } from "next-intl"; - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - createSite?: () => void; - onRefresh?: () => void; - isRefreshing?: boolean; - columnVisibility?: Record; - enableColumnVisibility?: boolean; -} - -export function SitesDataTable({ - columns, - data, - createSite, - onRefresh, - isRefreshing, - columnVisibility, - enableColumnVisibility -}: DataTableProps) { - const t = useTranslations(); - - return ( - - ); -} diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index d8f50e997..c78577731 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -1,37 +1,42 @@ "use client"; -import { Column, ColumnDef } from "@tanstack/react-table"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; -import { SitesDataTable } from "@app/components/SitesDataTable"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; + +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; -import { Button } from "@app/components/ui/button"; -import { - ArrowRight, - ArrowUpDown, - ArrowUpRight, - Check, - MoreHorizontal, - X -} from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { AxiosResponse } from "axios"; -import { useState, useEffect } from "react"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { toast } from "@app/hooks/useToast"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useTranslations } from "next-intl"; -import { parseDataSize } from "@app/lib/dataSize"; -import { Badge } from "@app/components/ui/badge"; import { InfoPopup } from "@app/components/ui/info-popup"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { build } from "@server/build"; +import { type PaginationState } from "@tanstack/react-table"; +import { + ArrowDown01Icon, + ArrowRight, + ArrowUp10Icon, + ArrowUpRight, + ChevronsUpDownIcon, + MoreHorizontal +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; +import { ColumnFilterButton } from "./ColumnFilterButton"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "./ui/controlled-data-table"; export type SiteRow = { id: number; @@ -52,79 +57,91 @@ export type SiteRow = { type SitesTableProps = { sites: SiteRow[]; + pagination: PaginationState; orgId: string; + rowCount: number; }; -export default function SitesTable({ sites, orgId }: SitesTableProps) { +export default function SitesTable({ + sites, + orgId, + pagination, + rowCount +}: SitesTableProps) { const router = useRouter(); + const pathname = usePathname(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); - const [rows, setRows] = useState(sites); - const [isRefreshing, setIsRefreshing] = useState(false); + const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); const api = createApiClient(useEnvContext()); const t = useTranslations(); - const { env } = useEnvContext(); - // Update local state when props change (e.g., after refresh) - useEffect(() => { - setRows(sites); - }, [sites]); + const booleanSearchFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); - const refreshData = async () => { - console.log("Data refreshed"); - setIsRefreshing(true); - try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); - } catch (error) { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } finally { - setIsRefreshing(false); + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + const sp = new URLSearchParams(searchParams); + sp.delete(column); + sp.delete("page"); + + if (value) { + sp.set(column, value); } - }; + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + } - const deleteSite = (siteId: number) => { - api.delete(`/site/${siteId}`) - .catch((e) => { - console.error(t("siteErrorDelete"), e); - toast({ - variant: "destructive", - title: t("siteErrorDelete"), - description: formatAxiosError(e, t("siteErrorDelete")) - }); - }) - .then(() => { + function refreshData() { + startTransition(async () => { + try { router.refresh(); - setIsDeleteModalOpen(false); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + } - const newRows = rows.filter((row) => row.id !== siteId); - - setRows(newRows); - }); - }; + function deleteSite(siteId: number) { + startTransition(async () => { + await api + .delete(`/site/${siteId}`) + .catch((e) => { + console.error(t("siteErrorDelete"), e); + toast({ + variant: "destructive", + title: t("siteErrorDelete"), + description: formatAxiosError(e, t("siteErrorDelete")) + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); + }); + } const columns: ExtendedColumnDef[] = [ { accessorKey: "name", enableHiding: false, - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("name")}; } }, { @@ -132,18 +149,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { accessorKey: "nice", friendlyName: t("identifier"), enableHiding: true, - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("identifier")}; }, cell: ({ row }) => { return {row.original.nice || "-"}; @@ -152,17 +159,24 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { { accessorKey: "online", friendlyName: t("online"), - header: ({ column }) => { + header: () => { return ( - + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("online")} + className="p-3" + /> ); }, cell: ({ row }) => { @@ -194,58 +208,59 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { { accessorKey: "mbIn", friendlyName: t("dataIn"), - header: ({ column }) => { + header: () => { + const dataInOrder = getSortDirection( + "megabytesIn", + searchParams + ); + const Icon = + dataInOrder === "asc" + ? ArrowDown01Icon + : dataInOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); - }, - sortingFn: (rowA, rowB) => - parseDataSize(rowA.original.mbIn) - - parseDataSize(rowB.original.mbIn) + } }, { accessorKey: "mbOut", friendlyName: t("dataOut"), - header: ({ column }) => { + header: () => { + const dataOutOrder = getSortDirection( + "megabytesOut", + searchParams + ); + + const Icon = + dataOutOrder === "asc" + ? ArrowDown01Icon + : dataOutOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); - }, - sortingFn: (rowA, rowB) => - parseDataSize(rowA.original.mbOut) - - parseDataSize(rowB.original.mbOut) + } }, { accessorKey: "type", friendlyName: t("type"), - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("type")}; }, cell: ({ row }) => { const originalRow = row.original; @@ -290,18 +305,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { { accessorKey: "exitNode", friendlyName: t("exitNode"), - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("exitNode")}; }, cell: ({ row }) => { const originalRow = row.original; @@ -354,18 +359,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { }, { accessorKey: "address", - header: ({ column }: { column: Column }) => { - return ( - - ); + header: () => { + return {t("address")}; }, cell: ({ row }: { row: any }) => { const originalRow = row.original; @@ -428,6 +423,30 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { } ]; + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + return ( <> {selectedSite && ( @@ -444,27 +463,42 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { } buttonText={t("siteConfirmDelete")} - onConfirm={async () => deleteSite(selectedSite!.id)} + onConfirm={async () => + startTransition(() => deleteSite(selectedSite!.id)) + } string={selectedSite.name} title={t("siteDelete")} /> )} - - router.push(`/${orgId}/settings/sites/create`) + rows={sites} + tableId="sites-table" + searchPlaceholder={t("searchSitesProgress")} + pagination={pagination} + onPaginationChange={handlePaginationChange} + onAdd={() => + startNavigation(() => + router.push(`/${orgId}/settings/sites/create`) + ) } + isNavigatingToAddPage={isNavigatingToAddPage} + searchQuery={searchParams.get("query")?.toString()} + onSearch={handleSearchChange} + addButtonText={t("siteAdd")} onRefresh={refreshData} - isRefreshing={isRefreshing} + isRefreshing={isRefreshing || isFiltering} + rowCount={rowCount} columnVisibility={{ niceId: false, nice: false, exitNode: false, address: false }} - enableColumnVisibility={true} + enableColumnVisibility + stickyLeftColumn="name" + stickyRightColumn="actions" /> ); diff --git a/src/components/TanstackQueryProvider.tsx b/src/components/TanstackQueryProvider.tsx index 9a6e7dd99..ab469c2b3 100644 --- a/src/components/TanstackQueryProvider.tsx +++ b/src/components/TanstackQueryProvider.tsx @@ -1,11 +1,13 @@ "use client"; -import * as React from "react"; -import { QueryClientProvider } from "@tanstack/react-query"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { QueryClient } from "@tanstack/react-query"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient } from "@app/lib/api"; -import { durationToMs } from "@app/lib/durationToMs"; +import { + keepPreviousData, + QueryClient, + QueryClientProvider +} from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import * as React from "react"; export type ReactQueryProviderProps = { children: React.ReactNode; @@ -22,7 +24,8 @@ export function TanstackQueryProvider({ children }: ReactQueryProviderProps) { staleTime: 0, meta: { api - } + }, + placeholderData: keepPreviousData }, mutations: { meta: { api } diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 0a1cf287b..1b7b0c694 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -2,34 +2,41 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Button } from "@app/components/ui/button"; -import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; +import { InfoPopup } from "@app/components/ui/info-popup"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { formatFingerprintInfo, formatPlatform } from "@app/lib/formatDeviceFingerprint"; +import { build } from "@server/build"; +import type { PaginationState } from "@tanstack/react-table"; import { + ArrowDown01Icon, ArrowRight, - ArrowUpDown, + ArrowUp10Icon, ArrowUpRight, - MoreHorizontal, - CircleSlash + ChevronsUpDownIcon, + CircleSlash, + MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; import ClientDownloadBanner from "./ClientDownloadBanner"; +import { ColumnFilterButton } from "./ColumnFilterButton"; import { Badge } from "./ui/badge"; -import { build } from "@server/build"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { InfoPopup } from "@app/components/ui/info-popup"; +import { ControlledDataTable } from "./ui/controlled-data-table"; export type ClientRow = { id: number; @@ -65,9 +72,15 @@ export type ClientRow = { type ClientTableProps = { userClients: ClientRow[]; orgId: string; + pagination: PaginationState; + rowCount: number; }; -export default function UserDevicesTable({ userClients }: ClientTableProps) { +export default function UserDevicesTable({ + userClients, + pagination, + rowCount +}: ClientTableProps) { const router = useRouter(); const t = useTranslations(); @@ -77,6 +90,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { ); const api = createApiClient(useEnvContext()); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const [isRefreshing, startTransition] = useTransition(); const defaultUserColumnVisibility = { @@ -188,8 +206,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { try { // Fetch approvalId for this client using clientId query parameter const approvalsRes = await api.get<{ - data: { approvals: Array<{ approvalId: number; clientId: number }> }; - }>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`); + data: { + approvals: Array<{ approvalId: number; clientId: number }>; + }; + }>( + `/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}` + ); const approval = approvalsRes.data.data.approvals[0]; @@ -202,9 +224,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { return; } - await api.put(`/org/${clientRow.orgId}/approvals/${approval.approvalId}`, { - decision: "approved" - }); + await api.put( + `/org/${clientRow.orgId}/approvals/${approval.approvalId}`, + { + decision: "approved" + } + ); toast({ title: t("accessApprovalUpdated"), @@ -230,8 +255,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { try { // Fetch approvalId for this client using clientId query parameter const approvalsRes = await api.get<{ - data: { approvals: Array<{ approvalId: number; clientId: number }> }; - }>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`); + data: { + approvals: Array<{ approvalId: number; clientId: number }>; + }; + }>( + `/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}` + ); const approval = approvalsRes.data.data.approvals[0]; @@ -244,9 +273,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { return; } - await api.put(`/org/${clientRow.orgId}/approvals/${approval.approvalId}`, { - decision: "denied" - }); + await api.put( + `/org/${clientRow.orgId}/approvals/${approval.approvalId}`, + { + decision: "denied" + } + ); toast({ title: t("accessApprovalUpdated"), @@ -279,21 +311,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { accessorKey: "name", enableHiding: false, friendlyName: t("name"), - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("name")}, cell: ({ row }) => { const r = row.original; const fingerprintInfo = r.fingerprint @@ -343,40 +361,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "niceId", friendlyName: t("identifier"), - header: ({ column }) => { - return ( - - ); - } + header: () => {t("identifier")} }, { accessorKey: "userEmail", friendlyName: t("users"), - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("users")}, cell: ({ row }) => { const r = row.original; return r.userId ? ( @@ -398,20 +388,31 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }, { accessorKey: "online", - friendlyName: t("connectivity"), - header: ({ column }) => { + friendlyName: t("online"), + header: () => { return ( - + onValueChange={(value) => + handleFilterChange("online", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("online")} + className="p-3" + /> ); }, cell: ({ row }) => { @@ -436,18 +437,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "mbIn", friendlyName: t("dataIn"), - header: ({ column }) => { + header: () => { + const dataInOrder = getSortDirection( + "megabytesIn", + searchParams + ); + + const Icon = + dataInOrder === "asc" + ? ArrowDown01Icon + : dataInOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -455,18 +463,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "mbOut", friendlyName: t("dataOut"), - header: ({ column }) => { + header: () => { + const dataOutOrder = getSortDirection( + "megabytesOut", + searchParams + ); + + const Icon = + dataOutOrder === "asc" + ? ArrowDown01Icon + : dataOutOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -474,21 +489,52 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "client", friendlyName: t("agent"), - header: ({ column }) => { - return ( - - ); - }, + ]} + selectedValue={searchParams.get("agent") ?? undefined} + onValueChange={(value) => + handleFilterChange("agent", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("agent")} + className="p-3" + /> + ), cell: ({ row }) => { const originalRow = row.original; @@ -514,21 +560,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "subnet", friendlyName: t("address"), - header: ({ column }) => { - return ( - - ); - } + header: () => {t("address")} } ]; @@ -548,20 +580,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { - {clientRow.approvalState === "pending" && ( - <> - approveDevice(clientRow)} - > - {t("approve")} - - denyDevice(clientRow)} - > - {t("deny")} - - - )} + {clientRow.approvalState === "pending" && + build !== "oss" && ( + <> + + approveDevice(clientRow) + } + > + {t("approve")} + + + denyDevice(clientRow) + } + > + {t("deny")} + + + )} { if (clientRow.archived) { @@ -621,7 +658,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }); return baseColumns; - }, [hasRowsWithoutUserId, t]); + }, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]); const statusFilterOptions = useMemo(() => { const allOptions = [ @@ -652,12 +689,59 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { } ]; + if (build === "oss") { + return allOptions.filter( + (option) => + option.value !== "pending" && option.value !== "denied" + ); + } + return allOptions; }, [t]); - const statusFilterDefaultValues = useMemo(() => { - return ["active", "pending"]; - }, []); + function handleFilterChange( + column: string, + value: string | null | undefined | string[] + ) { + searchParams.delete(column); + searchParams.delete("page"); + + if (typeof value === "string") { + searchParams.set(column, value); + } else if (value) { + for (const val of value) { + searchParams.append(column, val); + } + } + + filter({ + searchParams + }); + } + + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); return ( <> @@ -682,17 +766,19 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { )} - { - if (selectedValues.length === 0) return true; - const rowArchived = row.archived; - const rowBlocked = row.blocked; - const approvalState = row.approvalState; - const isActive = !rowArchived && !rowBlocked && approvalState !== "pending" && approvalState !== "denied"; - - if (selectedValues.includes("active") && isActive) - return true; - if ( - selectedValues.includes("pending") && - approvalState === "pending" - ) - return true; - if ( - selectedValues.includes("denied") && - approvalState === "denied" - ) - return true; - if ( - selectedValues.includes("archived") && - rowArchived - ) - return true; - if ( - selectedValues.includes("blocked") && - rowBlocked - ) - return true; - return false; + onValueChange: (selectedValues: string[]) => { + handleFilterChange("status", selectedValues); }, - defaultValues: statusFilterDefaultValues + values: searchParams.getAll("status") } ]} /> diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx new file mode 100644 index 000000000..4b87a5209 --- /dev/null +++ b/src/components/ui/controlled-data-table.tsx @@ -0,0 +1,597 @@ +"use client"; + +import { + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + PaginationState, + useReactTable +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { Button } from "@app/components/ui/button"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Input } from "@app/components/ui/input"; +import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility"; + +import { Columns, Filter, Plus, RefreshCw, Search } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useMemo, useState } from "react"; + +// Extended ColumnDef type that includes optional friendlyName for column visibility dropdown +export type ExtendedColumnDef = ColumnDef< + TData, + TValue +> & { + friendlyName?: string; +}; + +type FilterOption = { + id: string; + label: string; + value: string; +}; + +type DataTableFilter = { + id: string; + label: string; + options: FilterOption[]; + multiSelect?: boolean; + onValueChange: (selectedValues: string[]) => void; + values?: string[]; + displayMode?: "label" | "calculated"; // How to display the filter button text +}; + +export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void; + +type ControlledDataTableProps = { + columns: ExtendedColumnDef[]; + rows: TData[]; + tableId: string; + addButtonText?: string; + onAdd?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; + isNavigatingToAddPage?: boolean; + searchPlaceholder?: string; + filters?: DataTableFilter[]; + filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter) + columnVisibility?: Record; + enableColumnVisibility?: boolean; + onSearch?: (input: string) => void; + searchQuery?: string; + onPaginationChange: DataTablePaginationUpdateFn; + stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column + stickyRightColumn?: string; // Column ID or accessorKey for right sticky column (typically "actions") + rowCount: number; + pagination: PaginationState; +}; + +export function ControlledDataTable({ + columns, + rows, + addButtonText, + onAdd, + onRefresh, + isRefreshing, + searchPlaceholder = "Search...", + filters, + filterDisplayMode = "label", + columnVisibility: defaultColumnVisibility, + enableColumnVisibility = false, + tableId, + pagination, + stickyLeftColumn, + onSearch, + searchQuery, + onPaginationChange, + stickyRightColumn, + rowCount, + isNavigatingToAddPage +}: ControlledDataTableProps) { + const t = useTranslations(); + + const [columnFilters, setColumnFilters] = useState([]); + + const [columnVisibility, setColumnVisibility] = useStoredColumnVisibility( + tableId, + defaultColumnVisibility + ); + + // TODO: filters + const activeFilters = useMemo(() => { + const initial: Record = {}; + filters?.forEach((filter) => { + initial[filter.id] = filter.values || []; + }); + return initial; + }, [filters]); + + console.log({ + pagination, + rowCount + }); + + const table = useReactTable({ + data: rows, + columns, + getCoreRowModel: getCoreRowModel(), + // getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: (state) => { + const newState = + typeof state === "function" ? state(pagination) : state; + onPaginationChange(newState); + }, + manualFiltering: true, + manualPagination: true, + rowCount, + state: { + columnFilters, + columnVisibility, + pagination + } + }); + + // Calculate display text for a filter based on selected values + const getFilterDisplayText = (filter: DataTableFilter): string => { + const selectedValues = activeFilters[filter.id] || []; + + if (selectedValues.length === 0) { + return filter.label; + } + + const selectedOptions = filter.options.filter((option) => + selectedValues.includes(option.value) + ); + + if (selectedOptions.length === 0) { + return filter.label; + } + + if (selectedOptions.length === 1) { + return selectedOptions[0].label; + } + + // Multiple selections: always join with "and" + return selectedOptions.map((opt) => opt.label).join(" or "); + }; + + const handleFilterChange = ( + filterId: string, + optionValue: string, + checked: boolean + ) => { + const currentValues = activeFilters[filterId] || []; + const filter = filters?.find((f) => f.id === filterId); + + if (!filter) return; + + let newValues: string[]; + + if (filter.multiSelect) { + // Multi-select: add or remove the value + if (checked) { + newValues = [...currentValues, optionValue]; + } else { + newValues = currentValues.filter((v) => v !== optionValue); + } + } else { + // Single-select: replace the value + newValues = checked ? [optionValue] : []; + } + + filter.onValueChange(newValues); + }; + + // Helper function to check if a column should be sticky + const isStickyColumn = ( + columnId: string | undefined, + accessorKey: string | undefined, + position: "left" | "right" + ): boolean => { + if (position === "left" && stickyLeftColumn) { + return ( + columnId === stickyLeftColumn || + accessorKey === stickyLeftColumn + ); + } + if (position === "right" && stickyRightColumn) { + return ( + columnId === stickyRightColumn || + accessorKey === stickyRightColumn + ); + } + return false; + }; + + // Get sticky column classes + const getStickyClasses = ( + columnId: string | undefined, + accessorKey: string | undefined + ): string => { + if (isStickyColumn(columnId, accessorKey, "left")) { + return "md:sticky md:left-0 z-10 bg-card [mask-image:linear-gradient(to_left,transparent_0%,black_20px)]"; + } + if (isStickyColumn(columnId, accessorKey, "right")) { + return "sticky right-0 z-10 w-auto min-w-fit bg-card [mask-image:linear-gradient(to_right,transparent_0%,black_20px)]"; + } + return ""; + }; + + return ( +
    + + +
    + {onSearch && ( +
    + + onSearch(e.currentTarget.value) + } + className="w-full pl-8" + /> + +
    + )} + + {filters && filters.length > 0 && ( +
    + {filters.map((filter) => { + const selectedValues = + activeFilters[filter.id] || []; + const hasActiveFilters = + selectedValues.length > 0; + const displayMode = + filter.displayMode || filterDisplayMode; + const displayText = + displayMode === "calculated" + ? getFilterDisplayText(filter) + : filter.label; + + return ( + + + + + + + {filter.label} + + + {filter.options.map( + (option) => { + const isChecked = + selectedValues.includes( + option.value + ); + return ( + { + handleFilterChange( + filter.id, + option.value, + checked + ); + }} + onSelect={(e) => + e.preventDefault() + } + > + {option.label} + + ); + } + )} + + + ); + })} +
    + )} +
    +
    + {onRefresh && ( +
    + +
    + )} + {onAdd && addButtonText && ( +
    + +
    + )} +
    +
    + +
    + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const columnId = header.column.id; + const accessorKey = ( + header.column.columnDef as any + ).accessorKey as string | undefined; + const stickyClasses = + getStickyClasses( + columnId, + accessorKey + ); + const isRightSticky = + isStickyColumn( + columnId, + accessorKey, + "right" + ); + const hasHideableColumns = + enableColumnVisibility && + table + .getAllColumns() + .some((col) => + col.getCanHide() + ); + + return ( + + {header.isPlaceholder ? null : isRightSticky && + hasHideableColumns ? ( +
    + + + + + + + {t( + "toggleColumns" + ) || + "Toggle columns"} + + + {table + .getAllColumns() + .filter( + ( + column + ) => + column.getCanHide() + ) + .map( + ( + column + ) => { + const columnDef = + column.columnDef as any; + const friendlyName = + columnDef.friendlyName; + const displayName = + friendlyName || + (typeof columnDef.header === + "string" + ? columnDef.header + : column.id); + return ( + + column.toggleVisibility( + !!value + ) + } + onSelect={( + e + ) => + e.preventDefault() + } + > + { + displayName + } + + ); + } + )} + + +
    + {flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} +
    +
    + ) : ( + flexRender( + header.column + .columnDef + .header, + header.getContext() + ) + )} +
    + ); + })} +
    + ))} +
    + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => { + const columnId = + cell.column.id; + const accessorKey = ( + cell.column + .columnDef as any + ).accessorKey as + | string + | undefined; + const stickyClasses = + getStickyClasses( + columnId, + accessorKey + ); + const isRightSticky = + isStickyColumn( + columnId, + accessorKey, + "right" + ); + return ( + + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + No results found. + + + )} + +
    +
    +
    + {rowCount > 0 && ( + + onPaginationChange({ + ...pagination, + pageSize + }) + } + onPageChange={(pageIndex) => { + onPaginationChange({ + ...pagination, + pageIndex + }); + }} + isServerPagination + pageSize={pagination.pageSize} + pageIndex={pagination.pageIndex} + /> + )} +
    +
    +
    +
    + ); +} diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index af61bb53d..4d79ba0d7 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -151,11 +151,20 @@ type DataTableFilter = { label: string; options: FilterOption[]; multiSelect?: boolean; - filterFn: (row: any, selectedValues: (string | number | boolean)[]) => boolean; + filterFn: ( + row: any, + selectedValues: (string | number | boolean)[] + ) => boolean; defaultValues?: (string | number | boolean)[]; displayMode?: "label" | "calculated"; // How to display the filter button text }; +export type DataTablePaginationState = PaginationState & { + pageCount: number; +}; + +export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void; + type DataTableProps = { columns: ExtendedColumnDef[]; data: TData[]; @@ -178,6 +187,11 @@ type DataTableProps = { defaultPageSize?: number; columnVisibility?: Record; enableColumnVisibility?: boolean; + manualFiltering?: boolean; + onSearch?: (input: string) => void; + searchQuery?: string; + pagination?: DataTablePaginationState; + onPaginationChange?: DataTablePaginationUpdateFn; persistColumnVisibility?: boolean | string; stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column stickyRightColumn?: string; // Column ID or accessorKey for right sticky column (typically "actions") @@ -203,7 +217,12 @@ export function DataTable({ columnVisibility: defaultColumnVisibility, enableColumnVisibility = false, persistColumnVisibility = false, + manualFiltering = false, + pagination: paginationState, stickyLeftColumn, + onSearch, + searchQuery, + onPaginationChange, stickyRightColumn }: DataTableProps) { const t = useTranslations(); @@ -248,22 +267,25 @@ export function DataTable({ const [columnVisibility, setColumnVisibility] = useState( initialColumnVisibility ); - const [pagination, setPagination] = useState({ + const [_pagination, setPagination] = useState({ pageIndex: 0, pageSize: pageSize }); + + const pagination = paginationState ?? _pagination; + const [activeTab, setActiveTab] = useState( defaultTab || tabs?.[0]?.id || "" ); - const [activeFilters, setActiveFilters] = useState>( - () => { - const initial: Record = {}; - filters?.forEach((filter) => { - initial[filter.id] = filter.defaultValues || []; - }); - return initial; - } - ); + const [activeFilters, setActiveFilters] = useState< + Record + >(() => { + const initial: Record = {}; + filters?.forEach((filter) => { + initial[filter.id] = filter.defaultValues || []; + }); + return initial; + }); // Track initial values to avoid storing defaults on first render const initialPageSize = useRef(pageSize); @@ -298,6 +320,11 @@ export function DataTable({ return result; }, [data, tabs, activeTab, filters, activeFilters]); + console.log({ + pagination, + paginationState + }); + const table = useReactTable({ data: filteredData, columns, @@ -309,12 +336,18 @@ export function DataTable({ getFilteredRowModel: getFilteredRowModel(), onGlobalFilterChange: setGlobalFilter, onColumnVisibilityChange: setColumnVisibility, - onPaginationChange: setPagination, + onPaginationChange: onPaginationChange + ? (state) => { + const newState = + typeof state === "function" ? state(pagination) : state; + onPaginationChange(newState); + } + : setPagination, + manualFiltering, + manualPagination: Boolean(paginationState), + pageCount: paginationState?.pageCount, initialState: { - pagination: { - pageSize: pageSize, - pageIndex: 0 - }, + pagination, columnVisibility: initialColumnVisibility }, state: { @@ -368,11 +401,11 @@ export function DataTable({ setActiveFilters((prev) => { const currentValues = prev[filterId] || []; const filter = filters?.find((f) => f.id === filterId); - + if (!filter) return prev; let newValues: (string | number | boolean)[]; - + if (filter.multiSelect) { // Multi-select: add or remove the value if (checked) { @@ -397,7 +430,7 @@ export function DataTable({ // Calculate display text for a filter based on selected values const getFilterDisplayText = (filter: DataTableFilter): string => { const selectedValues = activeFilters[filter.id] || []; - + if (selectedValues.length === 0) { return filter.label; } @@ -477,12 +510,15 @@ export function DataTable({
    - table.setGlobalFilter( - String(e.target.value) - ) - } + defaultValue={searchQuery} + value={onSearch ? undefined : globalFilter} + onChange={(e) => { + onSearch + ? onSearch(e.currentTarget.value) + : table.setGlobalFilter( + String(e.target.value) + ); + }} className="w-full pl-8" /> @@ -490,13 +526,17 @@ export function DataTable({ {filters && filters.length > 0 && (
    {filters.map((filter) => { - const selectedValues = activeFilters[filter.id] || []; - const hasActiveFilters = selectedValues.length > 0; - const displayMode = filter.displayMode || filterDisplayMode; - const displayText = displayMode === "calculated" - ? getFilterDisplayText(filter) - : filter.label; - + const selectedValues = + activeFilters[filter.id] || []; + const hasActiveFilters = + selectedValues.length > 0; + const displayMode = + filter.displayMode || filterDisplayMode; + const displayText = + displayMode === "calculated" + ? getFilterDisplayText(filter) + : filter.label; + return ( @@ -507,37 +547,54 @@ export function DataTable({ > {displayText} - {displayMode === "label" && hasActiveFilters && ( - - {selectedValues.length} - - )} + {displayMode === "label" && + hasActiveFilters && ( + + { + selectedValues.length + } + + )} - + {filter.label} - {filter.options.map((option) => { - const isChecked = selectedValues.includes(option.value); - return ( - - handleFilterChange( - filter.id, - option.value, + {filter.options.map( + (option) => { + const isChecked = + selectedValues.includes( + option.value + ); + return ( + e.preventDefault()} - > - {option.label} - - ); - })} + ) => + handleFilterChange( + filter.id, + option.value, + checked + ) + } + onSelect={(e) => + e.preventDefault() + } + > + {option.label} + + ); + } + )} ); diff --git a/src/hooks/useNavigationContext.ts b/src/hooks/useNavigationContext.ts new file mode 100644 index 000000000..71b7c5523 --- /dev/null +++ b/src/hooks/useNavigationContext.ts @@ -0,0 +1,36 @@ +import { useSearchParams, usePathname, useRouter } from "next/navigation"; +import { useTransition } from "react"; + +export function useNavigationContext() { + const router = useRouter(); + const searchParams = useSearchParams(); + const path = usePathname(); + const [isNavigating, startTransition] = useTransition(); + + function navigate({ + searchParams: params, + pathname = path, + replace = false + }: { + pathname?: string; + searchParams?: URLSearchParams; + replace?: boolean; + }) { + startTransition(() => { + const fullPath = pathname + (params ? `?${params.toString()}` : ""); + + if (replace) { + router.replace(fullPath); + } else { + router.push(fullPath); + } + }); + } + + return { + pathname: path, + searchParams: new URLSearchParams(searchParams), // we want the search params to be writeable + navigate, + isNavigating + }; +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 0aa04dcf1..03670397f 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -18,11 +18,16 @@ import type { import type { ListTargetsResponse } from "@server/routers/target"; import type { ListUsersResponse } from "@server/routers/user"; import type ResponseT from "@server/types/Response"; -import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { + infiniteQueryOptions, + keepPreviousData, + queryOptions +} from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; import z from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; +import { wait } from "./wait"; export type ProductUpdate = { link: string | null; @@ -88,8 +93,7 @@ export const productUpdatesQueries = { }; export const clientFilterSchema = z.object({ - filter: z.enum(["machine", "user"]), - limit: z.int().prefault(1000).optional() + pageSize: z.int().prefault(1000).optional() }); export const orgQueries = { @@ -98,14 +102,13 @@ export const orgQueries = { filters }: { orgId: string; - filters: z.infer; + filters?: z.infer; }) => queryOptions({ queryKey: ["ORG", orgId, "CLIENTS", filters] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams({ - ...filters, - limit: (filters.limit ?? 1000).toString() + pageSize: (filters?.pageSize ?? 1000).toString() }); const res = await meta!.api.get< @@ -190,19 +193,16 @@ export const logAnalyticsFiltersSchema = z.object({ .refine((val) => !isNaN(Date.parse(val)), { error: "timeStart must be a valid ISO date string" }) - .optional(), + .optional() + .catch(undefined), timeEnd: z .string() .refine((val) => !isNaN(Date.parse(val)), { error: "timeEnd must be a valid ISO date string" }) - .optional(), - resourceId: z - .string() - .optional() - .transform(Number) - .pipe(z.int().positive()) .optional() + .catch(undefined), + resourceId: z.coerce.number().optional().catch(undefined) }); export type LogAnalyticsFilters = z.TypeOf; @@ -363,22 +363,50 @@ export const approvalQueries = { orgId: string, filters: z.infer ) => - queryOptions({ + infiniteQueryOptions({ queryKey: ["APPROVALS", orgId, filters] as const, - queryFn: async ({ signal, meta }) => { + queryFn: async ({ signal, pageParam, meta }) => { const sp = new URLSearchParams(); if (filters.approvalState) { sp.set("approvalState", filters.approvalState); } + if (pageParam) { + sp.set("cursorPending", pageParam.cursorPending.toString()); + sp.set( + "cursorTimestamp", + pageParam.cursorTimestamp.toString() + ); + } const res = await meta!.api.get< - AxiosResponse<{ approvals: ApprovalItem[]; }> + AxiosResponse<{ + approvals: ApprovalItem[]; + pagination: { + total: number; + limit: number; + cursorPending: number | null; + cursorTimestamp: number | null; + }; + }> >(`/org/${orgId}/approvals?${sp.toString()}`, { signal }); return res.data.data; - } + }, + initialPageParam: null as { + cursorPending: number; + cursorTimestamp: number; + } | null, + placeholderData: keepPreviousData, + getNextPageParam: ({ pagination }) => + pagination.cursorPending != null && + pagination.cursorTimestamp != null + ? { + cursorPending: pagination.cursorPending, + cursorTimestamp: pagination.cursorTimestamp + } + : null }), pendingCount: (orgId: string) => queryOptions({ @@ -390,6 +418,12 @@ export const approvalQueries = { signal }); return res.data.data.count; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "seconds"); + } + return false; } }) }; diff --git a/src/lib/sortColumn.ts b/src/lib/sortColumn.ts new file mode 100644 index 000000000..fcb4cc98f --- /dev/null +++ b/src/lib/sortColumn.ts @@ -0,0 +1,52 @@ +import type { SortOrder } from "@app/lib/types/sort"; + +export function getNextSortOrder( + column: string, + searchParams: URLSearchParams +) { + const sp = new URLSearchParams(searchParams); + + let nextDirection: SortOrder = "indeterminate"; + + if (sp.get("sort_by") === column) { + nextDirection = (sp.get("order") as SortOrder) ?? "indeterminate"; + } + + switch (nextDirection) { + case "indeterminate": { + nextDirection = "asc"; + break; + } + case "asc": { + nextDirection = "desc"; + break; + } + default: { + nextDirection = "indeterminate"; + break; + } + } + + sp.delete("sort_by"); + sp.delete("order"); + + if (nextDirection !== "indeterminate") { + sp.set("sort_by", column); + sp.set("order", nextDirection); + } + + return sp; +} + +export function getSortDirection( + column: string, + searchParams: URLSearchParams +) { + let currentDirection: SortOrder = "indeterminate"; + + if (searchParams.get("sort_by") === column) { + currentDirection = + (searchParams.get("order") as SortOrder) ?? "indeterminate"; + } + return currentDirection; +} diff --git a/src/lib/types/sort.ts b/src/lib/types/sort.ts new file mode 100644 index 000000000..69161f5af --- /dev/null +++ b/src/lib/types/sort.ts @@ -0,0 +1 @@ +export type SortOrder = "asc" | "desc" | "indeterminate";