diff --git a/messages/en-US.json b/messages/en-US.json index e4bcbd623..03cdc3ddb 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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", @@ -2673,7 +2674,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.", diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index a0f1b5386..826e11c17 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -471,11 +471,7 @@ export default function GeneralPage() { : `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}` } > - diff --git a/src/app/[orgId]/settings/logs/connection/page.tsx b/src/app/[orgId]/settings/logs/connection/page.tsx index e15708f8e..6eaedff5a 100644 --- a/src/app/[orgId]/settings/logs/connection/page.tsx +++ b/src/app/[orgId]/settings/logs/connection/page.tsx @@ -451,11 +451,7 @@ export default function ConnectionLogsPage() { - @@ -497,11 +493,7 @@ export default function ConnectionLogsPage() { - diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index 537124ad1..4d3b48c6c 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -60,21 +60,29 @@ 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 + } + ], 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, diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index c531d506d..0f7122c7d 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -38,11 +38,23 @@ import { ControlledDataTable } from "./ui/controlled-data-table"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; + +export type InternalResourceSiteRow = { + siteId: number; + siteName: string; + siteNiceId: string; +}; export type InternalResourceRow = { id: number; name: string; orgId: string; + sites: InternalResourceSiteRow[]; siteName: string; siteAddress: string | null; // mode: "host" | "cidr" | "port"; @@ -101,6 +113,102 @@ function isSafeUrlForLink(href: string): boolean { } } +const MAX_SITE_LINKS = 3; + +function ClientResourceSiteLinks({ + orgId, + sites +}: { + orgId: string; + sites: InternalResourceSiteRow[]; +}) { + if (sites.length === 0) { + return -; + } + const visible = sites.slice(0, MAX_SITE_LINKS); + const overflow = sites.slice(MAX_SITE_LINKS); + + return ( +
+ {visible.map((site) => ( + + + + ))} + {overflow.length > 0 ? ( + + ) : null} +
+ ); +} + +function OverflowSitesPopover({ + orgId, + sites +}: { + orgId: string; + sites: InternalResourceSiteRow[]; +}) { + const [open, setOpen] = useState(false); + + return ( + + + + + setOpen(true)} + onMouseLeave={() => setOpen(false)} + > + + + + ); +} + type ClientResourcesTableProps = { internalResources: InternalResourceRow[]; orgId: string; @@ -223,20 +331,18 @@ export default function ClientResourcesTable({ } }, { - accessorKey: "siteName", - friendlyName: t("site"), - header: () => {t("site")}, + id: "sites", + accessorFn: (row) => + row.sites.map((s) => s.siteName).join(", ") || row.siteName, + friendlyName: t("sites"), + header: () => {t("sites")}, cell: ({ row }) => { const resourceRow = row.original; return ( - - - + ); } }, diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 177571dff..1ad7b3632 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -68,7 +68,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, diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 8e8795a0d..e7bdfb795 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -70,7 +70,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, @@ -78,8 +78,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: diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index d669c3b15..6bc807046 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -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,32 @@ export type InternalResourceData = { const tagSchema = z.object({ id: z.string(), text: z.string() }); +function buildSelectedSitesForResource( + resource: InternalResourceData, + catalog: Site[] +): Selectedsite[] { + const fromCatalog = catalog.find((s) => s.siteId === resource.siteId); + if (fromCatalog) { + return [ + { + name: fromCatalog.name, + siteId: fromCatalog.siteId, + type: fromCatalog.type + } + ]; + } + 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; @@ -272,13 +299,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() @@ -467,7 +495,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, @@ -489,7 +517,7 @@ export function InternalResourceForm({ } : { name: "", - siteId: availableSites[0]?.siteId ?? 0, + siteIds: availableSites[0] ? [availableSites[0].siteId] : [], mode: "host", destination: "", alias: null, @@ -509,8 +537,18 @@ export function InternalResourceForm({ clients: [] }; - const [selectedSite, setSelectedSite] = useState( - availableSites[0] + const [selectedSites, setSelectedSites] = useState(() => + variant === "edit" && resource + ? buildSelectedSitesForResource(resource, sites) + : availableSites[0] + ? [ + { + name: availableSites[0].name, + siteId: availableSites[0].siteId, + type: availableSites[0].type + } + ] + : [] ); const form = useForm({ @@ -542,7 +580,7 @@ export function InternalResourceForm({ if (variant === "create" && open) { form.reset({ name: "", - siteId: availableSites[0]?.siteId ?? 0, + siteIds: availableSites[0] ? [availableSites[0].siteId] : [], mode: "host", destination: "", alias: null, @@ -561,12 +599,23 @@ export function InternalResourceForm({ users: [], clients: [] }); + setSelectedSites( + availableSites[0] + ? [ + { + name: availableSites[0].name, + siteId: availableSites[0].siteId, + type: availableSites[0].type + } + ] + : [] + ); setTcpPortMode("all"); setUdpPortMode("all"); setTcpCustomPorts(""); setUdpCustomPorts(""); } - }, [variant, open]); + }, [variant, open, form, sites]); // Reset when edit dialog opens / resource changes useEffect(() => { @@ -575,7 +624,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, @@ -594,6 +643,9 @@ export function InternalResourceForm({ users: [], clients: [] }); + setSelectedSites( + buildSelectedSitesForResource(resource, sites) + ); setTcpPortMode( getPortModeFromString(resource.tcpPortRangeString) ); @@ -615,7 +667,7 @@ export function InternalResourceForm({ previousResourceId.current = resource.id; } } - }, [variant, resource, form]); + }, [variant, resource, form, sites]); // When edit dialog closes, clear previousResourceId so next open (for any resource) resets from fresh data useEffect(() => { @@ -651,8 +703,10 @@ export function InternalResourceForm({
{ + const siteIds = values.siteIds; onSubmit({ ...values, + siteIds, clients: (values.clients ?? []).map((c) => ({ id: c.clientId.toString(), text: c.name @@ -729,11 +783,11 @@ export function InternalResourceForm({
( - {t("site")} + {t("sites")} @@ -743,40 +797,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" - )} + + {formatMultiSitesSelectorLabel( + selectedSites, + t + )} + - { - setSelectedSite( - site + setSelectedSites( + sites ); field.onChange( - site.siteId + sites.map( + (s) => + s.siteId + ) ); }} /> diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx index 3a53a859f..14e87ff75 100644 --- a/src/components/LogDataTable.tsx +++ b/src/components/LogDataTable.tsx @@ -405,7 +405,11 @@ export function LogDataTable({ onClick={() => !disabled && onExport() } - disabled={isExporting || disabled || isExportDisabled} + disabled={ + isExporting || + disabled || + isExportDisabled + } > {isExporting ? ( diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx new file mode 100644 index 000000000..407e3b3e1 --- /dev/null +++ b/src/components/multi-site-selector.tsx @@ -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 ( + + setSiteSearchQuery(v)} + /> + + {t("siteNotFound")} + + {sitesShown.map((site) => ( + { + toggleSite(site); + }} + > + {}} + aria-hidden + tabIndex={-1} + /> + {site.name} + + ))} + + + + ); +} diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index 261655bb0..5cffd8978 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -43,8 +43,8 @@ const Checkbox = React.forwardRef< className={cn(checkboxVariants({ variant }), className)} {...props} > - - + + ));