From 38ac4c59805342a9b139d2bf0e9b93ede0897cc4 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 28 Jan 2026 04:46:54 +0100 Subject: [PATCH 01/38] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20paginated=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/site/listSites.ts | 4 +-- src/app/[orgId]/settings/sites/page.tsx | 2 -- src/components/SitesTable.tsx | 47 +++++++++---------------- src/lib/queries.ts | 40 ++++++++++----------- 4 files changed, 36 insertions(+), 57 deletions(-) diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 4fe05c265..68fa05b13 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -77,7 +77,7 @@ const listSitesSchema = z.object({ limit: z .string() .optional() - .default("1000") + .default("1") .transform(Number) .pipe(z.int().positive()), offset: z @@ -130,7 +130,7 @@ type SiteWithUpdateAvailable = Awaited>[0] & { export type ListSitesResponse = { sites: SiteWithUpdateAvailable[]; - pagination: { total: number; limit: number; offset: number }; + pagination: { total: number; limit: number; offset: number; }; }; registry.registerPath({ diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 85f0e2b1a..877eb594c 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -60,8 +60,6 @@ export default async function SitesPage(props: SitesPageProps) { return ( <> - {/* */} - (null); const [rows, setRows] = useState(sites); - const [isRefreshing, setIsRefreshing] = useState(false); + const [isRefreshing, startTransition] = 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 refreshData = async () => { - console.log("Data refreshed"); - setIsRefreshing(true); try { - await new Promise((resolve) => setTimeout(resolve, 200)); router.refresh(); } catch (error) { toast({ @@ -84,8 +71,6 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { description: t("refreshError"), variant: "destructive" }); - } finally { - setIsRefreshing(false); } }; @@ -456,7 +441,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { createSite={() => router.push(`/${orgId}/settings/sites/create`) } - onRefresh={refreshData} + onRefresh={() => startTransition(refreshData)} isRefreshing={isRefreshing} columnVisibility={{ niceId: false, diff --git a/src/lib/queries.ts b/src/lib/queries.ts index f0dfa811a..45746ed3a 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -113,7 +113,7 @@ export const orgQueries = { return res.data.data.clients; } }), - users: ({ orgId }: { orgId: string }) => + users: ({ orgId }: { orgId: string; }) => queryOptions({ queryKey: ["ORG", orgId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -124,7 +124,7 @@ export const orgQueries = { return res.data.data.users; } }), - roles: ({ orgId }: { orgId: string }) => + roles: ({ orgId }: { orgId: string; }) => queryOptions({ queryKey: ["ORG", orgId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -136,7 +136,7 @@ export const orgQueries = { } }), - sites: ({ orgId }: { orgId: string }) => + sites: ({ orgId }: { orgId: string; }) => queryOptions({ queryKey: ["ORG", orgId, "SITES"] as const, queryFn: async ({ signal, meta }) => { @@ -147,7 +147,7 @@ export const orgQueries = { } }), - domains: ({ orgId }: { orgId: string }) => + domains: ({ orgId }: { orgId: string; }) => queryOptions({ queryKey: ["ORG", orgId, "DOMAINS"] as const, queryFn: async ({ signal, meta }) => { @@ -169,7 +169,7 @@ export const orgQueries = { queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse<{ - idps: { idpId: number; name: string }[]; + idps: { idpId: number; name: string; }[]; }> >( build === "saas" || useOrgOnlyIdp @@ -188,23 +188,19 @@ 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() + .optional().catch(undefined), + resourceId: z.coerce.number().optional().catch(undefined) }); export type LogAnalyticsFilters = z.TypeOf; + export const logQueries = { requestAnalytics: ({ orgId, @@ -234,7 +230,7 @@ export const logQueries = { }; export const resourceQueries = { - resourceUsers: ({ resourceId }: { resourceId: number }) => + resourceUsers: ({ resourceId }: { resourceId: number; }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -244,7 +240,7 @@ export const resourceQueries = { return res.data.data.users; } }), - resourceRoles: ({ resourceId }: { resourceId: number }) => + resourceRoles: ({ resourceId }: { resourceId: number; }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -255,7 +251,7 @@ export const resourceQueries = { return res.data.data.roles; } }), - siteResourceUsers: ({ siteResourceId }: { siteResourceId: number }) => + siteResourceUsers: ({ siteResourceId }: { siteResourceId: number; }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -265,7 +261,7 @@ export const resourceQueries = { return res.data.data.users; } }), - siteResourceRoles: ({ siteResourceId }: { siteResourceId: number }) => + siteResourceRoles: ({ siteResourceId }: { siteResourceId: number; }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -276,7 +272,7 @@ export const resourceQueries = { return res.data.data.roles; } }), - siteResourceClients: ({ siteResourceId }: { siteResourceId: number }) => + siteResourceClients: ({ siteResourceId }: { siteResourceId: number; }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "CLIENTS"] as const, queryFn: async ({ signal, meta }) => { @@ -287,7 +283,7 @@ export const resourceQueries = { return res.data.data.clients; } }), - resourceTargets: ({ resourceId }: { resourceId: number }) => + resourceTargets: ({ resourceId }: { resourceId: number; }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "TARGETS"] as const, queryFn: async ({ signal, meta }) => { @@ -298,7 +294,7 @@ export const resourceQueries = { return res.data.data.targets; } }), - resourceWhitelist: ({ resourceId }: { resourceId: number }) => + resourceWhitelist: ({ resourceId }: { resourceId: number; }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "WHITELISTS"] as const, queryFn: async ({ signal, meta }) => { @@ -371,7 +367,7 @@ export const approvalQueries = { } const res = await meta!.api.get< - AxiosResponse<{ approvals: ApprovalItem[] }> + AxiosResponse<{ approvals: ApprovalItem[]; }> >(`/org/${orgId}/approvals?${sp.toString()}`, { signal }); @@ -383,7 +379,7 @@ export const approvalQueries = { queryKey: ["APPROVALS", orgId, "COUNT", "pending"] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< - AxiosResponse<{ count: number }> + AxiosResponse<{ count: number; }> >(`/org/${orgId}/approvals/count?approvalState=pending`, { signal }); From c89c1a03da759aa675b2c15f968e2e74d39afa9d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 29 Jan 2026 05:05:34 +0100 Subject: [PATCH 02/38] =?UTF-8?q?=F0=9F=8E=A8=20use=20prettier=20for=20for?= =?UTF-8?q?matting=20typescript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 01a2820390847edec61898203687092ab6d6c5bf Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 29 Jan 2026 05:07:27 +0100 Subject: [PATCH 03/38] =?UTF-8?q?=F0=9F=9A=A7=20POC:=20pagination=20in=20s?= =?UTF-8?q?ites=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/site/listSites.ts | 34 ++--- src/app/[orgId]/settings/sites/page.tsx | 27 +++- src/components/SitesDataTable.tsx | 50 -------- src/components/SitesTable.tsx | 66 +++++++--- src/components/ui/data-table.tsx | 158 ++++++++++++++++-------- src/lib/queries.ts | 35 +++--- 6 files changed, 217 insertions(+), 153 deletions(-) delete mode 100644 src/components/SitesDataTable.tsx diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 68fa05b13..dab79c8d9 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -74,18 +74,20 @@ const listSitesParamsSchema = z.strictObject({ }); const listSitesSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1") - .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) }); function querySites(orgId: string, accessibleSiteIds: number[]) { @@ -130,7 +132,7 @@ type SiteWithUpdateAvailable = Awaited>[0] & { export type ListSitesResponse = { sites: SiteWithUpdateAvailable[]; - pagination: { total: number; limit: number; offset: number; }; + pagination: { total: number; pageSize: number; page: number }; }; registry.registerPath({ @@ -160,7 +162,7 @@ export async function listSites( ) ); } - const { limit, offset } = parsedQuery.data; + const { pageSize, page } = parsedQuery.data; const parsedParams = listSitesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -216,7 +218,9 @@ export async function listSites( ) ); - const sitesList = await baseQuery.limit(limit).offset(offset); + const sitesList = await baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)); const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; @@ -267,8 +271,8 @@ export async function listSites( sites: sitesWithUpdates, pagination: { total: totalCount, - limit, - offset + pageSize, + page } }, success: true, diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 877eb594c..69bb599c7 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(); @@ -67,7 +78,17 @@ export default async function SitesPage(props: SitesPageProps) { - + ); } 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 58c2366b3..497715b19 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -1,10 +1,14 @@ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { SitesDataTable } from "@app/components/SitesDataTable"; + import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import { + DataTable, + ExtendedColumnDef, + type DataTablePaginationState +} from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -26,7 +30,7 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState, useTransition } from "react"; export type SiteRow = { @@ -48,15 +52,21 @@ export type SiteRow = { type SitesTableProps = { sites: SiteRow[]; + pagination: DataTablePaginationState; orgId: string; }; -export default function SitesTable({ sites, orgId }: SitesTableProps) { +export default function SitesTable({ + sites, + orgId, + pagination +}: SitesTableProps) { const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); - const [rows, setRows] = useState(sites); const [isRefreshing, startTransition] = useTransition(); const api = createApiClient(useEnvContext()); @@ -87,10 +97,6 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { .then(() => { router.refresh(); setIsDeleteModalOpen(false); - - const newRows = rows.filter((row) => row.id !== siteId); - - setRows(newRows); }); }; @@ -413,6 +419,11 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { } ]; + console.log({ + sites, + pagination + }); + return ( <> {selectedSite && ( @@ -429,27 +440,50 @@ 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`) - } + data={sites} + persistPageSize="sites-table" + title={t("sites")} + searchPlaceholder={t("searchSitesProgress")} + manualFiltering + pagination={pagination} + onPaginationChange={(newPage) => { + console.log({ + newPage + }); + const sp = new URLSearchParams(searchParams); + sp.set("page", (newPage.pageIndex + 1).toString()); + sp.set("pageSize", newPage.pageSize.toString()); + startTransition(() => + router.push(`${pathname}?${sp.toString()}`) + ); + }} + onAdd={() => router.push(`/${orgId}/settings/sites/create`)} + addButtonText={t("siteAdd")} onRefresh={() => startTransition(refreshData)} isRefreshing={isRefreshing} + defaultSort={{ + id: "name", + desc: false + }} columnVisibility={{ niceId: false, nice: false, exitNode: false, address: false }} - enableColumnVisibility={true} + enableColumnVisibility + stickyLeftColumn="name" + stickyRightColumn="actions" /> ); diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index af61bb53d..bb350577d 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; + searchValue?: 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, + searchValue, + 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); @@ -309,7 +331,16 @@ 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: !!paginationState, + pageCount: paginationState?.pageCount, initialState: { pagination: { pageSize: pageSize, @@ -368,11 +399,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 +428,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 +508,14 @@ export function DataTable({
- table.setGlobalFilter( - String(e.target.value) - ) - } + value={searchValue ?? globalFilter ?? ""} + onChange={(e) => { + onSearch + ? onSearch(e.currentTarget.value) + : table.setGlobalFilter( + String(e.target.value) + ); + }} className="w-full pl-8" /> @@ -490,13 +523,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 +544,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/lib/queries.ts b/src/lib/queries.ts index 45746ed3a..6c8e67c0b 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -113,7 +113,7 @@ export const orgQueries = { return res.data.data.clients; } }), - users: ({ orgId }: { orgId: string; }) => + users: ({ orgId }: { orgId: string }) => queryOptions({ queryKey: ["ORG", orgId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -124,7 +124,7 @@ export const orgQueries = { return res.data.data.users; } }), - roles: ({ orgId }: { orgId: string; }) => + roles: ({ orgId }: { orgId: string }) => queryOptions({ queryKey: ["ORG", orgId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -136,7 +136,7 @@ export const orgQueries = { } }), - sites: ({ orgId }: { orgId: string; }) => + sites: ({ orgId }: { orgId: string }) => queryOptions({ queryKey: ["ORG", orgId, "SITES"] as const, queryFn: async ({ signal, meta }) => { @@ -147,7 +147,7 @@ export const orgQueries = { } }), - domains: ({ orgId }: { orgId: string; }) => + domains: ({ orgId }: { orgId: string }) => queryOptions({ queryKey: ["ORG", orgId, "DOMAINS"] as const, queryFn: async ({ signal, meta }) => { @@ -169,7 +169,7 @@ export const orgQueries = { queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse<{ - idps: { idpId: number; name: string; }[]; + idps: { idpId: number; name: string }[]; }> >( build === "saas" || useOrgOnlyIdp @@ -188,19 +188,20 @@ export const logAnalyticsFiltersSchema = z.object({ .refine((val) => !isNaN(Date.parse(val)), { error: "timeStart must be a valid ISO date string" }) - .optional().catch(undefined), + .optional() + .catch(undefined), timeEnd: z .string() .refine((val) => !isNaN(Date.parse(val)), { error: "timeEnd must be a valid ISO date string" }) - .optional().catch(undefined), + .optional() + .catch(undefined), resourceId: z.coerce.number().optional().catch(undefined) }); export type LogAnalyticsFilters = z.TypeOf; - export const logQueries = { requestAnalytics: ({ orgId, @@ -230,7 +231,7 @@ export const logQueries = { }; export const resourceQueries = { - resourceUsers: ({ resourceId }: { resourceId: number; }) => + resourceUsers: ({ resourceId }: { resourceId: number }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -240,7 +241,7 @@ export const resourceQueries = { return res.data.data.users; } }), - resourceRoles: ({ resourceId }: { resourceId: number; }) => + resourceRoles: ({ resourceId }: { resourceId: number }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -251,7 +252,7 @@ export const resourceQueries = { return res.data.data.roles; } }), - siteResourceUsers: ({ siteResourceId }: { siteResourceId: number; }) => + siteResourceUsers: ({ siteResourceId }: { siteResourceId: number }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -261,7 +262,7 @@ export const resourceQueries = { return res.data.data.users; } }), - siteResourceRoles: ({ siteResourceId }: { siteResourceId: number; }) => + siteResourceRoles: ({ siteResourceId }: { siteResourceId: number }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -272,7 +273,7 @@ export const resourceQueries = { return res.data.data.roles; } }), - siteResourceClients: ({ siteResourceId }: { siteResourceId: number; }) => + siteResourceClients: ({ siteResourceId }: { siteResourceId: number }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "CLIENTS"] as const, queryFn: async ({ signal, meta }) => { @@ -283,7 +284,7 @@ export const resourceQueries = { return res.data.data.clients; } }), - resourceTargets: ({ resourceId }: { resourceId: number; }) => + resourceTargets: ({ resourceId }: { resourceId: number }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "TARGETS"] as const, queryFn: async ({ signal, meta }) => { @@ -294,7 +295,7 @@ export const resourceQueries = { return res.data.data.targets; } }), - resourceWhitelist: ({ resourceId }: { resourceId: number; }) => + resourceWhitelist: ({ resourceId }: { resourceId: number }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "WHITELISTS"] as const, queryFn: async ({ signal, meta }) => { @@ -367,7 +368,7 @@ export const approvalQueries = { } const res = await meta!.api.get< - AxiosResponse<{ approvals: ApprovalItem[]; }> + AxiosResponse<{ approvals: ApprovalItem[] }> >(`/org/${orgId}/approvals?${sp.toString()}`, { signal }); @@ -379,7 +380,7 @@ export const approvalQueries = { queryKey: ["APPROVALS", orgId, "COUNT", "pending"] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< - AxiosResponse<{ count: number; }> + AxiosResponse<{ count: number }> >(`/org/${orgId}/approvals/count?approvalState=pending`, { signal }); From d374ea6ea655371493e92bb07c6b885df8559ad9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 29 Jan 2026 05:07:41 +0100 Subject: [PATCH 04/38] =?UTF-8?q?=F0=9F=9A=A7wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SitesTable.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 497715b19..77698a2e8 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -457,9 +457,6 @@ export default function SitesTable({ manualFiltering pagination={pagination} onPaginationChange={(newPage) => { - console.log({ - newPage - }); const sp = new URLSearchParams(searchParams); sp.set("page", (newPage.pageIndex + 1).toString()); sp.set("pageSize", newPage.pageSize.toString()); From b04385a3404267ae6b2cbc30b87623a0a0cb13b8 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 29 Jan 2026 05:48:41 +0100 Subject: [PATCH 05/38] =?UTF-8?q?=F0=9F=9A=A7=20search=20on=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 14 ++++++++++++- package.json | 3 ++- server/routers/site/listSites.ts | 36 ++++++++++++++++++++++---------- src/components/SitesTable.tsx | 34 +++++++++++++++++------------- src/components/ui/data-table.tsx | 7 ++++--- 5 files changed, 64 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4a01c8c52..af864a576 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,6 +104,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", @@ -13944,7 +13945,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", @@ -23240,6 +23240,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 25d94c4d4..5de7629d7 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,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", @@ -152,6 +153,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", @@ -164,7 +166,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/routers/site/listSites.ts b/server/routers/site/listSites.ts index dab79c8d9..cefaeaf3c 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -4,7 +4,7 @@ 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, count, eq, ilike, inArray, or, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -87,10 +87,29 @@ const listSitesSchema = z.object({ .min(0) .optional() .catch(1) - .default(1) + .default(1), + query: z.string().optional() }); -function querySites(orgId: string, accessibleSiteIds: number[]) { +function querySites( + orgId: string, + accessibleSiteIds: number[], + query: string = "" +) { + let conditions = and( + inArray(sites.siteId, accessibleSiteIds), + eq(sites.orgId, orgId) + ); + + if (query) { + conditions = and( + conditions, + or( + ilike(sites.name, "%" + query + "%"), + ilike(sites.niceId, "%" + query + "%") + ) + ); + } return db .select({ siteId: sites.siteId, @@ -118,12 +137,7 @@ function querySites(orgId: string, accessibleSiteIds: number[]) { remoteExitNodes, eq(remoteExitNodes.exitNodeId, sites.exitNodeId) ) - .where( - and( - inArray(sites.siteId, accessibleSiteIds), - eq(sites.orgId, orgId) - ) - ); + .where(conditions); } type SiteWithUpdateAvailable = Awaited>[0] & { @@ -162,7 +176,7 @@ export async function listSites( ) ); } - const { pageSize, page } = parsedQuery.data; + const { pageSize, page, query } = parsedQuery.data; const parsedParams = listSitesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -206,7 +220,7 @@ export async function listSites( } const accessibleSiteIds = accessibleSites.map((site) => site.siteId); - const baseQuery = querySites(orgId, accessibleSiteIds); + const baseQuery = querySites(orgId, accessibleSiteIds, query); const countQuery = db .select({ count: count() }) diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 77698a2e8..5cbc92f69 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -21,7 +21,7 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { parseDataSize } from "@app/lib/dataSize"; import { build } from "@server/build"; -import { Column } from "@tanstack/react-table"; +import { Column, type PaginationState } from "@tanstack/react-table"; import { ArrowRight, ArrowUpDown, @@ -31,7 +31,8 @@ import { import { useTranslations } from "next-intl"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState, useTransition } from "react"; +import { useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; export type SiteRow = { id: number; @@ -419,10 +420,20 @@ export default function SitesTable({ } ]; - console.log({ - sites, - pagination - }); + const handlePaginationChange = (newPage: PaginationState) => { + const sp = new URLSearchParams(searchParams); + sp.set("page", (newPage.pageIndex + 1).toString()); + sp.set("pageSize", newPage.pageSize.toString()); + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + }; + + // const = useDebouncedCallback() + + const handleSearchChange = useDebouncedCallback((query: string) => { + const sp = new URLSearchParams(searchParams); + sp.set("query", query); + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + }, 300); return ( <> @@ -456,15 +467,10 @@ export default function SitesTable({ searchPlaceholder={t("searchSitesProgress")} manualFiltering pagination={pagination} - onPaginationChange={(newPage) => { - const sp = new URLSearchParams(searchParams); - sp.set("page", (newPage.pageIndex + 1).toString()); - sp.set("pageSize", newPage.pageSize.toString()); - startTransition(() => - router.push(`${pathname}?${sp.toString()}`) - ); - }} + onPaginationChange={handlePaginationChange} onAdd={() => router.push(`/${orgId}/settings/sites/create`)} + searchQuery={searchParams.get("query")?.toString()} + onSearch={handleSearchChange} addButtonText={t("siteAdd")} onRefresh={() => startTransition(refreshData)} isRefreshing={isRefreshing} diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index bb350577d..63e75f933 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -189,7 +189,7 @@ type DataTableProps = { enableColumnVisibility?: boolean; manualFiltering?: boolean; onSearch?: (input: string) => void; - searchValue?: string; + searchQuery?: string; pagination?: DataTablePaginationState; onPaginationChange?: DataTablePaginationUpdateFn; persistColumnVisibility?: boolean | string; @@ -221,7 +221,7 @@ export function DataTable({ pagination: paginationState, stickyLeftColumn, onSearch, - searchValue, + searchQuery, onPaginationChange, stickyRightColumn }: DataTableProps) { @@ -508,7 +508,8 @@ export function DataTable({
{ onSearch ? onSearch(e.currentTarget.value) From 89695df0129f5e72c2bc2d410dce2066012b938c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 30 Jan 2026 05:39:01 +0100 Subject: [PATCH 06/38] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20pagination=20and=20?= =?UTF-8?q?search=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/site/listSites.ts | 21 +- src/app/[orgId]/settings/sites/page.tsx | 4 +- src/components/SitesTable.tsx | 36 +- src/components/UserDevicesTable.tsx | 89 ++-- src/components/ui/data-table.tsx | 12 +- src/components/ui/manual-data-table.tsx | 567 ++++++++++++++++++++++++ 6 files changed, 667 insertions(+), 62 deletions(-) create mode 100644 src/components/ui/manual-data-table.tsx diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index cefaeaf3c..e77f63330 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -222,15 +222,24 @@ export async function listSites( const accessibleSiteIds = accessibleSites.map((site) => site.siteId); const baseQuery = querySites(orgId, accessibleSiteIds, query); + let conditions = and( + inArray(sites.siteId, accessibleSiteIds), + eq(sites.orgId, orgId) + ); + if (query) { + conditions = and( + conditions, + or( + ilike(sites.name, "%" + query + "%"), + ilike(sites.niceId, "%" + query + "%") + ) + ); + } + const countQuery = db .select({ count: count() }) .from(sites) - .where( - and( - inArray(sites.siteId, accessibleSiteIds), - eq(sites.orgId, orgId) - ) - ); + .where(conditions); const sitesList = await baseQuery .limit(pageSize) diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 69bb599c7..161c757f6 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -81,10 +81,8 @@ export default async function SitesPage(props: SitesPageProps) { router.push(`${pathname}?${sp.toString()}`)); }; - // const = useDebouncedCallback() - const handleSearchChange = useDebouncedCallback((query: string) => { const sp = new URLSearchParams(searchParams); sp.set("query", query); + sp.delete("page"); startTransition(() => router.push(`${pathname}?${sp.toString()}`)); }, 300); + console.log({ + pagination, + rowCount + }); + return ( <> {selectedSite && ( @@ -459,13 +464,11 @@ export default function SitesTable({ /> )} - router.push(`/${orgId}/settings/sites/create`)} @@ -474,10 +477,7 @@ export default function SitesTable({ addButtonText={t("siteAdd")} onRefresh={() => startTransition(refreshData)} isRefreshing={isRefreshing} - defaultSort={{ - id: "name", - desc: false - }} + rowCount={rowCount} columnVisibility={{ niceId: false, nice: false, diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 9d1469f1d..edc840882 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -13,7 +13,10 @@ 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 { formatFingerprintInfo, formatPlatform } from "@app/lib/formatDeviceFingerprint"; +import { + formatFingerprintInfo, + formatPlatform +} from "@app/lib/formatDeviceFingerprint"; import { ArrowRight, ArrowUpDown, @@ -188,9 +191,13 @@ 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]; if (!approval) { @@ -202,9 +209,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,9 +240,13 @@ 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]; if (!approval) { @@ -244,9 +258,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"), @@ -398,7 +415,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }, { accessorKey: "online", - friendlyName: t("connectivity"), + friendlyName: t("online"), header: ({ column }) => { 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} + /> + )} +
+
+ +
+ ); +} From 066305b09558aee3ed7bace863976afe5324deae Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 31 Jan 2026 00:45:14 +0100 Subject: [PATCH 07/38] =?UTF-8?q?=E2=9C=A8=20toggle=20column=20sorting=20&?= =?UTF-8?q?=20pagination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/site/listSites.ts | 73 ++++++++--------- src/components/SitesTable.tsx | 129 ++++++++++--------------------- src/hooks/useSortColumn.ts | 56 ++++++++++++++ src/lib/types/sort.ts | 1 + 4 files changed, 135 insertions(+), 124 deletions(-) create mode 100644 src/hooks/useSortColumn.ts create mode 100644 src/lib/types/sort.ts diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index e77f63330..9c25897e8 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, ilike, 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"; @@ -88,28 +98,15 @@ const listSitesSchema = z.object({ .optional() .catch(1) .default(1), - query: z.string().optional() + query: z.string().optional(), + sort_by: z + .enum(["megabytesIn", "megabytesOut"]) + .optional() + .catch(undefined), + order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc") }); -function querySites( - orgId: string, - accessibleSiteIds: number[], - query: string = "" -) { - let conditions = and( - inArray(sites.siteId, accessibleSiteIds), - eq(sites.orgId, orgId) - ); - - if (query) { - conditions = and( - conditions, - or( - ilike(sites.name, "%" + query + "%"), - ilike(sites.niceId, "%" + query + "%") - ) - ); - } +function querySitesBase() { return db .select({ siteId: sites.siteId, @@ -136,11 +133,10 @@ function querySites( .leftJoin( remoteExitNodes, eq(remoteExitNodes.exitNodeId, sites.exitNodeId) - ) - .where(conditions); + ); } -type SiteWithUpdateAvailable = Awaited>[0] & { +type SiteWithUpdateAvailable = Awaited>[0] & { newtUpdateAvailable?: boolean; }; @@ -176,7 +172,7 @@ export async function listSites( ) ); } - const { pageSize, page, query } = parsedQuery.data; + const { pageSize, page, query, sort_by, order } = parsedQuery.data; const parsedParams = listSitesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -220,7 +216,7 @@ export async function listSites( } const accessibleSiteIds = accessibleSites.map((site) => site.siteId); - const baseQuery = querySites(orgId, accessibleSiteIds, query); + const baseQuery = querySitesBase(); let conditions = and( inArray(sites.siteId, accessibleSiteIds), @@ -241,23 +237,30 @@ export async function listSites( .from(sites) .where(conditions); - const sitesList = await baseQuery + const siteListQuery = baseQuery + .where(conditions) .limit(pageSize) .offset(pageSize * (page - 1)); + + if (sort_by) { + siteListQuery.orderBy( + order === "asc" ? asc(sites[sort_by]) : desc(sites[sort_by]) + ); + } const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; // 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[] = ( + await siteListQuery + ).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 { diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 68a7fc374..f99da8895 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -12,15 +12,18 @@ import { } from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useSortColumn } from "@app/hooks/useSortColumn"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { parseDataSize } from "@app/lib/dataSize"; import { build } from "@server/build"; -import { Column, type PaginationState } from "@tanstack/react-table"; +import { type PaginationState } from "@tanstack/react-table"; import { + ArrowDown01Icon, ArrowRight, - ArrowUpDown, + ArrowUp10Icon, ArrowUpRight, + ChevronsUpDownIcon, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -71,6 +74,8 @@ export default function SitesTable({ const [selectedSite, setSelectedSite] = useState(null); const [isRefreshing, startTransition] = useTransition(); + const [getSortDirection, toggleSorting] = useSortColumn(); + const api = createApiClient(useEnvContext()); const t = useTranslations(); @@ -102,22 +107,15 @@ export default function SitesTable({ }); }; + const dataInOrder = getSortDirection("megabytesIn"); + const dataOutOrder = getSortDirection("megabytesOut"); + const columns: ExtendedColumnDef[] = [ { accessorKey: "name", enableHiding: false, - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("name")}; } }, { @@ -125,18 +123,8 @@ export default function SitesTable({ accessorKey: "nice", friendlyName: t("identifier"), enableHiding: true, - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("identifier")}; }, cell: ({ row }) => { return {row.original.nice || "-"}; @@ -145,18 +133,8 @@ export default function SitesTable({ { accessorKey: "online", friendlyName: t("online"), - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("online")}; }, cell: ({ row }) => { const originalRow = row.original; @@ -187,16 +165,20 @@ export default function SitesTable({ { accessorKey: "mbIn", friendlyName: t("dataIn"), - header: ({ column }) => { + header: () => { + const Icon = + dataInOrder === "asc" + ? ArrowDown01Icon + : dataInOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); }, @@ -207,16 +189,20 @@ export default function SitesTable({ { accessorKey: "mbOut", friendlyName: t("dataOut"), - header: ({ column }) => { + header: () => { + const Icon = + dataOutOrder === "asc" + ? ArrowDown01Icon + : dataOutOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); }, @@ -227,18 +213,8 @@ export default function SitesTable({ { accessorKey: "type", friendlyName: t("type"), - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("type")}; }, cell: ({ row }) => { const originalRow = row.original; @@ -283,18 +259,8 @@ export default function SitesTable({ { accessorKey: "exitNode", friendlyName: t("exitNode"), - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("exitNode")}; }, cell: ({ row }) => { const originalRow = row.original; @@ -347,18 +313,8 @@ export default function SitesTable({ }, { accessorKey: "address", - header: ({ column }: { column: Column }) => { - return ( - - ); + header: () => { + return {t("address")}; }, cell: ({ row }: { row: any }) => { const originalRow = row.original; @@ -435,11 +391,6 @@ export default function SitesTable({ startTransition(() => router.push(`${pathname}?${sp.toString()}`)); }, 300); - console.log({ - pagination, - rowCount - }); - return ( <> {selectedSite && ( diff --git a/src/hooks/useSortColumn.ts b/src/hooks/useSortColumn.ts new file mode 100644 index 000000000..95fb673eb --- /dev/null +++ b/src/hooks/useSortColumn.ts @@ -0,0 +1,56 @@ +import type { SortOrder } from "@app/lib/types/sort"; +import { useSearchParams, useRouter, usePathname } from "next/navigation"; +import { startTransition } from "react"; + +export function useSortColumn() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const toggleSorting = (column: string) => { + 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); + } + + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + }; + + function getSortDirection(column: string) { + let currentDirection: SortOrder = "indeterminate"; + + if (searchParams.get("sort_by") === column) { + currentDirection = + (searchParams.get("order") as SortOrder) ?? "indeterminate"; + } + return currentDirection; + } + + return [getSortDirection, toggleSorting] as const; +} 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"; From cda6b67befb30b2bb0b501af9814359333c14279 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 31 Jan 2026 03:02:39 +0100 Subject: [PATCH 08/38] =?UTF-8?q?=E2=9C=A8=20search,=20filter=20&=20pagina?= =?UTF-8?q?te=20sites=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 3 +- server/routers/site/listSites.ts | 14 ++- src/components/ColumnFilter.tsx | 16 ++-- src/components/ColumnFilterButton.tsx | 126 ++++++++++++++++++++++++++ src/components/OrgSelector.tsx | 2 +- src/components/SitesTable.tsx | 99 ++++++++++++++------ 6 files changed, 223 insertions(+), 37 deletions(-) create mode 100644 src/components/ColumnFilterButton.tsx diff --git a/messages/en-US.json b/messages/en-US.json index f2affe11a..8fffe02da 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1164,7 +1164,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/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 9c25897e8..1cc54fabd 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -103,7 +103,12 @@ const listSitesSchema = z.object({ .enum(["megabytesIn", "megabytesOut"]) .optional() .catch(undefined), - order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc") + order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc"), + online: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined) }); function querySitesBase() { @@ -172,7 +177,6 @@ export async function listSites( ) ); } - const { pageSize, page, query, sort_by, order } = parsedQuery.data; const parsedParams = listSitesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -215,6 +219,9 @@ export async function listSites( .where(eq(sites.orgId, orgId)); } + const { pageSize, page, query, sort_by, order, online } = + parsedQuery.data; + const accessibleSiteIds = accessibleSites.map((site) => site.siteId); const baseQuery = querySitesBase(); @@ -231,6 +238,9 @@ export async function listSites( ) ); } + if (typeof online !== "undefined") { + conditions = and(conditions, eq(sites.online, online)); + } const countQuery = db .select({ count: count() }) 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/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/SitesTable.tsx b/src/components/SitesTable.tsx index f99da8895..5076149f9 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -24,6 +24,7 @@ import { ArrowUp10Icon, ArrowUpRight, ChevronsUpDownIcon, + Funnel, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -35,6 +36,9 @@ import { ManualDataTable, type ExtendedColumnDef } from "./ui/manual-data-table"; +import { ColumnFilter } from "./ColumnFilter"; +import { ColumnFilterButton } from "./ColumnFilterButton"; +import z from "zod"; export type SiteRow = { id: number; @@ -79,33 +83,57 @@ export default function SitesTable({ const api = createApiClient(useEnvContext()); const t = useTranslations(); - const refreshData = async () => { - try { - router.refresh(); - } catch (error) { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } - }; + const booleanSearchFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); - 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 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()}`)); + } + + function refreshData() { + startTransition(async () => { + try { router.refresh(); - setIsDeleteModalOpen(false); - }); - }; + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + } + + 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 dataInOrder = getSortDirection("megabytesIn"); const dataOutOrder = getSortDirection("megabytesOut"); @@ -134,7 +162,24 @@ export default function SitesTable({ accessorKey: "online", friendlyName: t("online"), header: () => { - return {t("online")}; + return ( + + handleFilterChange("online", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("online")} + className="p-3" + /> + ); }, cell: ({ row }) => { const originalRow = row.original; @@ -426,7 +471,7 @@ export default function SitesTable({ searchQuery={searchParams.get("query")?.toString()} onSearch={handleSearchChange} addButtonText={t("siteAdd")} - onRefresh={() => startTransition(refreshData)} + onRefresh={refreshData} isRefreshing={isRefreshing} rowCount={rowCount} columnVisibility={{ From bb1a375484a1a4eccd117d97eb25eded67a2ab5a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 4 Feb 2026 02:20:28 +0100 Subject: [PATCH 09/38] =?UTF-8?q?=E2=9C=A8=20paginate,=20search=20&=20filt?= =?UTF-8?q?er=20resources=20by=20enabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/listResources.ts | 80 +++++++++----- .../[orgId]/settings/resources/proxy/page.tsx | 21 +++- src/components/ProxyResourcesTable.tsx | 104 +++++++++++++++--- src/components/SitesTable.tsx | 14 ++- ...ta-table.tsx => controlled-data-table.tsx} | 42 ++++--- src/hooks/useNavigationContext.ts | 36 ++++++ 6 files changed, 227 insertions(+), 70 deletions(-) rename src/components/ui/{manual-data-table.tsx => controlled-data-table.tsx} (96%) create mode 100644 src/hooks/useNavigationContext.ts diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index c17e65a40..a60d27e6f 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -17,7 +17,7 @@ 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 } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -27,19 +27,30 @@ const listResourcesParamsSchema = z.strictObject({ }); 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"]) + .optional() + .catch(undefined) }); // (resource fields + a single joined target) @@ -95,7 +106,7 @@ export type ResourceWithTargets = { }>; }; -function queryResources(accessibleResourceIds: number[], orgId: string) { +function queryResourcesBase() { return db .select({ resourceId: resources.resourceId, @@ -147,18 +158,12 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) - ) - .where( - and( - inArray(resources.resourceId, accessibleResourceIds), - eq(resources.orgId, orgId) - ) ); } export type ListResourcesResponse = { resources: ResourceWithTargets[]; - pagination: { total: number; limit: number; offset: number }; + pagination: { total: number; pageSize: number; page: number }; }; registry.registerPath({ @@ -190,7 +195,7 @@ export async function listResources( ) ); } - const { limit, offset } = parsedQuery.data; + const { page, pageSize, authState, enabled, query } = parsedQuery.data; const parsedParams = listResourcesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -252,14 +257,37 @@ export async function listResources( (resource) => resource.resourceId ); + let conditions = and( + and( + inArray(resources.resourceId, accessibleResourceIds), + eq(resources.orgId, orgId) + ) + ); + + if (query) { + conditions = and( + conditions, + or( + ilike(resources.name, "%" + query + "%"), + ilike(resources.fullDomain, "%" + query + "%") + ) + ); + } + if (typeof enabled !== "undefined") { + conditions = and(conditions, eq(resources.enabled, enabled)); + } + const countQuery: any = db .select({ count: count() }) .from(resources) - .where(inArray(resources.resourceId, accessibleResourceIds)); + .where(conditions); - const baseQuery = queryResources(accessibleResourceIds, orgId); + const baseQuery = queryResourcesBase(); - const rows: JoinedRow[] = await baseQuery.limit(limit).offset(offset); + const rows: JoinedRow[] = await baseQuery + .where(conditions) + .limit(pageSize) + .offset(pageSize * (page - 1)); // avoids TS issues with reduce/never[] const map = new Map(); @@ -324,8 +352,8 @@ export async function listResources( resources: resourcesList, pagination: { total: totalCount, - limit, - offset + pageSize, + page } }, success: true, 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/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 69b180c47..20eabc4d2 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -31,8 +31,14 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } 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"; export type TargetHealth = { targetId: number; @@ -117,18 +123,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 +150,7 @@ export default function ProxyResourcesTable({ useState(); const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); const refreshData = () => { startTransition(() => { @@ -236,7 +247,7 @@ export default function ProxyResourcesTable({ - + {monitoredTargets.length > 0 && ( <> {monitoredTargets.map((target) => ( @@ -456,7 +467,24 @@ 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 }) => ( { + 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 +611,27 @@ 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" diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 5076149f9..761177762 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -33,9 +33,9 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; import { - ManualDataTable, + ControlledDataTable, type ExtendedColumnDef -} from "./ui/manual-data-table"; +} from "./ui/controlled-data-table"; import { ColumnFilter } from "./ColumnFilter"; import { ColumnFilterButton } from "./ColumnFilterButton"; import z from "zod"; @@ -77,6 +77,7 @@ export default function SitesTable({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); const [getSortDirection, toggleSorting] = useSortColumn(); @@ -460,14 +461,19 @@ export default function SitesTable({ /> )} - router.push(`/${orgId}/settings/sites/create`)} + onAdd={() => + startNavigation(() => + router.push(`/${orgId}/settings/sites/create`) + ) + } + isNavigatingToAddPage={isNavigatingToAddPage} searchQuery={searchParams.get("query")?.toString()} onSearch={handleSearchChange} addButtonText={t("siteAdd")} diff --git a/src/components/ui/manual-data-table.tsx b/src/components/ui/controlled-data-table.tsx similarity index 96% rename from src/components/ui/manual-data-table.tsx rename to src/components/ui/controlled-data-table.tsx index 8653d465b..c6fb505cf 100644 --- a/src/components/ui/manual-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -64,7 +64,7 @@ type DataTableFilter = { export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void; -type ManualDataTableProps = { +type ControlledDataTableProps = { columns: ExtendedColumnDef[]; rows: TData[]; tableId: string; @@ -72,12 +72,13 @@ type ManualDataTableProps = { 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; + onSearch?: (input: string) => void; searchQuery?: string; onPaginationChange: DataTablePaginationUpdateFn; stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column @@ -86,7 +87,7 @@ type ManualDataTableProps = { pagination: PaginationState; }; -export function ManualDataTable({ +export function ControlledDataTable({ columns, rows, addButtonText, @@ -105,8 +106,9 @@ export function ManualDataTable({ searchQuery, onPaginationChange, stickyRightColumn, - rowCount -}: ManualDataTableProps) { + rowCount, + isNavigatingToAddPage +}: ControlledDataTableProps) { const t = useTranslations(); const [columnFilters, setColumnFilters] = useState([]); @@ -217,17 +219,20 @@ export function ManualDataTable({
-
- - onSearch(e.currentTarget.value) - } - className="w-full pl-8" - /> - -
+ {onSearch && ( +
+ + onSearch(e.currentTarget.value) + } + className="w-full pl-8" + /> + +
+ )} + {filters && filters.length > 0 && (
{filters.map((filter) => { @@ -326,7 +331,10 @@ export function ManualDataTable({ )} {onAdd && addButtonText && (
- 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 + }; +} From 1fc40b301743d54967cdc8acdffdf9ae2dc0856a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 4 Feb 2026 03:42:05 +0100 Subject: [PATCH 10/38] =?UTF-8?q?=E2=9C=A8=20filter=20by=20auth=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/listResources.ts | 72 ++++++++++++++- src/components/ProxyResourcesTable.tsx | 107 ++++++++--------------- 2 files changed, 107 insertions(+), 72 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index a60d27e6f..a9c4d88cd 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -17,7 +17,18 @@ 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, ilike } from "drizzle-orm"; +import { + sql, + eq, + or, + inArray, + and, + count, + ilike, + asc, + not, + isNull +} from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -48,7 +59,7 @@ const listResourcesSchema = z.object({ .optional() .catch(undefined), authState: z - .enum(["protected", "not_protected"]) + .enum(["protected", "not_protected", "none"]) .optional() .catch(undefined) }); @@ -277,9 +288,63 @@ export async function listResources( conditions = and(conditions, eq(resources.enabled, enabled)); } + if (typeof authState !== "undefined") { + switch (authState) { + case "none": + conditions = and(conditions, eq(resources.http, false)); + break; + case "protected": + conditions = and( + conditions, + 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 = and( + conditions, + not(eq(resources.sso, true)), + not(eq(resources.emailWhitelistEnabled, true)), + isNull(resourceHeaderAuth.headerAuthId), + isNull(resourcePincode.pincodeId), + isNull(resourcePassword.passwordId) + ); + break; + } + } + const countQuery: any = db .select({ count: count() }) .from(resources) + .leftJoin( + resourcePassword, + eq(resourcePassword.resourceId, resources.resourceId) + ) + .leftJoin( + resourcePincode, + eq(resourcePincode.resourceId, resources.resourceId) + ) + .leftJoin( + resourceHeaderAuth, + eq(resourceHeaderAuth.resourceId, resources.resourceId) + ) + .leftJoin( + resourceHeaderAuthExtendedCompatibility, + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + resources.resourceId + ) + ) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) + .leftJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ) .where(conditions); const baseQuery = queryResourcesBase(); @@ -287,7 +352,8 @@ export async function listResources( const rows: JoinedRow[] = await baseQuery .where(conditions) .limit(pageSize) - .offset(pageSize * (page - 1)); + .offset(pageSize * (page - 1)) + .orderBy(asc(resources.resourceId)); // avoids TS issues with reduce/never[] const map = new Map(); diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 20eabc4d2..f57601b05 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -185,23 +185,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[] }) { @@ -313,38 +314,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 || "-"}; } @@ -370,19 +347,7 @@ export default function ProxyResourcesTable({ id: "status", accessorKey: "status", friendlyName: t("status"), - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("status")}, cell: ({ row }) => { const resourceRow = row.original; return ; @@ -430,19 +395,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 ( @@ -487,16 +456,16 @@ export default function ProxyResourcesTable({ ), cell: ({ row }) => ( - toggleResourceEnabled(val, row.original.id) + startTransition(() => + toggleResourceEnabled(val, row.original.id) + ) } /> ) From 67949b4968c8fb3c05f9c38ae75ed159f6fdc052 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 4 Feb 2026 04:10:08 +0100 Subject: [PATCH 11/38] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20healthStatus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/listResources.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index a9c4d88cd..dc19cf550 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -61,6 +61,10 @@ const listResourcesSchema = z.object({ authState: z .enum(["protected", "not_protected", "none"]) .optional() + .catch(undefined), + healthStatus: z + .enum(["online", "degraded", "offline", "unknown"]) + .optional() .catch(undefined) }); @@ -206,7 +210,8 @@ export async function listResources( ) ); } - const { page, pageSize, authState, enabled, query } = parsedQuery.data; + const { page, pageSize, authState, enabled, query, healthStatus } = + parsedQuery.data; const parsedParams = listResourcesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -318,6 +323,15 @@ export async function listResources( } } + if (typeof healthStatus !== "undefined") { + switch (healthStatus) { + case "online": + break; + default: + break; + } + } + const countQuery: any = db .select({ count: count() }) .from(resources) From d309ec249e42605684bc79f971daf0bf81bb9ae6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 5 Feb 2026 03:15:18 +0100 Subject: [PATCH 12/38] =?UTF-8?q?=E2=9C=A8=20filter=20resources=20by=20sta?= =?UTF-8?q?tus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 4 +- server/db/sqlite/schema/schema.ts | 4 +- server/routers/resource/listResources.ts | 215 +++++++++++++++-------- server/routers/site/listSites.ts | 8 +- src/components/ProxyResourcesTable.tsx | 34 +++- 5 files changed, 185 insertions(+), 80 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 3c9574704..98c134798 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -187,7 +187,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") }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 4137db3cb..f26ecc088 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -213,7 +213,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") }); diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index dc19cf550..16b83e0ed 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -27,7 +27,8 @@ import { ilike, asc, not, - isNull + isNull, + type SQL } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; @@ -63,7 +64,7 @@ const listResourcesSchema = z.object({ .optional() .catch(undefined), healthStatus: z - .enum(["online", "degraded", "offline", "unknown"]) + .enum(["no_targets", "healthy", "degraded", "offline", "unknown"]) .optional() .catch(undefined) }); @@ -86,13 +87,18 @@ type JoinedRow = { domainId: string | null; headerAuthId: number | null; - targetId: number | null; - targetIp: string | null; - targetPort: number | null; - targetEnabled: boolean | null; + // total_targets: number; + // healthy_targets: number; + // unhealthy_targets: number; + // unknown_targets: number; - hcHealth: string | null; - hcEnabled: boolean | null; + // targetId: number | null; + // targetIp: string | null; + // targetPort: number | null; + // targetEnabled: boolean | null; + + // hcHealth: string | null; + // hcEnabled: boolean | null; }; // grouped by resource with targets[]) @@ -117,10 +123,68 @@ export type ResourceWithTargets = { ip: string; port: number; enabled: boolean; - healthStatus?: "healthy" | "unhealthy" | "unknown"; + healthStatus: "healthy" | "unhealthy" | "unknown" | null; }>; }; +// 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 countResourcesBase() { + return db + .select({ count: count() }) + .from(resources) + .leftJoin( + resourcePassword, + eq(resourcePassword.resourceId, resources.resourceId) + ) + .leftJoin( + resourcePincode, + eq(resourcePincode.resourceId, resources.resourceId) + ) + .leftJoin( + resourceHeaderAuth, + eq(resourceHeaderAuth.resourceId, resources.resourceId) + ) + .leftJoin( + resourceHeaderAuthExtendedCompatibility, + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + resources.resourceId + ) + ) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) + .leftJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ) + .groupBy( + resources.resourceId, + resourcePassword.passwordId, + resourcePincode.pincodeId, + resourceHeaderAuth.headerAuthId, + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId + ); +} + function queryResourcesBase() { return db .select({ @@ -140,14 +204,7 @@ function queryResourcesBase() { 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( @@ -173,6 +230,13 @@ function queryResourcesBase() { .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) + ) + .groupBy( + resources.resourceId, + resourcePassword.passwordId, + resourcePincode.pincodeId, + resourceHeaderAuth.headerAuthId, + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId ); } @@ -323,45 +387,52 @@ export async function listResources( } } + let aggregateFilters: SQL | null | undefined = null; + if (typeof healthStatus !== "undefined") { switch (healthStatus) { - case "online": + case "healthy": + aggregateFilters = and( + sql`${total_targets} > 0`, + sql`${healthy_targets} = ${total_targets}` + ); break; - default: + 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 countQuery: any = db - .select({ count: count() }) - .from(resources) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) - ) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .leftJoin( - resourceHeaderAuth, - eq(resourceHeaderAuth.resourceId, resources.resourceId) - ) - .leftJoin( - resourceHeaderAuthExtendedCompatibility, - eq( - resourceHeaderAuthExtendedCompatibility.resourceId, - resources.resourceId - ) - ) - .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) - .leftJoin( - targetHealthCheck, - eq(targetHealthCheck.targetId, targets.targetId) - ) - .where(conditions); + let baseQuery = queryResourcesBase(); + let countQuery = countResourcesBase().where(conditions); - const baseQuery = queryResourcesBase(); + if (aggregateFilters) { + // @ts-expect-error idk why this is causing a type error + baseQuery = baseQuery.having(aggregateFilters); + } + if (aggregateFilters) { + // @ts-expect-error idk why this is causing a type error + countQuery = countQuery.having(aggregateFilters); + } const rows: JoinedRow[] = await baseQuery .where(conditions) @@ -369,6 +440,27 @@ export async function listResources( .offset(pageSize * (page - 1)) .orderBy(asc(resources.resourceId)); + 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(sql`${targets.resourceId} in ${resourceIdList}`) + .leftJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ); + // avoids TS issues with reduce/never[] const map = new Map(); @@ -396,30 +488,9 @@ 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()); diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 1cc54fabd..8a0a85abd 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -111,6 +111,9 @@ const listSitesSchema = z.object({ .catch(undefined) }); +function countSitesBase() { + return db.select({ count: count() }).from(sites); +} function querySitesBase() { return db .select({ @@ -242,10 +245,7 @@ export async function listSites( conditions = and(conditions, eq(sites.online, online)); } - const countQuery = db - .select({ count: count() }) - .from(sites) - .where(conditions); + const countQuery = countSitesBase().where(conditions); const siteListQuery = baseQuery .where(conditions) diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index f57601b05..ca8f0443e 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -45,7 +45,7 @@ export type TargetHealth = { ip: string; port: number; enabled: boolean; - healthStatus?: "healthy" | "unhealthy" | "unknown"; + healthStatus: "healthy" | "unhealthy" | "unknown" | null; }; export type ResourceRow = { @@ -347,7 +347,33 @@ export default function ProxyResourcesTable({ id: "status", accessorKey: "status", friendlyName: t("status"), - header: () => {t("status")}, + header: () => ( + + handleFilterChange("healthStatus", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("status")} + className="p-3" + /> + ), cell: ({ row }) => { const resourceRow = row.original; return ; @@ -558,6 +584,10 @@ export default function ProxyResourcesTable({ }); }, 300); + console.log({ + rowCount + }); + return ( <> {selectedResource && ( From 748af1d8cb57789726a5e5519244640b448c5904 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 5 Feb 2026 05:21:25 +0100 Subject: [PATCH 13/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20cleanup=20code=20for?= =?UTF-8?q?=20searching=20&=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/listResources.ts | 69 +++++------------------- server/routers/site/listSites.ts | 31 ++++++----- 2 files changed, 30 insertions(+), 70 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 16b83e0ed..add3b2b5c 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -148,43 +148,6 @@ const unhealthy_targets = sql`SUM( END ) `; -function countResourcesBase() { - return db - .select({ count: count() }) - .from(resources) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) - ) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .leftJoin( - resourceHeaderAuth, - eq(resourceHeaderAuth.resourceId, resources.resourceId) - ) - .leftJoin( - resourceHeaderAuthExtendedCompatibility, - eq( - resourceHeaderAuthExtendedCompatibility.resourceId, - resources.resourceId - ) - ) - .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) - .leftJoin( - targetHealthCheck, - eq(targetHealthCheck.targetId, targets.targetId) - ) - .groupBy( - resources.resourceId, - resourcePassword.passwordId, - resourcePincode.pincodeId, - resourceHeaderAuth.headerAuthId, - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId - ); -} - function queryResourcesBase() { return db .select({ @@ -422,23 +385,20 @@ export async function listResources( } } - let baseQuery = queryResourcesBase(); - let countQuery = countResourcesBase().where(conditions); - - if (aggregateFilters) { - // @ts-expect-error idk why this is causing a type error - baseQuery = baseQuery.having(aggregateFilters); - } - if (aggregateFilters) { - // @ts-expect-error idk why this is causing a type error - countQuery = countQuery.having(aggregateFilters); - } - - const rows: JoinedRow[] = await baseQuery + const baseQuery = queryResourcesBase() .where(conditions) - .limit(pageSize) - .offset(pageSize * (page - 1)) - .orderBy(asc(resources.resourceId)); + .having(aggregateFilters ?? sql`1 = 1`); + + // 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 = @@ -495,9 +455,6 @@ export async function listResources( const resourcesList: ResourceWithTargets[] = Array.from(map.values()); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0]?.count ?? 0; - return response(res, { data: { resources: resourcesList, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 8a0a85abd..e27a328a7 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -226,7 +226,6 @@ export async function listSites( parsedQuery.data; const accessibleSiteIds = accessibleSites.map((site) => site.siteId); - const baseQuery = querySitesBase(); let conditions = and( inArray(sites.siteId, accessibleSiteIds), @@ -245,27 +244,31 @@ export async function listSites( conditions = and(conditions, eq(sites.online, online)); } - const countQuery = countSitesBase().where(conditions); + 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 siteListQuery = baseQuery - .where(conditions) .limit(pageSize) - .offset(pageSize * (page - 1)); - - if (sort_by) { - siteListQuery.orderBy( - order === "asc" ? asc(sites[sort_by]) : desc(sites[sort_by]) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(sites[sort_by]) + : desc(sites[sort_by]) + : asc(sites.siteId) ); - } - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0].count; + + const [totalCount, rows] = await Promise.all([ + countQuery, + siteListQuery + ]); // Get latest version asynchronously without blocking the response const latestNewtVersionPromise = getLatestNewtVersion(); - const sitesWithUpdates: SiteWithUpdateAvailable[] = ( - await siteListQuery - ).map((site) => { + const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => { const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; // Initially set to false, will be updated if version check succeeds siteWithUpdate.newtUpdateAvailable = false; From 609ffccd67641f6b642edc1fd92d781517923fc4 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 5 Feb 2026 05:35:59 +0100 Subject: [PATCH 14/38] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20fix=20typescript?= =?UTF-8?q?=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/target/handleHealthcheckStatusMessage.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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(); From 6c85171091245380313f8b0964fc839f8444c40b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Feb 2026 02:42:15 +0100 Subject: [PATCH 15/38] =?UTF-8?q?=E2=9C=A8serverside=20filter+paginate=20c?= =?UTF-8?q?lient=20resources=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 2 +- server/routers/resource/listResources.ts | 32 ----- server/routers/site/listSites.ts | 2 +- .../siteResource/listAllSiteResourcesByOrg.ts | 127 ++++++++++++------ .../settings/resources/client/page.tsx | 33 ++--- src/components/ClientResourcesTable.tsx | 86 +++++++++--- src/components/ProxyResourcesTable.tsx | 16 +-- src/components/ui/controlled-data-table.tsx | 3 +- 8 files changed, 183 insertions(+), 118 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 98c134798..82bd80e00 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 add3b2b5c..26a0d613d 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 e27a328a7..c65f8d100 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 dee1eebc9..2392eb767 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 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/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index c49cde8d2..5dd9ae87a 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 ca8f0443e..a22d96b67 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 c6fb505cf..88f033849 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({ From 0547396213cc89814dec60ece0b2691e2f0ddde9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Feb 2026 02:44:23 +0100 Subject: [PATCH 16/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20do=20not=20sort=20cl?= =?UTF-8?q?ient=20resources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ClientResourcesTable.tsx | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 5dd9ae87a..e3fcd11af 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -131,19 +131,7 @@ export default function ClientResourcesTable({ accessorKey: "name", enableHiding: false, friendlyName: t("name"), - header: ({ column }) => { - return ( - - ); - } + header: () => {t("name")} }, { id: "niceId", From ccddb9244d71c8cddcd0becae04a39c57a558b60 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Feb 2026 03:14:03 +0100 Subject: [PATCH 17/38] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20add=20types=20on?= =?UTF-8?q?=20`mode`=20in=20sqlite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/sqlite/schema/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index f26ecc088..858563693 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -247,7 +247,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 From d521e79662cecd9c6cec6ef08f94c537bba2054a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Feb 2026 03:21:00 +0100 Subject: [PATCH 18/38] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20fix=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/siteResource/createSiteResource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 588f064c2585e291fff9c9555477a78a10d34a8d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Feb 2026 03:53:14 +0100 Subject: [PATCH 19/38] =?UTF-8?q?=F0=9F=9A=B8=20make=20resource=20enabled?= =?UTF-8?q?=20switch=20optimistic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProxyResourcesTable.tsx | 63 ++++++++++++++++++++------ 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index a22d96b67..ba69dec4a 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -32,7 +32,13 @@ 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"; @@ -479,18 +485,9 @@ export default function ProxyResourcesTable({ /> ), cell: ({ row }) => ( - - startTransition(() => - toggleResourceEnabled(val, row.original.id) - ) - } + ) }, @@ -632,3 +629,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()} + /> + + ); +} From 4a31a7b84b539046cc82fb1ac7312f2b19401725 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Feb 2026 03:55:11 +0100 Subject: [PATCH 20/38] =?UTF-8?q?=F0=9F=9A=A8=20fix=20lint=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../generateNewEnterpriseLicense.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts index 7cffb9d7a..94ee311b1 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,8 +117,11 @@ export async function generateNewEnterpriseLicense( ); } - const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE; - const tierPrice = getLicensePriceSet()[tier] + const tier = + licenseData.tier === "big_license" + ? LicenseId.BIG_LICENSE + : LicenseId.SMALL_LICENSE; + const tierPrice = getLicensePriceSet()[tier]; const session = await stripe!.checkout.sessions.create({ client_reference_id: keyId.toString(), @@ -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", From 67b63d3084b5937cc2faf21afbe3f32c0de5d473 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Feb 2026 04:52:21 +0100 Subject: [PATCH 21/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20make=20code=20cleanr?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listClients.ts | 87 +++++++++---------- server/routers/resource/listResources.ts | 29 +++---- server/routers/site/listSites.ts | 30 +++---- .../siteResource/listAllSiteResourcesByOrg.ts | 18 ++-- server/types/Pagination.ts | 5 ++ 5 files changed, 81 insertions(+), 88 deletions(-) create mode 100644 server/types/Pagination.ts diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index b4e2eb56a..bb59755cd 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -29,6 +29,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import NodeCache from "node-cache"; import semver from "semver"; import { getUserDeviceName } from "@server/db/names"; +import type { PaginatedResponse } from "@server/types/Pagination"; const olmVersionCache = new NodeCache({ stdTTL: 3600 }); @@ -89,38 +90,29 @@ 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()), + .catch(1) + .default(1), + query: z.string().optional(), + sort_by: z + .enum(["megabytesIn", "megabytesOut"]) + .optional() + .catch(undefined), filter: z.enum(["user", "machine"]).optional() }); -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, @@ -156,8 +148,7 @@ function queryClients( .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 +166,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 +177,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 +208,7 @@ export async function listClients( ) ); } - const { limit, offset, filter } = parsedQuery.data; + const { page, pageSize, query, filter } = parsedQuery.data; const parsedParams = listClientsParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -267,28 +257,31 @@ export async function listClients( const accessibleClientIds = accessibleClients.map( (client) => client.clientId ); - const baseQuery = queryClients(orgId, accessibleClientIds, filter); + const baseQuery = queryClientsBase(); // Get client count with filter - const countConditions = [ + const conditions = [ inArray(clients.clientId, accessibleClientIds), eq(clients.orgId, orgId) ]; if (filter === "user") { - countConditions.push(isNotNull(clients.userId)); + conditions.push(isNotNull(clients.userId)); } else if (filter === "machine") { - countConditions.push(isNull(clients.userId)); + conditions.push(isNull(clients.userId)); } - const countQuery = db - .select({ count: count() }) - .from(clients) - .where(and(...countConditions)); + const countQuery = db.$count( + queryClientsBase().where(and(...conditions)) + ); - const clientsList = await baseQuery.limit(limit).offset(offset); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0].count; + const [clientsList, totalCount] = await Promise.all([ + baseQuery + .where(and(...conditions)) + .limit(page) + .offset(pageSize * (page - 1)), + countQuery + ]); // Get associated sites for all clients const clientIds = clientsList.map((client) => client.clientId); @@ -368,8 +361,8 @@ export async function listClients( clients: olmsWithUpdates, pagination: { total: totalCount, - limit, - offset + page, + pageSize } }, success: true, diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 26a0d613d..cf0769ca8 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -33,6 +33,7 @@ import { 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() @@ -171,10 +172,9 @@ function queryResourcesBase() { ); } -export type ListResourcesResponse = { +export type ListResourcesResponse = PaginatedResponse<{ resources: ResourceWithTargets[]; - pagination: { total: number; pageSize: number; page: number }; -}; +}>; registry.registerPath({ method: "get", @@ -268,16 +268,15 @@ export async function listResources( (resource) => resource.resourceId ); - let conditions = and( + const conditions = [ and( inArray(resources.resourceId, accessibleResourceIds), eq(resources.orgId, orgId) ) - ); + ]; if (query) { - conditions = and( - conditions, + conditions.push( or( ilike(resources.name, "%" + query + "%"), ilike(resources.fullDomain, "%" + query + "%") @@ -285,17 +284,16 @@ export async function listResources( ); } if (typeof enabled !== "undefined") { - conditions = and(conditions, eq(resources.enabled, enabled)); + conditions.push(eq(resources.enabled, enabled)); } if (typeof authState !== "undefined") { switch (authState) { case "none": - conditions = and(conditions, eq(resources.http, false)); + conditions.push(eq(resources.http, false)); break; case "protected": - conditions = and( - conditions, + conditions.push( or( eq(resources.sso, true), eq(resources.emailWhitelistEnabled, true), @@ -306,8 +304,7 @@ export async function listResources( ); break; case "not_protected": - conditions = and( - conditions, + conditions.push( not(eq(resources.sso, true)), not(eq(resources.emailWhitelistEnabled, true)), isNull(resourceHeaderAuth.headerAuthId), @@ -318,7 +315,7 @@ export async function listResources( } } - let aggregateFilters: SQL | null | undefined = null; + let aggregateFilters: SQL | undefined = sql`1 = 1`; if (typeof healthStatus !== "undefined") { switch (healthStatus) { @@ -354,8 +351,8 @@ export async function listResources( } const baseQuery = queryResourcesBase() - .where(conditions) - .having(aggregateFilters ?? sql`1 = 1`); + .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")); diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index c65f8d100..cc2924995 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -22,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 { @@ -111,9 +112,6 @@ const listSitesSchema = z.object({ .catch(undefined) }); -function countSitesBase() { - return db.select({ count: count() }).from(sites); -} function querySitesBase() { return db .select({ @@ -148,10 +146,9 @@ type SiteWithUpdateAvailable = Awaited>[0] & { newtUpdateAvailable?: boolean; }; -export type ListSitesResponse = { +export type ListSitesResponse = PaginatedResponse<{ sites: SiteWithUpdateAvailable[]; - pagination: { total: number; pageSize: number; page: number }; -}; +}>; registry.registerPath({ method: "get", @@ -227,13 +224,14 @@ export async function listSites( const accessibleSiteIds = accessibleSites.map((site) => site.siteId); - let conditions = and( - inArray(sites.siteId, accessibleSiteIds), - eq(sites.orgId, orgId) - ); + const conditions = [ + and( + inArray(sites.siteId, accessibleSiteIds), + eq(sites.orgId, orgId) + ) + ]; if (query) { - conditions = and( - conditions, + conditions.push( or( ilike(sites.name, "%" + query + "%"), ilike(sites.niceId, "%" + query + "%") @@ -241,13 +239,15 @@ export async function listSites( ); } if (typeof online !== "undefined") { - conditions = and(conditions, eq(sites.online, online)); + conditions.push(eq(sites.online, online)); } - const baseQuery = querySitesBase().where(conditions); + 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(conditions)); + const countQuery = db.$count( + querySitesBase().where(and(...conditions)) + ); const siteListQuery = baseQuery .limit(pageSize) diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 2392eb767..f15d2eccb 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -9,6 +9,7 @@ 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() @@ -33,14 +34,13 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ mode: z.enum(["host", "cidr"]).optional().catch(undefined) }); -export type ListAllSiteResourcesByOrgResponse = { +export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ siteResources: (SiteResource & { siteName: string; siteNiceId: string; siteAddress: string | null; })[]; - pagination: { total: number; pageSize: number; page: number }; -}; +}>; function querySiteResourcesBase() { return db @@ -114,10 +114,9 @@ export async function listAllSiteResourcesByOrg( const { orgId } = parsedParams.data; const { page, pageSize, query, mode } = parsedQuery.data; - let conditions = and(eq(siteResources.orgId, orgId)); + const conditions = [and(eq(siteResources.orgId, orgId))]; if (query) { - conditions = and( - conditions, + conditions.push( or( ilike(siteResources.name, "%" + query + "%"), ilike(siteResources.destination, "%" + query + "%"), @@ -129,16 +128,15 @@ export async function listAllSiteResourcesByOrg( } if (mode) { - conditions = and(conditions, eq(siteResources.mode, mode)); + conditions.push(eq(siteResources.mode, mode)); } - const baseQuery = querySiteResourcesBase().where(conditions); + const baseQuery = querySiteResourcesBase().where(and(...conditions)); const countQuery = db.$count( - querySiteResourcesBase().where(conditions) + querySiteResourcesBase().where(and(...conditions)) ); - // Get all site resources for the org with site names const [siteResourcesList, totalCount] = await Promise.all([ baseQuery .limit(pageSize) 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; +}; From 9f2fd34e995b8dbf32fe06aa7c60a9df9d014a8a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Feb 2026 05:37:44 +0100 Subject: [PATCH 22/38] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20user=20devices=20en?= =?UTF-8?q?dpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/index.ts | 1 + server/routers/client/listClients.ts | 30 +- server/routers/client/listUserDevices.ts | 302 ++++++++++++++++++ server/routers/external.ts | 7 + server/routers/integration.ts | 7 + server/routers/resource/listResources.ts | 2 +- .../[orgId]/settings/clients/user/page.tsx | 81 +++-- .../CreateInternalResourceDialog.tsx | 5 +- src/components/EditInternalResourceDialog.tsx | 5 +- src/lib/queries.ts | 8 +- 10 files changed, 374 insertions(+), 74 deletions(-) create mode 100644 server/routers/client/listUserDevices.ts 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 bb59755cd..5c3702dd6 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -105,11 +105,7 @@ const listClientsSchema = z.object({ .catch(1) .default(1), query: z.string().optional(), - sort_by: z - .enum(["megabytesIn", "megabytesOut"]) - .optional() - .catch(undefined), - filter: z.enum(["user", "machine"]).optional() + sort_by: z.enum(["megabytesIn", "megabytesOut"]).optional().catch(undefined) }); function queryClientsBase() { @@ -134,15 +130,7 @@ function queryClientsBase() { 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)) @@ -208,7 +196,7 @@ export async function listClients( ) ); } - const { page, pageSize, query, filter } = parsedQuery.data; + const { page, pageSize, query } = parsedQuery.data; const parsedParams = listClientsParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -262,15 +250,10 @@ export async function listClients( // Get client count with filter const conditions = [ inArray(clients.clientId, accessibleClientIds), - eq(clients.orgId, orgId) + eq(clients.orgId, orgId), + isNull(clients.userId) ]; - if (filter === "user") { - conditions.push(isNotNull(clients.userId)); - } else if (filter === "machine") { - conditions.push(isNull(clients.userId)); - } - const countQuery = db.$count( queryClientsBase().where(and(...conditions)) ); @@ -312,11 +295,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; - const newName = getUserDeviceName(model, client.name); return { ...client, - name: newName, sites: sitesByClient[client.clientId] || [] }; }); diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts new file mode 100644 index 000000000..95a5b6cab --- /dev/null +++ b/server/routers/client/listUserDevices.ts @@ -0,0 +1,302 @@ +import { + clients, + currentFingerprint, + db, + olms, + orgs, + roleClients, + userClients, + users +} from "@server/db"; +import { getUserDeviceName } from "@server/db/names"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import { and, eq, inArray, isNotNull, or, sql } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import NodeCache from "node-cache"; +import semver from "semver"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const olmVersionCache = new NodeCache({ stdTTL: 3600 }); + +async function getLatestOlmVersion(): Promise { + 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) +}); + +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 } = 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 = [ + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId), + isNotNull(clients.userId) + ]; + + const baseQuery = queryUserDevicesBase().where(and(...conditions)); + + const countQuery = db.$count(baseQuery.as("filtered_clients")); + + const [clientsList, totalCount] = await Promise.all([ + baseQuery.limit(pageSize).offset(pageSize * (page - 1)), + countQuery + ]); + + // Merge clients with their site associations and replace name with device name + const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsList.map( + (client) => { + const model = client.deviceModel || null; + const newName = getUserDeviceName(model, client.name); + const OlmWithUpdate: OlmWithUpdateAvailable = { + ...client, + name: newName + }; + // Initially set to false, will be updated if version check succeeds + OlmWithUpdate.olmUpdateAvailable = false; + return OlmWithUpdate; + } + ); + + // Try to get the latest version, but don't block if it fails + try { + const latestOlmVersion = await getLatestOlmVersion(); + + if (latestOlmVersion) { + olmsWithUpdates.forEach((client) => { + try { + client.olmUpdateAvailable = semver.lt( + client.olmVersion ? client.olmVersion : "", + latestOlmVersion + ); + } catch (error) { + client.olmUpdateAvailable = false; + } + }); + } + } catch (error) { + // Log the error but don't let it block the response + logger.warn( + "Failed to check for OLM updates, continuing without update info:", + error + ); + } + + return response(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 aff01bfa8..bfffeaca2 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -143,6 +143,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 9bb263987..85c9009de 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -818,6 +818,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 cf0769ca8..090ea9713 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -380,7 +380,7 @@ export async function listResources( hcEnabled: targetHealthCheck.hcEnabled }) .from(targets) - .where(sql`${targets.resourceId} in ${resourceIdList}`) + .where(inArray(targets.resourceId, resourceIdList)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index 35a2b2e31..d047a60f2 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -1,11 +1,12 @@ -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { 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 }>; @@ -18,14 +19,21 @@ export default async function ClientsPage(props: ClientsPageProps) { const params = await props.params; - 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`, - await authCookieHeader() - ); - userClients = userRes.data.data.clients; + const userRes = await internal.get< + AxiosResponse + >(`/org/${params.orgId}/user-devices`, await authCookieHeader()); + const responseData = userRes.data.data; + userClients = responseData.devices; + pagination = responseData.pagination; } catch (e) {} function formatSize(mb: number): string { @@ -39,31 +47,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 +77,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 }; @@ -91,6 +97,11 @@ export default async function ClientsPage(props: ClientsPageProps) { const userClientRows: ClientRow[] = userClients.map(mapClientToRow); + console.log({ + userClientRows, + pagination + }); + return ( <> ; + 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< From 49435398a8af8c6b1fee9010fe567bb342a172e1 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Feb 2026 02:50:59 +0100 Subject: [PATCH 23/38] =?UTF-8?q?=F0=9F=94=A5=20cleanup=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listClients.ts | 32 +++++++++++----------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 5c3702dd6..588f1edd6 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -1,35 +1,27 @@ -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 { - and, - count, - eq, - inArray, - isNotNull, - isNull, - or, - sql -} from "drizzle-orm"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import { and, eq, inArray, isNull, or, 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 type { PaginatedResponse } from "@server/types/Pagination"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; const olmVersionCache = new NodeCache({ stdTTL: 3600 }); From fd7f6b2b998a5ed5a92256688774e254eb21a085 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Feb 2026 02:51:28 +0100 Subject: [PATCH 24/38] =?UTF-8?q?=E2=9C=A8=20filter=20user=20devices=20API?= =?UTF-8?q?=20finished?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listUserDevices.ts | 158 ++++++++++++++++++++++- 1 file changed, 151 insertions(+), 7 deletions(-) diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 95a5b6cab..7ef5f784e 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -5,6 +5,7 @@ import { olms, orgs, roleClients, + sites, userClients, users } from "@server/db"; @@ -14,7 +15,20 @@ import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; import type { PaginatedResponse } from "@server/types/Pagination"; -import { and, eq, inArray, isNotNull, or, sql } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + ilike, + inArray, + isNotNull, + isNull, + not, + or, + sql, + type SQL +} from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import NodeCache from "node-cache"; @@ -96,7 +110,40 @@ const listUserDevicesSchema = z.object({ .catch(1) .default(1), query: z.string().optional(), - sort_by: z.enum(["megabytesIn", "megabytesOut"]).optional().catch(undefined) + 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", + "macos", + "ios", + "ipados", + "unknown" + ]) + .optional() + .catch(undefined), + filters: z.preprocess( + (val: string) => { + return val.split(","); // the search query array is an array joined by a comma + }, + z + .array( + z.enum(["active", "pending", "denied", "blocked", "archived"]) + ) + .optional() + .default(["active", "pending"]) + .catch(["active", "pending"]) + ) }); function queryUserDevicesBase() { @@ -175,7 +222,28 @@ export async function listUserDevices( ) ); } - const { page, pageSize, query } = parsedQuery.data; + const { + page, + pageSize, + query, + sort_by, + online, + filters, + agent, + order + } = parsedQuery.data; + + console.log({ query: req.query }); + console.log({ + page, + pageSize, + query, + sort_by, + online, + filters, + agent, + order + }); const parsedParams = listUserDevicesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -226,17 +294,93 @@ export async function listUserDevices( ); // Get client count with filter const conditions = [ - inArray(clients.clientId, accessibleClientIds), - eq(clients.orgId, orgId), - isNotNull(clients.userId) + and( + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId), + isNotNull(clients.userId) + ) ]; + if (query) { + conditions.push( + or( + ilike(clients.name, "%" + query + "%"), + ilike(users.name, "%" + 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" + } 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 (filters.length > 0) { + const filterAggregates: (SQL | undefined)[] = []; + + if (filters.includes("active")) { + filterAggregates.push( + and( + eq(clients.archived, false), + eq(clients.blocked, false), + or( + eq(clients.approvalState, "approved"), + isNull(clients.approvalState) // approval state of `NULL` means approved by default + ) + ) + ); + } + if (filters.includes("pending")) { + filterAggregates.push(eq(clients.approvalState, "pending")); + } + if (filters.includes("denied")) { + filterAggregates.push(eq(clients.approvalState, "denied")); + } + if (filters.includes("archived")) { + filterAggregates.push(eq(clients.archived, true)); + } + if (filters.includes("blocked")) { + filterAggregates.push(eq(clients.blocked, true)); + } + + 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([ - baseQuery.limit(pageSize).offset(pageSize * (page - 1)), + listDevicesQuery, countQuery ]); From db6327c4ff1f60f59d564e0bd6b08d514bbf6a1e Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Feb 2026 02:52:23 +0100 Subject: [PATCH 25/38] =?UTF-8?q?=F0=9F=94=87=20remove=20console.logs=20in?= =?UTF-8?q?=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listUserDevices.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 7ef5f784e..0c07e40c7 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -233,18 +233,6 @@ export async function listUserDevices( order } = parsedQuery.data; - console.log({ query: req.query }); - console.log({ - page, - pageSize, - query, - sort_by, - online, - filters, - agent, - order - }); - const parsedParams = listUserDevicesParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( From 5d7f082ebf2afdb8edc0914763b78fbc4cf4fa4c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Feb 2026 04:41:42 +0100 Subject: [PATCH 26/38] =?UTF-8?q?=E2=9C=A8=20sort=20user=20device=20table?= =?UTF-8?q?=20&=20refactor=20sort=20into=20common=20functino?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listUserDevices.ts | 58 +-- .../[orgId]/settings/clients/user/page.tsx | 17 +- src/components/ClientResourcesTable.tsx | 2 +- src/components/SitesTable.tsx | 74 ++-- src/components/UserDevicesTable.tsx | 396 ++++++++++-------- src/hooks/useSortColumn.ts | 56 --- src/lib/sortColumn.ts | 52 +++ 7 files changed, 366 insertions(+), 289 deletions(-) delete mode 100644 src/hooks/useSortColumn.ts create mode 100644 src/lib/sortColumn.ts diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 0c07e40c7..479d16a0a 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -1,3 +1,4 @@ +import { build } from "@server/build"; import { clients, currentFingerprint, @@ -132,9 +133,12 @@ const listUserDevicesSchema = z.object({ ]) .optional() .catch(undefined), - filters: z.preprocess( - (val: string) => { - return val.split(","); // the search query array is an array joined by a comma + 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( @@ -222,16 +226,8 @@ export async function listUserDevices( ) ); } - const { - page, - pageSize, - query, - sort_by, - online, - filters, - agent, - order - } = parsedQuery.data; + const { page, pageSize, query, sort_by, online, status, agent, order } = + parsedQuery.data; const parsedParams = listUserDevicesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -293,7 +289,7 @@ export async function listUserDevices( conditions.push( or( ilike(clients.name, "%" + query + "%"), - ilike(users.name, "%" + query + "%") + ilike(users.email, "%" + query + "%") ) ); } @@ -321,34 +317,40 @@ export async function listUserDevices( } } - if (filters.length > 0) { + if (status.length > 0) { const filterAggregates: (SQL | undefined)[] = []; - if (filters.includes("active")) { + if (status.includes("active")) { filterAggregates.push( and( eq(clients.archived, false), eq(clients.blocked, false), - or( - eq(clients.approvalState, "approved"), - isNull(clients.approvalState) // approval state of `NULL` means approved by default - ) + 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 (filters.includes("pending")) { - filterAggregates.push(eq(clients.approvalState, "pending")); - } - if (filters.includes("denied")) { - filterAggregates.push(eq(clients.approvalState, "denied")); - } - if (filters.includes("archived")) { + + if (status.includes("archived")) { filterAggregates.push(eq(clients.archived, true)); } - if (filters.includes("blocked")) { + 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)); } diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index d047a60f2..fcb24e4e3 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -10,6 +10,7 @@ import { getTranslations } from "next-intl/server"; type ClientsPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; @@ -18,6 +19,7 @@ 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: ListUserDevicesResponse["devices"] = []; @@ -30,7 +32,10 @@ export default async function ClientsPage(props: ClientsPageProps) { try { const userRes = await internal.get< AxiosResponse - >(`/org/${params.orgId}/user-devices`, await authCookieHeader()); + >( + `/org/${params.orgId}/user-devices?${searchParams.toString()}`, + await authCookieHeader() + ); const responseData = userRes.data.data; userClients = responseData.devices; pagination = responseData.pagination; @@ -97,11 +102,6 @@ export default async function ClientsPage(props: ClientsPageProps) { const userClientRows: ClientRow[] = userClients.map(mapClientToRow); - console.log({ - userClientRows, - pagination - }); - return ( <> ); diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index e3fcd11af..126eb2421 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -382,7 +382,7 @@ export default function ClientResourcesTable({ onPaginationChange={handlePaginationChange} pagination={pagination} rowCount={rowCount} - isRefreshing={isRefreshing} + isRefreshing={isRefreshing || isFiltering} enableColumnVisibility columnVisibility={{ niceId: false, diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 761177762..c78577731 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -12,10 +12,10 @@ import { } from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useSortColumn } from "@app/hooks/useSortColumn"; +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 { parseDataSize } from "@app/lib/dataSize"; import { build } from "@server/build"; import { type PaginationState } from "@tanstack/react-table"; import { @@ -24,21 +24,19 @@ import { ArrowUp10Icon, ArrowUpRight, ChevronsUpDownIcon, - Funnel, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +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"; -import { ColumnFilter } from "./ColumnFilter"; -import { ColumnFilterButton } from "./ColumnFilterButton"; -import z from "zod"; export type SiteRow = { id: number; @@ -71,16 +69,18 @@ export default function SitesTable({ rowCount }: SitesTableProps) { const router = useRouter(); - const searchParams = useSearchParams(); const pathname = usePathname(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); - const [getSortDirection, toggleSorting] = useSortColumn(); - const api = createApiClient(useEnvContext()); const t = useTranslations(); @@ -136,9 +136,6 @@ export default function SitesTable({ }); } - const dataInOrder = getSortDirection("megabytesIn"); - const dataOutOrder = getSortDirection("megabytesOut"); - const columns: ExtendedColumnDef[] = [ { accessorKey: "name", @@ -212,6 +209,10 @@ export default function SitesTable({ accessorKey: "mbIn", friendlyName: t("dataIn"), header: () => { + const dataInOrder = getSortDirection( + "megabytesIn", + searchParams + ); const Icon = dataInOrder === "asc" ? ArrowDown01Icon @@ -221,21 +222,23 @@ export default function SitesTable({ return ( ); - }, - sortingFn: (rowA, rowB) => - parseDataSize(rowA.original.mbIn) - - parseDataSize(rowB.original.mbIn) + } }, { accessorKey: "mbOut", friendlyName: t("dataOut"), header: () => { + const dataOutOrder = getSortDirection( + "megabytesOut", + searchParams + ); + const Icon = dataOutOrder === "asc" ? ArrowDown01Icon @@ -245,16 +248,13 @@ export default function SitesTable({ return ( ); - }, - sortingFn: (rowA, rowB) => - parseDataSize(rowA.original.mbOut) - - parseDataSize(rowB.original.mbOut) + } }, { accessorKey: "type", @@ -423,18 +423,28 @@ export default function SitesTable({ } ]; + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + const handlePaginationChange = (newPage: PaginationState) => { - const sp = new URLSearchParams(searchParams); - sp.set("page", (newPage.pageIndex + 1).toString()); - sp.set("pageSize", newPage.pageSize.toString()); - startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); }; const handleSearchChange = useDebouncedCallback((query: string) => { - const sp = new URLSearchParams(searchParams); - sp.set("query", query); - sp.delete("page"); - startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); }, 300); return ( @@ -478,7 +488,7 @@ export default function SitesTable({ onSearch={handleSearchChange} addButtonText={t("siteAdd")} onRefresh={refreshData} - isRefreshing={isRefreshing} + isRefreshing={isRefreshing || isFiltering} rowCount={rowCount} columnVisibility={{ niceId: false, diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index edc840882..7e441547e 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -2,37 +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 { build } from "@server/build"; +import type { PaginationState } from "@tanstack/react-table"; import { - formatFingerprintInfo, - formatPlatform -} from "@app/lib/formatDeviceFingerprint"; -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; @@ -68,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(); @@ -80,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 = { @@ -296,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 @@ -360,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 ? ( @@ -416,19 +389,30 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "online", friendlyName: t("online"), - header: ({ column }) => { + header: () => { return ( - + onValueChange={(value) => + handleFilterChange("online", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("online")} + className="p-3" + /> ); }, cell: ({ row }) => { @@ -453,18 +437,29 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "mbIn", friendlyName: t("dataIn"), - header: ({ column }) => { + header: () => { + const dataInOrder = getSortDirection( + "megabytesIn", + searchParams + ); + + console.log({ + dataInOrder, + searchParams: Object.fromEntries(searchParams.entries()) + }); + const Icon = + dataInOrder === "asc" + ? ArrowDown01Icon + : dataInOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -472,18 +467,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 ( ); } @@ -491,21 +493,48 @@ 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; @@ -531,21 +560,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "subnet", friendlyName: t("address"), - header: ({ column }) => { - return ( - - ); - } + header: () => {t("address")} } ]; @@ -643,7 +658,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }); return baseColumns; - }, [hasRowsWithoutUserId, t]); + }, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]); const statusFilterOptions = useMemo(() => { const allOptions = [ @@ -691,6 +706,53 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { return ["active", "pending"]; }, []); + const [, setCount] = useState(0); + + function handleFilterChange( + column: string, + value: string | undefined | null | 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 + }); + setCount((c) => c + 1); + } + + 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 ( <> {selectedClient && !selectedClient.userId && ( @@ -714,67 +776,69 @@ 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"; + // filters={[ + // { + // id: "status", + // label: t("status") || "Status", + // multiSelect: true, + // displayMode: "calculated", + // options: statusFilterOptions, + // filterFn: ( + // row: ClientRow, + // selectedValues: (string | number | boolean)[] + // ) => { + // 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; - }, - defaultValues: statusFilterDefaultValues - } - ]} + // 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; + // }, + // defaultValues: statusFilterDefaultValues + // } + // ]} /> ); diff --git a/src/hooks/useSortColumn.ts b/src/hooks/useSortColumn.ts deleted file mode 100644 index 95fb673eb..000000000 --- a/src/hooks/useSortColumn.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { SortOrder } from "@app/lib/types/sort"; -import { useSearchParams, useRouter, usePathname } from "next/navigation"; -import { startTransition } from "react"; - -export function useSortColumn() { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - - const toggleSorting = (column: string) => { - 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); - } - - startTransition(() => router.push(`${pathname}?${sp.toString()}`)); - }; - - function getSortDirection(column: string) { - let currentDirection: SortOrder = "indeterminate"; - - if (searchParams.get("sort_by") === column) { - currentDirection = - (searchParams.get("order") as SortOrder) ?? "indeterminate"; - } - return currentDirection; - } - - return [getSortDirection, toggleSorting] as const; -} 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; +} From 1889386f647add7b16621c2da781f616bd32c8b6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Feb 2026 04:51:37 +0100 Subject: [PATCH 27/38] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20table=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/UserDevicesTable.tsx | 97 ++++++++++----------- src/components/ui/controlled-data-table.tsx | 20 ++--- 2 files changed, 54 insertions(+), 63 deletions(-) diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 7e441547e..e2fe6669c 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -706,8 +706,6 @@ export default function UserDevicesTable({ return ["active", "pending"]; }, []); - const [, setCount] = useState(0); - function handleFilterChange( column: string, value: string | undefined | null | string[] @@ -726,7 +724,6 @@ export default function UserDevicesTable({ filter({ searchParams }); - setCount((c) => c + 1); } function toggleSort(column: string) { @@ -791,54 +788,54 @@ export default function UserDevicesTable({ rowCount={rowCount} stickyLeftColumn="name" stickyRightColumn="actions" - // filters={[ - // { - // id: "status", - // label: t("status") || "Status", - // multiSelect: true, - // displayMode: "calculated", - // options: statusFilterOptions, - // filterFn: ( - // row: ClientRow, - // selectedValues: (string | number | boolean)[] - // ) => { - // 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"; + filters={[ + { + id: "status", + label: t("status") || "Status", + multiSelect: true, + displayMode: "calculated", + options: statusFilterOptions, + onFilter: ( + selectedValues: (string | number | boolean)[] + ) => { + console.log({ selectedValues }); + // 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; - // }, - // defaultValues: statusFilterDefaultValues - // } - // ]} + // 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; + }, + values: statusFilterDefaultValues + } + ]} /> ); diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index 88f033849..996526e4b 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -33,7 +33,7 @@ import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility" import { Columns, Filter, Plus, RefreshCw, Search } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useMemo, useState } from "react"; // Extended ColumnDef type that includes optional friendlyName for column visibility dropdown export type ExtendedColumnDef = ColumnDef< @@ -54,11 +54,8 @@ type DataTableFilter = { label: string; options: FilterOption[]; multiSelect?: boolean; - filterFn: ( - row: any, - selectedValues: (string | number | boolean)[] - ) => boolean; - defaultValues?: (string | number | boolean)[]; + onFilter: (selectedValues: (string | number | boolean)[]) => void; + values?: (string | number | boolean)[]; displayMode?: "label" | "calculated"; // How to display the filter button text }; @@ -119,15 +116,13 @@ export function ControlledDataTable({ ); // TODO: filters - const [activeFilters, setActiveFilters] = useState< - Record - >(() => { + const activeFilters = useMemo(() => { const initial: Record = {}; filters?.forEach((filter) => { - initial[filter.id] = filter.defaultValues || []; + initial[filter.id] = filter.values || []; }); return initial; - }); + }, [filters]); console.log({ pagination, @@ -147,7 +142,6 @@ export function ControlledDataTable({ }, manualFiltering: true, manualPagination: true, - // pageCount: pagination.pageCount, rowCount, state: { columnFilters, @@ -177,7 +171,7 @@ export function ControlledDataTable({ } // Multiple selections: always join with "and" - return selectedOptions.map((opt) => opt.label).join(" and "); + return selectedOptions.map((opt) => opt.label).join(" or "); }; // Helper function to check if a column should be sticky From 577cb913433a8c713f12ebacc1a9f54f595f2bd9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Feb 2026 05:37:01 +0100 Subject: [PATCH 28/38] =?UTF-8?q?=E2=9C=A8=20whole=20table=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/UserDevicesTable.tsx | 52 +++++---------------- src/components/ui/controlled-data-table.tsx | 45 ++++++++++++++---- 2 files changed, 47 insertions(+), 50 deletions(-) diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index e2fe6669c..7fe04ee45 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -699,16 +699,21 @@ export default function UserDevicesTable({ return allOptions; }, [t]); - const statusFilterDefaultValues = useMemo(() => { + const statusFilterValues = useMemo(() => { + const status = searchParams.getAll("status"); + if (status.length > 0) { + return status; + } + if (build === "oss") { return ["active"]; } return ["active", "pending"]; - }, []); + }, [searchParams]); function handleFilterChange( column: string, - value: string | undefined | null | string[] + value: string | null | undefined | string[] ) { searchParams.delete(column); searchParams.delete("page"); @@ -795,45 +800,10 @@ export default function UserDevicesTable({ multiSelect: true, displayMode: "calculated", options: statusFilterOptions, - onFilter: ( - selectedValues: (string | number | boolean)[] - ) => { - console.log({ selectedValues }); - // 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); }, - values: statusFilterDefaultValues + values: statusFilterValues } ]} /> diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index 996526e4b..4b87a5209 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -46,7 +46,7 @@ export type ExtendedColumnDef = ColumnDef< type FilterOption = { id: string; label: string; - value: string | number | boolean; + value: string; }; type DataTableFilter = { @@ -54,8 +54,8 @@ type DataTableFilter = { label: string; options: FilterOption[]; multiSelect?: boolean; - onFilter: (selectedValues: (string | number | boolean)[]) => void; - values?: (string | number | boolean)[]; + onValueChange: (selectedValues: string[]) => void; + values?: string[]; displayMode?: "label" | "calculated"; // How to display the filter button text }; @@ -117,7 +117,7 @@ export function ControlledDataTable({ // TODO: filters const activeFilters = useMemo(() => { - const initial: Record = {}; + const initial: Record = {}; filters?.forEach((filter) => { initial[filter.id] = filter.values || []; }); @@ -174,6 +174,33 @@ export function ControlledDataTable({ 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, @@ -285,11 +312,11 @@ export function ControlledDataTable({ onCheckedChange={( checked ) => { - // handleFilterChange( - // filter.id, - // option.value, - // checked - // ) + handleFilterChange( + filter.id, + option.value, + checked + ); }} onSelect={(e) => e.preventDefault() From ff61b22e7e6afaced97fb7b62d0eda7a89d24d0a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Feb 2026 05:37:52 +0100 Subject: [PATCH 29/38] =?UTF-8?q?=E2=99=BB=EF=B8=8Fdo=20not=20set=20defaul?= =?UTF-8?q?t=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/UserDevicesTable.tsx | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 7fe04ee45..642ef1f71 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -699,18 +699,6 @@ export default function UserDevicesTable({ return allOptions; }, [t]); - const statusFilterValues = useMemo(() => { - const status = searchParams.getAll("status"); - if (status.length > 0) { - return status; - } - - if (build === "oss") { - return ["active"]; - } - return ["active", "pending"]; - }, [searchParams]); - function handleFilterChange( column: string, value: string | null | undefined | string[] @@ -803,7 +791,7 @@ export default function UserDevicesTable({ onValueChange: (selectedValues: string[]) => { handleFilterChange("status", selectedValues); }, - values: statusFilterValues + values: searchParams.getAll("status") } ]} /> From b0af0d9cd5db40e41c882cba0c8548ed2f3bcf1d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Feb 2026 00:31:21 +0100 Subject: [PATCH 30/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20keep=20previous=20da?= =?UTF-8?q?ta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/TanstackQueryProvider.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 } From 7f73cde7945ed5cbcaba037e05fe7908cae2862b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Feb 2026 00:45:20 +0100 Subject: [PATCH 31/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refetch=20approval?= =?UTF-8?q?=20count=20every=2030s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/queries.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 6a3dd7ffb..1d19c1e73 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -383,6 +383,12 @@ export const approvalQueries = { signal }); return res.data.data.count; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "seconds"); + } + return false; } }) }; From da514ef3143cd983cff9d21db95c1351ac777af0 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Feb 2026 00:45:34 +0100 Subject: [PATCH 32/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/private/routers/approvals/countApprovals.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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) ) ); From 3ba2cb19a9eda825dd44629f1b4bac49195454bf Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Feb 2026 03:20:49 +0100 Subject: [PATCH 33/38] =?UTF-8?q?=E2=9C=A8=20approval=20feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 + .../routers/approvals/listApprovals.ts | 161 ++++++++++++------ server/routers/client/listUserDevices.ts | 2 - src/components/ApprovalFeed.tsx | 80 ++++++--- src/lib/queries.ts | 43 ++++- 5 files changed, 201 insertions(+), 87 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index f9627fcc0..ac6ab691a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -459,6 +459,8 @@ "filterByApprovalState": "Filter By Approval State", "approvalListEmpty": "No approvals", "approvalState": "Approval State", + "approvalLoadMore": "Load more", + "loadingApprovals": "Loading Approvals", "approve": "Approve", "approved": "Approved", "denied": "Denied", diff --git a/server/private/routers/approvals/listApprovals.ts b/server/private/routers/approvals/listApprovals.ts index 600eec871..5639b4407 100644 --- a/server/private/routers/approvals/listApprovals.ts +++ b/server/private/routers/approvals/listApprovals.ts @@ -30,7 +30,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"; @@ -39,18 +39,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() @@ -63,13 +71,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": @@ -85,6 +101,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, @@ -107,7 +143,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))) @@ -120,22 +157,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) @@ -154,15 +181,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 { @@ -185,11 +212,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( @@ -217,7 +263,13 @@ export async function listApprovals( ) ); } - const { limit, offset, approvalState, clientId } = parsedQuery.data; + const { + limit, + cursorPending, + cursorTimestamp, + approvalState, + clientId + } = parsedQuery.data; const { orgId } = parsedParams.data; @@ -234,13 +286,15 @@ export async function listApprovals( } } - 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(*)` }) @@ -252,7 +306,8 @@ export async function listApprovals( pagination: { total: count, limit, - offset + cursorPending: nextCursorPending, + cursorTimestamp: nextCursorTimestamp } }, success: true, diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 479d16a0a..d152250bb 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -6,7 +6,6 @@ import { olms, orgs, roleClients, - sites, userClients, users } from "@server/db"; @@ -25,7 +24,6 @@ import { inArray, isNotNull, isNull, - not, or, sql, type SQL diff --git a/src/components/ApprovalFeed.tsx b/src/components/ApprovalFeed.tsx index 4c6122c60..9abcbeed7 100644 --- a/src/components/ApprovalFeed.tsx +++ b/src/components/ApprovalFeed.tsx @@ -2,23 +2,25 @@ 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"; import { Fragment, useActionState } from "react"; +import { ApprovalsEmptyState } from "./ApprovalsEmptyState"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { Card, CardHeader } from "./ui/card"; +import { InfoPopup } from "./ui/info-popup"; import { Label } from "./ui/label"; import { Select, @@ -28,8 +30,6 @@ import { SelectValue } from "./ui/select"; import { Separator } from "./ui/separator"; -import { InfoPopup } from "./ui/info-popup"; -import { ApprovalsEmptyState } from "./ApprovalsEmptyState"; export type ApprovalFeedProps = { orgId: string; @@ -50,11 +50,17 @@ export function ApprovalFeed({ Object.fromEntries(searchParams.entries()) ); - const { data, isFetching, refetch } = useQuery( - approvalQueries.listApprovals(orgId, filters) - ); + const { + data, + isFetching, + isLoading, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage + } = useInfiniteQuery(approvalQueries.listApprovals(orgId, filters)); - 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) { @@ -110,13 +116,13 @@ export function ApprovalFeed({ onClick={() => { refetch(); }} - disabled={isFetching} + disabled={isFetching || isLoading} className="lg:static gap-2" > {t("refresh")} @@ -140,13 +146,30 @@ export function ApprovalFeed({ ))} {approvals.length === 0 && ( -
  • - {t("approvalListEmpty")} +
  • + {isLoading + ? t("loadingApprovals") + : t("approvalListEmpty")} + + {isLoading && ( + + )}
  • )} + {hasNextPage && ( + + )}
    ); } @@ -209,19 +232,19 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {   {approval.type === "user_device" && ( - {approval.deviceName ? ( - <> - {t("requestingNewDeviceApproval")}:{" "} - {approval.niceId ? ( - - {approval.deviceName} - - ) : ( - {approval.deviceName} - )} + {approval.deviceName ? ( + <> + {t("requestingNewDeviceApproval")}:{" "} + {approval.niceId ? ( + + {approval.deviceName} + + ) : ( + {approval.deviceName} + )} {approval.fingerprint && (
    @@ -229,7 +252,10 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) { {t("deviceInformation")}
    - {formatFingerprintInfo(approval.fingerprint, t)} + {formatFingerprintInfo( + approval.fingerprint, + t + )}
    diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 1d19c1e73..fe5350ff9 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -16,11 +16,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; @@ -356,22 +361,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({ From 5b779ba9fe059389ab1e460dec9be258c768313a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Feb 2026 03:21:12 +0100 Subject: [PATCH 34/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ApprovalFeed.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ApprovalFeed.tsx b/src/components/ApprovalFeed.tsx index 9abcbeed7..a158c1d45 100644 --- a/src/components/ApprovalFeed.tsx +++ b/src/components/ApprovalFeed.tsx @@ -167,7 +167,7 @@ export function ApprovalFeed({ loading={isFetchingNextPage} onClick={() => fetchNextPage()} > - {isFetchingNextPage ? t("loading") : t("approvalLoadMore")} + {t("approvalLoadMore")} )}
    From c94d246c24c8894ebfecd1e7aef8941de3b56804 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Feb 2026 04:00:45 +0100 Subject: [PATCH 35/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20list=20machine=20que?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listClients.ts | 98 ++++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 14 deletions(-) diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 588f1edd6..9ba7c6843 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -15,7 +15,18 @@ import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; import type { PaginatedResponse } from "@server/types/Pagination"; -import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + ilike, + inArray, + isNull, + or, + sql, + type SQL +} from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import NodeCache from "node-cache"; @@ -97,7 +108,29 @@ const listClientsSchema = z.object({ .catch(1) .default(1), query: z.string().optional(), - sort_by: z.enum(["megabytesIn", "megabytesOut"]).optional().catch(undefined) + 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 queryClientsBase() { @@ -188,7 +221,8 @@ export async function listClients( ) ); } - const { page, pageSize, query } = parsedQuery.data; + const { page, pageSize, online, query, status, sort_by, order } = + parsedQuery.data; const parsedParams = listClientsParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -237,24 +271,60 @@ export async function listClients( const accessibleClientIds = accessibleClients.map( (client) => client.clientId ); - const baseQuery = queryClientsBase(); // Get client count with filter const conditions = [ - inArray(clients.clientId, accessibleClientIds), - eq(clients.orgId, orgId), - isNull(clients.userId) + and( + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId), + isNull(clients.userId) + ) ]; - const countQuery = db.$count( - queryClientsBase().where(and(...conditions)) - ); + if (typeof online !== "undefined") { + conditions.push(eq(clients.online, online)); + } + + if (status.length > 0) { + const filterAggregates: (SQL | undefined)[] = []; + + 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([ - baseQuery - .where(and(...conditions)) - .limit(page) - .offset(pageSize * (page - 1)), + listMachinesQuery, countQuery ]); From d6ade102dc084f1f51dfadac1c8e20135c5582c1 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Feb 2026 05:14:37 +0100 Subject: [PATCH 36/38] =?UTF-8?q?=E2=9C=A8=20filter=20&=20paginate=20on=20?= =?UTF-8?q?machine=20clients=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listUserDevices.ts | 4 +- .../[orgId]/settings/clients/machine/page.tsx | 20 +- src/components/MachineClientsTable.tsx | 260 ++++++++++-------- src/components/ProxyResourcesTable.tsx | 8 +- src/components/UserDevicesTable.tsx | 8 +- 5 files changed, 176 insertions(+), 124 deletions(-) diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index d152250bb..65dba7e6c 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -124,6 +124,7 @@ const listUserDevicesSchema = z.object({ "windows", "android", "cli", + "olm", "macos", "ios", "ipados", @@ -302,7 +303,8 @@ export async function listUserDevices( ios: "Pangolin iOS", ipados: "Pangolin iPadOS", macos: "Pangolin macOS", - cli: "Pangolin CLI" + cli: "Pangolin CLI", + olm: "Olm CLI" } satisfies Record< Exclude, string 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/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index ad01c40fa..5af44dd53 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 ( ); } @@ -356,22 +374,8 @@ export default function MachineClientsTable({ }, { accessorKey: "subnet", - friendlyName: "Address", - header: ({ column }) => { - return ( - - ); - } + friendlyName: t("address"), + header: () => {t("address")} } ]; @@ -455,7 +459,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 +531,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 +576,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/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index ba69dec4a..490904c71 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -611,11 +611,9 @@ export default function ProxyResourcesTable({ onSearch={handleSearchChange} onPaginationChange={handlePaginationChange} onAdd={() => - startNavigation(() => { - router.push( - `/${orgId}/settings/resources/proxy/create` - ); - }) + startNavigation(() => + router.push(`/${orgId}/settings/resources/proxy/create`) + ) } addButtonText={t("resourceAdd")} onRefresh={refreshData} diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 642ef1f71..1b7b0c694 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -443,10 +443,6 @@ export default function UserDevicesTable({ searchParams ); - console.log({ - dataInOrder, - searchParams: Object.fromEntries(searchParams.entries()) - }); const Icon = dataInOrder === "asc" ? ArrowDown01Icon @@ -520,6 +516,10 @@ export default function UserDevicesTable({ value: "cli", label: "Pangolin CLI" }, + { + value: "olm", + label: "Olm CLI" + }, { value: "unknown", label: t("unknown") From 45cd4df6e5a3d71001424c0a6b036f582bd4130d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 11 Feb 2026 00:37:42 +0100 Subject: [PATCH 37/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MachineClientsTable.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index 5af44dd53..97de41130 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -336,21 +336,7 @@ export default function MachineClientsTable({ { accessorKey: "client", friendlyName: t("agent"), - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("agent")}, cell: ({ row }) => { const originalRow = row.original; From 6d1665004b1d40f4e655c6a3b2a40bd78a387331 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 11 Feb 2026 04:34:53 +0100 Subject: [PATCH 38/38] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20fix=20type=20erro?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../clients/user/[niceId]/general/page.tsx | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) 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 eef934768..6dbc40b30 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -594,7 +594,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .biometricsEnabled + .biometricsEnabled === + true ) : "-"} @@ -612,7 +613,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .diskEncrypted + .diskEncrypted === + true ) : "-"} @@ -630,7 +632,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .firewallEnabled + .firewallEnabled === + true ) : "-"} @@ -648,7 +651,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .autoUpdatesEnabled + .autoUpdatesEnabled === + true ) : "-"} @@ -666,7 +670,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .tpmAvailable + .tpmAvailable === + true ) : "-"} @@ -685,7 +690,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .windowsAntivirusEnabled + .windowsAntivirusEnabled === + true ) : "-"} @@ -703,7 +709,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .macosSipEnabled + .macosSipEnabled === + true ) : "-"} @@ -722,7 +729,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .macosGatekeeperEnabled + .macosGatekeeperEnabled === + true ) : "-"} @@ -741,7 +749,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .macosFirewallStealthMode + .macosFirewallStealthMode === + true ) : "-"} @@ -759,7 +768,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .linuxAppArmorEnabled + .linuxAppArmorEnabled === + true ) : "-"} @@ -777,7 +787,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .linuxSELinuxEnabled + .linuxSELinuxEnabled === + true ) : "-"}