mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-13 21:36:38 +00:00
Merge branch 'private-http' of github.com:fosrl/pangolin into private-http
This commit is contained in:
@@ -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.",
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
|
||||||
: t(
|
|
||||||
"selectSite"
|
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
<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
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
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)}
|
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>
|
||||||
));
|
));
|
||||||
|
|||||||
Reference in New Issue
Block a user