"use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { DataTable } from "@app/components/ui/data-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { Button } from "@app/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { ArrowDown01Icon, ArrowUp10Icon, ArrowUpDown, ArrowUpRight, ChevronDown, ChevronsUpDownIcon, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState, useTransition } from "react"; import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; import { ControlledDataTable } from "./ui/controlled-data-table"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { cn } from "@app/lib/cn"; export type InternalResourceSiteRow = { siteId: number; siteName: string; siteNiceId: string; online: boolean; }; export type InternalResourceRow = { id: number; name: string; orgId: string; sites: InternalResourceSiteRow[]; siteNames: string[]; siteAddresses: (string | null)[]; siteIds: number[]; siteNiceIds: string[]; // mode: "host" | "cidr" | "port"; mode: "host" | "cidr" | "http"; scheme: "http" | "https" | null; ssl: boolean; // protocol: string | null; // proxyPort: number | null; destination: string; httpHttpsPort: number | null; alias: string | null; aliasAddress: string | null; niceId: string; tcpPortRangeString: string | null; udpPortRangeString: string | null; disableIcmp: boolean; authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; subdomain?: string | null; domainId?: string | null; fullDomain?: string | null; }; function resolveHttpHttpsDisplayPort( mode: "http", httpHttpsPort: number | null ): number { if (httpHttpsPort != null) { return httpHttpsPort; } return 80; } function formatDestinationDisplay(row: InternalResourceRow): string { const { mode, destination, httpHttpsPort, scheme } = row; if (mode !== "http") { return destination; } const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort); const downstreamScheme = scheme ?? "http"; const hostPart = destination.includes(":") && !destination.startsWith("[") ? `[${destination}]` : destination; return `${downstreamScheme}://${hostPart}:${port}`; } function isSafeUrlForLink(href: string): boolean { try { void new URL(href); return true; } catch { return false; } } type AggregateSitesStatus = "allOnline" | "partial" | "allOffline"; function aggregateSitesStatus( resourceSites: InternalResourceSiteRow[] ): AggregateSitesStatus { if (resourceSites.length === 0) { return "allOffline"; } const onlineCount = resourceSites.filter((rs) => rs.online).length; if (onlineCount === resourceSites.length) return "allOnline"; if (onlineCount > 0) return "partial"; return "allOffline"; } function aggregateStatusDotClass(status: AggregateSitesStatus): string { switch (status) { case "allOnline": return "bg-green-500"; case "partial": return "bg-yellow-500"; case "allOffline": default: return "bg-gray-500"; } } function ClientResourceSitesStatusCell({ orgId, resourceSites }: { orgId: string; resourceSites: InternalResourceSiteRow[]; }) { const t = useTranslations(); if (resourceSites.length === 0) { return -; } const aggregate = aggregateSitesStatus(resourceSites); const countLabel = t("multiSitesSelectorSitesCount", { count: resourceSites.length }); return ( {resourceSites.map((site) => { const isOnline = site.online; return (
{site.siteName}
{isOnline ? t("online") : t("offline")} ); })} ); } type ClientResourcesTableProps = { internalResources: InternalResourceRow[]; orgId: string; pagination: PaginationState; rowCount: number; }; export default function ClientResourcesTable({ internalResources, orgId, pagination, rowCount }: ClientResourcesTableProps) { const router = useRouter(); const { navigate: filter, isNavigating: isFiltering, searchParams } = useNavigationContext(); const t = useTranslations(); const { env } = useEnvContext(); const api = createApiClient({ env }); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedInternalResource, setSelectedInternalResource] = useState(); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [editingResource, setEditingResource] = useState(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isRefreshing, startTransition] = useTransition(); const refreshData = () => { startTransition(() => { try { router.refresh(); } catch (error) { toast({ title: t("error"), description: t("refreshError"), variant: "destructive" }); } }); }; const deleteInternalResource = async ( resourceId: number, siteId: number ) => { try { await api.delete(`/site-resource/${resourceId}`).then(() => { startTransition(() => { router.refresh(); setIsDeleteModalOpen(false); }); }); } catch (e) { console.error(t("resourceErrorDelete"), e); toast({ variant: "destructive", title: t("resourceErrorDelte"), description: formatAxiosError(e, t("v")) }); } }; function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) { const { siteNames, siteNiceIds, orgId } = resourceRow; if (!siteNames || siteNames.length === 0) { return -; } if (siteNames.length === 1) { return ( ); } return ( {siteNames.map((siteName, idx) => ( {siteName} ))} ); } const internalColumns: ExtendedColumnDef[] = [ { accessorKey: "name", enableHiding: false, friendlyName: t("name"), header: () => { const nameOrder = getSortDirection("name", searchParams); const Icon = nameOrder === "asc" ? ArrowDown01Icon : nameOrder === "desc" ? ArrowUp10Icon : ChevronsUpDownIcon; return ( ); } }, { id: "niceId", accessorKey: "niceId", friendlyName: t("identifier"), enableHiding: true, header: ({ column }) => { return ( ); }, cell: ({ row }) => { return {row.original.niceId || "-"}; } }, { id: "sites", accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "), friendlyName: t("sites"), header: () => {t("sites")}, cell: ({ row }) => { const resourceRow = row.original; return ( ); } }, { accessorKey: "mode", friendlyName: t("editInternalResourceDialogMode"), header: () => ( handleFilterChange("mode", value)} searchPlaceholder={t("searchPlaceholder")} emptyMessage={t("emptySearchOptions")} label={t("editInternalResourceDialogMode")} className="p-3" /> ), cell: ({ row }) => { const resourceRow = row.original; const modeLabels: Record< "host" | "cidr" | "port" | "http", string > = { host: t("editInternalResourceDialogModeHost"), cidr: t("editInternalResourceDialogModeCidr"), port: t("editInternalResourceDialogModePort"), http: t("editInternalResourceDialogModeHttp") }; return {modeLabels[resourceRow.mode]}; } }, { accessorKey: "destination", friendlyName: t("resourcesTableDestination"), header: () => ( {t("resourcesTableDestination")} ), cell: ({ row }) => { const resourceRow = row.original; const display = formatDestinationDisplay(resourceRow); return ( ); } }, { accessorKey: "alias", friendlyName: t("resourcesTableAlias"), header: () => ( {t("resourcesTableAlias")} ), cell: ({ row }) => { const resourceRow = row.original; if (resourceRow.mode === "host" && resourceRow.alias) { return ( ); } if (resourceRow.mode === "http") { const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.fullDomain}`; return ( ); } return -; } }, { accessorKey: "aliasAddress", friendlyName: t("resourcesTableAliasAddress"), enableHiding: true, header: () => (
{t("resourcesTableAliasAddress")}
), cell: ({ row }) => { const resourceRow = row.original; return resourceRow.aliasAddress ? ( ) : ( - ); } }, { id: "actions", enableHiding: false, header: () => , cell: ({ row }) => { const resourceRow = row.original; return (
{ setSelectedInternalResource( resourceRow ); setIsDeleteModalOpen(true); }} > {t("delete")}
); } } ]; function handleFilterChange( column: string, value: string | undefined | null ) { searchParams.delete(column); searchParams.delete("page"); if (value) { searchParams.set(column, value); } filter({ searchParams }); } function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); filter({ searchParams: newSearch }); } const handlePaginationChange = (newPage: PaginationState) => { searchParams.set("page", (newPage.pageIndex + 1).toString()); searchParams.set("pageSize", newPage.pageSize.toString()); filter({ searchParams }); }; const handleSearchChange = useDebouncedCallback((query: string) => { searchParams.set("query", query); searchParams.delete("page"); filter({ searchParams }); }, 300); return ( <> {selectedInternalResource && ( { setIsDeleteModalOpen(val); setSelectedInternalResource(null); }} dialog={

{t("resourceQuestionRemove")}

{t("resourceMessageRemove")}

} buttonText={t("resourceDeleteConfirm")} onConfirm={async () => deleteInternalResource( selectedInternalResource!.id, selectedInternalResource!.siteIds[0] ) } string={selectedInternalResource.name} title={t("resourceDelete")} /> )} setIsCreateDialogOpen(true)} addButtonText={t("resourceAdd")} onSearch={handleSearchChange} onRefresh={refreshData} onPaginationChange={handlePaginationChange} pagination={pagination} rowCount={rowCount} isRefreshing={isRefreshing || isFiltering} enableColumnVisibility columnVisibility={{ niceId: false, aliasAddress: false }} stickyLeftColumn="name" stickyRightColumn="actions" /> {editingResource && ( { // Delay refresh to allow modal to close smoothly setTimeout(() => { router.refresh(); setEditingResource(null); }, 150); }} /> )} { // Delay refresh to allow modal to close smoothly setTimeout(() => { router.refresh(); }, 150); }} /> ); }