From 06a31bb7160378c82bedac588ae9ef125e1f1e95 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 2 Dec 2025 18:58:51 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20separate=20machine=20clien?= =?UTF-8?q?t=20&=20user=20devices=20tables=20+=20move=20common=20functions?= =?UTF-8?q?=20into=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 6 +- .../[orgId]/settings/clients/machine/page.tsx | 40 +- src/app/[orgId]/settings/clients/page.tsx | 11 - .../[orgId]/settings/clients/user/page.tsx | 35 +- src/components/ClientResourcesTable.tsx | 57 +- src/components/ClientsTable.tsx | 985 ------------------ src/components/MachineClientsTable.tsx | 698 +++++++++++++ src/components/ProxyResourcesTable.tsx | 2 +- src/components/UserDevicesTable.tsx | 677 ++++++++++++ src/hooks/useLocalStorage.ts | 4 +- src/hooks/useStoredColumnVisibility.ts | 81 ++ src/hooks/useStoredPageSize.ts | 60 ++ 12 files changed, 1546 insertions(+), 1110 deletions(-) delete mode 100644 src/components/ClientsTable.tsx create mode 100644 src/components/MachineClientsTable.tsx create mode 100644 src/components/UserDevicesTable.tsx create mode 100644 src/hooks/useStoredColumnVisibility.ts create mode 100644 src/hooks/useStoredPageSize.ts diff --git a/messages/en-US.json b/messages/en-US.json index 279da0e3..6483b025 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1881,8 +1881,10 @@ "enterpriseEdition": "Enterprise Edition", "unlicensed": "Unlicensed", "beta": "Beta", - "manageClients": "Manage Clients", - "manageClientsDescription": "Clients are devices that can connect to your sites", + "manageUserDevices": "Manage User Devices", + "manageUserDevicesDescription": "View user devices (laptops, phones, tablets) that can access your sites through clients", + "manageMachineClients": "Manage Machine Clients", + "manageMachineClientsDescription": "Create and manage credentials for automated systems and infrastructure (CI/CD, VMs, VPCs) to securely access your sites", "clientsTableUserClients": "User", "clientsTableMachineClients": "Machine", "licenseTableValidUntil": "Valid Until", diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx index e0b4ebf7..b450b09f 100644 --- a/src/app/[orgId]/settings/clients/machine/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -1,11 +1,11 @@ +import type { ClientRow } from "@app/components/MachineClientsTable"; +import MachineClientsTable from "@app/components/MachineClientsTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; 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 { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; -import type { ClientRow } from "@app/components/ClientsTable"; -import ClientsTable from "@app/components/ClientsTable"; type ClientsPageProps = { params: Promise<{ orgId: string }>; @@ -18,27 +18,16 @@ export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); const params = await props.params; - 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 [userRes, machineRes] = await Promise.all([ - internal.get>( - `/org/${params.orgId}/clients?filter=user`, - await authCookieHeader() - ), - internal.get>( - `/org/${params.orgId}/clients?filter=machine`, - await authCookieHeader() - ) - ]); - userClients = userRes.data.data.clients; + const machineRes = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/clients?filter=machine`, + await authCookieHeader() + ); machineClients = machineRes.data.data.clients; } catch (e) {} @@ -71,21 +60,18 @@ export default async function ClientsPage(props: ClientsPageProps) { }; }; - const userClientRows: ClientRow[] = userClients.map(mapClientToRow); const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow); return ( <> - ); diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx index 40bcc569..aeea1c83 100644 --- a/src/app/[orgId]/settings/clients/page.tsx +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -1,11 +1,3 @@ -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import { AxiosResponse } from "axios"; -import { ClientRow } from "../../../../components/ClientsTable"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { ListClientsResponse } from "@server/routers/client"; -import ClientsTable from "../../../../components/ClientsTable"; -import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; type ClientsPageProps = { @@ -16,9 +8,6 @@ type ClientsPageProps = { export const dynamic = "force-dynamic"; export default async function ClientsPage(props: ClientsPageProps) { - const t = await getTranslations(); - const params = await props.params; - redirect(`/${params.orgId}/settings/clients/user`); } diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index e0b4ebf7..399588fc 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -4,12 +4,11 @@ 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/ClientsTable"; -import ClientsTable from "@app/components/ClientsTable"; +import type { ClientRow } from "@app/components/MachineClientsTable"; +import UserDevicesTable from "@app/components/UserDevicesTable"; type ClientsPageProps = { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; }; export const dynamic = "force-dynamic"; @@ -18,28 +17,15 @@ export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); const params = await props.params; - 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 [userRes, machineRes] = await Promise.all([ - internal.get>( - `/org/${params.orgId}/clients?filter=user`, - await authCookieHeader() - ), - internal.get>( - `/org/${params.orgId}/clients?filter=machine`, - await authCookieHeader() - ) - ]); + const userRes = await internal.get>( + `/org/${params.orgId}/clients?filter=user`, + await authCookieHeader() + ); userClients = userRes.data.data.clients; - machineClients = machineRes.data.data.clients; } catch (e) {} function formatSize(mb: number): string { @@ -72,20 +58,17 @@ export default async function ClientsPage(props: ClientsPageProps) { }; const userClientRows: ClientRow[] = userClients.map(mapClientToRow); - const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow); return ( <> - ); diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 0d246fb3..39578a9b 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -87,59 +87,6 @@ export type ResourceRow = { targets?: TargetHealth[]; }; -function getOverallHealthStatus( - targets?: TargetHealth[] -): "online" | "degraded" | "offline" | "unknown" { - if (!targets || targets.length === 0) { - return "unknown"; - } - - const monitoredTargets = targets.filter( - (t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown" - ); - - if (monitoredTargets.length === 0) { - return "unknown"; - } - - const healthyCount = monitoredTargets.filter( - (t) => t.healthStatus === "healthy" - ).length; - const unhealthyCount = monitoredTargets.filter( - (t) => t.healthStatus === "unhealthy" - ).length; - - if (healthyCount === monitoredTargets.length) { - return "online"; - } else if (unhealthyCount === monitoredTargets.length) { - return "offline"; - } else { - return "degraded"; - } -} - -function StatusIcon({ - status, - className = "" -}: { - status: "online" | "degraded" | "offline" | "unknown"; - className?: string; -}) { - const iconClass = `h-4 w-4 ${className}`; - - switch (status) { - case "online": - return ; - case "degraded": - return ; - case "offline": - return ; - case "unknown": - return ; - default: - return null; - } -} export type InternalResourceRow = { id: number; name: string; @@ -157,8 +104,6 @@ export type InternalResourceRow = { alias: string | null; }; -type Site = ListSitesResponse["sites"][0]; - type ClientResourcesTableProps = { internalResources: InternalResourceRow[]; orgId: string; @@ -655,7 +600,7 @@ export default function ClientResourcesTable({ -
+
{internalTable diff --git a/src/components/ClientsTable.tsx b/src/components/ClientsTable.tsx deleted file mode 100644 index 96f9430d..00000000 --- a/src/components/ClientsTable.tsx +++ /dev/null @@ -1,985 +0,0 @@ -"use client"; - -import { - ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, - getPaginationRowModel, - SortingState, - getSortedRowModel, - ColumnFiltersState, - getFilteredRowModel, - VisibilityState -} from "@tanstack/react-table"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuCheckboxItem, - DropdownMenuLabel, - DropdownMenuSeparator -} from "@app/components/ui/dropdown-menu"; -import { Button } from "@app/components/ui/button"; -import { - ArrowRight, - ArrowUpDown, - ArrowUpRight, - Check, - MoreHorizontal, - X, - RefreshCw, - Columns, - Search, - Plus -} from "lucide-react"; -import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useState, useEffect, useMemo } from "react"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { toast } from "@app/hooks/useToast"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -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; - name: string; - subnet: string; - // siteIds: string; - mbIn: string; - mbOut: string; - orgId: string; - online: boolean; - olmVersion?: string; - olmUpdateAvailable: boolean; - userId: string | null; - username: string | null; - userEmail: string | null; -}; - -type ClientTableProps = { - userClients: ClientRow[]; - machineClients: ClientRow[]; - orgId: string; - defaultView?: "user" | "machine"; -}; - -const STORAGE_KEYS = { - PAGE_SIZE: "datatable-page-size", - COLUMN_VISIBILITY: "datatable-column-visibility", - getTablePageSize: (tableId?: string) => - tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE, - getTableColumnVisibility: (tableId?: string) => - tableId - ? `datatable-${tableId}-column-visibility` - : STORAGE_KEYS.COLUMN_VISIBILITY -}; - -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); - } -}; - -const getStoredColumnVisibility = ( - tableId?: string, - defaultVisibility?: Record -): Record => { - if (typeof window === "undefined") return defaultVisibility || {}; - - try { - const key = STORAGE_KEYS.getTableColumnVisibility(tableId); - const stored = localStorage.getItem(key); - if (stored) { - const parsed = JSON.parse(stored); - // Validate that it's an object - if (typeof parsed === "object" && parsed !== null) { - return parsed; - } - } - } catch (error) { - console.warn( - "Failed to read column visibility from localStorage:", - error - ); - } - return defaultVisibility || {}; -}; - -const setStoredColumnVisibility = ( - visibility: Record, - tableId?: string -): void => { - if (typeof window === "undefined") return; - - try { - const key = STORAGE_KEYS.getTableColumnVisibility(tableId); - localStorage.setItem(key, JSON.stringify(visibility)); - } catch (error) { - console.warn( - "Failed to save column visibility 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(() => - getStoredPageSize("user-clients", 20) - ); - const [machinePageSize, setMachinePageSize] = useState(() => - getStoredPageSize("machine-clients", 20) - ); - - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [selectedClient, setSelectedClient] = useState( - null - ); - - const api = createApiClient(useEnvContext()); - const [isRefreshing, setIsRefreshing] = useState(false); - - const [userSorting, setUserSorting] = useState([]); - const [userColumnFilters, setUserColumnFilters] = - useState([]); - const [userGlobalFilter, setUserGlobalFilter] = useState([]); - - const [machineSorting, setMachineSorting] = useState([]); - const [machineColumnFilters, setMachineColumnFilters] = - useState([]); - const [machineGlobalFilter, setMachineGlobalFilter] = useState([]); - - const defaultUserColumnVisibility = { - client: false, - subnet: false - }; - const defaultMachineColumnVisibility = { - client: false, - subnet: false, - userId: false - }; - - const [userColumnVisibility, setUserColumnVisibility] = - useState(() => - getStoredColumnVisibility( - "user-clients", - defaultUserColumnVisibility - ) - ); - const [machineColumnVisibility, setMachineColumnVisibility] = - useState(() => - getStoredColumnVisibility( - "machine-clients", - defaultMachineColumnVisibility - ) - ); - - const currentView = searchParams.get("view") || defaultView; - - const refreshData = async () => { - console.log("Data refreshed"); - setIsRefreshing(true); - try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); - } catch (error) { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } finally { - setIsRefreshing(false); - } - }; - - 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) => { - console.error("Error deleting client", e); - toast({ - variant: "destructive", - title: "Error deleting client", - description: formatAxiosError(e, "Error deleting client") - }); - }) - .then(() => { - router.refresh(); - setIsDeleteModalOpen(false); - }); - }; - - const getSearchInput = () => { - if (currentView === "machine") { - return ( -
- - machineTable.setGlobalFilter(String(e.target.value)) - } - className="w-full pl-8" - /> - -
- ); - } - return ( -
- - userTable.setGlobalFilter(String(e.target.value)) - } - className="w-full pl-8" - /> - -
- ); - }; - - const getActionButton = () => { - // Only show create button on machine clients tab - if (currentView === "machine") { - return ( - - ); - } - return null; - }; - - // Check if there are any rows without userIds in the current view's data - const hasRowsWithoutUserId = useMemo(() => { - const currentData = currentView === "machine" ? machineClients : userClients; - return currentData?.some((client) => !client.userId) ?? false; - }, [currentView, machineClients, userClients]); - - const columns: ExtendedColumnDef[] = useMemo(() => { - const baseColumns: ExtendedColumnDef[] = [ - { - accessorKey: "name", - enableHiding: false, - friendlyName: "Name", - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "userId", - friendlyName: "User", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const r = row.original; - return r.userId ? ( - - - - ) : ( - "-" - ); - } - }, - // { - // accessorKey: "siteName", - // header: ({ column }) => { - // return ( - // - // ); - // }, - // cell: ({ row }) => { - // const r = row.original; - // return ( - // - // - // - // ); - // } - // }, - { - accessorKey: "online", - friendlyName: "Connectivity", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const originalRow = row.original; - if (originalRow.online) { - return ( - -
- Connected -
- ); - } else { - return ( - -
- Disconnected -
- ); - } - } - }, - { - accessorKey: "mbIn", - friendlyName: "Data In", - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "mbOut", - friendlyName: "Data Out", - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "client", - friendlyName: t("client"), - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const originalRow = row.original; - - return ( -
- -
- Olm - {originalRow.olmVersion && ( - - v{originalRow.olmVersion} - - )} -
-
- {originalRow.olmUpdateAvailable && ( - - )} -
- ); - } - }, - { - accessorKey: "subnet", - friendlyName: "Address", - header: ({ column }) => { - return ( - - ); - } - }, - ]; - - // Only include actions column if there are rows without userIds - if (hasRowsWithoutUserId) { - baseColumns.push({ - id: "actions", - enableHiding: false, - header: ({ table }) => { - const hasHideableColumns = table - .getAllColumns() - .some((column) => column.getCanHide()); - if (!hasHideableColumns) { - return ; - } - return ( -
- - - - - - - {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} - - ); - })} - - -
- ); - }, - cell: ({ row }) => { - const clientRow = row.original; - return !clientRow.userId ? ( -
- - - - - - {/* */} - {/* */} - {/* View settings */} - {/* */} - {/* */} - { - setSelectedClient(clientRow); - setIsDeleteModalOpen(true); - }} - > - Delete - - - - - - -
- ) : null; - } - }); - } - - return baseColumns; - }, [hasRowsWithoutUserId, t]); - - 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: userColumnVisibility - }, - 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: machineColumnVisibility - }, - 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"); - }; - - // Persist column visibility changes to localStorage - useEffect(() => { - setStoredColumnVisibility(userColumnVisibility, "user-clients"); - }, [userColumnVisibility]); - - useEffect(() => { - setStoredColumnVisibility(machineColumnVisibility, "machine-clients"); - }, [machineColumnVisibility]); - - return ( - <> - {selectedClient && ( - { - setIsDeleteModalOpen(val); - setSelectedClient(null); - }} - dialog={ -
-

{t("deleteClientQuestion")}

-

{t("clientMessageRemove")}

-
- } - buttonText="Confirm Delete Client" - onConfirm={async () => deleteClient(selectedClient!.id)} - string={selectedClient.name} - title="Delete Client" - /> - )} - -
- - - -
- {getSearchInput()} - - - - {t("clientsTableUserClients")} - - - {t("clientsTableMachineClients")} - - -
-
-
- -
-
{getActionButton()}
-
-
- - -
-
- - {userTable - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers - .filter((header) => - header.column.getIsVisible() - ) - .map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ))} - - ))} - - - {userTable.getRowModel().rows - ?.length ? ( - userTable - .getRowModel() - .rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => ( - - {flexRender( - cell - .column - .columnDef - .cell, - cell.getContext() - )} - - ))} - - )) - ) : ( - - - {t("noResults")} - - - )} - -
-
-
- -
- - -
- - - {machineTable - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers - .filter((header) => - header.column.getIsVisible() - ) - .map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ))} - - ))} - - - {machineTable.getRowModel().rows - ?.length ? ( - machineTable - .getRowModel() - .rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => ( - - {flexRender( - cell - .column - .columnDef - .cell, - cell.getContext() - )} - - ))} - - )) - ) : ( - - - {t("noResults")} - - - )} - -
-
-
- -
-
- - - -
- - ); -} diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx new file mode 100644 index 00000000..0ba8bd13 --- /dev/null +++ b/src/components/MachineClientsTable.tsx @@ -0,0 +1,698 @@ +"use client"; + +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { Button } from "@app/components/ui/button"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Input } from "@app/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility"; +import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable +} from "@tanstack/react-table"; +import { + ArrowRight, + ArrowUpDown, + ArrowUpRight, + Columns, + MoreHorizontal, + Plus, + RefreshCw, + Search +} 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 { InfoPopup } from "./ui/info-popup"; + +export type ClientRow = { + id: number; + name: string; + subnet: string; + // siteIds: string; + mbIn: string; + mbOut: string; + orgId: string; + online: boolean; + olmVersion?: string; + olmUpdateAvailable: boolean; + userId: string | null; + username: string | null; + userEmail: string | null; +}; + +type ClientTableProps = { + machineClients: ClientRow[]; + orgId: string; +}; + +export default function MachineClientsTable({ + machineClients, + orgId +}: ClientTableProps) { + const router = useRouter(); + + const t = useTranslations(); + + const [machinePageSize, setMachinePageSize] = useStoredPageSize( + "machine-clients", + 20 + ); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedClient, setSelectedClient] = useState( + null + ); + + const api = createApiClient(useEnvContext()); + const [isRefreshing, startTransition] = useTransition(); + + const [machineSorting, setMachineSorting] = useState([]); + const [machineColumnFilters, setMachineColumnFilters] = + useState([]); + const [machineGlobalFilter, setMachineGlobalFilter] = useState([]); + + const defaultMachineColumnVisibility = { + client: false, + subnet: false, + userId: false + }; + + const [machineColumnVisibility, setMachineColumnVisibility] = + useStoredColumnVisibility( + "machine-clients", + defaultMachineColumnVisibility + ); + + const refreshData = async () => { + try { + router.refresh(); + console.log("Data refreshed"); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }; + + const deleteClient = (clientId: number) => { + api.delete(`/client/${clientId}`) + .catch((e) => { + console.error("Error deleting client", e); + toast({ + variant: "destructive", + title: "Error deleting client", + description: formatAxiosError(e, "Error deleting client") + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); + }; + + // Check if there are any rows without userIds in the current view's data + const hasRowsWithoutUserId = useMemo(() => { + return machineClients.some((client) => !client.userId) ?? false; + }, [machineClients]); + + const columns: ExtendedColumnDef[] = useMemo(() => { + const baseColumns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: "Name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "userId", + friendlyName: "User", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const r = row.original; + return r.userId ? ( + + + + ) : ( + "-" + ); + } + }, + // { + // accessorKey: "siteName", + // header: ({ column }) => { + // return ( + // + // ); + // }, + // cell: ({ row }) => { + // const r = row.original; + // return ( + // + // + // + // ); + // } + // }, + { + accessorKey: "online", + friendlyName: "Connectivity", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + if (originalRow.online) { + return ( + +
+ Connected +
+ ); + } else { + return ( + +
+ Disconnected +
+ ); + } + } + }, + { + accessorKey: "mbIn", + friendlyName: "Data In", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "mbOut", + friendlyName: "Data Out", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "client", + friendlyName: t("client"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + + return ( +
+ +
+ Olm + {originalRow.olmVersion && ( + + v{originalRow.olmVersion} + + )} +
+
+ {originalRow.olmUpdateAvailable && ( + + )} +
+ ); + } + }, + { + accessorKey: "subnet", + friendlyName: "Address", + header: ({ column }) => { + return ( + + ); + } + } + ]; + + // Only include actions column if there are rows without userIds + if (hasRowsWithoutUserId) { + baseColumns.push({ + id: "actions", + enableHiding: false, + header: ({ table }) => { + const hasHideableColumns = table + .getAllColumns() + .some((column) => column.getCanHide()); + if (!hasHideableColumns) { + return ; + } + return ( +
+ + + + + + + {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} + + ); + })} + + +
+ ); + }, + cell: ({ row }) => { + const clientRow = row.original; + return !clientRow.userId ? ( +
+ + + + + + {/* */} + {/* */} + {/* View settings */} + {/* */} + {/* */} + { + setSelectedClient(clientRow); + setIsDeleteModalOpen(true); + }} + > + + Delete + + + + + + + +
+ ) : null; + } + }); + } + + return baseColumns; + }, [hasRowsWithoutUserId, t]); + + 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: machineColumnVisibility + }, + state: { + sorting: machineSorting, + columnFilters: machineColumnFilters, + globalFilter: machineGlobalFilter, + columnVisibility: machineColumnVisibility + } + }); + + return ( + <> + {selectedClient && ( + { + setIsDeleteModalOpen(val); + setSelectedClient(null); + }} + dialog={ +
+

{t("deleteClientQuestion")}

+

{t("clientMessageRemove")}

+
+ } + buttonText="Confirm Delete Client" + onConfirm={async () => deleteClient(selectedClient!.id)} + string={selectedClient.name} + title="Delete Client" + /> + )} + +
+ + +
+
+ + machineTable.setGlobalFilter( + String(e.target.value) + ) + } + className="w-full pl-8" + /> + +
+
+
+
+ +
+
+ {" "} + +
+
+
+ +
+ + + {machineTable + .getHeaderGroups() + .map((headerGroup) => ( + + {headerGroup.headers + .filter((header) => + header.column.getIsVisible() + ) + .map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} + + ))} + + ))} + + + {machineTable.getRowModel().rows?.length ? ( + machineTable + .getRowModel() + .rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {t("noResults")} + + + )} + +
+
+
+ +
+
+
+
+ + ); +} diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 14830840..29107100 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -814,7 +814,7 @@ export default function ProxyResourcesTable({ -
+
{proxyTable diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx new file mode 100644 index 00000000..f4573eec --- /dev/null +++ b/src/components/UserDevicesTable.tsx @@ -0,0 +1,677 @@ +"use client"; + +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { Button } from "@app/components/ui/button"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Input } from "@app/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable +} from "@tanstack/react-table"; +import { + ArrowRight, + ArrowUpDown, + ArrowUpRight, + Columns, + MoreHorizontal, + RefreshCw, + Search +} 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 { InfoPopup } from "./ui/info-popup"; + +import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility"; +import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; + +export type ClientRow = { + id: number; + name: string; + subnet: string; + // siteIds: string; + mbIn: string; + mbOut: string; + orgId: string; + online: boolean; + olmVersion?: string; + olmUpdateAvailable: boolean; + userId: string | null; + username: string | null; + userEmail: string | null; +}; + +type ClientTableProps = { + userClients: ClientRow[]; + orgId: string; +}; + +export default function UserDevicesTable({ userClients }: ClientTableProps) { + const router = useRouter(); + const t = useTranslations(); + + const [userPageSize, setUserPageSize] = useStoredPageSize( + "user-clients", + 20 + ); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedClient, setSelectedClient] = useState( + null + ); + + const api = createApiClient(useEnvContext()); + const [isRefreshing, startTransition] = useTransition(); + + const [userSorting, setUserSorting] = useState([]); + const [userColumnFilters, setUserColumnFilters] = + useState([]); + const [userGlobalFilter, setUserGlobalFilter] = useState([]); + + const defaultUserColumnVisibility = { + client: false, + subnet: false + }; + + const [userColumnVisibility, setUserColumnVisibility] = + useStoredColumnVisibility("user-clients", defaultUserColumnVisibility); + + const refreshData = async () => { + try { + router.refresh(); + console.log("Data refreshed"); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }; + + const deleteClient = (clientId: number) => { + api.delete(`/client/${clientId}`) + .catch((e) => { + console.error("Error deleting client", e); + toast({ + variant: "destructive", + title: "Error deleting client", + description: formatAxiosError(e, "Error deleting client") + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); + }; + + // Check if there are any rows without userIds in the current view's data + const hasRowsWithoutUserId = useMemo(() => { + return userClients.some((client) => !client.userId); + }, [userClients]); + + const columns: ExtendedColumnDef[] = useMemo(() => { + const baseColumns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: "Name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "userId", + friendlyName: "User", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const r = row.original; + return r.userId ? ( + + + + ) : ( + "-" + ); + } + }, + // { + // accessorKey: "siteName", + // header: ({ column }) => { + // return ( + // + // ); + // }, + // cell: ({ row }) => { + // const r = row.original; + // return ( + // + // + // + // ); + // } + // }, + { + accessorKey: "online", + friendlyName: "Connectivity", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + if (originalRow.online) { + return ( + +
+ Connected +
+ ); + } else { + return ( + +
+ Disconnected +
+ ); + } + } + }, + { + accessorKey: "mbIn", + friendlyName: "Data In", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "mbOut", + friendlyName: "Data Out", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "client", + friendlyName: t("client"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + + return ( +
+ +
+ Olm + {originalRow.olmVersion && ( + + v{originalRow.olmVersion} + + )} +
+
+ {originalRow.olmUpdateAvailable && ( + + )} +
+ ); + } + }, + { + accessorKey: "subnet", + friendlyName: "Address", + header: ({ column }) => { + return ( + + ); + } + } + ]; + + // Only include actions column if there are rows without userIds + if (hasRowsWithoutUserId) { + baseColumns.push({ + id: "actions", + enableHiding: false, + header: ({ table }) => { + const hasHideableColumns = table + .getAllColumns() + .some((column) => column.getCanHide()); + if (!hasHideableColumns) { + return ; + } + return ( +
+ + + + + + + {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} + + ); + })} + + +
+ ); + }, + cell: ({ row }) => { + const clientRow = row.original; + return !clientRow.userId ? ( +
+ + + + + + {/* */} + {/* */} + {/* View settings */} + {/* */} + {/* */} + { + setSelectedClient(clientRow); + setIsDeleteModalOpen(true); + }} + > + + Delete + + + + + + + +
+ ) : null; + } + }); + } + + return baseColumns; + }, [hasRowsWithoutUserId, t]); + + 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: userColumnVisibility + }, + state: { + sorting: userSorting, + columnFilters: userColumnFilters, + globalFilter: userGlobalFilter, + columnVisibility: userColumnVisibility + } + }); + + return ( + <> + {selectedClient && ( + { + setIsDeleteModalOpen(val); + setSelectedClient(null); + }} + dialog={ +
+

{t("deleteClientQuestion")}

+

{t("clientMessageRemove")}

+
+ } + buttonText="Confirm Delete Client" + onConfirm={async () => deleteClient(selectedClient!.id)} + string={selectedClient.name} + title="Delete Client" + /> + )} + +
+ + +
+
+ + userTable.setGlobalFilter( + String(e.target.value) + ) + } + className="w-full pl-8" + /> + +
+
+
+
+ +
+
+
+ +
+
+ + {userTable + .getHeaderGroups() + .map((headerGroup) => ( + + {headerGroup.headers + .filter((header) => + header.column.getIsVisible() + ) + .map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} + + ))} + + ))} + + + {userTable.getRowModel().rows?.length ? ( + userTable + .getRowModel() + .rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {t("noResults")} + + + )} + +
+
+
+ +
+ + +
+ + ); +} diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts index e7fdc353..2448b1e2 100644 --- a/src/hooks/useLocalStorage.ts +++ b/src/hooks/useLocalStorage.ts @@ -2,8 +2,8 @@ import { useState, useEffect, useCallback, - Dispatch, - SetStateAction + type Dispatch, + type SetStateAction } from "react"; type SetValue = Dispatch>; diff --git a/src/hooks/useStoredColumnVisibility.ts b/src/hooks/useStoredColumnVisibility.ts new file mode 100644 index 00000000..1d5d6662 --- /dev/null +++ b/src/hooks/useStoredColumnVisibility.ts @@ -0,0 +1,81 @@ +import type { VisibilityState } from "@tanstack/react-table"; +import { useCallback, useState } from "react"; + +const STORAGE_KEYS = { + COLUMN_VISIBILITY: "datatable-column-visibility", + getTableColumnVisibility: (tableId: string) => + `datatable-${tableId}-column-visibility` +}; + +const getStoredColumnVisibility = ( + tableId: string, + defaultVisibility?: Record +): Record => { + if (typeof window === "undefined") return defaultVisibility || {}; + + try { + const key = STORAGE_KEYS.getTableColumnVisibility(tableId); + const stored = localStorage.getItem(key); + if (stored) { + const parsed = JSON.parse(stored); + // Validate that it's an object + if (typeof parsed === "object" && parsed !== null) { + return parsed; + } + } + } catch (error) { + console.warn( + "Failed to read column visibility from localStorage:", + error + ); + } + return defaultVisibility || {}; +}; + +const setStoredColumnVisibility = ( + visibility: Record, + tableId: string +): void => { + if (typeof window === "undefined") return; + + try { + const key = STORAGE_KEYS.getTableColumnVisibility(tableId); + localStorage.setItem(key, JSON.stringify(visibility)); + } catch (error) { + console.warn( + "Failed to save column visibility to localStorage:", + error + ); + } +}; + +export function useStoredColumnVisibility( + tableId: string, + defaultColumnVisibility?: Record +) { + const [columnVisibility, setVisibility] = useState(() => + getStoredColumnVisibility(tableId, defaultColumnVisibility) + ); + + const setColumnVisibility = useCallback( + ( + updaterOrValue: + | VisibilityState + | ((old: VisibilityState) => VisibilityState) + ) => { + if (typeof updaterOrValue === "function") { + setVisibility((oldValue) => { + const newValue = updaterOrValue(oldValue); + setStoredColumnVisibility(newValue, tableId); + return newValue; + }); + } else { + setVisibility(updaterOrValue); + setStoredColumnVisibility(updaterOrValue, tableId); + } + }, + [tableId] + ); + + return [columnVisibility, setColumnVisibility] as const; +} diff --git a/src/hooks/useStoredPageSize.ts b/src/hooks/useStoredPageSize.ts new file mode 100644 index 00000000..2c4e7692 --- /dev/null +++ b/src/hooks/useStoredPageSize.ts @@ -0,0 +1,60 @@ +import { useState, useCallback } from "react"; + +const STORAGE_KEYS = { + PAGE_SIZE: "datatable-page-size", + getTablePageSize: (tableId: string) => `datatable-${tableId}-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 function useStore +export function useStoredPageSize(tableId: string, defaultPageSize?: number) { + const [pageSize, setSize] = useState(() => + getStoredPageSize(tableId, defaultPageSize) + ); + + const setPageSize = useCallback( + (updaterOrValue: number | ((old: number) => number)) => { + if (typeof updaterOrValue === "function") { + setSize((oldValue) => { + const newValue = updaterOrValue(oldValue); + setStoredPageSize(newValue, tableId); + return newValue; + }); + } else { + setSize(updaterOrValue); + setStoredPageSize(updaterOrValue, tableId); + } + }, + [tableId] + ); + + return [pageSize, setPageSize] as const; +}