mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-13 13:26:36 +00:00
add multi site selector for ha on private resources
This commit is contained in:
@@ -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 <span>-</span>;
|
||||
}
|
||||
const visible = sites.slice(0, MAX_SITE_LINKS);
|
||||
const overflow = sites.slice(MAX_SITE_LINKS);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{visible.map((site) => (
|
||||
<Link
|
||||
key={site.siteId}
|
||||
href={`/${orgId}/settings/sites/${site.siteNiceId}`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full gap-1"
|
||||
>
|
||||
<span className="max-w-[10rem] truncate">
|
||||
{site.siteName}
|
||||
</span>
|
||||
<ArrowUpRight className="h-3.5 w-3.5 shrink-0" />
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
{overflow.length > 0 ? (
|
||||
<OverflowSitesPopover orgId={orgId} sites={overflow} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverflowSitesPopover({
|
||||
orgId,
|
||||
sites
|
||||
}: {
|
||||
orgId: string;
|
||||
sites: InternalResourceSiteRow[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1 px-2 font-normal"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
+{sites.length}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="top"
|
||||
className="w-auto max-w-xs p-2"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<ul className="flex flex-col gap-1.5 text-sm">
|
||||
{sites.map((site) => (
|
||||
<li key={site.siteId}>
|
||||
<Link
|
||||
href={`/${orgId}/settings/sites/${site.siteNiceId}`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-1"
|
||||
>
|
||||
<span className="truncate">
|
||||
{site.siteName}
|
||||
</span>
|
||||
<ArrowUpRight className="ml-auto h-3.5 w-3.5 shrink-0" />
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type ClientResourcesTableProps = {
|
||||
internalResources: InternalResourceRow[];
|
||||
orgId: string;
|
||||
@@ -223,20 +331,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>
|
||||
<ClientResourceSiteLinks
|
||||
orgId={resourceRow.orgId}
|
||||
sites={resourceRow.sites}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<Selectedsite>(
|
||||
availableSites[0]
|
||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>(() =>
|
||||
variant === "edit" && resource
|
||||
? buildSelectedSitesForResource(resource, sites)
|
||||
: availableSites[0]
|
||||
? [
|
||||
{
|
||||
name: availableSites[0].name,
|
||||
siteId: availableSites[0].siteId,
|
||||
type: availableSites[0].type
|
||||
}
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
@@ -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({
|
||||
<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
|
||||
@@ -729,11 +783,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>
|
||||
@@ -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"
|
||||
)}
|
||||
<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
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
117
src/components/multi-site-selector.tsx
Normal file
117
src/components/multi-site-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user