filter & paginate on machine clients table

This commit is contained in:
Fred KISSIE
2026-02-10 05:14:37 +01:00
parent c94d246c24
commit d6ade102dc
5 changed files with 176 additions and 124 deletions

View File

@@ -124,6 +124,7 @@ const listUserDevicesSchema = z.object({
"windows", "windows",
"android", "android",
"cli", "cli",
"olm",
"macos", "macos",
"ios", "ios",
"ipados", "ipados",
@@ -302,7 +303,8 @@ export async function listUserDevices(
ios: "Pangolin iOS", ios: "Pangolin iOS",
ipados: "Pangolin iPadOS", ipados: "Pangolin iPadOS",
macos: "Pangolin macOS", macos: "Pangolin macOS",
cli: "Pangolin CLI" cli: "Pangolin CLI",
olm: "Olm CLI"
} satisfies Record< } satisfies Record<
Exclude<typeof agent, undefined | "unknown">, Exclude<typeof agent, undefined | "unknown">,
string string

View File

@@ -7,10 +7,11 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { ListClientsResponse } from "@server/routers/client"; import { ListClientsResponse } from "@server/routers/client";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { Pagination } from "@server/types/Pagination";
type ClientsPageProps = { type ClientsPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>; searchParams: Promise<Record<string, string>>;
}; };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -19,17 +20,25 @@ export default async function ClientsPage(props: ClientsPageProps) {
const t = await getTranslations(); const t = await getTranslations();
const params = await props.params; const params = await props.params;
const searchParams = new URLSearchParams(await props.searchParams);
let machineClients: ListClientsResponse["clients"] = []; let machineClients: ListClientsResponse["clients"] = [];
let pagination: Pagination = {
page: 1,
total: 0,
pageSize: 20
};
try { try {
const machineRes = await internal.get< const machineRes = await internal.get<
AxiosResponse<ListClientsResponse> AxiosResponse<ListClientsResponse>
>( >(
`/org/${params.orgId}/clients?filter=machine`, `/org/${params.orgId}/clients?${searchParams.toString()}`,
await authCookieHeader() await authCookieHeader()
); );
machineClients = machineRes.data.data.clients; const responseData = machineRes.data.data;
machineClients = responseData.clients;
pagination = responseData.pagination;
} catch (e) {} } catch (e) {}
function formatSize(mb: number): string { function formatSize(mb: number): string {
@@ -80,6 +89,11 @@ export default async function ClientsPage(props: ClientsPageProps) {
<MachineClientsTable <MachineClientsTable
machineClients={machineClientRows} machineClients={machineClientRows}
orgId={params.orgId} orgId={params.orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/> />
</> </>
); );

View File

@@ -16,13 +16,23 @@ import {
ArrowRight, ArrowRight,
ArrowUpDown, ArrowUpDown,
MoreHorizontal, MoreHorizontal,
CircleSlash CircleSlash,
ArrowDown01Icon,
ArrowUp10Icon,
ChevronsUpDownIcon
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { Badge } from "./ui/badge"; 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 = { export type ClientRow = {
id: number; id: number;
@@ -48,14 +58,24 @@ export type ClientRow = {
type ClientTableProps = { type ClientTableProps = {
machineClients: ClientRow[]; machineClients: ClientRow[];
orgId: string; orgId: string;
pagination: PaginationState;
rowCount: number;
}; };
export default function MachineClientsTable({ export default function MachineClientsTable({
machineClients, machineClients,
orgId orgId,
pagination,
rowCount
}: ClientTableProps) { }: ClientTableProps) {
const router = useRouter(); const router = useRouter();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const t = useTranslations(); const t = useTranslations();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@@ -65,6 +85,7 @@ export default function MachineClientsTable({
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const defaultMachineColumnVisibility = { const defaultMachineColumnVisibility = {
subnet: false, subnet: false,
@@ -182,22 +203,8 @@ export default function MachineClientsTable({
{ {
accessorKey: "name", accessorKey: "name",
enableHiding: false, enableHiding: false,
friendlyName: "Name", friendlyName: t("name"),
header: ({ column }) => { header: () => <span className="px-3">{t("name")}</span>,
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
return ( return (
@@ -224,38 +231,35 @@ export default function MachineClientsTable({
{ {
accessorKey: "niceId", accessorKey: "niceId",
friendlyName: "Identifier", friendlyName: "Identifier",
header: ({ column }) => { header: () => <span className="px-3">{t("identifier")}</span>
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
}, },
{ {
accessorKey: "online", accessorKey: "online",
friendlyName: "Connectivity", friendlyName: t("online"),
header: ({ column }) => { header: () => {
return ( return (
<Button <ColumnFilterButton
variant="ghost" options={[
onClick={() => {
column.toggleSorting( value: "true",
column.getIsSorted() === "asc" label: t("connected")
) },
{
value: "false",
label: t("disconnected")
}
]}
selectedValue={
searchParams.get("online") ?? undefined
} }
> onValueChange={(value) =>
Connectivity handleFilterChange("online", value)
<ArrowUpDown className="ml-2 h-4 w-4" /> }
</Button> searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("online")}
className="p-3"
/>
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
@@ -279,38 +283,52 @@ export default function MachineClientsTable({
}, },
{ {
accessorKey: "mbIn", accessorKey: "mbIn",
friendlyName: "Data In", friendlyName: t("dataIn"),
header: ({ column }) => { header: () => {
const dataInOrder = getSortDirection(
"megabytesIn",
searchParams
);
const Icon =
dataInOrder === "asc"
? ArrowDown01Icon
: dataInOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return ( return (
<Button <Button
variant="ghost" variant="ghost"
onClick={() => onClick={() => toggleSort("megabytesIn")}
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
> >
Data In {t("dataIn")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <Icon className="ml-2 h-4 w-4" />
</Button> </Button>
); );
} }
}, },
{ {
accessorKey: "mbOut", accessorKey: "mbOut",
friendlyName: "Data Out", friendlyName: t("dataOut"),
header: ({ column }) => { header: () => {
const dataOutOrder = getSortDirection(
"megabytesOut",
searchParams
);
const Icon =
dataOutOrder === "asc"
? ArrowDown01Icon
: dataOutOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return ( return (
<Button <Button
variant="ghost" variant="ghost"
onClick={() => onClick={() => toggleSort("megabytesOut")}
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
> >
Data Out {t("dataOut")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <Icon className="ml-2 h-4 w-4" />
</Button> </Button>
); );
} }
@@ -356,22 +374,8 @@ export default function MachineClientsTable({
}, },
{ {
accessorKey: "subnet", accessorKey: "subnet",
friendlyName: "Address", friendlyName: t("address"),
header: ({ column }) => { header: () => <span className="px-3">{t("address")}</span>
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
} }
]; ];
@@ -455,7 +459,56 @@ export default function MachineClientsTable({
} }
return baseColumns; 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 ( return (
<> <>
@@ -478,20 +531,25 @@ export default function MachineClientsTable({
title="Delete Client" title="Delete Client"
/> />
)} )}
<DataTable <ControlledDataTable
columns={columns} columns={columns}
data={machineClients || []} rows={machineClients}
persistPageSize="machine-clients" tableId="machine-clients"
searchPlaceholder={t("resourcesSearch")} searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onAdd={() => onAdd={() =>
router.push(`/${orgId}/settings/clients/machine/create`) startNavigation(() =>
router.push(`/${orgId}/settings/clients/machine/create`)
)
} }
pagination={pagination}
rowCount={rowCount}
addButtonText={t("createClient")} addButtonText={t("createClient")}
onRefresh={refreshData} onRefresh={refreshData}
isRefreshing={isRefreshing} isRefreshing={isRefreshing || isFiltering}
enableColumnVisibility={true} onSearch={handleSearchChange}
persistColumnVisibility="machine-clients" onPaginationChange={handlePaginationChange}
isNavigatingToAddPage={isNavigatingToAddPage}
enableColumnVisibility
columnVisibility={defaultMachineColumnVisibility} columnVisibility={defaultMachineColumnVisibility}
stickyLeftColumn="name" stickyLeftColumn="name"
stickyRightColumn="actions" stickyRightColumn="actions"
@@ -518,30 +576,10 @@ export default function MachineClientsTable({
value: "blocked" value: "blocked"
} }
], ],
filterFn: ( onValueChange(selectedValues: string[]) {
row: ClientRow, handleFilterChange("status", selectedValues);
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;
}, },
defaultValues: ["active"] // Default to showing active clients values: searchParams.getAll("status")
} }
]} ]}
/> />

View File

@@ -611,11 +611,9 @@ export default function ProxyResourcesTable({
onSearch={handleSearchChange} onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange} onPaginationChange={handlePaginationChange}
onAdd={() => onAdd={() =>
startNavigation(() => { startNavigation(() =>
router.push( router.push(`/${orgId}/settings/resources/proxy/create`)
`/${orgId}/settings/resources/proxy/create` )
);
})
} }
addButtonText={t("resourceAdd")} addButtonText={t("resourceAdd")}
onRefresh={refreshData} onRefresh={refreshData}

View File

@@ -443,10 +443,6 @@ export default function UserDevicesTable({
searchParams searchParams
); );
console.log({
dataInOrder,
searchParams: Object.fromEntries(searchParams.entries())
});
const Icon = const Icon =
dataInOrder === "asc" dataInOrder === "asc"
? ArrowDown01Icon ? ArrowDown01Icon
@@ -520,6 +516,10 @@ export default function UserDevicesTable({
value: "cli", value: "cli",
label: "Pangolin CLI" label: "Pangolin CLI"
}, },
{
value: "olm",
label: "Olm CLI"
},
{ {
value: "unknown", value: "unknown",
label: t("unknown") label: t("unknown")