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.",
"resourceType": "Resource Type",
"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.",
"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 <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>",
"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>",
"licenseRequiredToUse": "<enterpriseEditionLink>Enterprise Edition license</enterpriseEditionLink> is required. <bookADemoLink>Book a demo or POC</bookADemoLink>",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition license</enterpriseEditionLink> is required. <bookADemoLink>Book a demo or POC</bookADemoLink>",
"certResolver": "Certificate Resolver",
"certResolverDescription": "Select the certificate resolver to use for this resource.",
"selectCertResolver": "Select Certificate Resolver",
@@ -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<PrivateResourceMode>[] = [
{ value: "host", label: t("createInternalResourceDialogModeHost") },
{ value: "cidr", label: t("createInternalResourceDialogModeCidr") },
const modeOptions: DescribedSelectOption<PrivateResourceMode>[] = [
{
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() {
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<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">
<FormField
control={form.control}
@@ -283,91 +398,6 @@ export default function CreatePrivateResourcePage() {
/>
</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 === "inference") && (
<SettingsFormCell span="full">
@@ -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<Record<NewResourceType, string>> = {
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<NewResourceType>[] =
const typeOptions: DescribedSelectOption<NewResourceType>[] =
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() {
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<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">
<Form {...baseForm}>
<form
@@ -876,27 +924,6 @@ export default function Page() {
</Form>
</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 && (
<SettingsFormCell span="full">
<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 (
<Alert>
<Globe className="h-4 w-4" />
<AlertTitle>{t("domainPickerNoDomainsAvailableTitle")}</AlertTitle>
<AlertTitle>
{t("domainPickerNoDomainsAvailableTitle")}
</AlertTitle>
<AlertDescription className="space-y-3">
<p>{t("domainPickerNoDomainsAvailableDescription")}</p>
<Button asChild size="sm" variant="outline">
@@ -592,7 +594,6 @@ export default function DomainPicker({
)}
</p>
<PaidFeaturesAlert
showBookADemo={false}
tiers={
tierMatrix[
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";
const bannerContentClassName = "py-3 px-4";
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 bannerTextClassName = "whitespace-nowrap shrink-0";
const docsLinkClassName =
"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 =
"https://docs.pangolin.net/self-host/enterprise-edition";
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() {
return function bookADemoLinkRenderer(chunks: React.ReactNode) {
return (
<Link
href={BOOK_A_DEMO_URL}
target="_blank"
rel="noopener noreferrer"
className={docsLinkClassName}
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
<span className="whitespace-nowrap">
<Link
href={BOOK_A_DEMO_URL}
target="_blank"
rel="noopener noreferrer"
className={docsLinkClassName}
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
.
</span>
);
};
}
@@ -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) {
<CardContent className={bannerContentClassName}>
<div className={bannerRowClassName}>
<KeyRound className={bannerIconClassName} />
<span>
<span className={bannerTextClassName}>
{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) {
</Card>
) : null}
{build === "enterprise" && !hasEnterpriseLicense ? (
{(build === "enterprise" || build === "oss") &&
!hasEnterpriseLicense ? (
<Card className={bannerClassName}>
<CardContent className={bannerContentClassName}>
<div className={bannerRowClassName}>
<KeyRound className={bannerIconClassName} />
<span>
{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>
<span className={bannerTextClassName}>
{t.rich("ossEnterpriseEditionRequired", {
enterpriseEditionLink:
enterpriseDocsLinkRenderer,
pangolinCloudLink: pangolinCloudLinkRenderer,
bookADemoLink: bookADemoLinkRenderer
})}
</span>