Merge branch 'dev' of github.com:fosrl/pangolin into dev

This commit is contained in:
Owen
2026-06-05 16:12:46 -07:00
22 changed files with 1411 additions and 1046 deletions

View File

@@ -1,128 +1,173 @@
"use client";
import { cn } from "@app/lib/cn";
import { ChevronsUpDown, ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import type { Control, FieldValues, Path } from "react-hook-form";
import { useWatch } from "react-hook-form";
import {
MultiSitesSelector,
formatMultiSitesSelectorLabel
} from "./multi-site-selector";
import { SitesSelector, type Selectedsite } from "./site-selector";
import { Button } from "./ui/button";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "./ui/form";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
type SingleSiteProps = {
multiSite?: false;
selectedSite: Selectedsite | null;
onSiteChange: (site: Selectedsite | null) => void;
};
type MultiSiteProps = {
multiSite: true;
selectedSites: Selectedsite[];
onSitesChange: (sites: Selectedsite[]) => void;
};
export type BrowserGatewayTargetFormProps = {
type BaseProps<T extends FieldValues> = {
control: Control<T>;
orgId: string;
destination: string;
defaultPort: number;
destinationPort: string;
onDestinationChange: (v: string) => void;
onDestinationPortChange: (v: string) => void;
destinationField: Path<T>;
destinationPortField: Path<T>;
learnMoreHref?: string;
} & (SingleSiteProps | MultiSiteProps);
defaultPort: number;
};
export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
type MultiSiteFormProps<T extends FieldValues> = BaseProps<T> & {
multiSite: true;
sitesField: Path<T>;
};
type SingleSiteFormProps<T extends FieldValues> = BaseProps<T> & {
multiSite?: false;
siteField: Path<T>;
};
export type BrowserGatewayTargetFormProps<T extends FieldValues = FieldValues> =
| MultiSiteFormProps<T>
| SingleSiteFormProps<T>;
export function BrowserGatewayTargetForm<T extends FieldValues>(
props: BrowserGatewayTargetFormProps<T>
) {
const t = useTranslations();
const [siteOpen, setSiteOpen] = useState(false);
const siteSelector =
props.multiSite === true ? (
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{formatMultiSitesSelectorLabel(
props.selectedSites,
t
)}
</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">
<MultiSitesSelector
orgId={props.orgId}
selectedSites={props.selectedSites}
onSelectionChange={props.onSitesChange}
/>
</PopoverContent>
</Popover>
) : (
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{props.selectedSite?.name ?? t("siteSelect")}
</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">
<SitesSelector
orgId={props.orgId}
selectedSite={props.selectedSite}
onSelectSite={(site) => {
props.onSiteChange(site);
setSiteOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
const sitesFieldName =
props.multiSite === true ? props.sitesField : props.siteField;
const watchedSites = useWatch({
control: props.control,
name: sitesFieldName
});
const showMultiSiteDisclaimer =
props.multiSite === true &&
((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1;
return (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-semibold">
{t("sites")}
</label>
{siteSelector}
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">
{t("destination")}
</label>
<Input
value={props.destination}
onChange={(e) =>
props.onDestinationChange(e.target.value)
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">{t("port")}</label>
<Input
type="number"
value={props.destinationPort}
onChange={(e) =>
props.onDestinationPortChange(e.target.value)
}
/>
</div>
<div className="grid grid-cols-3 gap-4 items-start">
<FormField
control={props.control}
name={sitesFieldName}
render={({ field }) => (
<FormItem>
<FormLabel>{t("sites")}</FormLabel>
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between font-normal",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
props.multiSite === true
? (
field.value as Selectedsite[]
)?.length === 0 &&
"text-muted-foreground"
: !field.value &&
"text-muted-foreground"
)}
>
<span className="truncate">
{props.multiSite === true
? formatMultiSitesSelectorLabel(
(field.value as Selectedsite[]) ??
[],
t
)
: ((
field.value as Selectedsite | null
)?.name ??
t("siteSelect"))}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
{props.multiSite === true ? (
<MultiSitesSelector
orgId={props.orgId}
selectedSites={
(field.value as Selectedsite[]) ??
[]
}
onSelectionChange={field.onChange}
/>
) : (
<SitesSelector
orgId={props.orgId}
selectedSite={
field.value as Selectedsite | null
}
onSelectSite={(site) => {
field.onChange(site);
setSiteOpen(false);
}}
/>
)}
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={props.control}
name={props.destinationField}
render={({ field }) => (
<FormItem>
<FormLabel>{t("destination")}</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={props.control}
name={props.destinationPortField}
render={({ field }) => (
<FormItem>
<FormLabel>{t("port")}</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{props.multiSite === true && props.selectedSites.length > 1 && (
{showMultiSiteDisclaimer && (
<p className="text-sm text-muted-foreground">
{t("bgTargetMultiSiteDisclaimer")}{" "}
<a

View File

@@ -408,12 +408,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
? t("standaloneHcEditTitle")
: t("standaloneHcCreateTitle");
const description =
mode === "autoSave"
? t("configureHealthCheckDescription", {
target: (props as any).targetAddress
})
: t("standaloneHcDescription");
const description = t("configureHealthCheckDescription");
const disableTabInputs = mode === "autoSave" && !watchedEnabled;
const isSnmpOrIcmp = watchedMode === "snmp" || watchedMode === "icmp";

View File

@@ -1813,9 +1813,9 @@ export function PrivateResourceForm({
{/* Mode */}
<div className="space-y-2">
<SettingsSubsectionTitle>
<p className="font-semibold text-sm">
{t("sshServerMode")}
</SettingsSubsectionTitle>
</p>
<StrategySelect<"standard" | "native">
value={sshServerMode}
options={[
@@ -1870,9 +1870,9 @@ export function PrivateResourceForm({
</div>
<div className="space-y-2">
<SettingsSubsectionTitle>
<p className="font-semibold text-sm">
{t("sshAuthenticationMethod")}
</SettingsSubsectionTitle>
</p>
<FormField
control={form.control}
name="pamMode"
@@ -1965,9 +1965,9 @@ export function PrivateResourceForm({
{/* Daemon Location (standard + push) */}
{showDaemonLocation && (
<div className="space-y-2">
<SettingsSubsectionTitle>
<p className="font-semibold text-sm">
{t("sshAuthDaemonLocation")}
</SettingsSubsectionTitle>
</p>
<FormField
control={form.control}
name="authDaemonMode"

View File

@@ -90,7 +90,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionTitle>
<InfoSectionContent>
<span className="inline-flex items-center">
{resource.mode!.toUpperCase()}
{resource.ssl ? "HTTPS" : "HTTP"}
</span>
</InfoSectionContent>
</InfoSection>

View File

@@ -200,7 +200,7 @@ export function ResourcePoliciesTable({
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${policyRow.orgId}/settings/policies/resource/${policyRow.niceId}`}
href={`/${policyRow.orgId}/settings/policies/resources/public/${policyRow.niceId}`}
>
<DropdownMenuItem>
{t("viewSettings")}
@@ -219,7 +219,7 @@ export function ResourcePoliciesTable({
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${policyRow.orgId}/settings/policies/resource/${policyRow.niceId}`}
href={`/${policyRow.orgId}/settings/policies/resources/public/${policyRow.niceId}`}
>
<Button variant={"outline"}>
{t("edit")}
@@ -288,7 +288,7 @@ export function ResourcePoliciesTable({
onAdd={() =>
startNavigation(() =>
router.push(
`/${orgId}/settings/policies/resource/create`
`/${orgId}/settings/policies/resources/public/create`
)
)
}

View File

@@ -70,7 +70,7 @@ export function SettingsSubsectionHeader({
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("space-y-0.5", className)}>{children}</div>;
return <div className={cn("py-3 space-y-0.5", className)}>{children}</div>;
}
export function SettingsSubsectionTitle({
@@ -80,9 +80,7 @@ export function SettingsSubsectionTitle({
children: React.ReactNode;
className?: string;
}) {
return (
<h3 className={cn("text-sm font-semibold", className)}>{children}</h3>
);
return <h3 className={cn("font-semibold", className)}>{children}</h3>;
}
export function SettingsSubsectionDescription({

View File

@@ -157,7 +157,7 @@ export function LabelsSelector({
/>
<Select defaultValue={randomColor} name="color">
<SelectTrigger className="w-18 [&_[data-name]]:hidden [&_[svg]]:hidden!">
<SelectTrigger className="w-auto min-w-24">
<SelectValue
placeholder={t("selectColor")}
/>

View File

@@ -140,7 +140,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
if (res && res.status === 201) {
const niceId = res.data.data.niceId;
router.push(
`/${org.org.orgId}/settings/policies/resource/${niceId}`
`/${org.org.orgId}/settings/policies/resources/public/${niceId}`
);
toast({
title: t("success"),

View File

@@ -109,7 +109,7 @@ export function EditPolicyNameSectionForm({
if (payload.niceId && payload.niceId !== policy.niceId) {
router.replace(
`/${org.org.orgId}/settings/policies/resource/${payload.niceId}`
`/${org.org.orgId}/settings/policies/resources/public/${payload.niceId}`
);
}

View File

@@ -1,15 +1,12 @@
import { cn } from "@app/lib/cn";
import type { DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { orgQueries } from "@app/lib/queries";
import { CaretSortIcon } from "@radix-ui/react-icons";
import type { ListSitesResponse } from "@server/routers/site";
import { type ListTargetsResponse } from "@server/routers/target";
import type { ArrayElement } from "@server/types/ArrayElement";
import { useQuery } from "@tanstack/react-query";
import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import { useState } from "react";
import { ContainersSelector } from "./ContainersSelector";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
@@ -28,23 +25,21 @@ export type LocalTarget = Omit<
"protocol"
>;
export type ResourceTargetAddressItemProps = {
export type ResourceTargetSiteItemProps = {
getDockerStateForSite: (siteId: number) => DockerState;
updateTarget: (targetId: number, data: Partial<LocalTarget>) => void;
orgId: string;
proxyTarget: LocalTarget;
isHttp: boolean;
refreshContainersForSite: (siteId: number) => void;
};
export function ResourceTargetAddressItem({
export function ResourceTargetSiteItem({
orgId,
getDockerStateForSite,
updateTarget,
proxyTarget,
isHttp,
refreshContainersForSite
}: ResourceTargetAddressItemProps) {
}: ResourceTargetSiteItemProps) {
const t = useTranslations();
const [selectedSite, setSelectedSite] = useState<Pick<
@@ -76,62 +71,78 @@ export function ResourceTargetAddressItem({
});
};
return (
<div
className="flex w-full min-w-0 items-center h-9 border border-input rounded-md"
key={proxyTarget.targetId}
>
{selectedSite && selectedSite.type === "newt" && (
<ContainersSelector
site={selectedSite}
containers={
getDockerStateForSite(selectedSite.siteId).containers
}
isAvailable={
getDockerStateForSite(selectedSite.siteId).isAvailable
}
onContainerSelect={handleContainerSelectForTarget}
onRefresh={() =>
refreshContainersForSite(selectedSite.siteId)
}
/>
)}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
className={cn(
"h-9 min-w-0 flex-1 justify-between px-3 rounded-none hover:bg-transparent",
!proxyTarget.siteId && "text-muted-foreground"
)}
>
<span className="truncate">
{proxyTarget.siteId
? selectedSite?.name
: t("siteSelect")}
</span>
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0">
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={(site) => {
updateTarget(proxyTarget.targetId, {
siteId: site.siteId,
siteType: site.type,
siteName: site.name
});
setSelectedSite(site);
}}
/>
</PopoverContent>
</Popover>
</div>
);
}
export type ResourceTargetAddressItemProps = {
updateTarget: (targetId: number, data: Partial<LocalTarget>) => void;
proxyTarget: LocalTarget;
isHttp: boolean;
};
export function ResourceTargetAddressItem({
updateTarget,
proxyTarget,
isHttp
}: ResourceTargetAddressItemProps) {
return (
<div className="flex items-center w-full" key={proxyTarget.targetId}>
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
{selectedSite && selectedSite.type === "newt" && (
<ContainersSelector
site={selectedSite}
containers={
getDockerStateForSite(selectedSite.siteId)
.containers
}
isAvailable={
getDockerStateForSite(selectedSite.siteId)
.isAvailable
}
onContainerSelect={handleContainerSelectForTarget}
onRefresh={() =>
refreshContainersForSite(selectedSite.siteId)
}
/>
)}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
className={cn(
"w-45 justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
"",
!proxyTarget.siteId && "text-muted-foreground"
)}
>
<span className="truncate max-w-37.5">
{proxyTarget.siteId
? selectedSite?.name
: t("siteSelect")}
</span>
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0">
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={(site) => {
updateTarget(proxyTarget.targetId, {
siteId: site.siteId,
siteType: site.type,
siteName: site.name
});
setSelectedSite(site);
}}
/>
</PopoverContent>
</Popover>
{isHttp && (
<Select
defaultValue={proxyTarget.method ?? "http"}
@@ -142,7 +153,7 @@ export function ResourceTargetAddressItem({
})
}
>
<SelectTrigger className="h-8 px-2 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-none">
<SelectTrigger className="h-9 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-none mr-0 pr-0">
{proxyTarget.method || "http"}
</SelectTrigger>
<SelectContent>
@@ -154,7 +165,7 @@ export function ResourceTargetAddressItem({
)}
{isHttp && (
<div className="flex items-center justify-center px-2 h-9">
<div className="flex items-center justify-center h-9 mr-0 pl-1">
{"://"}
</div>
)}
@@ -162,7 +173,7 @@ export function ResourceTargetAddressItem({
<Input
defaultValue={proxyTarget.ip}
placeholder="Host"
className="flex-1 min-w-30 px-2 border-none placeholder-gray-400 rounded-xs"
className="flex-1 min-w-30 border-none placeholder-gray-400 rounded-xs"
onBlur={(e) => {
const input = e.target.value.trim();
const hasProtocol = /^(https?|h2c):\/\//.test(input);
@@ -195,7 +206,7 @@ export function ResourceTargetAddressItem({
}
}}
/>
<div className="flex items-center justify-center px-2 h-9">
<div className="flex items-center justify-center h-9 mr-0">
{":"}
</div>
<Input