mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-01 21:46:38 +00:00
show site resources
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
64
src/app/[orgId]/settings/sites/[niceId]/resources/page.tsx
Normal file
64
src/app/[orgId]/settings/sites/[niceId]/resources/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
480
src/components/SiteResourcesOverview.tsx
Normal file
480
src/components/SiteResourcesOverview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
32
src/lib/formatSiteResourceAccess.ts
Normal file
32
src/lib/formatSiteResourceAccess.ts
Normal 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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user