diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 98c13479..82bd80e0 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -219,7 +219,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/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index add3b2b5..26a0d613 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -69,38 +69,6 @@ const listResourcesSchema = z.object({ .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; - - // total_targets: number; - // healthy_targets: number; - // unhealthy_targets: number; - // unknown_targets: number; - - // 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; diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index e27a328a..c65f8d10 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -247,7 +247,7 @@ export async function listSites( const baseQuery = querySitesBase().where(conditions); // we need to add `as` so that drizzle filters the result as a subquery - const countQuery = db.$count(baseQuery.as("filtered_sites")); + const countQuery = db.$count(querySitesBase().where(conditions)); const siteListQuery = baseQuery .limit(pageSize) diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index dee1eebc..2392eb76 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -1,11 +1,11 @@ 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"; @@ -15,18 +15,22 @@ const listAllSiteResourcesByOrgParamsSchema = z.strictObject({ }); 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 = { @@ -35,8 +39,36 @@ export type ListAllSiteResourcesByOrgResponse = { siteNiceId: string; siteAddress: string | null; })[]; + pagination: { total: number; pageSize: number; page: number }; }; +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", path: "/org/{orgId}/site-resources", @@ -80,39 +112,50 @@ export async function listAllSiteResourcesByOrg( } const { orgId } = parsedParams.data; - const { limit, offset } = parsedQuery.data; + const { page, pageSize, query, mode } = parsedQuery.data; + + let conditions = and(eq(siteResources.orgId, orgId)); + if (query) { + conditions = and( + conditions, + or( + ilike(siteResources.name, "%" + query + "%"), + ilike(siteResources.destination, "%" + query + "%"), + ilike(siteResources.alias, "%" + query + "%"), + ilike(siteResources.aliasAddress, "%" + query + "%"), + ilike(sites.name, "%" + query + "%") + ) + ); + } + + if (mode) { + conditions = and(conditions, eq(siteResources.mode, mode)); + } + + const baseQuery = querySiteResourcesBase().where(conditions); + + const countQuery = db.$count( + querySiteResourcesBase().where(conditions) + ); // 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 [siteResourcesList, totalCount] = await Promise.all([ + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(asc(siteResources.siteResourceId)), + countQuery + ]); - return response(res, { - data: { siteResources: siteResourcesList }, + return response(res, { + data: { + siteResources: siteResourcesList, + pagination: { + total: totalCount, + pageSize, + page + } + }, success: true, error: false, message: "Site resources retrieved successfully", diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index ac85520e..f5e1a701 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/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index c49cde8d..5dd9ae87 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(); @@ -180,9 +189,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 +324,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 +382,20 @@ export default function ClientResourcesTable({ /> )} - setIsCreateDialogOpen(true)} addButtonText={t("resourceAdd")} + onSearch={handleSearchChange} onRefresh={refreshData} + onPaginationChange={handlePaginationChange} + pagination={pagination} + rowCount={rowCount} isRefreshing={isRefreshing} - defaultSort={defaultSort} - enableColumnVisibility={true} - persistColumnVisibility="internal-resources" + enableColumnVisibility columnVisibility={{ niceId: false, aliasAddress: false diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index ca8f0443..a22d96b6 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, @@ -31,14 +31,12 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { useState, useTransition } from "react"; -import { ControlledDataTable } from "./ui/controlled-data-table"; -import type { PaginationState } from "@tanstack/react-table"; -import { useNavigationContext } from "@app/hooks/useNavigationContext"; 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; @@ -584,10 +582,6 @@ export default function ProxyResourcesTable({ }); }, 300); - console.log({ - rowCount - }); - return ( <> {selectedResource && ( diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index c6fb505c..88f03384 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -130,7 +130,8 @@ export function ControlledDataTable({ }); console.log({ - pagination + pagination, + rowCount }); const table = useReactTable({