split clients table

This commit is contained in:
miloschwartz
2025-11-05 16:43:27 -08:00
parent 9e2477587c
commit 4ade878320
5 changed files with 640 additions and 64 deletions

View File

@@ -145,7 +145,7 @@
"never": "Never",
"shareErrorSelectResource": "Please select a resource",
"resourceTitle": "Manage Resources",
"resourceDescription": "Create secure proxies to your private applications",
"resourceDescription": "Access resources on sites publically or privately",
"resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting resource",
@@ -1862,6 +1862,10 @@
"beta": "Beta",
"manageClients": "Manage Clients",
"manageClientsDescription": "Clients are devices that can connect to your sites",
"clientsTableUserClients": "User",
"clientsTableUserClientsDescription": "User clients are user devices in your organization.",
"clientsTableMachineClients": "Machine",
"clientsTableMachineClientsDescription": "Machine clients are clients created with credentials for machine connectivity not associated with a user.",
"licenseTableValidUntil": "Valid Until",
"saasLicenseKeysSettingsTitle": "Enterprise Licenses",
"saasLicenseKeysSettingsDescription": "Generate and manage Enterprise license keys for self-hosted Pangolin instances",

View File

@@ -10,7 +10,7 @@ import {
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, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -96,10 +96,25 @@ const listClientsSchema = z.object({
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
.pipe(z.number().int().nonnegative()),
filter: z
.enum(["user", "machine"])
.optional()
});
function queryClients(orgId: string, accessibleClientIds: number[]) {
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));
}
return db
.select({
clientId: clients.clientId,
@@ -121,12 +136,7 @@ function queryClients(orgId: string, accessibleClientIds: number[]) {
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
.leftJoin(olms, eq(clients.clientId, olms.clientId))
.leftJoin(users, eq(clients.userId, users.userId))
.where(
and(
inArray(clients.clientId, accessibleClientIds),
eq(clients.orgId, orgId)
)
);
.where(and(...conditions));
}
async function getSiteAssociations(clientIds: number[]) {
@@ -188,7 +198,7 @@ export async function listClients(
)
);
}
const { limit, offset } = parsedQuery.data;
const { limit, offset, filter } = parsedQuery.data;
const parsedParams = listClientsParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -237,18 +247,24 @@ export async function listClients(
const accessibleClientIds = accessibleClients.map(
(client) => client.clientId
);
const baseQuery = queryClients(orgId, accessibleClientIds);
const baseQuery = queryClients(orgId, accessibleClientIds, filter);
// Get client count with filter
const countConditions = [
inArray(clients.clientId, accessibleClientIds),
eq(clients.orgId, orgId)
];
if (filter === "user") {
countConditions.push(isNotNull(clients.userId));
} else if (filter === "machine") {
countConditions.push(isNull(clients.userId));
}
// Get client count
const countQuery = db
.select({ count: count() })
.from(clients)
.where(
and(
inArray(clients.clientId, accessibleClientIds),
eq(clients.orgId, orgId)
)
);
.where(and(...countConditions));
const clientsList = await baseQuery.limit(limit).offset(offset);
const totalCountResult = await countQuery;

View File

@@ -9,6 +9,7 @@ import { getTranslations } from "next-intl/server";
type ClientsPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;
};
export const dynamic = "force-dynamic";
@@ -17,13 +18,28 @@ export default async function ClientsPage(props: ClientsPageProps) {
const t = await getTranslations();
const params = await props.params;
let clients: ListClientsResponse["clients"] = [];
const searchParams = await props.searchParams;
// Default to 'user' view, or use the query param if provided
let defaultView: "user" | "machine" = "user";
defaultView = searchParams.view === "machine" ? "machine" : "user";
let userClients: ListClientsResponse["clients"] = [];
let machineClients: ListClientsResponse["clients"] = [];
try {
const res = await internal.get<AxiosResponse<ListClientsResponse>>(
`/org/${params.orgId}/clients`,
await authCookieHeader()
);
clients = res.data.data.clients;
const [userRes, machineRes] = await Promise.all([
internal.get<AxiosResponse<ListClientsResponse>>(
`/org/${params.orgId}/clients?filter=user`,
await authCookieHeader()
),
internal.get<AxiosResponse<ListClientsResponse>>(
`/org/${params.orgId}/clients?filter=machine`,
await authCookieHeader()
)
]);
userClients = userRes.data.data.clients;
machineClients = machineRes.data.data.clients;
} catch (e) {}
function formatSize(mb: number): string {
@@ -36,7 +52,7 @@ export default async function ClientsPage(props: ClientsPageProps) {
}
}
const clientRows: ClientRow[] = clients.map((client) => {
const mapClientToRow = (client: ListClientsResponse["clients"][0]): ClientRow => {
return {
name: client.name,
id: client.clientId,
@@ -51,7 +67,10 @@ export default async function ClientsPage(props: ClientsPageProps) {
username: client.username,
userEmail: client.userEmail
};
});
};
const userClientRows: ClientRow[] = userClients.map(mapClientToRow);
const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow);
return (
<>
@@ -60,7 +79,12 @@ export default async function ClientsPage(props: ClientsPageProps) {
description={t("manageClientsDescription")}
/>
<ClientsTable clients={clientRows} orgId={params.orgId} />
<ClientsTable
userClients={userClientRows}
machineClients={machineClientRows}
orgId={params.orgId}
defaultView={defaultView}
/>
</>
);
}

View File

@@ -5,6 +5,12 @@ import {
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
type TabFilter = {
id: string;
label: string;
filterFn: (row: any) => boolean;
};
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
@@ -13,6 +19,9 @@ interface DataTableProps<TData, TValue> {
addClient?: () => void;
columnVisibility?: Record<string, boolean>;
enableColumnVisibility?: boolean;
hideHeader?: boolean;
tabs?: TabFilter[];
defaultTab?: string;
}
export function ClientsDataTable<TData, TValue>({
@@ -22,22 +31,27 @@ export function ClientsDataTable<TData, TValue>({
onRefresh,
isRefreshing,
columnVisibility,
enableColumnVisibility
enableColumnVisibility,
hideHeader = false,
tabs,
defaultTab
}: DataTableProps<TData, TValue>) {
return (
<DataTable
columns={columns}
data={data}
data={data || []}
persistPageSize="clients-table"
title="Clients"
title={hideHeader ? undefined : "Clients"}
searchPlaceholder="Search clients..."
searchColumn="name"
onAdd={addClient}
onRefresh={onRefresh}
onAdd={hideHeader ? undefined : addClient}
onRefresh={hideHeader ? undefined : onRefresh}
isRefreshing={isRefreshing}
addButtonText="Add Client"
addButtonText={hideHeader ? undefined : "Add Client"}
columnVisibility={columnVisibility}
enableColumnVisibility={enableColumnVisibility}
tabs={tabs}
defaultTab={defaultTab}
/>
);
}

View File

@@ -1,12 +1,25 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ClientsDataTable } from "@app/components/ClientsDataTable";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel,
VisibilityState
} from "@tanstack/react-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import {
@@ -15,11 +28,15 @@ import {
ArrowUpRight,
Check,
MoreHorizontal,
X
X,
RefreshCw,
Columns,
Search,
Plus
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useEffect } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
@@ -28,6 +45,24 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import { Badge } from "./ui/badge";
import { InfoPopup } from "./ui/info-popup";
import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger
} from "@app/components/ui/tabs";
import { Alert, AlertDescription } from "@app/components/ui/alert";
export type ClientRow = {
id: number;
@@ -46,23 +81,93 @@ export type ClientRow = {
};
type ClientTableProps = {
clients: ClientRow[];
userClients: ClientRow[];
machineClients: ClientRow[];
orgId: string;
defaultView?: "user" | "machine";
};
export default function ClientsTable({ clients, orgId }: ClientTableProps) {
const router = useRouter();
const STORAGE_KEYS = {
PAGE_SIZE: "datatable-page-size",
getTablePageSize: (tableId?: string) =>
tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE
};
const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
if (typeof window === "undefined") return defaultSize;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
const stored = localStorage.getItem(key);
if (stored) {
const parsed = parseInt(stored, 10);
if (parsed > 0 && parsed <= 1000) {
return parsed;
}
}
} catch (error) {
console.warn("Failed to read page size from localStorage:", error);
}
return defaultSize;
};
const setStoredPageSize = (pageSize: number, tableId?: string): void => {
if (typeof window === "undefined") return;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
localStorage.setItem(key, pageSize.toString());
} catch (error) {
console.warn("Failed to save page size to localStorage:", error);
}
};
export default function ClientsTable({
userClients,
machineClients,
orgId,
defaultView = "user"
}: ClientTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const t = useTranslations();
const [userPageSize, setUserPageSize] = useState<number>(() =>
getStoredPageSize("user-clients", 20)
);
const [machinePageSize, setMachinePageSize] = useState<number>(() =>
getStoredPageSize("machine-clients", 20)
);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
);
const [rows, setRows] = useState<ClientRow[]>(clients);
const api = createApiClient(useEnvContext());
const [isRefreshing, setIsRefreshing] = useState(false);
const t = useTranslations();
const [userSorting, setUserSorting] = useState<SortingState>([]);
const [userColumnFilters, setUserColumnFilters] =
useState<ColumnFiltersState>([]);
const [userGlobalFilter, setUserGlobalFilter] = useState<any>([]);
const [machineSorting, setMachineSorting] = useState<SortingState>([]);
const [machineColumnFilters, setMachineColumnFilters] =
useState<ColumnFiltersState>([]);
const [machineGlobalFilter, setMachineGlobalFilter] = useState<any>([]);
const [userColumnVisibility, setUserColumnVisibility] = useState<VisibilityState>({
client: false,
subnet: false
});
const [machineColumnVisibility, setMachineColumnVisibility] = useState<VisibilityState>({
client: false,
subnet: false,
userId: false
});
const currentView = searchParams.get("view") || defaultView;
const refreshData = async () => {
console.log("Data refreshed");
@@ -81,6 +186,18 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
}
};
const handleTabChange = (value: string) => {
const params = new URLSearchParams(searchParams);
if (value === "machine") {
params.set("view", "machine");
} else {
params.delete("view");
}
const newUrl = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`;
router.replace(newUrl, { scroll: false });
};
const deleteClient = (clientId: number) => {
api.delete(`/client/${clientId}`)
.catch((e) => {
@@ -94,13 +211,60 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
const newRows = rows.filter((row) => row.id !== clientId);
setRows(newRows);
});
};
const getSearchInput = () => {
if (currentView === "machine") {
return (
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={t("resourcesSearch")}
value={machineGlobalFilter ?? ""}
onChange={(e) =>
machineTable.setGlobalFilter(
String(e.target.value)
)
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
);
}
return (
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={t("resourcesSearch")}
value={userGlobalFilter ?? ""}
onChange={(e) =>
userTable.setGlobalFilter(String(e.target.value))
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
);
};
const getActionButton = () => {
// Only show create button on machine clients tab
if (currentView === "machine") {
return (
<Button
onClick={() =>
router.push(`/${orgId}/settings/clients/create`)
}
>
<Plus className="mr-2 h-4 w-4" />
{t("createClient")}
</Button>
);
}
return null;
};
const columns: ColumnDef<ClientRow>[] = [
{
accessorKey: "name",
@@ -342,6 +506,75 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
}
];
const userTable = useReactTable({
data: userClients || [],
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setUserSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setUserColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setUserGlobalFilter,
onColumnVisibilityChange: setUserColumnVisibility,
initialState: {
pagination: {
pageSize: userPageSize,
pageIndex: 0
},
columnVisibility: {
client: false,
subnet: false
}
},
state: {
sorting: userSorting,
columnFilters: userColumnFilters,
globalFilter: userGlobalFilter,
columnVisibility: userColumnVisibility
}
});
const machineTable = useReactTable({
data: machineClients || [],
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setMachineSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setMachineColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setMachineGlobalFilter,
onColumnVisibilityChange: setMachineColumnVisibility,
initialState: {
pagination: {
pageSize: machinePageSize,
pageIndex: 0
},
columnVisibility: {
client: false,
subnet: false,
userId: false
}
},
state: {
sorting: machineSorting,
columnFilters: machineColumnFilters,
globalFilter: machineGlobalFilter,
columnVisibility: machineColumnVisibility
}
});
const handleUserPageSizeChange = (newPageSize: number) => {
setUserPageSize(newPageSize);
setStoredPageSize(newPageSize, "user-clients");
};
const handleMachinePageSizeChange = (newPageSize: number) => {
setMachinePageSize(newPageSize);
setStoredPageSize(newPageSize, "machine-clients");
};
return (
<>
{selectedClient && (
@@ -364,20 +597,305 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
/>
)}
<ClientsDataTable
columns={columns}
data={rows}
addClient={() => {
router.push(`/${orgId}/settings/clients/create`);
}}
onRefresh={refreshData}
isRefreshing={isRefreshing}
columnVisibility={{
client: false,
subnet: false
}}
enableColumnVisibility={true}
/>
<div className="container mx-auto max-w-12xl">
<Card>
<Tabs
defaultValue={defaultView}
className="w-full"
onValueChange={handleTabChange}
>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-0">
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
{getSearchInput()}
<TabsList className="grid grid-cols-2">
<TabsTrigger value="user">
{t("clientsTableUserClients")}
</TabsTrigger>
<TabsTrigger value="machine">
{t("clientsTableMachineClients")}
</TabsTrigger>
</TabsList>
</div>
<div className="flex items-center gap-2 sm:justify-end">
{currentView === "user" && userTable.getAllColumns().some((column) => column.getCanHide()) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Columns className="mr-0 sm:mr-2 h-4 w-4" />
<span className="hidden sm:inline">
{t("columns") || "Columns"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>
{t("toggleColumns") || "Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{userTable
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{typeof column.columnDef.header === "string"
? column.columnDef.header
: column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
{currentView === "machine" && machineTable.getAllColumns().some((column) => column.getCanHide()) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Columns className="mr-0 sm:mr-2 h-4 w-4" />
<span className="hidden sm:inline">
{t("columns") || "Columns"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>
{t("toggleColumns") || "Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{machineTable
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{typeof column.columnDef.header === "string"
? column.columnDef.header
: column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
<div>
<Button
variant="outline"
onClick={refreshData}
disabled={isRefreshing}
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
<span className="hidden sm:inline">
{t("refresh")}
</span>
</Button>
</div>
<div>{getActionButton()}</div>
</div>
</CardHeader>
<CardContent>
<TabsContent value="user">
<div className="mb-4">
<Alert variant="neutral">
<AlertDescription>
{t("clientsTableUserClientsDescription")}
</AlertDescription>
</Alert>
</div>
<Table>
<TableHeader>
{userTable
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers
.filter((header) => header.column.getIsVisible())
.map(
(header) => (
<TableHead
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{userTable.getRowModel().rows
?.length ? (
userTable
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
"selected"
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={
cell.id
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={
columns.length
}
className="h-24 text-center"
>
{t("noResults")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="mt-4">
<DataTablePagination
table={userTable}
onPageSizeChange={
handleUserPageSizeChange
}
/>
</div>
</TabsContent>
<TabsContent value="machine">
<div className="mb-4">
<Alert variant="neutral">
<AlertDescription>
{t("clientsTableMachineClientsDescription")}
</AlertDescription>
</Alert>
</div>
<Table>
<TableHeader>
{machineTable
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers
.filter((header) => header.column.getIsVisible())
.map(
(header) => (
<TableHead
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{machineTable.getRowModel().rows
?.length ? (
machineTable
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
"selected"
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={
cell.id
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={
columns.length
}
className="h-24 text-center"
>
{t("noResults")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="mt-4">
<DataTablePagination
table={machineTable}
onPageSizeChange={
handleMachinePageSizeChange
}
/>
</div>
</TabsContent>
</CardContent>
</Tabs>
</Card>
</div>
</>
);
}