mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-03 09:16:40 +00:00
✨ filter & paginate on machine clients table
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user