improve resource type picker

This commit is contained in:
miloschwartz
2026-08-13 15:52:33 -04:00
parent ed71c90d73
commit f9ff3a5715
6 changed files with 349 additions and 202 deletions
+16 -2
View File
@@ -289,6 +289,20 @@
"siteSelectionDescription": "This site will provide connectivity to the target.", "siteSelectionDescription": "This site will provide connectivity to the target.",
"resourceType": "Resource Type", "resourceType": "Resource Type",
"resourceTypeDescription": "This controls the resource protocol and how it will be rendered in the browser. This cant be changed later.", "resourceTypeDescription": "This controls the resource protocol and how it will be rendered in the browser. This cant 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 cant 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.", "resourceDomainDescription": "The resource will be served at this fully qualified domain name.",
"resourceHTTPSSettings": "HTTPS Settings", "resourceHTTPSSettings": "HTTPS Settings",
"resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS", "resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS",
@@ -3522,8 +3536,8 @@
"sourceAddress": "Source Address", "sourceAddress": "Source Address",
"destinationAddress": "Destination Address", "destinationAddress": "Destination Address",
"duration": "Duration", "duration": "Duration",
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a free demo or POC trial to learn more.</bookADemoLink>", "licenseRequiredToUse": "<enterpriseEditionLink>Enterprise Edition license</enterpriseEditionLink> is required. <bookADemoLink>Book a demo or POC</bookADemoLink>",
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a free demo or POC trial to learn more.</bookADemoLink>", "ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition license</enterpriseEditionLink> is required. <bookADemoLink>Book a demo or POC</bookADemoLink>",
"certResolver": "Certificate Resolver", "certResolver": "Certificate Resolver",
"certResolverDescription": "Select the certificate resolver to use for this resource.", "certResolverDescription": "Select the certificate resolver to use for this resource.",
"selectCertResolver": "Select Certificate Resolver", "selectCertResolver": "Select Certificate Resolver",
@@ -12,9 +12,9 @@ import {
} from "@app/components/Settings"; } from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle"; import HeaderTitle from "@app/components/SettingsSectionTitle";
import { import {
OptionSelect, DescribedSelect,
type OptionSelectOption type DescribedSelectOption
} from "@app/components/OptionSelect"; } from "@app/components/DescribedSelect";
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
@@ -147,24 +147,35 @@ export default function CreatePrivateResourcePage() {
const authDaemonMode = form.watch("authDaemonMode"); const authDaemonMode = form.watch("authDaemonMode");
const isNativeSsh = mode === "ssh" && authDaemonMode === "native"; const isNativeSsh = mode === "ssh" && authDaemonMode === "native";
const modeOptions: OptionSelectOption<PrivateResourceMode>[] = [ const modeOptions: DescribedSelectOption<PrivateResourceMode>[] = [
{ value: "host", label: t("createInternalResourceDialogModeHost") }, {
{ value: "cidr", label: t("createInternalResourceDialogModeCidr") }, value: "host",
title: t("createInternalResourceDialogModeHost"),
description: t("privateResourceTypeHostDescription")
},
{
value: "cidr",
title: t("createInternalResourceDialogModeCidr"),
description: t("privateResourceTypeCidrDescription")
},
...(!disableEnterpriseFeatures ...(!disableEnterpriseFeatures
? [ ? [
{ {
value: "http" as const, value: "http" as const,
label: t("createInternalResourceDialogModeHttp") title: t("createInternalResourceDialogModeHttp"),
description: t("privateResourceTypeHttpDescription")
}, },
{ {
value: "ssh" as const, value: "ssh" as const,
label: t("createInternalResourceDialogModeSsh") title: t("createInternalResourceDialogModeSsh"),
description: t("privateResourceTypeSshDescription")
} }
] ]
: []), : []),
{ {
value: "inference" as const, value: "inference" as const,
label: t("createInternalResourceDialogModeInference") title: t("createInternalResourceDialogModeInference"),
description: t("resourceTypeInferenceDescription")
} }
]; ];
@@ -260,6 +271,110 @@ export default function CreatePrivateResourcePage() {
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<SettingsFormGrid> <SettingsFormGrid>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("type")}
</FormLabel>
<FormControl>
<DescribedSelect<PrivateResourceMode>
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"
)}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"privateResourceTypeDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half"> <SettingsFormCell span="half">
<FormField <FormField
control={form.control} control={form.control}
@@ -283,91 +398,6 @@ export default function CreatePrivateResourcePage() {
/> />
</SettingsFormCell> </SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("type")}
</FormLabel>
<OptionSelect<PrivateResourceMode>
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}
/>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
{(mode === "http" || {(mode === "http" ||
mode === "inference") && ( mode === "inference") && (
<SettingsFormCell span="full"> <SettingsFormCell span="full">
@@ -18,9 +18,9 @@ import {
} from "@app/components/Settings"; } from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle"; import HeaderTitle from "@app/components/SettingsSectionTitle";
import { import {
OptionSelect, DescribedSelect,
type OptionSelectOption type DescribedSelectOption
} from "@app/components/OptionSelect"; } from "@app/components/DescribedSelect";
import { import {
StrategySelect, StrategySelect,
type StrategyOption type StrategyOption
@@ -775,26 +775,45 @@ export default function Page() {
} }
]; ];
let typeLabels: Partial<Record<NewResourceType, string>> = { const typeMeta: Record<
http: "HTTP", NewResourceType,
inference: t("createInternalResourceDialogModeInference"), { title: string; description: string }
tcp: "TCP", > = {
udp: "UDP" 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) { const typeOptions: DescribedSelectOption<NewResourceType>[] =
typeLabels = {
...typeLabels,
ssh: "SSH",
rdp: "RDP",
vnc: "VNC"
};
}
const typeOptions: OptionSelectOption<NewResourceType>[] =
availableTypes.map((type) => ({ availableTypes.map((type) => ({
value: type, value: type,
label: typeLabels[type] ?? type.toUpperCase() title: typeMeta[type].title,
description: typeMeta[type].description
})); }));
return ( return (
@@ -831,6 +850,35 @@ export default function Page() {
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<SettingsFormGrid> <SettingsFormGrid>
<SettingsFormCell span="half">
<div className="grid gap-2">
<Label>
{t("type")}
</Label>
<DescribedSelect<NewResourceType>
options={typeOptions}
value={resourceType}
onChange={
setResourceType
}
searchPlaceholder={t(
"resourceTypeSearch"
)}
emptyMessage={t(
"resourceTypeNotFound"
)}
placeholder={t(
"noneSelected"
)}
/>
<p className="text-muted-foreground text-sm">
{t(
"resourceTypeDescription"
)}
</p>
</div>
</SettingsFormCell>
<SettingsFormCell span="half"> <SettingsFormCell span="half">
<Form {...baseForm}> <Form {...baseForm}>
<form <form
@@ -876,27 +924,6 @@ export default function Page() {
</Form> </Form>
</SettingsFormCell> </SettingsFormCell>
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="text-sm font-medium">
{t("type")}
</p>
<OptionSelect<NewResourceType>
options={typeOptions}
value={resourceType}
onChange={
setResourceType
}
cols={6}
/>
<p className="text-sm text-muted-foreground">
{t(
"resourceTypeDescription"
)}
</p>
</div>
</SettingsFormCell>
{isHttpResource && ( {isHttpResource && (
<SettingsFormCell span="full"> <SettingsFormCell span="full">
<Form {...httpForm}> <Form {...httpForm}>
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useState } from "react";
export type DescribedSelectOption<TValue extends string> = {
value: TValue;
title: string;
description: string;
};
type DescribedSelectProps<TValue extends string> = {
options: ReadonlyArray<DescribedSelectOption<TValue>>;
value: TValue;
onChange: (value: TValue) => void;
searchPlaceholder: string;
emptyMessage: string;
placeholder?: string;
disabled?: boolean;
className?: string;
};
export function DescribedSelect<TValue extends string>({
options,
value,
onChange,
searchPlaceholder,
emptyMessage,
placeholder,
disabled,
className
}: DescribedSelectProps<TValue>) {
const [open, setOpen] = useState(false);
const selected = options.find((option) => option.value === value);
return (
<div className={cn("w-full", className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"h-9 w-full justify-between font-normal",
!selected && "text-muted-foreground"
)}
>
<span className="truncate text-left">
{selected?.title ?? placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={`${option.value} ${option.title} ${option.description}`}
onSelect={() => {
onChange(option.value);
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4 shrink-0",
option.value === value
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate">
{option.title}
</span>
<span className="text-muted-foreground text-xs leading-snug">
{option.description}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}
+3 -2
View File
@@ -516,7 +516,9 @@ export default function DomainPicker({
return ( return (
<Alert> <Alert>
<Globe className="h-4 w-4" /> <Globe className="h-4 w-4" />
<AlertTitle>{t("domainPickerNoDomainsAvailableTitle")}</AlertTitle> <AlertTitle>
{t("domainPickerNoDomainsAvailableTitle")}
</AlertTitle>
<AlertDescription className="space-y-3"> <AlertDescription className="space-y-3">
<p>{t("domainPickerNoDomainsAvailableDescription")}</p> <p>{t("domainPickerNoDomainsAvailableDescription")}</p>
<Button asChild size="sm" variant="outline"> <Button asChild size="sm" variant="outline">
@@ -592,7 +594,6 @@ export default function DomainPicker({
)} )}
</p> </p>
<PaidFeaturesAlert <PaidFeaturesAlert
showBookADemo={false}
tiers={ tiers={
tierMatrix[ tierMatrix[
TierFeature.WildcardSubdomain TierFeature.WildcardSubdomain
+24 -64
View File
@@ -45,11 +45,11 @@ const bannerClassName =
"mb-6 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden"; "mb-6 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden";
const bannerContentClassName = "py-3 px-4"; const bannerContentClassName = "py-3 px-4";
const bannerRowClassName = const bannerRowClassName =
"flex items-center gap-2.5 text-sm text-muted-foreground"; "flex items-center gap-2.5 text-sm text-muted-foreground whitespace-nowrap";
const bannerIconClassName = "size-4 shrink-0 text-black-500"; const bannerIconClassName = "size-4 shrink-0 text-black-500";
const bannerTextClassName = "whitespace-nowrap shrink-0";
const docsLinkClassName = const docsLinkClassName =
"inline-flex items-center gap-1 font-medium text-black-600 underline"; "inline-flex items-center gap-1 font-medium text-black-600 underline";
const PANGOLIN_CLOUD_SIGNUP_URL = "https://app.pangolin.net/auth/signup/";
const ENTERPRISE_DOCS_URL = const ENTERPRISE_DOCS_URL =
"https://docs.pangolin.net/self-host/enterprise-edition"; "https://docs.pangolin.net/self-host/enterprise-edition";
const BOOK_A_DEMO_URL = "https://click.fossorial.io/ep922"; const BOOK_A_DEMO_URL = "https://click.fossorial.io/ep922";
@@ -64,34 +64,21 @@ function getTierLinkRenderer(billingHref: string) {
}; };
} }
function getPangolinCloudLinkRenderer() {
return function pangolinCloudLinkRenderer(chunks: React.ReactNode) {
return (
<Link
href={PANGOLIN_CLOUD_SIGNUP_URL}
target="_blank"
rel="noopener noreferrer"
className={docsLinkClassName}
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
);
};
}
function getBookADemoLinkRenderer() { function getBookADemoLinkRenderer() {
return function bookADemoLinkRenderer(chunks: React.ReactNode) { return function bookADemoLinkRenderer(chunks: React.ReactNode) {
return ( return (
<Link <span className="whitespace-nowrap">
href={BOOK_A_DEMO_URL} <Link
target="_blank" href={BOOK_A_DEMO_URL}
rel="noopener noreferrer" target="_blank"
className={docsLinkClassName} rel="noopener noreferrer"
> className={docsLinkClassName}
{chunks} >
<ExternalLink className="size-3.5 shrink-0" /> {chunks}
</Link> <ExternalLink className="size-3.5 shrink-0" />
</Link>
.
</span>
); );
}; };
} }
@@ -114,10 +101,9 @@ function getDocsLinkRenderer(href: string) {
type Props = { type Props = {
tiers: Tier[]; tiers: Tier[];
showBookADemo?: boolean;
}; };
export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) { export function PaidFeaturesAlert({ tiers }: Props) {
const t = useTranslations(); const t = useTranslations();
const params = useParams(); const params = useParams();
const orgId = params?.orgId as string | undefined; const orgId = params?.orgId as string | undefined;
@@ -133,11 +119,8 @@ export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) {
? `/${orgId}/settings/billing` ? `/${orgId}/settings/billing`
: "https://pangolin.net/pricing"; : "https://pangolin.net/pricing";
const tierLinkRenderer = getTierLinkRenderer(billingHref); const tierLinkRenderer = getTierLinkRenderer(billingHref);
const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL); const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
const bookADemoLinkRenderer = showBookADemo const bookADemoLinkRenderer = getBookADemoLinkRenderer();
? getBookADemoLinkRenderer()
: () => null;
if (env.flags.disableEnterpriseFeatures) { if (env.flags.disableEnterpriseFeatures) {
return null; return null;
@@ -150,17 +133,12 @@ export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) {
<CardContent className={bannerContentClassName}> <CardContent className={bannerContentClassName}>
<div className={bannerRowClassName}> <div className={bannerRowClassName}>
<KeyRound className={bannerIconClassName} /> <KeyRound className={bannerIconClassName} />
<span> <span className={bannerTextClassName}>
{requiredTiersLabel {requiredTiersLabel
? isActive ? t.rich("upgradeToTierToUse", {
? t.rich("upgradeToTierToUse", { tier: requiredTiersLabel,
tier: requiredTiersLabel, tierLink: tierLinkRenderer
tierLink: tierLinkRenderer })
})
: t.rich("upgradeToTierToUse", {
tier: requiredTiersLabel,
tierLink: tierLinkRenderer
})
: isActive : isActive
? t("mustUpgradeToUse") ? t("mustUpgradeToUse")
: t("subscriptionRequiredToUse")} : t("subscriptionRequiredToUse")}
@@ -170,34 +148,16 @@ export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) {
</Card> </Card>
) : null} ) : null}
{build === "enterprise" && !hasEnterpriseLicense ? ( {(build === "enterprise" || build === "oss") &&
!hasEnterpriseLicense ? (
<Card className={bannerClassName}> <Card className={bannerClassName}>
<CardContent className={bannerContentClassName}> <CardContent className={bannerContentClassName}>
<div className={bannerRowClassName}> <div className={bannerRowClassName}>
<KeyRound className={bannerIconClassName} /> <KeyRound className={bannerIconClassName} />
<span> <span className={bannerTextClassName}>
{t.rich("licenseRequiredToUse", {
enterpriseLicenseLink:
enterpriseDocsLinkRenderer,
pangolinCloudLink: pangolinCloudLinkRenderer,
bookADemoLink: bookADemoLinkRenderer
})}
</span>
</div>
</CardContent>
</Card>
) : null}
{build === "oss" && !hasEnterpriseLicense ? (
<Card className={bannerClassName}>
<CardContent className={bannerContentClassName}>
<div className={bannerRowClassName}>
<KeyRound className={bannerIconClassName} />
<span>
{t.rich("ossEnterpriseEditionRequired", { {t.rich("ossEnterpriseEditionRequired", {
enterpriseEditionLink: enterpriseEditionLink:
enterpriseDocsLinkRenderer, enterpriseDocsLinkRenderer,
pangolinCloudLink: pangolinCloudLinkRenderer,
bookADemoLink: bookADemoLinkRenderer bookADemoLink: bookADemoLinkRenderer
})} })}
</span> </span>