diff --git a/.cursor/rules/Nomenclature.mdc b/.cursor/rules/Nomenclature.mdc index 33c4af797..d290f212e 100644 --- a/.cursor/rules/Nomenclature.mdc +++ b/.cursor/rules/Nomenclature.mdc @@ -1,6 +1,7 @@ --- +description: alwaysApply: true --- Proxy resources = public resources -Private resources = client resources +Private resources = client resources = site resources diff --git a/messages/en-US.json b/messages/en-US.json index c8e76add4..5492768f1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -112,6 +112,21 @@ "siteUpdatedDescription": "The site has been updated.", "siteGeneralDescription": "Configure the general settings for this site", "siteSettingDescription": "Configure the settings on the site", + "siteResourcesTab": "Resources", + "siteResourcesNoneOnSite": "This site has no public or private resources yet.", + "siteResourcesSectionPublic": "Public Resources", + "siteResourcesSectionPrivate": "Private Resources", + "siteResourcesSectionPublicDescription": "Resources exposed externally through domains or ports.", + "siteResourcesSectionPrivateDescription": "Resources available on your private network through the site.", + "siteResourcesViewAllPublic": "View all resources", + "siteResourcesViewAllPrivate": "View all resources", + "siteResourcesDialogDescription": "Overview of public and private resources associated with this site.", + "siteResourcesShowMore": "Show more", + "siteResourcesPermissionDenied": "You do not have permission to list these resources.", + "siteResourcesEmptyPublic": "No public resources target this site yet.", + "siteResourcesEmptyPrivate": "No private resources are associated with this site yet.", + "siteResourcesHowToAccess": "How to access", + "siteResourcesTargetsOnSite": "Targets on this site", "siteSetting": "{siteName} Settings", "siteNewtTunnel": "Newt Site (Recommended)", "siteNewtTunnelDescription": "Easiest way to create an entrypoint into any network. No extra setup.", diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index 1722a7993..c0f21a440 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -86,7 +86,12 @@ export async function getUserResources( .where(inArray(roleSiteResources.roleId, userRoleIds)) : Promise.resolve([]); - const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([ + const [ + directResources, + roleResourceResults, + directSiteResourceResults, + roleSiteResourceResults + ] = await Promise.all([ directResourcesQuery, roleResourcesQuery, directSiteResourcesQuery, @@ -118,24 +123,24 @@ export async function getUserResources( }> = []; if (accessibleResourceIds.length > 0) { resourcesData = await db - .select({ - resourceId: resources.resourceId, - name: resources.name, - fullDomain: resources.fullDomain, - ssl: resources.ssl, - enabled: resources.enabled, - sso: resources.sso, - protocol: resources.protocol, - emailWhitelistEnabled: resources.emailWhitelistEnabled - }) - .from(resources) - .where( - and( - inArray(resources.resourceId, accessibleResourceIds), - eq(resources.orgId, orgId), - eq(resources.enabled, true) - ) - ); + .select({ + resourceId: resources.resourceId, + name: resources.name, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + enabled: resources.enabled, + sso: resources.sso, + protocol: resources.protocol, + emailWhitelistEnabled: resources.emailWhitelistEnabled + }) + .from(resources) + .where( + and( + inArray(resources.resourceId, accessibleResourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ) + ); } // Get site resource details for accessible site resources @@ -166,7 +171,10 @@ export async function getUserResources( .from(siteResources) .where( and( - inArray(siteResources.siteResourceId, accessibleSiteResourceIds), + inArray( + siteResources.siteResourceId, + accessibleSiteResourceIds + ), eq(siteResources.orgId, orgId), eq(siteResources.enabled, true) ) @@ -246,7 +254,7 @@ export async function getUserResources( enabled: siteResource.enabled, alias: siteResource.alias, aliasAddress: siteResource.aliasAddress, - type: 'site' as const + type: "site" as const }; }); diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 88bb233ed..10f5ac0f1 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -5,6 +5,9 @@ import { orgs, remoteExitNodes, roleSites, + siteNetworks, + siteResources, + targets, sites, userSites } from "@server/db"; @@ -199,6 +202,18 @@ function querySitesBase() { exitNodeName: exitNodes.name, exitNodeEndpoint: exitNodes.endpoint, remoteExitNodeId: remoteExitNodes.remoteExitNodeId, + resourceCount: sql`( + SELECT COUNT(DISTINCT ${targets.resourceId}) + FROM ${targets} + WHERE ${targets.siteId} = ${sites.siteId} + ) + ( + SELECT COUNT(DISTINCT ${siteResources.siteResourceId}) + FROM ${siteResources} + INNER JOIN ${siteNetworks} + ON ${siteResources.networkId} = ${siteNetworks.networkId} + WHERE ${siteNetworks.siteId} = ${sites.siteId} + AND ${siteResources.orgId} = ${sites.orgId} + )`, status: sites.status }) .from(sites) @@ -319,7 +334,6 @@ export async function listSites( if (typeof status !== "undefined") { conditions.push(eq(sites.status, status)); } - const baseQuery = querySitesBase().where(and(...conditions)); // we need to add `as` so that drizzle filters the result as a subquery diff --git a/src/app/[orgId]/settings/provisioning/pending/page.tsx b/src/app/[orgId]/settings/provisioning/pending/page.tsx index ee7246821..a85b0d7d9 100644 --- a/src/app/[orgId]/settings/provisioning/pending/page.tsx +++ b/src/app/[orgId]/settings/provisioning/pending/page.tsx @@ -69,6 +69,7 @@ export default async function PendingSitesPage(props: PendingSitesPageProps) { address: site.address?.split("/")[0], mbIn: formatSize(site.megabytesIn || 0, site.type), mbOut: formatSize(site.megabytesOut || 0, site.type), + resourceCount: Number(site.resourceCount ?? 0), orgId: params.orgId, type: site.type as any, online: site.online, diff --git a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx index d5e11e9bc..ba65d06e0 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx @@ -42,6 +42,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { title: t("general"), href: `/${params.orgId}/settings/sites/${params.niceId}/general` }, + { + title: t("siteResourcesTab"), + href: `/${params.orgId}/settings/sites/${params.niceId}/resources` + }, ...(site.type !== "local" ? [ { diff --git a/src/app/[orgId]/settings/sites/[niceId]/resources/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/resources/page.tsx new file mode 100644 index 000000000..fcb460b87 --- /dev/null +++ b/src/app/[orgId]/settings/sites/[niceId]/resources/page.tsx @@ -0,0 +1,64 @@ +import SiteResourcesOverview from "@app/components/SiteResourcesOverview"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import type { ListResourcesResponse } from "@server/routers/resource"; +import type { GetSiteResponse } from "@server/routers/site"; +import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; +import type { AxiosResponse } from "axios"; + +type SiteResourcesPageProps = { + params: Promise<{ orgId: string; niceId: string }>; +}; + +export default async function SiteResourcesPage(props: SiteResourcesPageProps) { + const { orgId, niceId } = await props.params; + + const siteRes = await internal.get>( + `/org/${orgId}/site/${niceId}`, + await authCookieHeader() + ); + const site = siteRes.data.data; + + const baseSearch = new URLSearchParams({ + page: "1", + pageSize: "5", + siteId: String(site.siteId) + }); + + let initialPublicData: ListResourcesResponse | null = null; + let initialPrivateData: ListAllSiteResourcesByOrgResponse | null = null; + let initialPublicForbidden = false; + let initialPrivateForbidden = false; + + try { + const res = await internal.get>( + `/org/${orgId}/resources?${baseSearch.toString()}`, + await authCookieHeader() + ); + initialPublicData = res.data.data; + } catch (e: any) { + initialPublicForbidden = e?.response?.status === 403; + } + + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${orgId}/site-resources?${baseSearch.toString()}`, + await authCookieHeader() + ); + initialPrivateData = res.data.data; + } catch (e: any) { + initialPrivateForbidden = e?.response?.status === 403; + } + + return ( + + ); +} diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index d78666d78..631baee41 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -64,6 +64,7 @@ export default async function SitesPage(props: SitesPageProps) { address: site.address?.split("/")[0], mbIn: formatSize(site.megabytesIn || 0, site.type), mbOut: formatSize(site.megabytesOut || 0, site.type), + resourceCount: Number(site.resourceCount ?? 0), orgId: params.orgId, type: site.type as any, online: site.online, diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index f10f6414e..d60d58d76 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -49,6 +49,7 @@ import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; +import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess"; import { ResourceSitesStatusCell, type ResourceSiteRow @@ -86,28 +87,13 @@ export type InternalResourceRow = { 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}`; + return formatSiteResourceDestinationDisplay({ + mode: row.mode, + destination: row.destination, + httpHttpsPort: row.httpHttpsPort, + scheme: row.scheme + }); } function isSafeUrlForLink(href: string): boolean { @@ -609,6 +595,7 @@ export default function ClientResourcesTable({ rows={internalResources} tableId="internal-resources" searchPlaceholder={t("resourcesSearch")} + searchQuery={searchParams.get("query") ?? ""} onAdd={() => setIsCreateDialogOpen(true)} addButtonText={t("resourceAdd")} onSearch={handleSearchChange} diff --git a/src/components/MemberResourcesPortal.tsx b/src/components/MemberResourcesPortal.tsx index 8ce721c88..602735e56 100644 --- a/src/components/MemberResourcesPortal.tsx +++ b/src/components/MemberResourcesPortal.tsx @@ -67,7 +67,7 @@ type SiteResource = { enabled: boolean; alias: string | null; aliasAddress: string | null; - type: 'site'; + type: "site"; }; type MemberResourcesPortalProps = { @@ -130,7 +130,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => { resource.whitelist; const hasAnyInfo = - Boolean(resource.siteName) || Boolean(hasAuthMethods) || !resource.enabled; + Boolean(resource.siteName) || + Boolean(hasAuthMethods) || + !resource.enabled; if (!hasAnyInfo) return null; @@ -353,7 +355,9 @@ export default function MemberResourcesPortal({ const [resources, setResources] = useState([]); const [siteResources, setSiteResources] = useState([]); const [filteredResources, setFilteredResources] = useState([]); - const [filteredSiteResources, setFilteredSiteResources] = useState([]); + const [filteredSiteResources, setFilteredSiteResources] = useState< + SiteResource[] + >([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); @@ -381,7 +385,9 @@ export default function MemberResourcesPortal({ setResources(response.data.data.resources); setSiteResources(response.data.data.siteResources || []); setFilteredResources(response.data.data.resources); - setFilteredSiteResources(response.data.data.siteResources || []); + setFilteredSiteResources( + response.data.data.siteResources || [] + ); } else { setError("Failed to load resources"); } @@ -459,9 +465,10 @@ export default function MemberResourcesPortal({ case "domain-asc": case "domain-desc": // Sort by destination for site resources - const destCompare = sortBy === "domain-asc" - ? a.destination.localeCompare(b.destination) - : b.destination.localeCompare(a.destination); + const destCompare = + sortBy === "domain-asc" + ? a.destination.localeCompare(b.destination) + : b.destination.localeCompare(a.destination); return destCompare; case "status-enabled": return b.enabled ? 1 : -1; @@ -487,12 +494,14 @@ export default function MemberResourcesPortal({ startIndex + itemsPerPage ); const remainingSlots = itemsPerPage - paginatedResources.length; - const paginatedSiteResources = remainingSlots > 0 - ? filteredSiteResources.slice( - Math.max(0, startIndex - filteredResources.length), - Math.max(0, startIndex - filteredResources.length) + remainingSlots - ) - : []; + const paginatedSiteResources = + remainingSlots > 0 + ? filteredSiteResources.slice( + Math.max(0, startIndex - filteredResources.length), + Math.max(0, startIndex - filteredResources.length) + + remainingSlots + ) + : []; const handleOpenResource = (resource: Resource) => { // Open the resource in a new tab @@ -640,7 +649,8 @@ export default function MemberResourcesPortal({ {/* Resources Content */} - {filteredResources.length === 0 && filteredSiteResources.length === 0 ? ( + {filteredResources.length === 0 && + filteredSiteResources.length === 0 ? ( /* Enhanced Empty State */ @@ -697,87 +707,96 @@ export default function MemberResourcesPortal({ Public Resources

- Web applications and services accessible via browser + Web applications and services accessible via + browser

{paginatedResources.map((resource) => ( - -
-
-
- - - - - {resource.name} - - - -

- {resource.name} -

-
-
-
+ +
+
+
+ + + + + { + resource.name + } + + + +

+ { + resource.name + } +

+
+
+
+
+ +
+ +
+
+ +
+ + +
-
- +
+
-
- -
- - -
-
- -
- -
- - ))} -
+ + ))} +
)} @@ -790,7 +809,8 @@ export default function MemberResourcesPortal({ Private Resources

- Internal network resources accessible via client + Internal network resources accessible via + client

@@ -803,12 +823,16 @@ export default function MemberResourcesPortal({ - {siteResource.name} + { + siteResource.name + }

- {siteResource.name} + { + siteResource.name + }

@@ -818,39 +842,63 @@ export default function MemberResourcesPortal({
-
Resource Details
+
+ Resource Details +
- Mode: + + Mode: + - {siteResource.mode} + { + siteResource.mode + }
{siteResource.protocol && (
- Protocol: + + Protocol: + - {siteResource.protocol} + { + siteResource.protocol + }
)}
- Destination: + + Destination: + - {siteResource.destination} + { + siteResource.destination + }
{siteResource.alias && (
- Alias: + + Alias: + - {siteResource.alias} + { + siteResource.alias + }
)}
- Status: - - {siteResource.enabled ? 'Enabled' : 'Disabled'} + + Status: + + + {siteResource.enabled + ? "Enabled" + : "Disabled"}
@@ -864,7 +912,9 @@ export default function MemberResourcesPortal({ {/* Alias as primary */}
- {siteResource.alias} + { + siteResource.alias + }
+ ); +} + +function PrivateResourceMeta({ row }: { row: SiteResourceRow }) { + const t = useTranslations(); + const modeLabel: Record = { + host: t("editInternalResourceDialogModeHost"), + cidr: t("editInternalResourceDialogModeCidr"), + http: t("editInternalResourceDialogModeHttp") + }; + const dest = formatSiteResourceDestinationDisplay({ + mode: row.mode, + destination: row.destination, + httpHttpsPort: row.destinationPort ?? null, + scheme: row.scheme + }); + return ( +
+
+ {modeLabel[row.mode]} +
+
+ ); +} + +function PublicAccessMethod({ resource: r }: { resource: PublicResourceRow }) { + const t = useTranslations(); + if (!r.http) { + return ( + + ); + } + if (!r.domainId) { + return ( + + ); + } + const fullUrl = `${r.ssl ? "https" : "http"}://${toUnicode(r.fullDomain || "")}`; + return ( + + ); +} + +function PrivateAccessMethod({ row }: { row: SiteResourceRow }) { + if (row.mode === "http" && row.fullDomain) { + const url = `${row.ssl ? "https" : "http"}://${toUnicode(row.fullDomain)}`; + return ( + + ); + } + if (row.mode === "host" && row.alias) { + return ( + + ); + } + const fromAlias = row.alias?.trim(); + if (fromAlias) { + return ( + + ); + } + const dest = formatSiteResourceDestinationDisplay({ + mode: row.mode, + destination: row.destination, + httpHttpsPort: row.destinationPort, + scheme: row.scheme + }); + return ( + + ); +} + +type OverviewRow = { + key: number; + meta: ReactNode; + name: string; + access: ReactNode; + editHref: string; +}; + +type OverviewColumnProps = { + title: string; + description: string; + viewAllHref: string; + viewAllLabel: string; + emptyLabel: string; + isForbidden: boolean; + isFetching: boolean; + rows: OverviewRow[]; + canShowMore: boolean; + onShowMore: () => void; +}; + +function OverviewColumn({ + title, + description, + viewAllHref, + viewAllLabel, + emptyLabel, + isForbidden, + isFetching, + rows, + canShowMore, + onShowMore +}: OverviewColumnProps) { + const t = useTranslations(); + + const header = ( +
+
+
+

+ {title} +

+

+ {description} +

+
+ + {viewAllLabel} + +
+
+ ); + + if (isForbidden) { + return ( +
+ {header} +

+ {t("siteResourcesPermissionDenied")} +

+
+ ); + } + + return ( +
+ {header} + {rows.length === 0 ? ( +
+

+ {emptyLabel} +

+
+ ) : ( + <> +
+
+
    + {rows.map((row) => ( +
  • +
    + {row.meta} +
    +
    +
    + {row.name} +
    +
    + {row.access} +
    +
    +
    + +
    +
  • + ))} +
+
+ {canShowMore ? ( +
+ +
+ ) : null} + + )} +
+ ); +} + +type SiteResourcesOverviewProps = { + siteId: number; + initialPublicData: ListResourcesResponse | null; + initialPrivateData: ListAllSiteResourcesByOrgResponse | null; + initialPublicForbidden: boolean; + initialPrivateForbidden: boolean; + /** When not under `/[orgId]/...` routes, pass org id explicitly (e.g. credenza on sites list). */ + orgIdOverride?: string; +}; + +export default function SiteResourcesOverview({ + siteId, + initialPublicData, + initialPrivateData, + initialPublicForbidden, + initialPrivateForbidden, + orgIdOverride +}: SiteResourcesOverviewProps) { + const t = useTranslations(); + const params = useParams<{ orgId: string }>(); + const orgId = orgIdOverride ?? params.orgId; + const { env } = useEnvContext(); + const api = useMemo(() => createApiClient({ env }), [env]); + + const enabled = Boolean(orgId && siteId); + + const [publicPageSize, setPublicPageSize] = useState(INITIAL_PAGE_SIZE); + const [privatePageSize, setPrivatePageSize] = useState(INITIAL_PAGE_SIZE); + + const publicQuery = useQuery({ + queryKey: [ + "siteResourcesOverview", + "public", + orgId, + siteId, + publicPageSize + ] as const, + enabled: enabled && !initialPublicForbidden, + initialData: initialPublicData ?? undefined, + queryFn: async (): Promise => { + const sp = new URLSearchParams({ + page: "1", + pageSize: String(publicPageSize), + siteId: String(siteId) + }); + const res = await api.get( + `/org/${orgId}/resources?${sp.toString()}` + ); + const envelope = res.data as ResponseT; + const payload = envelope.data; + if (!payload) { + throw new Error("No data"); + } + return payload; + } + }); + + const privateQuery = useQuery({ + queryKey: [ + "siteResourcesOverview", + "private", + orgId, + siteId, + privatePageSize + ] as const, + enabled: enabled && !initialPrivateForbidden, + initialData: initialPrivateData ?? undefined, + queryFn: async (): Promise => { + const sp = new URLSearchParams({ + page: "1", + pageSize: String(privatePageSize), + siteId: String(siteId) + }); + const res = await api.get( + `/org/${orgId}/site-resources?${sp.toString()}` + ); + const envelope = + res.data as ResponseT; + const payload = envelope.data; + if (!payload) { + throw new Error("No data"); + } + return payload; + } + }); + + const publicList = publicQuery.data?.resources ?? []; + const publicTotal = publicQuery.data?.pagination.total ?? 0; + const privateList = privateQuery.data?.siteResources ?? []; + const privateTotal = privateQuery.data?.pagination.total ?? 0; + + const publicForbidden = + initialPublicForbidden || + (publicQuery.isError && isForbidden(publicQuery.error)); + const privateForbidden = + initialPrivateForbidden || + (privateQuery.isError && isForbidden(privateQuery.error)); + + const showEmptyPlaceholder = + !publicForbidden && + !privateForbidden && + publicList.length === 0 && + privateList.length === 0; + + const publicViewAllHref = `/${orgId}/settings/resources/proxy?siteId=${siteId}`; + const privateViewAllHref = `/${orgId}/settings/resources/client?siteId=${siteId}`; + + const publicRows = publicList.map((r) => ({ + key: r.resourceId, + meta: , + name: r.name, + access: , + editHref: `/${orgId}/settings/resources/proxy/${r.niceId}` + })); + + const privateRows = privateList.map((row) => { + const qs = new URLSearchParams({ + siteId: String(siteId), + query: row.niceId + }); + return { + key: row.siteResourceId, + meta: , + name: row.name, + access: , + editHref: `/${orgId}/settings/resources/client?${qs.toString()}` + }; + }); + + if (showEmptyPlaceholder) { + return ( + +

+ {t("siteResourcesNoneOnSite")} +

+
+ ); + } + + /** Left column = whichever side has a greater total; ties default to public first. */ + const publicOnLeft = publicTotal >= privateTotal; + + const publicColumn = ( + setPublicPageSize((n) => n + LOAD_MORE_INCREMENT)} + /> + ); + + const privateColumn = ( + + setPrivatePageSize((n) => n + LOAD_MORE_INCREMENT) + } + /> + ); + + return ( + +
+ {publicOnLeft + ? [publicColumn, privateColumn] + : [privateColumn, publicColumn]} +
+
+ ); +} diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 45c0d9a0b..b221f3f19 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -24,6 +24,7 @@ import { ArrowRight, ArrowUp10Icon, ArrowUpRight, + ChevronDown, ChevronsUpDownIcon, MoreHorizontal } from "lucide-react"; @@ -34,6 +35,16 @@ import { useState, useTransition, useEffect } from "react"; import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; +import SiteResourcesOverview from "@app/components/SiteResourcesOverview"; +import { + Credenza, + CredenzaBody, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; import { ControlledDataTable, type ExtendedColumnDef @@ -54,6 +65,7 @@ export type SiteRow = { exitNodeName?: string; exitNodeEndpoint?: string; remoteExitNodeId?: string; + resourceCount: number; }; type SitesTableProps = { @@ -79,6 +91,8 @@ export default function SitesTable({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); + const [resourcesDialogSite, setResourcesDialogSite] = + useState(null); const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); @@ -293,6 +307,29 @@ export default function SitesTable({ ); } }, + { + id: "resources", + accessorKey: "resourceCount", + friendlyName: t("resources"), + header: () => {t("resources")}, + cell: ({ row }) => { + const siteRow = row.original; + return ( + + ); + } + }, { accessorKey: "type", friendlyName: t("type"), @@ -503,6 +540,43 @@ export default function SitesTable({ return ( <> + { + if (!open) setResourcesDialogSite(null); + }} + > + + + {t("siteResourcesTab")} + + {t("siteResourcesDialogDescription")} + + + + {resourcesDialogSite != null && ( + + )} + + + + + + + {selectedSite && (