♻️ separate machine client & user devices tables + move common functions into hooks

This commit is contained in:
Fred KISSIE
2025-12-02 18:58:51 +01:00
parent 3d400b2321
commit 06a31bb716
12 changed files with 1546 additions and 1110 deletions

View File

@@ -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",

View File

@@ -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<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;
const machineRes = await internal.get<
AxiosResponse<ListClientsResponse>
>(
`/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 (
<>
<SettingsSectionTitle
title={t("manageClients")}
description={t("manageClientsDescription")}
title={t("manageMachineClients")}
description={t("manageMachineClientsDescription")}
/>
<ClientsTable
userClients={userClientRows}
<MachineClientsTable
machineClients={machineClientRows}
orgId={params.orgId}
defaultView={defaultView}
/>
</>
);

View File

@@ -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`);
}

View File

@@ -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<AxiosResponse<ListClientsResponse>>(
`/org/${params.orgId}/clients?filter=user`,
await authCookieHeader()
),
internal.get<AxiosResponse<ListClientsResponse>>(
`/org/${params.orgId}/clients?filter=machine`,
await authCookieHeader()
)
]);
const userRes = await internal.get<AxiosResponse<ListClientsResponse>>(
`/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 (
<>
<SettingsSectionTitle
title={t("manageClients")}
description={t("manageClientsDescription")}
title={t("manageUserDevices")}
description={t("manageUserDevicesDescription")}
/>
<ClientsTable
<UserDevicesTable
userClients={userClientRows}
machineClients={machineClientRows}
orgId={params.orgId}
defaultView={defaultView}
/>
</>
);

View File

@@ -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 <CheckCircle2 className={`${iconClass} text-green-500`} />;
case "degraded":
return <CheckCircle2 className={`${iconClass} text-yellow-500`} />;
case "offline":
return <XCircle className={`${iconClass} text-destructive`} />;
case "unknown":
return <Clock className={`${iconClass} text-muted-foreground`} />;
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({
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<div className="overflow-x-auto mt-9">
<Table>
<TableHeader>
{internalTable

View File

@@ -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<string, boolean>
): Record<string, boolean> => {
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<string, boolean>,
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<number>(() =>
getStoredPageSize("user-clients", 20)
);
const [machinePageSize, setMachinePageSize] = useState<number>(() =>
getStoredPageSize("machine-clients", 20)
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
);
const api = createApiClient(useEnvContext());
const [isRefreshing, setIsRefreshing] = useState(false);
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 defaultUserColumnVisibility = {
client: false,
subnet: false
};
const defaultMachineColumnVisibility = {
client: false,
subnet: false,
userId: false
};
const [userColumnVisibility, setUserColumnVisibility] =
useState<VisibilityState>(() =>
getStoredColumnVisibility(
"user-clients",
defaultUserColumnVisibility
)
);
const [machineColumnVisibility, setMachineColumnVisibility] =
useState<VisibilityState>(() =>
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 (
<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;
};
// 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<ClientRow>[] = useMemo(() => {
const baseColumns: ExtendedColumnDef<ClientRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: "Name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "userId",
friendlyName: "User",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
User
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
return r.userId ? (
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline">
{r.userEmail || r.username || r.userId}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
) : (
"-"
);
}
},
// {
// accessorKey: "siteName",
// header: ({ column }) => {
// return (
// <Button
// variant="ghost"
// onClick={() =>
// column.toggleSorting(column.getIsSorted() === "asc")
// }
// >
// Site
// <ArrowUpDown className="ml-2 h-4 w-4" />
// </Button>
// );
// },
// cell: ({ row }) => {
// const r = row.original;
// return (
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
// <Button variant="outline">
// {r.siteName}
// <ArrowUpRight className="ml-2 h-4 w-4" />
// </Button>
// </Link>
// );
// }
// },
{
accessorKey: "online",
friendlyName: "Connectivity",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Connectivity
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span>
</span>
);
}
}
},
{
accessorKey: "mbIn",
friendlyName: "Data In",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data In
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
friendlyName: "Data Out",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data Out
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "client",
friendlyName: t("client"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("client")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-2">
<span>Olm</span>
{originalRow.olmVersion && (
<span className="text-xs text-gray-500">
v{originalRow.olmVersion}
</span>
)}
</div>
</Badge>
{originalRow.olmUpdateAvailable && (
<InfoPopup info={t("olmUpdateAvailableInfo")} />
)}
</div>
);
}
},
{
accessorKey: "subnet",
friendlyName: "Address",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
];
// 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 <span className="p-3"></span>;
}
return (
<div className="flex flex-col items-end gap-1 p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
<Columns className="h-4 w-4" />
<span className="sr-only">
{t("columns") || "Columns"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>
{t("toggleColumns") || "Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{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 (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
onSelect={(e) => e.preventDefault()}
>
{displayName}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
cell: ({ row }) => {
const clientRow = row.original;
return !clientRow.userId ? (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* <Link */}
{/* className="block w-full" */}
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
{/* > */}
{/* <DropdownMenuItem> */}
{/* View settings */}
{/* </DropdownMenuItem> */}
{/* </Link> */}
<DropdownMenuItem
onClick={() => {
setSelectedClient(clientRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
>
<Button variant={"outline"}>
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
) : 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 && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div>
<p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p>
</div>
}
buttonText="Confirm Delete Client"
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
/>
)}
<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">
<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="overflow-x-auto">
<Table>
<TableHeader>
{userTable
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers
.filter((header) =>
header.column.getIsVisible()
)
.map((header) => (
<TableHead
key={header.id}
className={`whitespace-nowrap ${
header.column.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: header.column.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{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
}
className={`whitespace-nowrap ${
cell.column.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: cell.column.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{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>
<div className="mt-4">
<DataTablePagination
table={userTable}
onPageSizeChange={
handleUserPageSizeChange
}
/>
</div>
</TabsContent>
<TabsContent value="machine">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{machineTable
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers
.filter((header) =>
header.column.getIsVisible()
)
.map((header) => (
<TableHead
key={header.id}
className={`whitespace-nowrap ${
header.column.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: header.column.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{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
}
className={`whitespace-nowrap ${
cell.column.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: cell.column.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{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>
<div className="mt-4">
<DataTablePagination
table={machineTable}
onPageSizeChange={
handleMachinePageSizeChange
}
/>
</div>
</TabsContent>
</CardContent>
</Tabs>
</Card>
</div>
</>
);
}

View File

@@ -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<ClientRow | null>(
null
);
const api = createApiClient(useEnvContext());
const [isRefreshing, startTransition] = useTransition();
const [machineSorting, setMachineSorting] = useState<SortingState>([]);
const [machineColumnFilters, setMachineColumnFilters] =
useState<ColumnFiltersState>([]);
const [machineGlobalFilter, setMachineGlobalFilter] = useState<any>([]);
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<ClientRow>[] = useMemo(() => {
const baseColumns: ExtendedColumnDef<ClientRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: "Name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "userId",
friendlyName: "User",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
User
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
return r.userId ? (
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline">
{r.userEmail || r.username || r.userId}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
) : (
"-"
);
}
},
// {
// accessorKey: "siteName",
// header: ({ column }) => {
// return (
// <Button
// variant="ghost"
// onClick={() =>
// column.toggleSorting(column.getIsSorted() === "asc")
// }
// >
// Site
// <ArrowUpDown className="ml-2 h-4 w-4" />
// </Button>
// );
// },
// cell: ({ row }) => {
// const r = row.original;
// return (
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
// <Button variant="outline">
// {r.siteName}
// <ArrowUpRight className="ml-2 h-4 w-4" />
// </Button>
// </Link>
// );
// }
// },
{
accessorKey: "online",
friendlyName: "Connectivity",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Connectivity
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span>
</span>
);
}
}
},
{
accessorKey: "mbIn",
friendlyName: "Data In",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Data In
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
friendlyName: "Data Out",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Data Out
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "client",
friendlyName: t("client"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("client")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-2">
<span>Olm</span>
{originalRow.olmVersion && (
<span className="text-xs text-gray-500">
v{originalRow.olmVersion}
</span>
)}
</div>
</Badge>
{originalRow.olmUpdateAvailable && (
<InfoPopup info={t("olmUpdateAvailableInfo")} />
)}
</div>
);
}
},
{
accessorKey: "subnet",
friendlyName: "Address",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
}
];
// 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 <span className="p-3"></span>;
}
return (
<div className="flex flex-col items-end gap-1 p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
>
<Columns className="h-4 w-4" />
<span className="sr-only">
{t("columns") || "Columns"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48"
>
<DropdownMenuLabel>
{t("toggleColumns") || "Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{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 (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(
!!value
)
}
onSelect={(e) =>
e.preventDefault()
}
>
{displayName}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
cell: ({ row }) => {
const clientRow = row.original;
return !clientRow.userId ? (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* <Link */}
{/* className="block w-full" */}
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
{/* > */}
{/* <DropdownMenuItem> */}
{/* View settings */}
{/* </DropdownMenuItem> */}
{/* </Link> */}
<DropdownMenuItem
onClick={() => {
setSelectedClient(clientRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
Delete
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
>
<Button variant={"outline"}>
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
) : 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 && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div>
<p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p>
</div>
}
buttonText="Confirm Delete Client"
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
/>
)}
<div className="container mx-auto max-w-12xl">
<Card>
<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">
<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>
</div>
<div className="flex items-center gap-2 sm:justify-end">
<div>
<Button
variant="outline"
onClick={() => startTransition(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>
{" "}
<Button
onClick={() =>
router.push(
`/${orgId}/settings/clients/create`
)
}
>
<Plus className="mr-2 h-4 w-4" />
{t("createClient")}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto mt-9">
<Table>
<TableHeader>
{machineTable
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers
.filter((header) =>
header.column.getIsVisible()
)
.map((header) => (
<TableHead
key={header.id}
className={`whitespace-nowrap ${
header.column
.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: header
.column
.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{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}
className={`whitespace-nowrap ${
cell.column
.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: cell
.column
.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{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>
<div className="mt-4">
<DataTablePagination
table={machineTable}
onPageSizeChange={setMachinePageSize}
/>
</div>
</CardContent>
</Card>
</div>
</>
);
}

View File

@@ -814,7 +814,7 @@ export default function ProxyResourcesTable({
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<div className="overflow-x-auto mt-9">
<Table>
<TableHeader>
{proxyTable

View File

@@ -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<ClientRow | null>(
null
);
const api = createApiClient(useEnvContext());
const [isRefreshing, startTransition] = useTransition();
const [userSorting, setUserSorting] = useState<SortingState>([]);
const [userColumnFilters, setUserColumnFilters] =
useState<ColumnFiltersState>([]);
const [userGlobalFilter, setUserGlobalFilter] = useState<any>([]);
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<ClientRow>[] = useMemo(() => {
const baseColumns: ExtendedColumnDef<ClientRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: "Name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "userId",
friendlyName: "User",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
User
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
return r.userId ? (
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline">
{r.userEmail || r.username || r.userId}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
) : (
"-"
);
}
},
// {
// accessorKey: "siteName",
// header: ({ column }) => {
// return (
// <Button
// variant="ghost"
// onClick={() =>
// column.toggleSorting(column.getIsSorted() === "asc")
// }
// >
// Site
// <ArrowUpDown className="ml-2 h-4 w-4" />
// </Button>
// );
// },
// cell: ({ row }) => {
// const r = row.original;
// return (
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
// <Button variant="outline">
// {r.siteName}
// <ArrowUpRight className="ml-2 h-4 w-4" />
// </Button>
// </Link>
// );
// }
// },
{
accessorKey: "online",
friendlyName: "Connectivity",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Connectivity
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span>
</span>
);
}
}
},
{
accessorKey: "mbIn",
friendlyName: "Data In",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Data In
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
friendlyName: "Data Out",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Data Out
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "client",
friendlyName: t("client"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("client")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-2">
<span>Olm</span>
{originalRow.olmVersion && (
<span className="text-xs text-gray-500">
v{originalRow.olmVersion}
</span>
)}
</div>
</Badge>
{originalRow.olmUpdateAvailable && (
<InfoPopup info={t("olmUpdateAvailableInfo")} />
)}
</div>
);
}
},
{
accessorKey: "subnet",
friendlyName: "Address",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
}
];
// 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 <span className="p-3"></span>;
}
return (
<div className="flex flex-col items-end gap-1 p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
>
<Columns className="h-4 w-4" />
<span className="sr-only">
{t("columns") || "Columns"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48"
>
<DropdownMenuLabel>
{t("toggleColumns") || "Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{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 (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(
!!value
)
}
onSelect={(e) =>
e.preventDefault()
}
>
{displayName}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
cell: ({ row }) => {
const clientRow = row.original;
return !clientRow.userId ? (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* <Link */}
{/* className="block w-full" */}
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
{/* > */}
{/* <DropdownMenuItem> */}
{/* View settings */}
{/* </DropdownMenuItem> */}
{/* </Link> */}
<DropdownMenuItem
onClick={() => {
setSelectedClient(clientRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
Delete
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
>
<Button variant={"outline"}>
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
) : 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 && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div>
<p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p>
</div>
}
buttonText="Confirm Delete Client"
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
/>
)}
<div className="container mx-auto max-w-12xl">
<Card>
<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">
<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>
</div>
<div className="flex items-center gap-2 sm:justify-end">
<div>
<Button
variant="outline"
onClick={() => startTransition(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>
</CardHeader>
<CardContent>
<div className="overflow-x-auto mt-9">
<Table>
<TableHeader>
{userTable
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers
.filter((header) =>
header.column.getIsVisible()
)
.map((header) => (
<TableHead
key={header.id}
className={`whitespace-nowrap ${
header.column
.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: header
.column
.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{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}
className={`whitespace-nowrap ${
cell.column
.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: cell
.column
.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{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>
<div className="mt-4">
<DataTablePagination
table={userTable}
onPageSizeChange={setUserPageSize}
/>
</div>
</CardContent>
</Card>
</div>
</>
);
}

View File

@@ -2,8 +2,8 @@ import {
useState,
useEffect,
useCallback,
Dispatch,
SetStateAction
type Dispatch,
type SetStateAction
} from "react";
type SetValue<T> = Dispatch<SetStateAction<T>>;

View File

@@ -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<string, boolean>
): Record<string, boolean> => {
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<string, boolean>,
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<string, boolean>
) {
const [columnVisibility, setVisibility] = useState<VisibilityState>(() =>
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;
}

View File

@@ -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;
}