From f9ff3a57153222b2467aedd6e345117a5ed3391d Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Thu, 13 Aug 2026 15:52:33 -0400
Subject: [PATCH] improve resource type picker
---
messages/en-US.json | 18 +-
.../resources/private/create/page.tsx | 218 ++++++++++--------
.../settings/resources/public/create/page.tsx | 107 +++++----
src/components/DescribedSelect.tsx | 115 +++++++++
src/components/DomainPicker.tsx | 5 +-
src/components/PaidFeaturesAlert.tsx | 88 ++-----
6 files changed, 349 insertions(+), 202 deletions(-)
create mode 100644 src/components/DescribedSelect.tsx
diff --git a/messages/en-US.json b/messages/en-US.json
index a3bfe73b8..9f8ddebe4 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -289,6 +289,20 @@
"siteSelectionDescription": "This site will provide connectivity to the target.",
"resourceType": "Resource Type",
"resourceTypeDescription": "This controls the resource protocol and how it will be rendered in the browser. This can’t be changed later.",
+ "resourceTypeSearch": "Search resource types...",
+ "resourceTypeNotFound": "No resource type found",
+ "resourceTypeHttpDescription": "Proxy web traffic over HTTPS using a domain name",
+ "resourceTypeInferenceDescription": "Route AI inference requests through attached providers",
+ "resourceTypeSshDescription": "Access an SSH server from the browser",
+ "resourceTypeRdpDescription": "Access a remote desktop from the browser",
+ "resourceTypeVncDescription": "Access a VNC desktop from the browser",
+ "resourceTypeTcpDescription": "Proxy raw TCP traffic using a port number",
+ "resourceTypeUdpDescription": "Proxy raw UDP traffic using a port number",
+ "privateResourceTypeDescription": "This controls how clients reach the resource. This can’t be changed later.",
+ "privateResourceTypeHostDescription": "Expose a single host on the site network to connected clients",
+ "privateResourceTypeCidrDescription": "Expose a CIDR range on the site network to connected clients",
+ "privateResourceTypeHttpDescription": "Access an HTTP or HTTPS service through a domain",
+ "privateResourceTypeSshDescription": "Access an SSH server from connected clients",
"resourceDomainDescription": "The resource will be served at this fully qualified domain name.",
"resourceHTTPSSettings": "HTTPS Settings",
"resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS",
@@ -3522,8 +3536,8 @@
"sourceAddress": "Source Address",
"destinationAddress": "Destination Address",
"duration": "Duration",
- "licenseRequiredToUse": "An Enterprise Edition license or Pangolin Cloud is required to use this feature. Book a free demo or POC trial to learn more.",
- "ossEnterpriseEditionRequired": "The Enterprise Edition is required to use this feature. This feature is also available in Pangolin Cloud. Book a free demo or POC trial to learn more.",
+ "licenseRequiredToUse": "Enterprise Edition license is required. Book a demo or POC",
+ "ossEnterpriseEditionRequired": "Enterprise Edition license is required. Book a demo or POC",
"certResolver": "Certificate Resolver",
"certResolverDescription": "Select the certificate resolver to use for this resource.",
"selectCertResolver": "Select Certificate Resolver",
diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx
index 90f115bfd..96e8f84d8 100644
--- a/src/app/[orgId]/settings/resources/private/create/page.tsx
+++ b/src/app/[orgId]/settings/resources/private/create/page.tsx
@@ -12,9 +12,9 @@ import {
} from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import {
- OptionSelect,
- type OptionSelectOption
-} from "@app/components/OptionSelect";
+ DescribedSelect,
+ type DescribedSelectOption
+} from "@app/components/DescribedSelect";
import DomainPicker from "@app/components/DomainPicker";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { Button } from "@app/components/ui/button";
@@ -147,24 +147,35 @@ export default function CreatePrivateResourcePage() {
const authDaemonMode = form.watch("authDaemonMode");
const isNativeSsh = mode === "ssh" && authDaemonMode === "native";
- const modeOptions: OptionSelectOption[] = [
- { value: "host", label: t("createInternalResourceDialogModeHost") },
- { value: "cidr", label: t("createInternalResourceDialogModeCidr") },
+ const modeOptions: DescribedSelectOption[] = [
+ {
+ value: "host",
+ title: t("createInternalResourceDialogModeHost"),
+ description: t("privateResourceTypeHostDescription")
+ },
+ {
+ value: "cidr",
+ title: t("createInternalResourceDialogModeCidr"),
+ description: t("privateResourceTypeCidrDescription")
+ },
...(!disableEnterpriseFeatures
? [
{
value: "http" as const,
- label: t("createInternalResourceDialogModeHttp")
+ title: t("createInternalResourceDialogModeHttp"),
+ description: t("privateResourceTypeHttpDescription")
},
{
value: "ssh" as const,
- label: t("createInternalResourceDialogModeSsh")
+ title: t("createInternalResourceDialogModeSsh"),
+ description: t("privateResourceTypeSshDescription")
}
]
: []),
{
value: "inference" as const,
- label: t("createInternalResourceDialogModeInference")
+ title: t("createInternalResourceDialogModeInference"),
+ description: t("resourceTypeInferenceDescription")
}
];
@@ -260,6 +271,110 @@ export default function CreatePrivateResourcePage() {
+
+ (
+
+
+ {t("type")}
+
+
+
+ options={
+ modeOptions
+ }
+ value={field.value}
+ onChange={(
+ newMode
+ ) => {
+ field.onChange(
+ newMode
+ );
+ if (
+ newMode ===
+ "ssh"
+ ) {
+ form.setValue(
+ "authDaemonMode",
+ "native"
+ );
+ form.setValue(
+ "standardDaemonLocation",
+ "site"
+ );
+ form.setValue(
+ "destination",
+ null
+ );
+ form.setValue(
+ "destinationPort",
+ null
+ );
+ } else if (
+ newMode ===
+ "http"
+ ) {
+ form.setValue(
+ "destinationPort",
+ 443
+ );
+ } else if (
+ newMode ===
+ "inference"
+ ) {
+ form.setValue(
+ "siteIds",
+ []
+ );
+ setSelectedSites(
+ []
+ );
+ form.setValue(
+ "destination",
+ null
+ );
+ form.setValue(
+ "destinationPort",
+ null
+ );
+ form.setValue(
+ "providerIds",
+ []
+ );
+ setSelectedProviders(
+ []
+ );
+ } else {
+ form.setValue(
+ "destinationPort",
+ null
+ );
+ }
+ }}
+ searchPlaceholder={t(
+ "resourceTypeSearch"
+ )}
+ emptyMessage={t(
+ "resourceTypeNotFound"
+ )}
+ placeholder={t(
+ "noneSelected"
+ )}
+ />
+
+
+
+ {t(
+ "privateResourceTypeDescription"
+ )}
+
+
+ )}
+ />
+
+
-
- (
-
-
- {t("type")}
-
-
- options={modeOptions}
- value={field.value}
- onChange={(newMode) => {
- field.onChange(
- newMode
- );
- if (
- newMode ===
- "ssh"
- ) {
- form.setValue(
- "authDaemonMode",
- "native"
- );
- form.setValue(
- "standardDaemonLocation",
- "site"
- );
- form.setValue(
- "destination",
- null
- );
- form.setValue(
- "destinationPort",
- null
- );
- } else if (
- newMode ===
- "http"
- ) {
- form.setValue(
- "destinationPort",
- 443
- );
- } else if (
- newMode ===
- "inference"
- ) {
- form.setValue(
- "siteIds",
- []
- );
- setSelectedSites(
- []
- );
- form.setValue(
- "destination",
- null
- );
- form.setValue(
- "destinationPort",
- null
- );
- form.setValue(
- "providerIds",
- []
- );
- setSelectedProviders(
- []
- );
- } else {
- form.setValue(
- "destinationPort",
- null
- );
- }
- }}
- cols={4}
- />
-
-
- )}
- />
-
-
{(mode === "http" ||
mode === "inference") && (
diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx
index 65529f74a..667819c21 100644
--- a/src/app/[orgId]/settings/resources/public/create/page.tsx
+++ b/src/app/[orgId]/settings/resources/public/create/page.tsx
@@ -18,9 +18,9 @@ import {
} from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import {
- OptionSelect,
- type OptionSelectOption
-} from "@app/components/OptionSelect";
+ DescribedSelect,
+ type DescribedSelectOption
+} from "@app/components/DescribedSelect";
import {
StrategySelect,
type StrategyOption
@@ -775,26 +775,45 @@ export default function Page() {
}
];
- let typeLabels: Partial> = {
- http: "HTTP",
- inference: t("createInternalResourceDialogModeInference"),
- tcp: "TCP",
- udp: "UDP"
+ const typeMeta: Record<
+ NewResourceType,
+ { title: string; description: string }
+ > = {
+ http: {
+ title: t("createInternalResourceDialogModeHttp"),
+ description: t("resourceTypeHttpDescription")
+ },
+ inference: {
+ title: t("createInternalResourceDialogModeInference"),
+ description: t("resourceTypeInferenceDescription")
+ },
+ ssh: {
+ title: t("createInternalResourceDialogModeSsh"),
+ description: t("resourceTypeSshDescription")
+ },
+ rdp: {
+ title: t("rdpTitle"),
+ description: t("resourceTypeRdpDescription")
+ },
+ vnc: {
+ title: t("vncTitle"),
+ description: t("resourceTypeVncDescription")
+ },
+ tcp: {
+ title: t("createInternalResourceDialogTcp"),
+ description: t("resourceTypeTcpDescription")
+ },
+ udp: {
+ title: t("createInternalResourceDialogUdp"),
+ description: t("resourceTypeUdpDescription")
+ }
};
- if (enterpriseModesAllowed) {
- typeLabels = {
- ...typeLabels,
- ssh: "SSH",
- rdp: "RDP",
- vnc: "VNC"
- };
- }
-
- const typeOptions: OptionSelectOption[] =
+ const typeOptions: DescribedSelectOption[] =
availableTypes.map((type) => ({
value: type,
- label: typeLabels[type] ?? type.toUpperCase()
+ title: typeMeta[type].title,
+ description: typeMeta[type].description
}));
return (
@@ -831,6 +850,35 @@ export default function Page() {
+
+
+
+
+ options={typeOptions}
+ value={resourceType}
+ onChange={
+ setResourceType
+ }
+ searchPlaceholder={t(
+ "resourceTypeSearch"
+ )}
+ emptyMessage={t(
+ "resourceTypeNotFound"
+ )}
+ placeholder={t(
+ "noneSelected"
+ )}
+ />
+
+ {t(
+ "resourceTypeDescription"
+ )}
+
+
+
+
-
-
-
- {t("type")}
-
-
- options={typeOptions}
- value={resourceType}
- onChange={
- setResourceType
- }
- cols={6}
- />
-
- {t(
- "resourceTypeDescription"
- )}
-
-
-
-
{isHttpResource && (
- {chunks}
-
-
- );
- };
-}
-
function getBookADemoLinkRenderer() {
return function bookADemoLinkRenderer(chunks: React.ReactNode) {
return (
-
- {chunks}
-
-
+
+
+ {chunks}
+
+
+ .
+
);
};
}
@@ -114,10 +101,9 @@ function getDocsLinkRenderer(href: string) {
type Props = {
tiers: Tier[];
- showBookADemo?: boolean;
};
-export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) {
+export function PaidFeaturesAlert({ tiers }: Props) {
const t = useTranslations();
const params = useParams();
const orgId = params?.orgId as string | undefined;
@@ -133,11 +119,8 @@ export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) {
? `/${orgId}/settings/billing`
: "https://pangolin.net/pricing";
const tierLinkRenderer = getTierLinkRenderer(billingHref);
- const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
- const bookADemoLinkRenderer = showBookADemo
- ? getBookADemoLinkRenderer()
- : () => null;
+ const bookADemoLinkRenderer = getBookADemoLinkRenderer();
if (env.flags.disableEnterpriseFeatures) {
return null;
@@ -150,17 +133,12 @@ export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) {
-
+
{requiredTiersLabel
- ? isActive
- ? t.rich("upgradeToTierToUse", {
- tier: requiredTiersLabel,
- tierLink: tierLinkRenderer
- })
- : t.rich("upgradeToTierToUse", {
- tier: requiredTiersLabel,
- tierLink: tierLinkRenderer
- })
+ ? t.rich("upgradeToTierToUse", {
+ tier: requiredTiersLabel,
+ tierLink: tierLinkRenderer
+ })
: isActive
? t("mustUpgradeToUse")
: t("subscriptionRequiredToUse")}
@@ -170,34 +148,16 @@ export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) {
) : null}
- {build === "enterprise" && !hasEnterpriseLicense ? (
+ {(build === "enterprise" || build === "oss") &&
+ !hasEnterpriseLicense ? (
-
- {t.rich("licenseRequiredToUse", {
- enterpriseLicenseLink:
- enterpriseDocsLinkRenderer,
- pangolinCloudLink: pangolinCloudLinkRenderer,
- bookADemoLink: bookADemoLinkRenderer
- })}
-
-
-
-
- ) : null}
-
- {build === "oss" && !hasEnterpriseLicense ? (
-
-
-
-
-
+
{t.rich("ossEnterpriseEditionRequired", {
enterpriseEditionLink:
enterpriseDocsLinkRenderer,
- pangolinCloudLink: pangolinCloudLinkRenderer,
bookADemoLink: bookADemoLinkRenderer
})}