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", "createInternalResourceDialogName": "Name",
"createInternalResourceDialogSite": "Site", "createInternalResourceDialogSite": "Site",
"selectSite": "Select site...", "selectSite": "Select site...",
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
"noSitesFound": "No sites found.", "noSitesFound": "No sites found.",
"createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogProtocol": "Protocol",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2674,7 +2675,7 @@
"editInternalResourceDialogAddUsers": "Add Users", "editInternalResourceDialogAddUsers": "Add Users",
"editInternalResourceDialogAddClients": "Add Clients", "editInternalResourceDialogAddClients": "Add Clients",
"editInternalResourceDialogDestinationLabel": "Destination", "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.", "editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
"createInternalResourceDialogHttpConfiguration": "HTTP configuration", "createInternalResourceDialogHttpConfiguration": "HTTP configuration",
"createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.", "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; siteName: string;
siteNiceId: string; siteNiceId: string;
siteAddress: string | null; siteAddress: string | null;
siteOnline: boolean;
})[]; })[];
}>; }>;
@@ -106,7 +107,8 @@ function querySiteResourcesBase() {
fullDomain: siteResources.fullDomain, fullDomain: siteResources.fullDomain,
siteName: sites.name, siteName: sites.name,
siteNiceId: sites.niceId, siteNiceId: sites.niceId,
siteAddress: sites.address siteAddress: sites.address,
siteOnline: sites.online
}) })
.from(siteResources) .from(siteResources)
.innerJoin(sites, eq(siteResources.siteId, sites.siteId)); .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}` : `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
} }
> >
<Button <Button variant="outline" size="sm">
variant="outline"
size="sm"
className="text-xs h-6"
>
{row.original.resourceName} {row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-3 w-3" />
</Button> </Button>

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ import {
ArrowDown01Icon, ArrowDown01Icon,
ArrowUp10Icon, ArrowUp10Icon,
ArrowUpDown, ArrowUpDown,
ArrowUpRight, ChevronDown,
ChevronsUpDownIcon, ChevronsUpDownIcon,
MoreHorizontal MoreHorizontal
} from "lucide-react"; } from "lucide-react";
@@ -38,11 +38,20 @@ import { ControlledDataTable } from "./ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
import { cn } from "@app/lib/cn";
export type InternalResourceSiteRow = {
siteId: number;
siteName: string;
siteNiceId: string;
online: boolean;
};
export type InternalResourceRow = { export type InternalResourceRow = {
id: number; id: number;
name: string; name: string;
orgId: string; orgId: string;
sites: InternalResourceSiteRow[];
siteName: string; siteName: string;
siteAddress: string | null; siteAddress: string | null;
// mode: "host" | "cidr" | "port"; // 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 = { type ClientResourcesTableProps = {
internalResources: InternalResourceRow[]; internalResources: InternalResourceRow[];
orgId: string; orgId: string;
@@ -135,8 +247,6 @@ export default function ClientResourcesTable({
useState<InternalResourceRow | null>(); useState<InternalResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const { data: sites = [] } = useQuery(orgQueries.sites({ orgId }));
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const refreshData = () => { const refreshData = () => {
@@ -223,20 +333,18 @@ export default function ClientResourcesTable({
} }
}, },
{ {
accessorKey: "siteName", id: "sites",
friendlyName: t("site"), accessorFn: (row) =>
header: () => <span className="p-3">{t("site")}</span>, row.sites.map((s) => s.siteName).join(", ") || row.siteName,
friendlyName: t("sites"),
header: () => <span className="p-3">{t("sites")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
<Link <ClientResourceSitesStatusCell
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`} orgId={resourceRow.orgId}
> resourceSites={resourceRow.sites}
<Button variant="outline"> />
{resourceRow.siteName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
); );
} }
}, },
@@ -493,7 +601,6 @@ export default function ClientResourcesTable({
setOpen={setIsEditDialogOpen} setOpen={setIsEditDialogOpen}
resource={editingResource} resource={editingResource}
orgId={orgId} orgId={orgId}
sites={sites}
onSuccess={() => { onSuccess={() => {
// Delay refresh to allow modal to close smoothly // Delay refresh to allow modal to close smoothly
setTimeout(() => { setTimeout(() => {
@@ -508,7 +615,6 @@ export default function ClientResourcesTable({
open={isCreateDialogOpen} open={isCreateDialogOpen}
setOpen={setIsCreateDialogOpen} setOpen={setIsCreateDialogOpen}
orgId={orgId} orgId={orgId}
sites={sites}
onSuccess={() => { onSuccess={() => {
// Delay refresh to allow modal to close smoothly // Delay refresh to allow modal to close smoothly
setTimeout(() => { setTimeout(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -373,12 +373,12 @@ export default function UserDevicesTable({
<Link <Link
href={`/${r.orgId}/settings/access/users/${r.userId}`} href={`/${r.orgId}/settings/access/users/${r.userId}`}
> >
<Button variant="outline"> <Button variant="outline" size="sm">
{getUserDisplayName({ {getUserDisplayName({
email: r.userEmail, email: r.userEmail,
username: r.username username: r.username
}) || r.userId} }) || r.userId}
<ArrowUpRight className="ml-2 h-4 w-4" /> <ArrowUpRight className="ml-2 h-3 w-3" />
</Button> </Button>
</Link> </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)} className={cn(checkboxVariants({ variant }), className)}
{...props} {...props}
> >
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current"> <CheckboxPrimitive.Indicator className="flex items-center justify-center">
<Check className="h-4 w-4" /> <Check className="h-4 w-4 text-white" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
)); ));