diff --git a/messages/en-US.json b/messages/en-US.json index 3b1c43a4..0aa56ac5 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -144,8 +144,10 @@ "expires": "Expires", "never": "Never", "shareErrorSelectResource": "Please select a resource", - "resourceTitle": "Manage Resources", - "resourceDescription": "Access resources on sites publically or privately", + "proxyResourceTitle": "Manage Proxy Resources", + "proxyResourceDescription": "Access web resources on sites", + "clientResourceTitle": "Manage Client Resources", + "clientResourceDescription": "Access Internal resources on sites", "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", @@ -2184,7 +2186,7 @@ "generatedcredentials": "Generated Credentials", "copyandsavethesecredentials": "Copy and save these credentials", "copyandsavethesecredentialsdescription": "These credentials will not be shown again after you leave this page. Save them securely now.", - "credentialsSaved" : "Credentials Saved", + "credentialsSaved": "Credentials Saved", "credentialsSavedDescription": "Credentials have been regenerated and saved successfully.", "credentialsSaveError": "Credentials Save Error", "credentialsSaveErrorDescription": "An error occurred while regenerating and saving the credentials.", diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index a02f0ae0..34a7288f 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -1,11 +1,10 @@ import ClientResourcesTable from "@app/components/ClientResourcesTable"; -import type { InternalResourceRow } from "@app/components/ProxyResourcesTable"; +import type { InternalResourceRow } from "@app/components/ClientResourcesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; -import { pullEnv } from "@app/lib/pullEnv"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import OrgProvider from "@app/providers/OrgProvider"; -import type { GetOrgResponse } from "@server/routers/org"; import type { ListResourcesResponse } from "@server/routers/resource"; import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; import type { AxiosResponse } from "axios"; @@ -22,17 +21,8 @@ export default async function ClientResourcesPage( props: ClientResourcesPageProps ) { const params = await props.params; - const searchParams = await props.searchParams; const t = await getTranslations(); - const env = pullEnv(); - - // Default to 'proxy' view, or use the query param if provided - let defaultView: "proxy" | "internal" = "proxy"; - if (env.flags.enableClients) { - defaultView = searchParams.view === "internal" ? "internal" : "proxy"; - } - let resources: ListResourcesResponse["resources"] = []; try { const res = await internal.get>( @@ -52,13 +42,7 @@ export default async function ClientResourcesPage( let org = null; try { - const getOrg = cache(async () => - internal.get>( - `/org/${params.orgId}`, - await authCookieHeader() - ) - ); - const res = await getOrg(); + const res = await getCachedOrg(params.orgId); org = res.data.data; } catch { redirect(`/${params.orgId}/settings/resources`); @@ -90,18 +74,14 @@ export default async function ClientResourcesPage( return ( <> >( @@ -103,8 +93,8 @@ export default async function ProxyResourcesPage( return ( <> diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index b37f3eb1..0d246fb3 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -1,65 +1,21 @@ "use client"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, - getPaginationRowModel, - SortingState, - getSortedRowModel, - ColumnFiltersState, - getFilteredRowModel, - VisibilityState -} from "@tanstack/react-table"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +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, - DropdownMenuTrigger, DropdownMenuLabel, DropdownMenuSeparator, - DropdownMenuCheckboxItem + DropdownMenuTrigger } 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, @@ -68,16 +24,42 @@ import { 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 { ListSitesResponse } from "@server/routers/site"; import { - Tabs, - TabsContent, - TabsList, - TabsTrigger -} from "@app/components/ui/tabs"; -import { useSearchParams } from "next/navigation"; -import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, + VisibilityState +} from "@tanstack/react-table"; +import { + ArrowUpDown, + ArrowUpRight, + CheckCircle2, + Clock, + Columns, + MoreHorizontal, + Plus, + RefreshCw, + Search, + XCircle +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState, useTransition } from "react"; + import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; -import { Alert, AlertDescription } from "@app/components/ui/alert"; +import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; +import { siteQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; export type TargetHealth = { targetId: number; @@ -178,10 +160,8 @@ export type InternalResourceRow = { type Site = ListSitesResponse["sites"][0]; type ClientResourcesTableProps = { - resources: ResourceRow[]; internalResources: InternalResourceRow[]; orgId: string; - defaultView?: "proxy" | "internal"; defaultSort?: { id: string; desc: boolean; @@ -271,180 +251,60 @@ const setStoredColumnVisibility = ( }; export default function ClientResourcesTable({ - resources, internalResources, orgId, - defaultView = "proxy", defaultSort }: ClientResourcesTableProps) { 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 { data: sites = [] } = useQuery( + siteQueries.listPerOrg({ orgId, api }) ); - 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 [isRefreshing, startTransition] = useTransition(); + 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(); + console.log("Data refreshed"); } 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 ( - - ); - } - return ( - - ); - }; - - 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 @@ -465,417 +325,6 @@ export default function ClientResourcesTable({ } }; - 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 ( -
- - - {t("resourcesTableNoTargets")} - -
- ); - } - - 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 ( - - - - - - {monitoredTargets.length > 0 && ( - <> - {monitoredTargets.map((target) => ( - -
- - {`${target.ip}:${target.port}`} -
- - {target.healthStatus} - -
- ))} - - )} - {unknownTargets.length > 0 && ( - <> - {unknownTargets.map((target) => ( - -
- - {`${target.ip}:${target.port}`} -
- - {!target.enabled - ? t("disabled") - : t("resourcesTableNotMonitored")} - -
- ))} - - )} -
-
- ); - } - - const proxyColumns: ExtendedColumnDef[] = [ - { - accessorKey: "name", - enableHiding: false, - friendlyName: t("name"), - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "nice", - friendlyName: t("resource"), - enableHiding: true, - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "protocol", - friendlyName: t("protocol"), - header: () => {t("protocol")}, - cell: ({ row }) => { - const resourceRow = row.original; - return ( - - {resourceRow.http - ? resourceRow.ssl - ? "HTTPS" - : "HTTP" - : resourceRow.protocol.toUpperCase()} - - ); - } - }, - { - id: "status", - accessorKey: "status", - friendlyName: t("status"), - header: ({ column }) => { - return ( - - ); - }, - 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", - friendlyName: t("access"), - header: () => {t("access")}, - cell: ({ row }) => { - const resourceRow = row.original; - return ( -
- {!resourceRow.http ? ( - - ) : !resourceRow.domainId ? ( - - ) : ( - - )} -
- ); - } - }, - { - accessorKey: "authState", - friendlyName: t("authentication"), - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const resourceRow = row.original; - return ( -
- {resourceRow.authState === "protected" ? ( - - - {t("protected")} - - ) : resourceRow.authState === "not_protected" ? ( - - - {t("notProtected")} - - ) : ( - - - )} -
- ); - } - }, - { - accessorKey: "enabled", - friendlyName: t("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("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 resourceRow = row.original; - return ( -
- - - - - - - - {t("viewSettings")} - - - { - setSelectedResource(resourceRow); - setIsDeleteModalOpen(true); - }} - > - - {t("delete")} - - - - - - - -
- ); - } - } - ]; - const internalColumns: ExtendedColumnDef[] = [ { accessorKey: "name", @@ -1095,32 +544,6 @@ export default function ClientResourcesTable({ } ]; - 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, @@ -1147,21 +570,12 @@ export default function ClientResourcesTable({ } }); - 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, @@ -1171,26 +585,6 @@ export default function ClientResourcesTable({ 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 && ( - - -
- {getSearchInput()} - - {env.flags.enableClients && ( - - - {t("resourcesTableProxyResources")} - - - {t("resourcesTableClientResources")} - - - )} + +
+
+ + internalTable.setGlobalFilter( + String(e.target.value) + ) + } + className="w-full pl-8" + /> +
-
-
- -
-
{getActionButton()}
+
+
+
+
- - - -
- - - {proxyTable - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers - .filter((header) => - header.column.getIsVisible() - ) - .map((header) => ( - + {" "} + + + + + +
+
+ + {internalTable + .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) => ( - + ))} + + ))} + + + {internalTable.getRowModel().rows + ?.length ? ( + internalTable + .getRowModel() + .rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + - {flexRender( - cell - .column - .columnDef - .cell, - cell.getContext() - )} - - ))} - - )) - ) : ( - - - {t( - "resourcesTableNoProxyResourcesFound" - )} - + "name" + ? "md:sticky md:left-0 z-10 bg-card" + : "" + }`} + > + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext() + )} + + ))} - )} - -
-
-
- -
-
- -
- - - {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" - )} - - - )} - -
-
-
- -
-
-
- + )) + ) : ( + + + {t( + "resourcesTableNoInternalResourcesFound" + )} + + + )} + + +
+
+ +
+
diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 3ddf32bf..da6f435c 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -3,6 +3,8 @@ import { durationToMs } from "./durationToMs"; import { build } from "@server/build"; import { remote } from "./api"; import type ResponseT from "@server/types/Response"; +import type { ListSitesResponse } from "@server/routers/site"; +import type { AxiosInstance, AxiosResponse } from "axios"; export type ProductUpdate = { link: string | null; @@ -65,3 +67,16 @@ export const productUpdatesQueries = { // because we don't need to listen for new versions there }) }; + +export const siteQueries = { + listPerOrg: ({ orgId, api }: { orgId: string; api: AxiosInstance }) => + queryOptions({ + queryKey: ["SITE_PER_ORG", orgId] as const, + queryFn: async ({ signal }) => { + const res = await api.get>( + `/org/${orgId}/sites` + ); + return res.data.data.sites; + } + }) +};