Merge branch 'private-http' of github.com:fosrl/pangolin into private-http

This commit is contained in:
Owen
2026-04-12 17:47:59 -07:00
17 changed files with 343 additions and 102 deletions

View File

@@ -1837,6 +1837,7 @@
"createInternalResourceDialogName": "Name",
"createInternalResourceDialogSite": "Site",
"selectSite": "Select site...",
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
"noSitesFound": "No sites found.",
"createInternalResourceDialogProtocol": "Protocol",
"createInternalResourceDialogTcp": "TCP",
@@ -2674,7 +2675,7 @@
"editInternalResourceDialogAddUsers": "Add Users",
"editInternalResourceDialogAddClients": "Add Clients",
"editInternalResourceDialogDestinationLabel": "Destination",
"editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it, then complete the settings that apply to your setup.",
"editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it. Selecting multiple sites will create a high availability resource that can be accessed from any of the selected sites.",
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
"createInternalResourceDialogHttpConfiguration": "HTTP configuration",
"createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.",

View File

@@ -76,6 +76,7 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
siteName: string;
siteNiceId: string;
siteAddress: string | null;
siteOnline: boolean;
})[];
}>;
@@ -106,7 +107,8 @@ function querySiteResourcesBase() {
fullDomain: siteResources.fullDomain,
siteName: sites.name,
siteNiceId: sites.niceId,
siteAddress: sites.address
siteAddress: sites.address,
siteOnline: sites.online
})
.from(siteResources)
.innerJoin(sites, eq(siteResources.siteId, sites.siteId));

View File

@@ -471,11 +471,7 @@ export default function GeneralPage() {
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
}
>
<Button
variant="outline"
size="sm"
className="text-xs h-6"
>
<Button variant="outline" size="sm">
{row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>

View File

@@ -451,11 +451,7 @@ export default function ConnectionLogsPage() {
<Link
href={`/${row.original.orgId}/settings/resources/client/?query=${row.original.resourceNiceId}`}
>
<Button
variant="outline"
size="sm"
className="text-xs h-6"
>
<Button variant="outline" size="sm">
{row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
@@ -497,11 +493,7 @@ export default function ConnectionLogsPage() {
<Link
href={`/${row.original.orgId}/settings/clients/${clientType}/${row.original.clientNiceId}`}
>
<Button
variant="outline"
size="sm"
className="text-xs h-6"
>
<Button variant="outline" size="sm">
<Laptop className="mr-1 h-3 w-3" />
{row.original.clientName}
<ArrowUpRight className="ml-2 h-3 w-3" />
@@ -675,9 +667,7 @@ export default function ConnectionLogsPage() {
<div>
<strong>Ended At:</strong>{" "}
{row.endedAt
? new Date(
row.endedAt * 1000
).toLocaleString()
? new Date(row.endedAt * 1000).toLocaleString()
: "Active"}
</div>
<div>

View File

@@ -519,11 +519,7 @@ export default function GeneralPage() {
}
onClick={(e) => e.stopPropagation()}
>
<Button
variant="outline"
size="sm"
className="text-xs h-6"
>
<Button variant="outline" size="sm">
{row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>

View File

@@ -60,21 +60,30 @@ export default async function ClientResourcesPage(
const normalizedMode =
rawMode === "https"
? ("http" as const)
: rawMode === "host" || rawMode === "cidr" || rawMode === "http"
: rawMode === "host" ||
rawMode === "cidr" ||
rawMode === "http"
? rawMode
: ("host" as const);
return {
id: siteResource.siteResourceId,
name: siteResource.name,
orgId: params.orgId,
sites: [
{
siteId: siteResource.siteId,
siteName: siteResource.siteName,
siteNiceId: siteResource.siteNiceId,
online: siteResource.siteOnline
}
],
siteName: siteResource.siteName,
siteAddress: siteResource.siteAddress || null,
mode: normalizedMode,
scheme:
siteResource.scheme ??
(rawMode === "https" ? ("https" as const) : null),
ssl:
siteResource.ssl === true || rawMode === "https",
ssl: siteResource.ssl === true || rawMode === "https",
// protocol: siteResource.protocol,
// proxyPort: siteResource.proxyPort,
siteId: siteResource.siteId,

View File

@@ -20,7 +20,7 @@ import {
ArrowDown01Icon,
ArrowUp10Icon,
ArrowUpDown,
ArrowUpRight,
ChevronDown,
ChevronsUpDownIcon,
MoreHorizontal
} from "lucide-react";
@@ -38,11 +38,20 @@ import { ControlledDataTable } from "./ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useDebouncedCallback } from "use-debounce";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { cn } from "@app/lib/cn";
export type InternalResourceSiteRow = {
siteId: number;
siteName: string;
siteNiceId: string;
online: boolean;
};
export type InternalResourceRow = {
id: number;
name: string;
orgId: string;
sites: InternalResourceSiteRow[];
siteName: string;
siteAddress: string | null;
// mode: "host" | "cidr" | "port";
@@ -101,6 +110,109 @@ function isSafeUrlForLink(href: string): boolean {
}
}
type AggregateSitesStatus = "allOnline" | "partial" | "allOffline";
function aggregateSitesStatus(
resourceSites: InternalResourceSiteRow[]
): AggregateSitesStatus {
if (resourceSites.length === 0) {
return "allOffline";
}
const onlineCount = resourceSites.filter((rs) => rs.online).length;
if (onlineCount === resourceSites.length) return "allOnline";
if (onlineCount > 0) return "partial";
return "allOffline";
}
function aggregateStatusDotClass(status: AggregateSitesStatus): string {
switch (status) {
case "allOnline":
return "bg-green-500";
case "partial":
return "bg-yellow-500";
case "allOffline":
default:
return "bg-gray-500";
}
}
function ClientResourceSitesStatusCell({
orgId,
resourceSites
}: {
orgId: string;
resourceSites: InternalResourceSiteRow[];
}) {
const t = useTranslations();
if (resourceSites.length === 0) {
return <span>-</span>;
}
const aggregate = aggregateSitesStatus(resourceSites);
const countLabel = t("multiSitesSelectorSitesCount", {
count: resourceSites.length
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex h-8 items-center gap-2 px-0 font-normal"
>
<div
className={cn(
"h-2 w-2 shrink-0 rounded-full",
aggregateStatusDotClass(aggregate)
)}
/>
<span className="text-sm tabular-nums">{countLabel}</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-56">
{resourceSites.map((site) => {
const isOnline = site.online;
return (
<DropdownMenuItem key={site.siteId} asChild>
<Link
href={`/${orgId}/settings/sites/${site.siteNiceId}`}
className="flex cursor-pointer items-center justify-between gap-4"
>
<div className="flex min-w-0 items-center gap-2">
<div
className={cn(
"h-2 w-2 shrink-0 rounded-full",
isOnline
? "bg-green-500"
: "bg-gray-500"
)}
/>
<span className="truncate">
{site.siteName}
</span>
</div>
<span
className={cn(
"shrink-0 capitalize",
isOnline
? "text-green-600"
: "text-muted-foreground"
)}
>
{isOnline ? t("online") : t("offline")}
</span>
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
type ClientResourcesTableProps = {
internalResources: InternalResourceRow[];
orgId: string;
@@ -135,8 +247,6 @@ export default function ClientResourcesTable({
useState<InternalResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const { data: sites = [] } = useQuery(orgQueries.sites({ orgId }));
const [isRefreshing, startTransition] = useTransition();
const refreshData = () => {
@@ -223,20 +333,18 @@ export default function ClientResourcesTable({
}
},
{
accessorKey: "siteName",
friendlyName: t("site"),
header: () => <span className="p-3">{t("site")}</span>,
id: "sites",
accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", ") || row.siteName,
friendlyName: t("sites"),
header: () => <span className="p-3">{t("sites")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<Link
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
>
<Button variant="outline">
{resourceRow.siteName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
<ClientResourceSitesStatusCell
orgId={resourceRow.orgId}
resourceSites={resourceRow.sites}
/>
);
}
},
@@ -493,7 +601,6 @@ export default function ClientResourcesTable({
setOpen={setIsEditDialogOpen}
resource={editingResource}
orgId={orgId}
sites={sites}
onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
@@ -508,7 +615,6 @@ export default function ClientResourcesTable({
open={isCreateDialogOpen}
setOpen={setIsCreateDialogOpen}
orgId={orgId}
sites={sites}
onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {

View File

@@ -31,7 +31,6 @@ type CreateInternalResourceDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
orgId: string;
sites: Site[];
onSuccess?: () => void;
};
@@ -39,7 +38,6 @@ export default function CreateInternalResourceDialog({
open,
setOpen,
orgId,
sites,
onSuccess
}: CreateInternalResourceDialogProps) {
const t = useTranslations();
@@ -69,7 +67,7 @@ export default function CreateInternalResourceDialog({
`/org/${orgId}/site-resource`,
{
name: data.name,
siteId: data.siteId,
siteId: data.siteIds[0],
mode: data.mode,
destination: data.destination,
enabled: true,
@@ -156,7 +154,6 @@ export default function CreateInternalResourceDialog({
<InternalResourceForm
variant="create"
open={open}
sites={sites}
orgId={orgId}
formId="create-internal-resource-form"
onSubmit={handleSubmit}

View File

@@ -34,7 +34,6 @@ type EditInternalResourceDialogProps = {
setOpen: (val: boolean) => void;
resource: InternalResourceData;
orgId: string;
sites: Site[];
onSuccess?: () => void;
};
@@ -43,7 +42,6 @@ export default function EditInternalResourceDialog({
setOpen,
resource,
orgId,
sites,
onSuccess
}: EditInternalResourceDialogProps) {
const t = useTranslations();
@@ -71,7 +69,7 @@ export default function EditInternalResourceDialog({
await api.post(`/site-resource/${resource.id}`, {
name: data.name,
siteId: data.siteId,
siteId: data.siteIds[0],
mode: data.mode,
niceId: data.niceId,
destination: data.destination,
@@ -79,8 +77,12 @@ export default function EditInternalResourceDialog({
scheme: data.scheme,
ssl: data.ssl ?? false,
destinationPort: data.httpHttpsPort ?? null,
domainId: data.httpConfigDomainId ? data.httpConfigDomainId : undefined,
subdomain: data.httpConfigSubdomain ? data.httpConfigSubdomain : undefined
domainId: data.httpConfigDomainId
? data.httpConfigDomainId
: undefined,
subdomain: data.httpConfigSubdomain
? data.httpConfigSubdomain
: undefined
}),
...(data.mode === "host" && {
alias:
@@ -171,7 +173,6 @@ export default function EditInternalResourceDialog({
variant="edit"
open={open}
resource={resource}
sites={sites}
orgId={orgId}
siteResourceId={resource.id}
formId="edit-internal-resource-form"

View File

@@ -46,7 +46,11 @@ import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { SitesSelector, type Selectedsite } from "./site-selector";
import {
MultiSitesSelector,
formatMultiSitesSelectorLabel
} from "./multi-site-selector";
import type { Selectedsite } from "./site-selector";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { MachinesSelector } from "./machines-selector";
import DomainPicker from "@app/components/DomainPicker";
@@ -153,9 +157,21 @@ export type InternalResourceData = {
const tagSchema = z.object({ id: z.string(), text: z.string() });
function buildSelectedSitesForResource(
resource: InternalResourceData,
): Selectedsite[] {
return [
{
name: resource.siteName,
siteId: resource.siteId,
type: "newt"
}
];
}
export type InternalResourceFormValues = {
name: string;
siteId: number;
siteIds: number[];
mode: InternalResourceMode;
destination: string;
alias?: string | null;
@@ -180,7 +196,6 @@ type InternalResourceFormProps = {
variant: "create" | "edit";
resource?: InternalResourceData;
open?: boolean;
sites: Site[];
orgId: string;
siteResourceId?: number;
formId: string;
@@ -192,7 +207,6 @@ export function InternalResourceForm({
variant,
resource,
open,
sites,
orgId,
siteResourceId,
formId,
@@ -275,13 +289,14 @@ export function InternalResourceForm({
? "createInternalResourceDialogHttpConfigurationDescription"
: "editInternalResourceDialogHttpConfigurationDescription";
const siteIdsSchema = siteRequiredKey
? z.array(z.number().int().positive()).min(1, t(siteRequiredKey))
: z.array(z.number().int().positive()).min(1);
const formSchema = z
.object({
name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)),
siteId: z
.number()
.int()
.positive(siteRequiredKey ? t(siteRequiredKey) : undefined),
siteIds: siteIdsSchema,
mode: z.enum(["host", "cidr", "http"]),
destination: z
.string()
@@ -350,8 +365,6 @@ export function InternalResourceForm({
type FormData = z.infer<typeof formSchema>;
const availableSites = sites.filter((s) => s.type === "newt");
const rolesQuery = useQuery(orgQueries.roles({ orgId }));
const usersQuery = useQuery(orgQueries.users({ orgId }));
const clientsQuery = useQuery(orgQueries.machineClients({ orgId }));
@@ -470,7 +483,7 @@ export function InternalResourceForm({
variant === "edit" && resource
? {
name: resource.name,
siteId: resource.siteId,
siteIds: [resource.siteId],
mode: resource.mode ?? "host",
destination: resource.destination ?? "",
alias: resource.alias ?? null,
@@ -492,7 +505,7 @@ export function InternalResourceForm({
}
: {
name: "",
siteId: availableSites[0]?.siteId ?? 0,
siteIds: [],
mode: "host",
destination: "",
alias: null,
@@ -512,8 +525,10 @@ export function InternalResourceForm({
clients: []
};
const [selectedSite, setSelectedSite] = useState<Selectedsite>(
availableSites[0]
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>(() =>
variant === "edit" && resource
? buildSelectedSitesForResource(resource)
: []
);
const form = useForm<FormData>({
@@ -545,7 +560,7 @@ export function InternalResourceForm({
if (variant === "create" && open) {
form.reset({
name: "",
siteId: availableSites[0]?.siteId ?? 0,
siteIds: [],
mode: "host",
destination: "",
alias: null,
@@ -564,12 +579,13 @@ export function InternalResourceForm({
users: [],
clients: []
});
setSelectedSites([]);
setTcpPortMode("all");
setUdpPortMode("all");
setTcpCustomPorts("");
setUdpCustomPorts("");
}
}, [variant, open]);
}, [variant, open, form]);
// Reset when edit dialog opens / resource changes
useEffect(() => {
@@ -578,7 +594,7 @@ export function InternalResourceForm({
if (resourceChanged) {
form.reset({
name: resource.name,
siteId: resource.siteId,
siteIds: [resource.siteId],
mode: resource.mode ?? "host",
destination: resource.destination ?? "",
alias: resource.alias ?? null,
@@ -597,6 +613,9 @@ export function InternalResourceForm({
users: [],
clients: []
});
setSelectedSites(
buildSelectedSitesForResource(resource)
);
setTcpPortMode(
getPortModeFromString(resource.tcpPortRangeString)
);
@@ -658,8 +677,10 @@ export function InternalResourceForm({
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => {
const siteIds = values.siteIds;
onSubmit({
...values,
siteIds,
clients: (values.clients ?? []).map((c) => ({
id: c.clientId.toString(),
text: c.name
@@ -736,11 +757,11 @@ export function InternalResourceForm({
<div className="min-w-0 col-span-1">
<FormField
control={form.control}
name="siteId"
name="siteIds"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t("site")}
{t("sites")}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
@@ -750,40 +771,41 @@ export function InternalResourceForm({
role="combobox"
className={cn(
"w-full justify-between",
!field.value &&
selectedSites.length ===
0 &&
"text-muted-foreground"
)}
>
{field.value
? availableSites.find(
(s) =>
s.siteId ===
field.value
)?.name
: t(
"selectSite"
)}
<span className="truncate text-left">
{formatMultiSitesSelectorLabel(
selectedSites,
t
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<SitesSelector
<MultiSitesSelector
orgId={orgId}
selectedSite={
selectedSite
selectedSites={
selectedSites
}
filterTypes={[
"newt"
]}
onSelectSite={(
site
onSelectionChange={(
sites
) => {
setSelectedSite(
site
setSelectedSites(
sites
);
field.onChange(
site.siteId
sites.map(
(s) =>
s.siteId
)
);
}}
/>

View File

@@ -405,7 +405,11 @@ export function LogDataTable<TData, TValue>({
onClick={() =>
!disabled && onExport()
}
disabled={isExporting || disabled || isExportDisabled}
disabled={
isExporting ||
disabled ||
isExportDisabled
}
>
{isExporting ? (
<Loader className="mr-2 size-4 animate-spin" />

View File

@@ -352,9 +352,9 @@ export default function PendingSitesTable({
<Link
href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
>
<Button variant="outline">
<Button variant="outline" size="sm">
{originalRow.exitNodeName}
<ArrowUpRight className="ml-2 h-4 w-4" />
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);

View File

@@ -144,9 +144,9 @@ export default function ShareLinksTable({
<Link
href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}
>
<Button variant="outline">
<Button variant="outline" size="sm">
{r.resourceName}
<ArrowUpRight className="ml-2 h-4 w-4" />
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);

View File

@@ -362,9 +362,9 @@ export default function SitesTable({
<Link
href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
>
<Button variant="outline">
<Button variant="outline" size="sm">
{originalRow.exitNodeName}
<ArrowUpRight className="ml-2 h-4 w-4" />
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);

View File

@@ -373,12 +373,12 @@ export default function UserDevicesTable({
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline">
<Button variant="outline" size="sm">
{getUserDisplayName({
email: r.userEmail,
username: r.username
}) || r.userId}
<ArrowUpRight className="ml-2 h-4 w-4" />
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
) : (

View File

@@ -0,0 +1,117 @@
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
import { Checkbox } from "./ui/checkbox";
import { useTranslations } from "next-intl";
import { useDebounce } from "use-debounce";
import type { Selectedsite } from "./site-selector";
export type MultiSitesSelectorProps = {
orgId: string;
selectedSites: Selectedsite[];
onSelectionChange: (sites: Selectedsite[]) => void;
filterTypes?: string[];
};
export function formatMultiSitesSelectorLabel(
selectedSites: Selectedsite[],
t: (key: string, values?: { count: number }) => string
): string {
if (selectedSites.length === 0) {
return t("selectSites");
}
if (selectedSites.length === 1) {
return selectedSites[0]!.name;
}
return t("multiSitesSelectorSitesCount", {
count: selectedSites.length
});
}
export function MultiSitesSelector({
orgId,
selectedSites,
onSelectionChange,
filterTypes
}: MultiSitesSelectorProps) {
const t = useTranslations();
const [siteSearchQuery, setSiteSearchQuery] = useState("");
const [debouncedQuery] = useDebounce(siteSearchQuery, 150);
const { data: sites = [] } = useQuery(
orgQueries.sites({
orgId,
query: debouncedQuery,
perPage: 10
})
);
const sitesShown = useMemo(() => {
const base = filterTypes
? sites.filter((s) => filterTypes.includes(s.type))
: [...sites];
if (debouncedQuery.trim().length === 0 && selectedSites.length > 0) {
const selectedNotInBase = selectedSites.filter(
(sel) => !base.some((s) => s.siteId === sel.siteId)
);
return [...selectedNotInBase, ...base];
}
return base;
}, [debouncedQuery, sites, selectedSites, filterTypes]);
const selectedIds = useMemo(
() => new Set(selectedSites.map((s) => s.siteId)),
[selectedSites]
);
const toggleSite = (site: Selectedsite) => {
if (selectedIds.has(site.siteId)) {
onSelectionChange(
selectedSites.filter((s) => s.siteId !== site.siteId)
);
} else {
onSelectionChange([...selectedSites, site]);
}
};
return (
<Command shouldFilter={false}>
<CommandInput
placeholder={t("siteSearch")}
value={siteSearchQuery}
onValueChange={(v) => setSiteSearchQuery(v)}
/>
<CommandList>
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
<CommandGroup>
{sitesShown.map((site) => (
<CommandItem
key={site.siteId}
value={`${site.siteId}:${site.name}`}
onSelect={() => {
toggleSite(site);
}}
>
<Checkbox
className="pointer-events-none shrink-0"
checked={selectedIds.has(site.siteId)}
onCheckedChange={() => {}}
aria-hidden
tabIndex={-1}
/>
<span className="truncate">{site.name}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}

View File

@@ -43,8 +43,8 @@ const Checkbox = React.forwardRef<
className={cn(checkboxVariants({ variant }), className)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
<Check className="h-4 w-4" />
<CheckboxPrimitive.Indicator className="flex items-center justify-center">
<Check className="h-4 w-4 text-white" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));