"use client"; import { ColumnDef, flexRender, getCoreRowModel, useReactTable, getPaginationRowModel, SortingState, getSortedRowModel, ColumnFiltersState, getFilteredRowModel, VisibilityState } from "@tanstack/react-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, MoreHorizontal, ArrowUpRight, ShieldOff, ShieldCheck, RefreshCw, Columns, Settings2, Plus, Search, ChevronDown, Clock, Wifi, WifiOff, CheckCircle2, XCircle } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState, useEffect } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { formatAxiosError } from "@app/lib/api"; import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { Switch } from "@app/components/ui/switch"; import { AxiosResponse } from "axios"; import { UpdateResourceResponse } from "@server/routers/resource"; import { ListSitesResponse } from "@server/routers/site"; import { useTranslations } from "next-intl"; import { InfoPopup } from "@app/components/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 { useSearchParams } from "next/navigation"; import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import { Alert, AlertDescription } from "@app/components/ui/alert"; export type TargetHealth = { targetId: number; ip: string; port: number; enabled: boolean; healthStatus?: "healthy" | "unhealthy" | "unknown"; }; export type ResourceRow = { id: number; nice: string | null; name: string; orgId: string; domain: string; authState: string; http: boolean; protocol: string; proxyPort: number | null; enabled: boolean; domainId?: string; ssl: boolean; targetHost?: string; targetPort?: number; 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; orgId: string; siteName: string; siteAddress: string | null; mode: "host" | "cidr" | "port"; protocol: string | null; proxyPort: number | null; siteId: number; siteNiceId: string; destination: string; destinationPort: number | null; alias: string | null; }; type Site = ListSitesResponse["sites"][0]; type ResourcesTableProps = { resources: ResourceRow[]; internalResources: InternalResourceRow[]; orgId: string; defaultView?: "proxy" | "internal"; defaultSort?: { id: string; desc: boolean; }; }; 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 ResourcesTable({ resources, internalResources, orgId, defaultView = "proxy", defaultSort }: ResourcesTableProps) { const router = useRouter(); const searchParams = useSearchParams(); const t = useTranslations(); const { env } = useEnvContext(); const api = createApiClient({ env }); const [proxyPageSize, setProxyPageSize] = useState(() => getStoredPageSize("proxy-resources", 20) ); const [internalPageSize, setInternalPageSize] = useState(() => getStoredPageSize("internal-resources", 20) ); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedResource, setSelectedResource] = useState(); const [selectedInternalResource, setSelectedInternalResource] = useState(); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [editingResource, setEditingResource] = useState(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [sites, setSites] = useState([]); const [proxySorting, setProxySorting] = useState( defaultSort ? [defaultSort] : [] ); const [proxyColumnFilters, setProxyColumnFilters] = useState([]); const [proxyGlobalFilter, setProxyGlobalFilter] = useState([]); const [internalSorting, setInternalSorting] = useState( defaultSort ? [defaultSort] : [] ); const [internalColumnFilters, setInternalColumnFilters] = useState([]); const [internalGlobalFilter, setInternalGlobalFilter] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); const [proxyColumnVisibility, setProxyColumnVisibility] = useState(() => getStoredColumnVisibility("proxy-resources", {}) ); const [internalColumnVisibility, setInternalColumnVisibility] = useState(() => getStoredColumnVisibility("internal-resources", {}) ); 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); } }; useEffect(() => { const fetchSites = async () => { try { const res = await api.get>( `/org/${orgId}/sites` ); setSites(res.data.data.sites); } catch (error) { console.error("Failed to fetch sites:", error); } }; if (orgId) { fetchSites(); } }, [orgId]); const handleTabChange = (value: string) => { const params = new URLSearchParams(searchParams); if (value === "internal") { params.set("view", "internal"); } else { params.delete("view"); } const newUrl = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`; router.replace(newUrl, { scroll: false }); }; const getSearchInput = () => { if (currentView === "internal") { return ( internalTable.setGlobalFilter( String(e.target.value) ) } className="w-full pl-8" /> ); } return ( proxyTable.setGlobalFilter(String(e.target.value)) } className="w-full pl-8" /> ); }; const getActionButton = () => { if (currentView === "internal") { return ( setIsCreateDialogOpen(true)}> {t("resourceAdd")} ); } return ( router.push(`/${orgId}/settings/resources/create`) } > {t("resourceAdd")} ); }; const deleteResource = (resourceId: number) => { api.delete(`/resource/${resourceId}`) .catch((e) => { console.error(t("resourceErrorDelte"), e); toast({ variant: "destructive", title: t("resourceErrorDelte"), description: formatAxiosError(e, t("resourceErrorDelte")) }); }) .then(() => { router.refresh(); setIsDeleteModalOpen(false); }); }; const deleteInternalResource = async ( resourceId: number, siteId: number ) => { try { await api.delete( `/org/${orgId}/site/${siteId}/resource/${resourceId}` ); router.refresh(); setIsDeleteModalOpen(false); } catch (e) { console.error(t("resourceErrorDelete"), e); toast({ variant: "destructive", title: t("resourceErrorDelte"), description: formatAxiosError(e, t("v")) }); } }; async function toggleResourceEnabled(val: boolean, resourceId: number) { const res = await api .post>( `resource/${resourceId}`, { enabled: val } ) .catch((e) => { toast({ variant: "destructive", title: t("resourcesErrorUpdate"), description: formatAxiosError( e, t("resourcesErrorUpdateDescription") ) }); }); } function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) { const overallStatus = getOverallHealthStatus(targets); if (!targets || targets.length === 0) { return ( No targets ); } const monitoredTargets = targets.filter( (t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown" ); const unknownTargets = targets.filter( (t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown" ); return ( {overallStatus === "online" && "Healthy"} {overallStatus === "degraded" && "Degraded"} {overallStatus === "offline" && "Offline"} {overallStatus === "unknown" && "Unknown"} {monitoredTargets.length > 0 && ( <> {monitoredTargets.map((target) => ( {`${target.ip}:${target.port}`} {target.healthStatus} ))} > )} {unknownTargets.length > 0 && ( <> {unknownTargets.map((target) => ( {`${target.ip}:${target.port}`} {!target.enabled ? "Disabled" : "Not monitored"} ))} > )} ); } const proxyColumns: ColumnDef[] = [ { accessorKey: "name", enableHiding: false, header: ({ column }) => { return ( column.toggleSorting(column.getIsSorted() === "asc") } > {t("name")} ); } }, { accessorKey: "nice", header: ({ column }) => { return ( column.toggleSorting(column.getIsSorted() === "asc") } > {t("resource")} ); } }, { accessorKey: "protocol", header: () => {t("protocol")}, cell: ({ row }) => { const resourceRow = row.original; return ( {resourceRow.http ? resourceRow.ssl ? "HTTPS" : "HTTP" : resourceRow.protocol.toUpperCase()} ); } }, { id: "status", accessorKey: "status", header: ({ column }) => { return ( column.toggleSorting(column.getIsSorted() === "asc") } > {t("status")} ); }, cell: ({ row }) => { const resourceRow = row.original; return ; }, sortingFn: (rowA, rowB) => { const statusA = getOverallHealthStatus(rowA.original.targets); const statusB = getOverallHealthStatus(rowB.original.targets); const statusOrder = { online: 3, degraded: 2, offline: 1, unknown: 0 }; return statusOrder[statusA] - statusOrder[statusB]; } }, { accessorKey: "domain", header: () => {t("access")}, cell: ({ row }) => { const resourceRow = row.original; return ( {!resourceRow.http ? ( ) : !resourceRow.domainId ? ( ) : ( )} ); } }, { accessorKey: "authState", header: ({ column }) => { return ( column.toggleSorting(column.getIsSorted() === "asc") } > {t("authentication")} ); }, cell: ({ row }) => { const resourceRow = row.original; return ( {resourceRow.authState === "protected" ? ( {t("protected")} ) : resourceRow.authState === "not_protected" ? ( {t("notProtected")} ) : ( - )} ); } }, { accessorKey: "enabled", header: () => {t("enabled")}, cell: ({ row }) => ( toggleResourceEnabled(val, row.original.id) } /> ) }, { id: "actions", enableHiding: false, header: ({ table }) => { const hasHideableColumns = table .getAllColumns() .some((column) => column.getCanHide()); if (!hasHideableColumns) { return ; } return ( {t("columns") || "Columns"} {t("toggleColumns") || "Toggle columns"} {table .getAllColumns() .filter((column) => column.getCanHide()) .map((column) => { return ( column.toggleVisibility(!!value) } > {typeof column.columnDef.header === "string" ? column.columnDef.header : column.id} ); })} ); }, cell: ({ row }) => { const resourceRow = row.original; return ( {t("openMenu")} {t("viewSettings")} { setSelectedResource(resourceRow); setIsDeleteModalOpen(true); }} > {t("delete")} {t("edit")} ); } } ]; const internalColumns: ColumnDef[] = [ { accessorKey: "name", enableHiding: false, header: ({ column }) => { return ( column.toggleSorting(column.getIsSorted() === "asc") } > {t("name")} ); } }, { accessorKey: "siteName", header: () => {t("siteName")}, cell: ({ row }) => { const resourceRow = row.original; return ( {resourceRow.siteName} ); } }, { accessorKey: "mode", header: () => ( {t("editInternalResourceDialogMode")} ), cell: ({ row }) => { const resourceRow = row.original; const modeLabels: Record<"host" | "cidr" | "port", string> = { host: t("editInternalResourceDialogModeHost"), cidr: t("editInternalResourceDialogModeCidr"), port: t("editInternalResourceDialogModePort") }; return {modeLabels[resourceRow.mode]}; } }, { accessorKey: "destination", header: () => ( {t("resourcesTableDestination")} ), cell: ({ row }) => { const resourceRow = row.original; let displayText: string; let copyText: string; if ( resourceRow.mode === "port" && resourceRow.protocol && resourceRow.proxyPort && resourceRow.destinationPort ) { const protocol = resourceRow.protocol.toUpperCase(); // For port mode: site part uses alias or site address, destination part uses destination IP // If site address has CIDR notation, extract just the IP address let siteAddress = resourceRow.siteAddress; if (siteAddress && siteAddress.includes("/")) { siteAddress = siteAddress.split("/")[0]; } const siteDisplay = resourceRow.alias || siteAddress; displayText = `${protocol} ${siteDisplay}:${resourceRow.proxyPort} -> ${resourceRow.destination}:${resourceRow.destinationPort}`; copyText = `${siteDisplay}:${resourceRow.proxyPort}`; } else if (resourceRow.mode === "host") { // For host mode: use alias if available, otherwise use destination const destinationDisplay = resourceRow.alias || resourceRow.destination; displayText = destinationDisplay; copyText = destinationDisplay; } else if (resourceRow.mode === "cidr") { displayText = resourceRow.destination; copyText = resourceRow.destination; } else { const destinationDisplay = resourceRow.alias || resourceRow.destination; displayText = destinationDisplay; copyText = destinationDisplay; } return ( ); } }, { id: "actions", enableHiding: false, header: ({ table }) => { const hasHideableColumns = table .getAllColumns() .some((column) => column.getCanHide()); if (!hasHideableColumns) { return ; } return ( {t("columns") || "Columns"} {t("toggleColumns") || "Toggle columns"} {table .getAllColumns() .filter((column) => column.getCanHide()) .map((column) => { return ( column.toggleVisibility(!!value) } > {typeof column.columnDef.header === "string" ? column.columnDef.header : column.id} ); })} ); }, cell: ({ row }) => { const resourceRow = row.original; return ( {t("openMenu")} { setSelectedInternalResource( resourceRow ); setIsDeleteModalOpen(true); }} > {t("delete")} { setEditingResource(resourceRow); setIsEditDialogOpen(true); }} > {t("edit")} ); } } ]; const proxyTable = useReactTable({ data: resources, columns: proxyColumns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), onSortingChange: setProxySorting, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setProxyColumnFilters, getFilteredRowModel: getFilteredRowModel(), onGlobalFilterChange: setProxyGlobalFilter, onColumnVisibilityChange: setProxyColumnVisibility, initialState: { pagination: { pageSize: proxyPageSize, pageIndex: 0 }, columnVisibility: proxyColumnVisibility }, state: { sorting: proxySorting, columnFilters: proxyColumnFilters, globalFilter: proxyGlobalFilter, columnVisibility: proxyColumnVisibility } }); const internalTable = useReactTable({ data: internalResources, columns: internalColumns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), onSortingChange: setInternalSorting, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setInternalColumnFilters, getFilteredRowModel: getFilteredRowModel(), onGlobalFilterChange: setInternalGlobalFilter, onColumnVisibilityChange: setInternalColumnVisibility, initialState: { pagination: { pageSize: internalPageSize, pageIndex: 0 }, columnVisibility: internalColumnVisibility }, state: { sorting: internalSorting, columnFilters: internalColumnFilters, globalFilter: internalGlobalFilter, columnVisibility: internalColumnVisibility } }); const handleProxyPageSizeChange = (newPageSize: number) => { setProxyPageSize(newPageSize); setStoredPageSize(newPageSize, "proxy-resources"); }; const handleInternalPageSizeChange = (newPageSize: number) => { setInternalPageSize(newPageSize); setStoredPageSize(newPageSize, "internal-resources"); }; // Persist column visibility changes to localStorage useEffect(() => { setStoredColumnVisibility(proxyColumnVisibility, "proxy-resources"); }, [proxyColumnVisibility]); useEffect(() => { setStoredColumnVisibility( internalColumnVisibility, "internal-resources" ); }, [internalColumnVisibility]); return ( <> {selectedResource && ( { setIsDeleteModalOpen(val); setSelectedResource(null); }} dialog={ {t("resourceQuestionRemove")} {t("resourceMessageRemove")} } buttonText={t("resourceDeleteConfirm")} onConfirm={async () => deleteResource(selectedResource!.id)} string={selectedResource.name} title={t("resourceDelete")} /> )} {selectedInternalResource && ( { setIsDeleteModalOpen(val); setSelectedInternalResource(null); }} dialog={ {t("resourceQuestionRemove")} {t("resourceMessageRemove")} } buttonText={t("resourceDeleteConfirm")} onConfirm={async () => deleteInternalResource( selectedInternalResource!.id, selectedInternalResource!.siteId ) } string={selectedInternalResource.name} title={t("resourceDelete")} /> )} {getSearchInput()} {env.flags.enableClients && ( {t("resourcesTableProxyResources")} {t("resourcesTableClientResources")} )} {t("refresh")} {getActionButton()} {proxyTable .getHeaderGroups() .map((headerGroup) => ( {headerGroup.headers .filter((header) => header.column.getIsVisible() ) .map((header) => ( {header.isPlaceholder ? null : flexRender( header .column .columnDef .header, header.getContext() )} ))} ))} {proxyTable.getRowModel().rows ?.length ? ( proxyTable .getRowModel() .rows.map((row) => ( {row .getVisibleCells() .map((cell) => ( {flexRender( cell .column .columnDef .cell, cell.getContext() )} ))} )) ) : ( {t( "resourcesTableNoProxyResourcesFound" )} )} {internalTable .getHeaderGroups() .map((headerGroup) => ( {headerGroup.headers .filter((header) => header.column.getIsVisible() ) .map((header) => ( {header.isPlaceholder ? null : flexRender( header .column .columnDef .header, header.getContext() )} ))} ))} {internalTable.getRowModel().rows ?.length ? ( internalTable .getRowModel() .rows.map((row) => ( {row .getVisibleCells() .map((cell) => ( {flexRender( cell .column .columnDef .cell, cell.getContext() )} ))} )) ) : ( {t( "resourcesTableNoInternalResourcesFound" )} )} {editingResource && ( { router.refresh(); setEditingResource(null); }} /> )} { router.refresh(); }} /> > ); }
{t("resourceQuestionRemove")}
{t("resourceMessageRemove")}