show site resources

This commit is contained in:
miloschwartz
2026-04-25 15:07:59 -07:00
parent ecacb26445
commit 477712b73c
13 changed files with 885 additions and 150 deletions

View File

@@ -1,6 +1,7 @@
--- ---
description:
alwaysApply: true alwaysApply: true
--- ---
Proxy resources = public resources Proxy resources = public resources
Private resources = client resources Private resources = client resources = site resources

View File

@@ -112,6 +112,21 @@
"siteUpdatedDescription": "The site has been updated.", "siteUpdatedDescription": "The site has been updated.",
"siteGeneralDescription": "Configure the general settings for this site", "siteGeneralDescription": "Configure the general settings for this site",
"siteSettingDescription": "Configure the settings on the 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", "siteSetting": "{siteName} Settings",
"siteNewtTunnel": "Newt Site (Recommended)", "siteNewtTunnel": "Newt Site (Recommended)",
"siteNewtTunnelDescription": "Easiest way to create an entrypoint into any network. No extra setup.", "siteNewtTunnelDescription": "Easiest way to create an entrypoint into any network. No extra setup.",

View File

@@ -86,7 +86,12 @@ export async function getUserResources(
.where(inArray(roleSiteResources.roleId, userRoleIds)) .where(inArray(roleSiteResources.roleId, userRoleIds))
: Promise.resolve([]); : Promise.resolve([]);
const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([ const [
directResources,
roleResourceResults,
directSiteResourceResults,
roleSiteResourceResults
] = await Promise.all([
directResourcesQuery, directResourcesQuery,
roleResourcesQuery, roleResourcesQuery,
directSiteResourcesQuery, directSiteResourcesQuery,
@@ -118,24 +123,24 @@ export async function getUserResources(
}> = []; }> = [];
if (accessibleResourceIds.length > 0) { if (accessibleResourceIds.length > 0) {
resourcesData = await db resourcesData = await db
.select({ .select({
resourceId: resources.resourceId, resourceId: resources.resourceId,
name: resources.name, name: resources.name,
fullDomain: resources.fullDomain, fullDomain: resources.fullDomain,
ssl: resources.ssl, ssl: resources.ssl,
enabled: resources.enabled, enabled: resources.enabled,
sso: resources.sso, sso: resources.sso,
protocol: resources.protocol, protocol: resources.protocol,
emailWhitelistEnabled: resources.emailWhitelistEnabled emailWhitelistEnabled: resources.emailWhitelistEnabled
}) })
.from(resources) .from(resources)
.where( .where(
and( and(
inArray(resources.resourceId, accessibleResourceIds), inArray(resources.resourceId, accessibleResourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true) eq(resources.enabled, true)
) )
); );
} }
// Get site resource details for accessible site resources // Get site resource details for accessible site resources
@@ -166,7 +171,10 @@ export async function getUserResources(
.from(siteResources) .from(siteResources)
.where( .where(
and( and(
inArray(siteResources.siteResourceId, accessibleSiteResourceIds), inArray(
siteResources.siteResourceId,
accessibleSiteResourceIds
),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true) eq(siteResources.enabled, true)
) )
@@ -246,7 +254,7 @@ export async function getUserResources(
enabled: siteResource.enabled, enabled: siteResource.enabled,
alias: siteResource.alias, alias: siteResource.alias,
aliasAddress: siteResource.aliasAddress, aliasAddress: siteResource.aliasAddress,
type: 'site' as const type: "site" as const
}; };
}); });

View File

@@ -5,6 +5,9 @@ import {
orgs, orgs,
remoteExitNodes, remoteExitNodes,
roleSites, roleSites,
siteNetworks,
siteResources,
targets,
sites, sites,
userSites userSites
} from "@server/db"; } from "@server/db";
@@ -199,6 +202,18 @@ function querySitesBase() {
exitNodeName: exitNodes.name, exitNodeName: exitNodes.name,
exitNodeEndpoint: exitNodes.endpoint, exitNodeEndpoint: exitNodes.endpoint,
remoteExitNodeId: remoteExitNodes.remoteExitNodeId, remoteExitNodeId: remoteExitNodes.remoteExitNodeId,
resourceCount: sql<number>`(
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 status: sites.status
}) })
.from(sites) .from(sites)
@@ -319,7 +334,6 @@ export async function listSites(
if (typeof status !== "undefined") { if (typeof status !== "undefined") {
conditions.push(eq(sites.status, status)); conditions.push(eq(sites.status, status));
} }
const baseQuery = querySitesBase().where(and(...conditions)); const baseQuery = querySitesBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery // we need to add `as` so that drizzle filters the result as a subquery

View File

@@ -69,6 +69,7 @@ export default async function PendingSitesPage(props: PendingSitesPageProps) {
address: site.address?.split("/")[0], address: site.address?.split("/")[0],
mbIn: formatSize(site.megabytesIn || 0, site.type), mbIn: formatSize(site.megabytesIn || 0, site.type),
mbOut: formatSize(site.megabytesOut || 0, site.type), mbOut: formatSize(site.megabytesOut || 0, site.type),
resourceCount: Number(site.resourceCount ?? 0),
orgId: params.orgId, orgId: params.orgId,
type: site.type as any, type: site.type as any,
online: site.online, online: site.online,

View File

@@ -42,6 +42,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
title: t("general"), title: t("general"),
href: `/${params.orgId}/settings/sites/${params.niceId}/general` href: `/${params.orgId}/settings/sites/${params.niceId}/general`
}, },
{
title: t("siteResourcesTab"),
href: `/${params.orgId}/settings/sites/${params.niceId}/resources`
},
...(site.type !== "local" ...(site.type !== "local"
? [ ? [
{ {

View File

@@ -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<AxiosResponse<GetSiteResponse>>(
`/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<AxiosResponse<ListResourcesResponse>>(
`/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<ListAllSiteResourcesByOrgResponse>
>(
`/org/${orgId}/site-resources?${baseSearch.toString()}`,
await authCookieHeader()
);
initialPrivateData = res.data.data;
} catch (e: any) {
initialPrivateForbidden = e?.response?.status === 403;
}
return (
<SiteResourcesOverview
siteId={site.siteId}
initialPublicData={initialPublicData}
initialPrivateData={initialPrivateData}
initialPublicForbidden={initialPublicForbidden}
initialPrivateForbidden={initialPrivateForbidden}
/>
);
}

View File

@@ -64,6 +64,7 @@ export default async function SitesPage(props: SitesPageProps) {
address: site.address?.split("/")[0], address: site.address?.split("/")[0],
mbIn: formatSize(site.megabytesIn || 0, site.type), mbIn: formatSize(site.megabytesIn || 0, site.type),
mbOut: formatSize(site.megabytesOut || 0, site.type), mbOut: formatSize(site.megabytesOut || 0, site.type),
resourceCount: Number(site.resourceCount ?? 0),
orgId: params.orgId, orgId: params.orgId,
type: site.type as any, type: site.type as any,
online: site.online, online: site.online,

View File

@@ -49,6 +49,7 @@ import { useDebouncedCallback } from "use-debounce";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
import { import {
ResourceSitesStatusCell, ResourceSitesStatusCell,
type ResourceSiteRow type ResourceSiteRow
@@ -86,28 +87,13 @@ export type InternalResourceRow = {
fullDomain?: 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 { function formatDestinationDisplay(row: InternalResourceRow): string {
const { mode, destination, httpHttpsPort, scheme } = row; return formatSiteResourceDestinationDisplay({
if (mode !== "http") { mode: row.mode,
return destination; destination: row.destination,
} httpHttpsPort: row.httpHttpsPort,
const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort); scheme: row.scheme
const downstreamScheme = scheme ?? "http"; });
const hostPart =
destination.includes(":") && !destination.startsWith("[")
? `[${destination}]`
: destination;
return `${downstreamScheme}://${hostPart}:${port}`;
} }
function isSafeUrlForLink(href: string): boolean { function isSafeUrlForLink(href: string): boolean {
@@ -609,6 +595,7 @@ export default function ClientResourcesTable({
rows={internalResources} rows={internalResources}
tableId="internal-resources" tableId="internal-resources"
searchPlaceholder={t("resourcesSearch")} searchPlaceholder={t("resourcesSearch")}
searchQuery={searchParams.get("query") ?? ""}
onAdd={() => setIsCreateDialogOpen(true)} onAdd={() => setIsCreateDialogOpen(true)}
addButtonText={t("resourceAdd")} addButtonText={t("resourceAdd")}
onSearch={handleSearchChange} onSearch={handleSearchChange}

View File

@@ -67,7 +67,7 @@ type SiteResource = {
enabled: boolean; enabled: boolean;
alias: string | null; alias: string | null;
aliasAddress: string | null; aliasAddress: string | null;
type: 'site'; type: "site";
}; };
type MemberResourcesPortalProps = { type MemberResourcesPortalProps = {
@@ -130,7 +130,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
resource.whitelist; resource.whitelist;
const hasAnyInfo = const hasAnyInfo =
Boolean(resource.siteName) || Boolean(hasAuthMethods) || !resource.enabled; Boolean(resource.siteName) ||
Boolean(hasAuthMethods) ||
!resource.enabled;
if (!hasAnyInfo) return null; if (!hasAnyInfo) return null;
@@ -353,7 +355,9 @@ export default function MemberResourcesPortal({
const [resources, setResources] = useState<Resource[]>([]); const [resources, setResources] = useState<Resource[]>([]);
const [siteResources, setSiteResources] = useState<SiteResource[]>([]); const [siteResources, setSiteResources] = useState<SiteResource[]>([]);
const [filteredResources, setFilteredResources] = useState<Resource[]>([]); const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
const [filteredSiteResources, setFilteredSiteResources] = useState<SiteResource[]>([]); const [filteredSiteResources, setFilteredSiteResources] = useState<
SiteResource[]
>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -381,7 +385,9 @@ export default function MemberResourcesPortal({
setResources(response.data.data.resources); setResources(response.data.data.resources);
setSiteResources(response.data.data.siteResources || []); setSiteResources(response.data.data.siteResources || []);
setFilteredResources(response.data.data.resources); setFilteredResources(response.data.data.resources);
setFilteredSiteResources(response.data.data.siteResources || []); setFilteredSiteResources(
response.data.data.siteResources || []
);
} else { } else {
setError("Failed to load resources"); setError("Failed to load resources");
} }
@@ -459,9 +465,10 @@ export default function MemberResourcesPortal({
case "domain-asc": case "domain-asc":
case "domain-desc": case "domain-desc":
// Sort by destination for site resources // Sort by destination for site resources
const destCompare = sortBy === "domain-asc" const destCompare =
? a.destination.localeCompare(b.destination) sortBy === "domain-asc"
: b.destination.localeCompare(a.destination); ? a.destination.localeCompare(b.destination)
: b.destination.localeCompare(a.destination);
return destCompare; return destCompare;
case "status-enabled": case "status-enabled":
return b.enabled ? 1 : -1; return b.enabled ? 1 : -1;
@@ -487,12 +494,14 @@ export default function MemberResourcesPortal({
startIndex + itemsPerPage startIndex + itemsPerPage
); );
const remainingSlots = itemsPerPage - paginatedResources.length; const remainingSlots = itemsPerPage - paginatedResources.length;
const paginatedSiteResources = remainingSlots > 0 const paginatedSiteResources =
? filteredSiteResources.slice( remainingSlots > 0
Math.max(0, startIndex - filteredResources.length), ? filteredSiteResources.slice(
Math.max(0, startIndex - filteredResources.length) + remainingSlots Math.max(0, startIndex - filteredResources.length),
) Math.max(0, startIndex - filteredResources.length) +
: []; remainingSlots
)
: [];
const handleOpenResource = (resource: Resource) => { const handleOpenResource = (resource: Resource) => {
// Open the resource in a new tab // Open the resource in a new tab
@@ -640,7 +649,8 @@ export default function MemberResourcesPortal({
</div> </div>
{/* Resources Content */} {/* Resources Content */}
{filteredResources.length === 0 && filteredSiteResources.length === 0 ? ( {filteredResources.length === 0 &&
filteredSiteResources.length === 0 ? (
/* Enhanced Empty State */ /* Enhanced Empty State */
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-20 text-center"> <CardContent className="flex flex-col items-center justify-center py-20 text-center">
@@ -697,87 +707,96 @@ export default function MemberResourcesPortal({
Public Resources Public Resources
</h3> </h3>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Web applications and services accessible via browser Web applications and services accessible via
browser
</p> </p>
</div> </div>
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8"> <div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
{paginatedResources.map((resource) => ( {paginatedResources.map((resource) => (
<Card key={resource.resourceId}> <Card key={resource.resourceId}>
<div className="p-6"> <div className="p-6">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center min-w-0 flex-1 gap-3 overflow-hidden"> <div className="flex items-center min-w-0 flex-1 gap-3 overflow-hidden">
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger className="min-w-0 max-w-full"> <TooltipTrigger className="min-w-0 max-w-full">
<CardTitle className="text-lg font-bold text-foreground truncate group-hover:text-primary transition-colors"> <CardTitle className="text-lg font-bold text-foreground truncate group-hover:text-primary transition-colors">
{resource.name} {
</CardTitle> resource.name
</TooltipTrigger> }
<TooltipContent> </CardTitle>
<p className="max-w-xs break-words"> </TooltipTrigger>
{resource.name} <TooltipContent>
</p> <p className="max-w-xs break-words">
</TooltipContent> {
</Tooltip> resource.name
</TooltipProvider> }
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex-shrink-0">
<ResourceInfo
resource={resource}
/>
</div>
</div>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() =>
handleOpenResource(
resource
)
}
className="text-sm text-muted-foreground font-medium text-left truncate flex-1"
disabled={!resource.enabled}
>
{resource.domain.replace(
/^https?:\/\//,
""
)}
</button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={() => {
navigator.clipboard.writeText(
resource.domain
);
toast({
title: "Copied to clipboard",
description:
"Resource URL has been copied to your clipboard.",
duration: 2000
});
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div> </div>
<div className="flex-shrink-0"> <div className="p-6 pt-0 mt-auto">
<ResourceInfo resource={resource} /> <Button
onClick={() =>
handleOpenResource(resource)
}
className="w-full h-9 transition-all group-hover:shadow-sm"
variant="outline"
size="sm"
disabled={!resource.enabled}
>
<ExternalLink className="h-3.5 w-3.5 mr-2" />
Open Resource
</Button>
</div> </div>
</div> </Card>
))}
<div className="flex items-center gap-2 mt-3"> </div>
<button
onClick={() =>
handleOpenResource(resource)
}
className="text-sm text-muted-foreground font-medium text-left truncate flex-1"
disabled={!resource.enabled}
>
{resource.domain.replace(
/^https?:\/\//,
""
)}
</button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={() => {
navigator.clipboard.writeText(
resource.domain
);
toast({
title: "Copied to clipboard",
description:
"Resource URL has been copied to your clipboard.",
duration: 2000
});
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="p-6 pt-0 mt-auto">
<Button
onClick={() =>
handleOpenResource(resource)
}
className="w-full h-9 transition-all group-hover:shadow-sm"
variant="outline"
size="sm"
disabled={!resource.enabled}
>
<ExternalLink className="h-3.5 w-3.5 mr-2" />
Open Resource
</Button>
</div>
</Card>
))}
</div>
</> </>
)} )}
@@ -790,7 +809,8 @@ export default function MemberResourcesPortal({
Private Resources Private Resources
</h3> </h3>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Internal network resources accessible via client Internal network resources accessible via
client
</p> </p>
</div> </div>
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8"> <div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
@@ -803,12 +823,16 @@ export default function MemberResourcesPortal({
<Tooltip> <Tooltip>
<TooltipTrigger className="min-w-0 max-w-full"> <TooltipTrigger className="min-w-0 max-w-full">
<CardTitle className="text-lg font-bold text-foreground truncate group-hover:text-primary transition-colors"> <CardTitle className="text-lg font-bold text-foreground truncate group-hover:text-primary transition-colors">
{siteResource.name} {
siteResource.name
}
</CardTitle> </CardTitle>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p className="max-w-xs break-words"> <p className="max-w-xs break-words">
{siteResource.name} {
siteResource.name
}
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -818,39 +842,63 @@ export default function MemberResourcesPortal({
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<InfoPopup> <InfoPopup>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="text-xs font-medium mb-1.5">Resource Details</div> <div className="text-xs font-medium mb-1.5">
Resource Details
</div>
<div> <div>
<span className="font-medium">Mode:</span> <span className="font-medium">
Mode:
</span>
<span className="ml-2 text-muted-foreground capitalize"> <span className="ml-2 text-muted-foreground capitalize">
{siteResource.mode} {
siteResource.mode
}
</span> </span>
</div> </div>
{siteResource.protocol && ( {siteResource.protocol && (
<div> <div>
<span className="font-medium">Protocol:</span> <span className="font-medium">
Protocol:
</span>
<span className="ml-2 text-muted-foreground uppercase"> <span className="ml-2 text-muted-foreground uppercase">
{siteResource.protocol} {
siteResource.protocol
}
</span> </span>
</div> </div>
)} )}
<div> <div>
<span className="font-medium">Destination:</span> <span className="font-medium">
Destination:
</span>
<span className="ml-2 text-muted-foreground"> <span className="ml-2 text-muted-foreground">
{siteResource.destination} {
siteResource.destination
}
</span> </span>
</div> </div>
{siteResource.alias && ( {siteResource.alias && (
<div> <div>
<span className="font-medium">Alias:</span> <span className="font-medium">
Alias:
</span>
<span className="ml-2 text-muted-foreground"> <span className="ml-2 text-muted-foreground">
{siteResource.alias} {
siteResource.alias
}
</span> </span>
</div> </div>
)} )}
<div> <div>
<span className="font-medium">Status:</span> <span className="font-medium">
<span className={`ml-2 ${siteResource.enabled ? 'text-green-600' : 'text-red-600'}`}> Status:
{siteResource.enabled ? 'Enabled' : 'Disabled'} </span>
<span
className={`ml-2 ${siteResource.enabled ? "text-green-600" : "text-red-600"}`}
>
{siteResource.enabled
? "Enabled"
: "Disabled"}
</span> </span>
</div> </div>
</div> </div>
@@ -864,7 +912,9 @@ export default function MemberResourcesPortal({
{/* Alias as primary */} {/* Alias as primary */}
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<div className="text-base font-semibold text-foreground text-left truncate flex-1"> <div className="text-base font-semibold text-foreground text-left truncate flex-1">
{siteResource.alias} {
siteResource.alias
}
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@@ -887,14 +937,18 @@ export default function MemberResourcesPortal({
</div> </div>
{/* Destination as secondary */} {/* Destination as secondary */}
<div className="text-xs text-muted-foreground truncate"> <div className="text-xs text-muted-foreground truncate">
{siteResource.destination} {
siteResource.destination
}
</div> </div>
</> </>
) : ( ) : (
/* Destination as primary when no alias */ /* Destination as primary when no alias */
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="text-sm text-muted-foreground font-medium text-left truncate flex-1"> <div className="text-sm text-muted-foreground font-medium text-left truncate flex-1">
{siteResource.destination} {
siteResource.destination
}
</div> </div>
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -0,0 +1,480 @@
"use client";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { Button } from "@app/components/ui/button";
import { InfoPopup } from "@app/components/ui/info-popup";
import { SettingsContainer } from "@app/components/Settings";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type { ListResourcesResponse } from "@server/routers/resource";
import type ResponseT from "@server/types/Response";
import { useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useParams } from "next/navigation";
import { toUnicode } from "punycode";
import { useMemo, useState, type ReactNode } from "react";
const INITIAL_PAGE_SIZE = 5;
const LOAD_MORE_INCREMENT = 20;
type SiteResourceRow =
ListAllSiteResourcesByOrgResponse["siteResources"][number];
type PublicResourceRow = ListResourcesResponse["resources"][number];
function isForbidden(e: unknown): boolean {
return isAxiosError(e) && e.response?.status === 403;
}
function isSafeUrlForLink(href: string): boolean {
try {
void new URL(href);
return true;
} catch {
return false;
}
}
/** Meta text inside the left column (width comes from the column wrapper). */
const OVERVIEW_META_CLASS = "w-full min-w-0 text-muted-foreground text-sm";
function publicProtocolLabel(r: PublicResourceRow): string {
if (r.http) {
return r.ssl ? "HTTPS" : "HTTP";
}
const p = (r.protocol || "").toLowerCase();
if (p === "tcp") return "TCP";
if (p === "udp") return "UDP";
return (r.protocol || "—").toUpperCase();
}
function PublicResourceMeta({ resource: r }: { resource: PublicResourceRow }) {
return (
<div className={OVERVIEW_META_CLASS}>
<div className="truncate font-medium text-foreground">
{publicProtocolLabel(r)}
</div>
</div>
);
}
function PrivateResourceMeta({ row }: { row: SiteResourceRow }) {
const t = useTranslations();
const modeLabel: Record<SiteResourceRow["mode"], string> = {
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 (
<div
className={OVERVIEW_META_CLASS}
title={`${modeLabel[row.mode]}\n${dest}`}
>
<div className="truncate font-medium text-foreground">
{modeLabel[row.mode]}
</div>
</div>
);
}
function PublicAccessMethod({ resource: r }: { resource: PublicResourceRow }) {
const t = useTranslations();
if (!r.http) {
return (
<CopyToClipboard
text={r.proxyPort?.toString() ?? ""}
isLink={false}
/>
);
}
if (!r.domainId) {
return (
<InfoPopup
info={t("domainNotFoundDescription")}
text={t("domainNotFound")}
/>
);
}
const fullUrl = `${r.ssl ? "https" : "http"}://${toUnicode(r.fullDomain || "")}`;
return (
<CopyToClipboard
text={fullUrl}
isLink={isSafeUrlForLink(fullUrl)}
displayText={fullUrl}
/>
);
}
function PrivateAccessMethod({ row }: { row: SiteResourceRow }) {
if (row.mode === "http" && row.fullDomain) {
const url = `${row.ssl ? "https" : "http"}://${toUnicode(row.fullDomain)}`;
return (
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
);
}
if (row.mode === "host" && row.alias) {
return (
<CopyToClipboard
text={row.alias}
isLink={false}
displayText={row.alias}
/>
);
}
const fromAlias = row.alias?.trim();
if (fromAlias) {
return (
<CopyToClipboard
text={fromAlias}
isLink={false}
displayText={fromAlias}
/>
);
}
const dest = formatSiteResourceDestinationDisplay({
mode: row.mode,
destination: row.destination,
httpHttpsPort: row.destinationPort,
scheme: row.scheme
});
return (
<CopyToClipboard
text={dest}
isLink={isSafeUrlForLink(dest)}
displayText={dest}
/>
);
}
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 = (
<div className="border-b px-5 py-5">
<div className="flex items-start justify-between gap-4">
<div className="text-lg space-y-0.5 pb-6">
<h2 className="text-1xl font-bold tracking-tight flex items-center gap-2">
{title}
</h2>
<p className="text-muted-foreground text-sm">
{description}
</p>
</div>
<Link
href={viewAllHref}
className="shrink-0 text-muted-foreground text-sm hover:underline"
>
{viewAllLabel}
</Link>
</div>
</div>
);
if (isForbidden) {
return (
<div className="min-w-0 overflow-hidden rounded-lg border h-full flex flex-col">
{header}
<p className="px-5 py-3 text-sm text-muted-foreground">
{t("siteResourcesPermissionDenied")}
</p>
</div>
);
}
return (
<div className="min-w-0 overflow-hidden rounded-lg border h-full flex flex-col">
{header}
{rows.length === 0 ? (
<div className="flex flex-1 items-center justify-center px-5 py-3">
<p className="text-center text-sm text-muted-foreground">
{emptyLabel}
</p>
</div>
) : (
<>
<div className="relative flex-1">
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-25 border-l border-border"
/>
<ul className="relative divide-y">
{rows.map((row) => (
<li key={row.key} className="flex">
<div className="w-25 min-w-0 shrink-0 px-5 py-3">
{row.meta}
</div>
<div className="min-w-0 min-h-0 flex-1 px-5 py-3">
<div className="truncate text-sm font-medium">
{row.name}
</div>
<div className="mt-1 min-w-0 break-words text-sm text-muted-foreground">
{row.access}
</div>
</div>
<div className="flex shrink-0 items-center px-5 py-3">
<Button
asChild
type="button"
variant="outline"
>
<Link href={row.editHref}>
{t("edit")}
</Link>
</Button>
</div>
</li>
))}
</ul>
</div>
{canShowMore ? (
<div className="border-t px-5 py-3 text-center">
<button
type="button"
onClick={onShowMore}
disabled={isFetching}
className="text-sm hover:underline text-muted-foreground cursor-pointer"
>
{isFetching
? t("loading")
: t("siteResourcesShowMore")}
</button>
</div>
) : null}
</>
)}
</div>
);
}
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<ListResourcesResponse> => {
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<ListResourcesResponse>;
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<ListAllSiteResourcesByOrgResponse> => {
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<ListAllSiteResourcesByOrgResponse>;
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: <PublicResourceMeta resource={r} />,
name: r.name,
access: <PublicAccessMethod resource={r} />,
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: <PrivateResourceMeta row={row} />,
name: row.name,
access: <PrivateAccessMethod row={row} />,
editHref: `/${orgId}/settings/resources/client?${qs.toString()}`
};
});
if (showEmptyPlaceholder) {
return (
<SettingsContainer>
<p className="pt-2 text-sm text-muted-foreground">
{t("siteResourcesNoneOnSite")}
</p>
</SettingsContainer>
);
}
/** Left column = whichever side has a greater total; ties default to public first. */
const publicOnLeft = publicTotal >= privateTotal;
const publicColumn = (
<OverviewColumn
key="public"
title={t("siteResourcesSectionPublic")}
description={t("siteResourcesSectionPublicDescription")}
viewAllHref={publicViewAllHref}
viewAllLabel={t("siteResourcesViewAllPublic")}
emptyLabel={t("siteResourcesEmptyPublic")}
isForbidden={publicForbidden}
isFetching={publicQuery.isFetching}
rows={publicRows}
canShowMore={publicList.length < publicTotal}
onShowMore={() => setPublicPageSize((n) => n + LOAD_MORE_INCREMENT)}
/>
);
const privateColumn = (
<OverviewColumn
key="private"
title={t("siteResourcesSectionPrivate")}
description={t("siteResourcesSectionPrivateDescription")}
viewAllHref={privateViewAllHref}
viewAllLabel={t("siteResourcesViewAllPrivate")}
emptyLabel={t("siteResourcesEmptyPrivate")}
isForbidden={privateForbidden}
isFetching={privateQuery.isFetching}
rows={privateRows}
canShowMore={privateList.length < privateTotal}
onShowMore={() =>
setPrivatePageSize((n) => n + LOAD_MORE_INCREMENT)
}
/>
);
return (
<SettingsContainer>
<div className="grid gap-6 md:grid-cols-2">
{publicOnLeft
? [publicColumn, privateColumn]
: [privateColumn, publicColumn]}
</div>
</SettingsContainer>
);
}

View File

@@ -24,6 +24,7 @@ import {
ArrowRight, ArrowRight,
ArrowUp10Icon, ArrowUp10Icon,
ArrowUpRight, ArrowUpRight,
ChevronDown,
ChevronsUpDownIcon, ChevronsUpDownIcon,
MoreHorizontal MoreHorizontal
} from "lucide-react"; } from "lucide-react";
@@ -34,6 +35,16 @@ import { useState, useTransition, useEffect } from "react";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import z from "zod"; import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { import {
ControlledDataTable, ControlledDataTable,
type ExtendedColumnDef type ExtendedColumnDef
@@ -54,6 +65,7 @@ export type SiteRow = {
exitNodeName?: string; exitNodeName?: string;
exitNodeEndpoint?: string; exitNodeEndpoint?: string;
remoteExitNodeId?: string; remoteExitNodeId?: string;
resourceCount: number;
}; };
type SitesTableProps = { type SitesTableProps = {
@@ -79,6 +91,8 @@ export default function SitesTable({
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null); const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
const [resourcesDialogSite, setResourcesDialogSite] =
useState<SiteRow | null>(null);
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition();
@@ -293,6 +307,29 @@ export default function SitesTable({
); );
} }
}, },
{
id: "resources",
accessorKey: "resourceCount",
friendlyName: t("resources"),
header: () => <span className="p-3">{t("resources")}</span>,
cell: ({ row }) => {
const siteRow = row.original;
return (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setResourcesDialogSite(siteRow)}
className="flex h-8 items-center gap-2 px-0 font-normal"
>
<span className="text-sm tabular-nums">
{siteRow.resourceCount} {t("resources")}
</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</Button>
);
}
},
{ {
accessorKey: "type", accessorKey: "type",
friendlyName: t("type"), friendlyName: t("type"),
@@ -503,6 +540,43 @@ export default function SitesTable({
return ( return (
<> <>
<Credenza
open={Boolean(resourcesDialogSite)}
onOpenChange={(open) => {
if (!open) setResourcesDialogSite(null);
}}
>
<CredenzaContent className="md:max-w-7xl">
<CredenzaHeader>
<CredenzaTitle>{t("siteResourcesTab")}</CredenzaTitle>
<CredenzaDescription>
{t("siteResourcesDialogDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{resourcesDialogSite != null && (
<SiteResourcesOverview
orgIdOverride={orgId}
siteId={resourcesDialogSite.id}
initialPublicData={null}
initialPrivateData={null}
initialPublicForbidden={false}
initialPrivateForbidden={false}
/>
)}
</CredenzaBody>
<CredenzaFooter>
<Button
type="button"
variant="outline"
onClick={() => setResourcesDialogSite(null)}
>
{t("close")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
{selectedSite && ( {selectedSite && (
<ConfirmDeleteDialog <ConfirmDeleteDialog
open={isDeleteModalOpen} open={isDeleteModalOpen}

View File

@@ -0,0 +1,32 @@
export type SiteResourceDestinationInput = {
mode: "host" | "cidr" | "http";
destination: string;
httpHttpsPort: number | null;
scheme: "http" | "https" | null;
};
export function resolveHttpHttpsDisplayPort(
mode: "http",
httpHttpsPort: number | null
): number {
if (httpHttpsPort != null) {
return httpHttpsPort;
}
return 80;
}
export function formatSiteResourceDestinationDisplay(
row: SiteResourceDestinationInput
): 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}`;
}