From 1564c4bee71f29b7998621727a4ca3dd3e9e26ac Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 12 Apr 2026 12:17:45 -0700 Subject: [PATCH 1/3] add multi site selector for ha on private resources --- messages/en-US.json | 3 +- src/app/[orgId]/settings/logs/access/page.tsx | 6 +- .../[orgId]/settings/logs/connection/page.tsx | 16 +-- .../[orgId]/settings/logs/request/page.tsx | 6 +- .../settings/resources/client/page.tsx | 14 +- src/components/ClientResourcesTable.tsx | 128 ++++++++++++++++-- .../CreateInternalResourceDialog.tsx | 2 +- src/components/EditInternalResourceDialog.tsx | 10 +- src/components/InternalResourceForm.tsx | 123 ++++++++++++----- src/components/LogDataTable.tsx | 6 +- src/components/multi-site-selector.tsx | 117 ++++++++++++++++ src/components/ui/checkbox.tsx | 4 +- 12 files changed, 356 insertions(+), 79 deletions(-) create mode 100644 src/components/multi-site-selector.tsx 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)} + > +
    + {sites.map((site) => ( +
  • + + + +
  • + ))} +
+
+
+ ); +} + 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} > - - + + )); From b5e239d1adb24e494e1892254457182c736a755f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 12 Apr 2026 12:24:52 -0700 Subject: [PATCH 2/3] adjust button size --- src/components/ClientResourcesTable.tsx | 4 ++-- src/components/PendingSitesTable.tsx | 4 ++-- src/components/ShareLinksTable.tsx | 4 ++-- src/components/SitesTable.tsx | 4 ++-- src/components/UserDevicesTable.tsx | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 0f7122c7d..4822f358e 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -143,7 +143,7 @@ function ClientResourceSiteLinks({ {site.siteName} - + ))} @@ -198,7 +198,7 @@ function OverflowSitesPopover({ {site.siteName} - + diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx index 12abcf7c4..f4156603e 100644 --- a/src/components/PendingSitesTable.tsx +++ b/src/components/PendingSitesTable.tsx @@ -352,9 +352,9 @@ export default function PendingSitesTable({ - ); diff --git a/src/components/ShareLinksTable.tsx b/src/components/ShareLinksTable.tsx index efac77df3..333cee03f 100644 --- a/src/components/ShareLinksTable.tsx +++ b/src/components/ShareLinksTable.tsx @@ -144,9 +144,9 @@ export default function ShareLinksTable({ - ); diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index cc02e5d37..4f459ffc1 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -362,9 +362,9 @@ export default function SitesTable({ - ); diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 52f2d1384..58a5ba402 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -373,12 +373,12 @@ export default function UserDevicesTable({ - ) : ( From 0cbcc0c29c0f38b1f0a01c56e0d7ae3569e3f5d3 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 12 Apr 2026 14:58:55 -0700 Subject: [PATCH 3/3] remove extra sites query --- .../siteResource/listAllSiteResourcesByOrg.ts | 4 +- .../settings/resources/client/page.tsx | 3 +- src/components/ClientResourcesTable.tsx | 168 +++++++++--------- .../CreateInternalResourceDialog.tsx | 3 - src/components/EditInternalResourceDialog.tsx | 3 - src/components/InternalResourceForm.tsx | 49 +---- 6 files changed, 97 insertions(+), 133 deletions(-) diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 3495d9767..de9083c2c 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -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)); diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index 4d3b48c6c..f63563cc9 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -73,7 +73,8 @@ export default async function ClientResourcesPage( { siteId: siteResource.siteId, siteName: siteResource.siteName, - siteNiceId: siteResource.siteNiceId + siteNiceId: siteResource.siteNiceId, + online: siteResource.siteOnline } ], siteName: siteResource.siteName, diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 4822f358e..fc1a6a6f3 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -20,7 +20,7 @@ import { ArrowDown01Icon, ArrowUp10Icon, ArrowUpDown, - ArrowUpRight, + ChevronDown, ChevronsUpDownIcon, MoreHorizontal } from "lucide-react"; @@ -38,16 +38,13 @@ 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"; +import { cn } from "@app/lib/cn"; export type InternalResourceSiteRow = { siteId: number; siteName: string; siteNiceId: string; + online: boolean; }; export type InternalResourceRow = { @@ -113,99 +110,106 @@ function isSafeUrlForLink(href: string): boolean { } } -const MAX_SITE_LINKS = 3; +type AggregateSitesStatus = "allOnline" | "partial" | "allOffline"; -function ClientResourceSiteLinks({ - orgId, - sites -}: { - orgId: string; - sites: InternalResourceSiteRow[]; -}) { - if (sites.length === 0) { - return -; +function aggregateSitesStatus( + resourceSites: InternalResourceSiteRow[] +): AggregateSitesStatus { + if (resourceSites.length === 0) { + return "allOffline"; } - const visible = sites.slice(0, MAX_SITE_LINKS); - const overflow = sites.slice(MAX_SITE_LINKS); - - return ( -
- {visible.map((site) => ( - - - - ))} - {overflow.length > 0 ? ( - - ) : null} -
- ); + const onlineCount = resourceSites.filter((rs) => rs.online).length; + if (onlineCount === resourceSites.length) return "allOnline"; + if (onlineCount > 0) return "partial"; + return "allOffline"; } -function OverflowSitesPopover({ +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, - sites + resourceSites }: { orgId: string; - sites: InternalResourceSiteRow[]; + resourceSites: InternalResourceSiteRow[]; }) { - const [open, setOpen] = useState(false); + const t = useTranslations(); + + if (resourceSites.length === 0) { + return -; + } + + const aggregate = aggregateSitesStatus(resourceSites); + const countLabel = t("multiSitesSelectorSitesCount", { + count: resourceSites.length + }); return ( - - + + - - setOpen(true)} - onMouseLeave={() => setOpen(false)} - > -
    - {sites.map((site) => ( -
  • + + + {resourceSites.map((site) => { + const isOnline = site.online; + return ( + - +
+ + {isOnline ? t("online") : t("offline")} + - - ))} - - - + + ); + })} + + ); } @@ -243,8 +247,6 @@ export default function ClientResourcesTable({ useState(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - const { data: sites = [] } = useQuery(orgQueries.sites({ orgId })); - const [isRefreshing, startTransition] = useTransition(); const refreshData = () => { @@ -339,9 +341,9 @@ export default function ClientResourcesTable({ cell: ({ row }) => { const resourceRow = row.original; return ( - ); } @@ -599,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(() => { @@ -614,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(() => { diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 1ad7b3632..c0483e35d 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -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(); @@ -155,7 +153,6 @@ export default function CreateInternalResourceDialog({ 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(); @@ -174,7 +172,6 @@ export default function EditInternalResourceDialog({ variant="edit" open={open} resource={resource} - sites={sites} orgId={orgId} siteResourceId={resource.id} formId="edit-internal-resource-form" diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 6bc807046..0d98fb30b 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -159,18 +159,7 @@ 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, @@ -207,7 +196,6 @@ type InternalResourceFormProps = { variant: "create" | "edit"; resource?: InternalResourceData; open?: boolean; - sites: Site[]; orgId: string; siteResourceId?: number; formId: string; @@ -218,7 +206,6 @@ export function InternalResourceForm({ variant, resource, open, - sites, orgId, siteResourceId, formId, @@ -375,8 +362,6 @@ export function InternalResourceForm({ type FormData = z.infer; - 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 })); @@ -517,7 +502,7 @@ export function InternalResourceForm({ } : { name: "", - siteIds: availableSites[0] ? [availableSites[0].siteId] : [], + siteIds: [], mode: "host", destination: "", alias: null, @@ -539,16 +524,8 @@ export function InternalResourceForm({ const [selectedSites, setSelectedSites] = useState(() => variant === "edit" && resource - ? buildSelectedSitesForResource(resource, sites) - : availableSites[0] - ? [ - { - name: availableSites[0].name, - siteId: availableSites[0].siteId, - type: availableSites[0].type - } - ] - : [] + ? buildSelectedSitesForResource(resource) + : [] ); const form = useForm({ @@ -580,7 +557,7 @@ export function InternalResourceForm({ if (variant === "create" && open) { form.reset({ name: "", - siteIds: availableSites[0] ? [availableSites[0].siteId] : [], + siteIds: [], mode: "host", destination: "", alias: null, @@ -599,23 +576,13 @@ export function InternalResourceForm({ users: [], clients: [] }); - setSelectedSites( - availableSites[0] - ? [ - { - name: availableSites[0].name, - siteId: availableSites[0].siteId, - type: availableSites[0].type - } - ] - : [] - ); + setSelectedSites([]); setTcpPortMode("all"); setUdpPortMode("all"); setTcpCustomPorts(""); setUdpCustomPorts(""); } - }, [variant, open, form, sites]); + }, [variant, open, form]); // Reset when edit dialog opens / resource changes useEffect(() => { @@ -644,7 +611,7 @@ export function InternalResourceForm({ clients: [] }); setSelectedSites( - buildSelectedSitesForResource(resource, sites) + buildSelectedSitesForResource(resource) ); setTcpPortMode( getPortModeFromString(resource.tcpPortRangeString) @@ -667,7 +634,7 @@ export function InternalResourceForm({ previousResourceId.current = resource.id; } } - }, [variant, resource, form, sites]); + }, [variant, resource, form]); // When edit dialog closes, clear previousResourceId so next open (for any resource) resets from fresh data useEffect(() => {