mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-30 14:36:46 +00:00
Merge pull request #2670 from Fredkiss3/feat/selector-filtering
Feat: selector filtering
This commit is contained in:
@@ -148,6 +148,11 @@
|
||||
"createLink": "Create Link",
|
||||
"resourcesNotFound": "No resources found",
|
||||
"resourceSearch": "Search resources",
|
||||
"machineSearch": "Search machines",
|
||||
"machinesSearch": "Search machine clients...",
|
||||
"machineNotFound": "No machines found",
|
||||
"userDeviceSearch": "Search user devices",
|
||||
"userDevicesSearch": "Search user devices...",
|
||||
"openMenu": "Open menu",
|
||||
"resource": "Resource",
|
||||
"title": "Title",
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { Resource, resources, sites } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db, resources } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const getResourceSchema = z.strictObject({
|
||||
resourceId: z
|
||||
|
||||
@@ -40,6 +40,7 @@ function queryTargets(resourceId: number) {
|
||||
resourceId: targets.resourceId,
|
||||
siteId: targets.siteId,
|
||||
siteType: sites.type,
|
||||
siteName: sites.name,
|
||||
hcEnabled: targetHealthCheck.hcEnabled,
|
||||
hcPath: targetHealthCheck.hcPath,
|
||||
hcScheme: targetHealthCheck.hcScheme,
|
||||
|
||||
@@ -124,20 +124,15 @@ export default function ReverseProxyTargetsPage(props: {
|
||||
resourceId: resource.resourceId
|
||||
})
|
||||
);
|
||||
const { data: sites = [], isLoading: isLoadingSites } = useQuery(
|
||||
orgQueries.sites({
|
||||
orgId: params.orgId
|
||||
})
|
||||
);
|
||||
|
||||
if (isLoadingSites || isLoadingTargets) {
|
||||
if (isLoadingTargets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<ProxyResourceTargetsForm
|
||||
sites={sites}
|
||||
orgId={params.orgId}
|
||||
initialTargets={remoteTargets}
|
||||
resource={resource}
|
||||
/>
|
||||
@@ -160,12 +155,12 @@ export default function ReverseProxyTargetsPage(props: {
|
||||
}
|
||||
|
||||
function ProxyResourceTargetsForm({
|
||||
sites,
|
||||
orgId,
|
||||
initialTargets,
|
||||
resource
|
||||
}: {
|
||||
initialTargets: LocalTarget[];
|
||||
sites: ListSitesResponse["sites"];
|
||||
orgId: string;
|
||||
resource: GetResourceResponse;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
@@ -243,17 +238,21 @@ function ProxyResourceTargetsForm({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { data: sites = [] } = useQuery(
|
||||
orgQueries.sites({
|
||||
orgId
|
||||
})
|
||||
);
|
||||
|
||||
const updateTarget = useCallback(
|
||||
(targetId: number, data: Partial<LocalTarget>) => {
|
||||
setTargets((prevTargets) => {
|
||||
const site = sites.find((site) => site.siteId === data.siteId);
|
||||
return prevTargets.map((target) =>
|
||||
target.targetId === targetId
|
||||
? {
|
||||
...target,
|
||||
...data,
|
||||
updated: true,
|
||||
siteType: site ? site.type : target.siteType
|
||||
updated: true
|
||||
}
|
||||
: target
|
||||
);
|
||||
@@ -453,7 +452,7 @@ function ProxyResourceTargetsForm({
|
||||
return (
|
||||
<ResourceTargetAddressItem
|
||||
isHttp={isHttp}
|
||||
sites={sites}
|
||||
orgId={orgId}
|
||||
getDockerStateForSite={getDockerStateForSite}
|
||||
proxyTarget={row.original}
|
||||
refreshContainersForSite={refreshContainersForSite}
|
||||
@@ -619,6 +618,7 @@ function ProxyResourceTargetsForm({
|
||||
method: isHttp ? "http" : null,
|
||||
port: 0,
|
||||
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
||||
siteName: sites.length > 0 ? sites[0].name : "",
|
||||
path: isHttp ? null : null,
|
||||
pathMatchType: isHttp ? null : null,
|
||||
rewritePath: isHttp ? null : null,
|
||||
|
||||
@@ -216,9 +216,7 @@ export default function Page() {
|
||||
const [remoteExitNodes, setRemoteExitNodes] = useState<
|
||||
ListRemoteExitNodesResponse["remoteExitNodes"]
|
||||
>([]);
|
||||
const [loadingExitNodes, setLoadingExitNodes] = useState(
|
||||
build === "saas"
|
||||
);
|
||||
const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas");
|
||||
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [showSnippets, setShowSnippets] = useState(false);
|
||||
@@ -282,6 +280,7 @@ export default function Page() {
|
||||
method: isHttp ? "http" : null,
|
||||
port: 0,
|
||||
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
||||
siteName: sites.length > 0 ? sites[0].name : "",
|
||||
path: isHttp ? null : null,
|
||||
pathMatchType: isHttp ? null : null,
|
||||
rewritePath: isHttp ? null : null,
|
||||
@@ -336,8 +335,7 @@ export default function Page() {
|
||||
|
||||
// In saas mode with no exit nodes, force HTTP
|
||||
const showTypeSelector =
|
||||
build !== "saas" ||
|
||||
(!loadingExitNodes && remoteExitNodes.length > 0);
|
||||
build !== "saas" || (!loadingExitNodes && remoteExitNodes.length > 0);
|
||||
|
||||
const baseForm = useForm({
|
||||
resolver: zodResolver(baseResourceFormSchema),
|
||||
@@ -600,7 +598,10 @@ export default function Page() {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorCreate"),
|
||||
description: formatAxiosError(e, t("resourceErrorCreateMessageDescription"))
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("resourceErrorCreateMessageDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -826,7 +827,8 @@ export default function Page() {
|
||||
cell: ({ row }) => (
|
||||
<ResourceTargetAddressItem
|
||||
isHttp={isHttp}
|
||||
sites={sites}
|
||||
orgId={orgId!.toString()}
|
||||
// sites={sites}
|
||||
getDockerStateForSite={getDockerStateForSite}
|
||||
proxyTarget={row.original}
|
||||
refreshContainersForSite={refreshContainersForSite}
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
import AccessTokenSection from "@app/components/AccessTokenUsage";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toUnicode } from "punycode";
|
||||
import { ResourceSelector, type SelectedResource } from "./resource-selector";
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
@@ -99,18 +100,21 @@ export default function CreateShareLinkForm({
|
||||
orgQueries.resources({ orgId: org?.org.orgId ?? "" })
|
||||
);
|
||||
|
||||
const resources = useMemo(
|
||||
() =>
|
||||
allResources
|
||||
.filter((r) => r.http)
|
||||
.map((r) => ({
|
||||
resourceId: r.resourceId,
|
||||
name: r.name,
|
||||
niceId: r.niceId,
|
||||
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
|
||||
})),
|
||||
[allResources]
|
||||
);
|
||||
const [selectedResource, setSelectedResource] =
|
||||
useState<SelectedResource | null>(null);
|
||||
|
||||
// const resources = useMemo(
|
||||
// () =>
|
||||
// allResources
|
||||
// .filter((r) => r.http)
|
||||
// .map((r) => ({
|
||||
// resourceId: r.resourceId,
|
||||
// name: r.name,
|
||||
// niceId: r.niceId,
|
||||
// resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
|
||||
// })),
|
||||
// [allResources]
|
||||
// );
|
||||
|
||||
const formSchema = z.object({
|
||||
resourceId: z.number({ message: t("shareErrorSelectResource") }),
|
||||
@@ -199,15 +203,11 @@ export default function CreateShareLinkForm({
|
||||
setAccessToken(token.accessToken);
|
||||
setAccessTokenId(token.accessTokenId);
|
||||
|
||||
const resource = resources.find(
|
||||
(r) => r.resourceId === values.resourceId
|
||||
);
|
||||
|
||||
onCreated?.({
|
||||
accessTokenId: token.accessTokenId,
|
||||
resourceId: token.resourceId,
|
||||
resourceName: values.resourceName,
|
||||
resourceNiceId: resource ? resource.niceId : "",
|
||||
resourceNiceId: selectedResource ? selectedResource.niceId : "",
|
||||
title: token.title,
|
||||
createdAt: token.createdAt,
|
||||
expiresAt: token.expiresAt
|
||||
@@ -217,11 +217,6 @@ export default function CreateShareLinkForm({
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function getSelectedResourceName(id: number) {
|
||||
const resource = resources.find((r) => r.resourceId === id);
|
||||
return `${resource?.name}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
@@ -241,7 +236,7 @@ export default function CreateShareLinkForm({
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-y-4 px-1">
|
||||
{!link && (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -269,10 +264,8 @@ export default function CreateShareLinkForm({
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? getSelectedResourceName(
|
||||
field.value
|
||||
)
|
||||
{selectedResource?.name
|
||||
? selectedResource.name
|
||||
: t(
|
||||
"resourceSelect"
|
||||
)}
|
||||
@@ -281,59 +274,34 @@ export default function CreateShareLinkForm({
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"resourceSearch"
|
||||
)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t(
|
||||
"resourcesNotFound"
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{resources.map(
|
||||
(
|
||||
r
|
||||
) => (
|
||||
<CommandItem
|
||||
value={`${r.name}:${r.resourceId}`}
|
||||
key={
|
||||
r.resourceId
|
||||
}
|
||||
onSelect={() => {
|
||||
form.setValue(
|
||||
"resourceId",
|
||||
r.resourceId
|
||||
);
|
||||
form.setValue(
|
||||
"resourceName",
|
||||
r.name
|
||||
);
|
||||
form.setValue(
|
||||
"resourceUrl",
|
||||
r.resourceUrl
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
r.resourceId ===
|
||||
field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{`${r.name}`}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<ResourceSelector
|
||||
orgId={
|
||||
org.org
|
||||
.orgId
|
||||
}
|
||||
selectedResource={
|
||||
selectedResource
|
||||
}
|
||||
onSelectResource={(
|
||||
r
|
||||
) => {
|
||||
form.setValue(
|
||||
"resourceId",
|
||||
r.resourceId
|
||||
);
|
||||
form.setValue(
|
||||
"resourceName",
|
||||
r.name
|
||||
);
|
||||
form.setValue(
|
||||
"resourceUrl",
|
||||
`${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
|
||||
);
|
||||
setSelectedResource(
|
||||
r
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -32,24 +27,24 @@ import {
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { Check, ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { SitesSelector, type Selectedsite } from "./site-selector";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
import { MachinesSelector } from "./machines-selector";
|
||||
|
||||
// --- Helpers (shared) ---
|
||||
|
||||
@@ -258,7 +253,14 @@ export function InternalResourceForm({
|
||||
authDaemonPort: z.number().int().positive().optional().nullable(),
|
||||
roles: z.array(tagSchema).optional(),
|
||||
users: z.array(tagSchema).optional(),
|
||||
clients: z.array(tagSchema).optional()
|
||||
clients: z
|
||||
.array(
|
||||
z.object({
|
||||
clientId: z.number(),
|
||||
name: z.string()
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
@@ -267,7 +269,7 @@ export function InternalResourceForm({
|
||||
|
||||
const rolesQuery = useQuery(orgQueries.roles({ orgId }));
|
||||
const usersQuery = useQuery(orgQueries.users({ orgId }));
|
||||
const clientsQuery = useQuery(orgQueries.clients({ orgId }));
|
||||
const clientsQuery = useQuery(orgQueries.machineClients({ orgId }));
|
||||
const resourceRolesQuery = useQuery({
|
||||
...resourceQueries.siteResourceRoles({
|
||||
siteResourceId: siteResourceId ?? 0
|
||||
@@ -325,12 +327,9 @@ export function InternalResourceForm({
|
||||
}));
|
||||
}
|
||||
if (clientsData) {
|
||||
existingClients = (
|
||||
clientsData as { clientId: number; name: string }[]
|
||||
).map((c) => ({
|
||||
id: c.clientId.toString(),
|
||||
text: c.name
|
||||
}));
|
||||
existingClients = [
|
||||
...(clientsData as { clientId: number; name: string }[])
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,6 +415,10 @@ export function InternalResourceForm({
|
||||
clients: []
|
||||
};
|
||||
|
||||
const [selectedSite, setSelectedSite] = useState<Selectedsite>(
|
||||
availableSites[0]
|
||||
);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues
|
||||
@@ -537,9 +540,15 @@ export function InternalResourceForm({
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
onSubmit(values as InternalResourceFormValues)
|
||||
)}
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
onSubmit({
|
||||
...values,
|
||||
clients: (values.clients ?? []).map((c) => ({
|
||||
id: c.clientId.toString(),
|
||||
text: c.name
|
||||
}))
|
||||
});
|
||||
})}
|
||||
className="space-y-6"
|
||||
id={formId}
|
||||
>
|
||||
@@ -600,46 +609,14 @@ export function InternalResourceForm({
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("searchSites")}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("noSitesFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableSites.map(
|
||||
(site) => (
|
||||
<CommandItem
|
||||
key={
|
||||
site.siteId
|
||||
}
|
||||
value={
|
||||
site.name
|
||||
}
|
||||
onSelect={() =>
|
||||
field.onChange(
|
||||
site.siteId
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
field.value ===
|
||||
site.siteId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{site.name}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSite}
|
||||
onSelectSite={(site) => {
|
||||
setSelectedSite(site);
|
||||
field.onChange(site.siteId);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
@@ -649,8 +626,7 @@ export function InternalResourceForm({
|
||||
</div>
|
||||
|
||||
<HorizontalTabs
|
||||
clientSide={true}
|
||||
defaultTab={0}
|
||||
clientSide
|
||||
items={[
|
||||
{
|
||||
title: t(
|
||||
@@ -667,7 +643,7 @@ export function InternalResourceForm({
|
||||
: [{ title: t("sshAccess"), href: "#" }])
|
||||
]}
|
||||
>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-4 mt-4 p-1">
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<label className="font-medium block">
|
||||
@@ -1038,7 +1014,7 @@ export function InternalResourceForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-4 mt-4 p-1">
|
||||
<div className="mb-8">
|
||||
<label className="font-medium block">
|
||||
{t("editInternalResourceDialogAccessControl")}
|
||||
@@ -1158,48 +1134,73 @@ export function InternalResourceForm({
|
||||
<FormLabel>
|
||||
{t("machineClients")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeClientsTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveClientsTagIndex
|
||||
}
|
||||
placeholder={
|
||||
t(
|
||||
"accessClientSelect"
|
||||
) ||
|
||||
"Select machine clients"
|
||||
}
|
||||
size="sm"
|
||||
tags={
|
||||
form.getValues()
|
||||
.clients ?? []
|
||||
}
|
||||
setTags={(newClients) =>
|
||||
form.setValue(
|
||||
"clients",
|
||||
newClients as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
)
|
||||
}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
allClients
|
||||
}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between w-full",
|
||||
"text-muted-foreground pl-1.5"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1",
|
||||
"overflow-x-auto"
|
||||
)}
|
||||
>
|
||||
{(
|
||||
field.value ??
|
||||
[]
|
||||
).map(
|
||||
(
|
||||
client
|
||||
) => (
|
||||
<span
|
||||
key={
|
||||
client.clientId
|
||||
}
|
||||
className={cn(
|
||||
"bg-muted-foreground/20 font-normal text-foreground rounded-sm",
|
||||
"py-1 px-1.5 text-xs"
|
||||
)}
|
||||
>
|
||||
{
|
||||
client.name
|
||||
}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<span className="pl-1">
|
||||
{t(
|
||||
"accessClientSelect"
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<MachinesSelector
|
||||
selectedMachines={
|
||||
field.value ??
|
||||
[]
|
||||
}
|
||||
orgId={orgId}
|
||||
onSelectMachines={(
|
||||
machines
|
||||
) => {
|
||||
form.setValue(
|
||||
"clients",
|
||||
machines
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -1211,7 +1212,7 @@ export function InternalResourceForm({
|
||||
|
||||
{/* SSH Access tab */}
|
||||
{!disableEnterpriseFeatures && mode !== "cidr" && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-4 mt-4 p-1">
|
||||
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
|
||||
<div className="mb-8">
|
||||
<label className="font-medium block">
|
||||
|
||||
@@ -540,7 +540,7 @@ export default function MachineClientsTable({
|
||||
columns={columns}
|
||||
rows={machineClients}
|
||||
tableId="machine-clients"
|
||||
searchPlaceholder={t("resourcesSearch")}
|
||||
searchPlaceholder={t("machinesSearch")}
|
||||
onAdd={() =>
|
||||
startNavigation(() =>
|
||||
router.push(`/${orgId}/settings/clients/machine/create`)
|
||||
|
||||
@@ -770,7 +770,7 @@ export default function UserDevicesTable({
|
||||
columns={columns}
|
||||
rows={userClients || []}
|
||||
tableId="user-clients"
|
||||
searchPlaceholder={t("resourcesSearch")}
|
||||
searchPlaceholder={t("userDevicesSearch")}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
enableColumnVisibility
|
||||
|
||||
108
src/components/machines-selector.tsx
Normal file
108
src/components/machines-selector.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import type { ListClientsResponse } from "@server/routers/client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "./ui/command";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type SelectedMachine = Pick<
|
||||
ListClientsResponse["clients"][number],
|
||||
"name" | "clientId"
|
||||
>;
|
||||
|
||||
export type MachineSelectorProps = {
|
||||
orgId: string;
|
||||
selectedMachines?: SelectedMachine[];
|
||||
onSelectMachines: (machine: SelectedMachine[]) => void;
|
||||
};
|
||||
|
||||
export function MachinesSelector({
|
||||
orgId,
|
||||
selectedMachines = [],
|
||||
onSelectMachines
|
||||
}: MachineSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [machineSearchQuery, setMachineSearchQuery] = useState("");
|
||||
|
||||
const [debouncedValue] = useDebounce(machineSearchQuery, 150);
|
||||
|
||||
const { data: machines = [] } = useQuery(
|
||||
orgQueries.machineClients({ orgId, perPage: 10, query: debouncedValue })
|
||||
);
|
||||
|
||||
// always include the selected machines in the list of machines shown (if the user isn't searching)
|
||||
const machinesShown = useMemo(() => {
|
||||
const allMachines: Array<SelectedMachine> = [...machines];
|
||||
if (debouncedValue.trim().length === 0) {
|
||||
for (const machine of selectedMachines) {
|
||||
if (
|
||||
!allMachines.find((mc) => mc.clientId === machine.clientId)
|
||||
) {
|
||||
allMachines.unshift(machine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allMachines;
|
||||
}, [machines, selectedMachines, debouncedValue]);
|
||||
|
||||
const selectedMachinesIds = new Set(
|
||||
selectedMachines.map((m) => m.clientId)
|
||||
);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("machineSearch")}
|
||||
value={machineSearchQuery}
|
||||
onValueChange={setMachineSearchQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("machineNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{machinesShown.map((m) => (
|
||||
<CommandItem
|
||||
value={`${m.name}:${m.clientId}`}
|
||||
key={m.clientId}
|
||||
onSelect={() => {
|
||||
let newMachineClients = [];
|
||||
if (selectedMachinesIds.has(m.clientId)) {
|
||||
newMachineClients = selectedMachines.filter(
|
||||
(mc) => mc.clientId !== m.clientId
|
||||
);
|
||||
} else {
|
||||
newMachineClients = [
|
||||
...selectedMachines,
|
||||
m
|
||||
];
|
||||
}
|
||||
onSelectMachines(newMachineClients);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedMachinesIds.has(m.clientId)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{`${m.name}`}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
97
src/components/resource-selector.tsx
Normal file
97
src/components/resource-selector.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "./ui/command";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import type { ListResourcesResponse } from "@server/routers/resource";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
export type SelectedResource = Pick<
|
||||
ListResourcesResponse["resources"][number],
|
||||
"name" | "resourceId" | "fullDomain" | "niceId" | "ssl"
|
||||
>;
|
||||
|
||||
export type ResourceSelectorProps = {
|
||||
orgId: string;
|
||||
selectedResource?: SelectedResource | null;
|
||||
onSelectResource: (resource: SelectedResource) => void;
|
||||
};
|
||||
|
||||
export function ResourceSelector({
|
||||
orgId,
|
||||
selectedResource,
|
||||
onSelectResource
|
||||
}: ResourceSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [resourceSearchQuery, setResourceSearchQuery] = useState("");
|
||||
|
||||
const [debouncedSearchQuery] = useDebounce(resourceSearchQuery, 150);
|
||||
|
||||
const { data: resources = [] } = useQuery(
|
||||
orgQueries.resources({
|
||||
orgId: orgId,
|
||||
query: debouncedSearchQuery,
|
||||
perPage: 10
|
||||
})
|
||||
);
|
||||
|
||||
// always include the selected resource in the list of resources shown
|
||||
const resourcesShown = useMemo(() => {
|
||||
const allResources: Array<SelectedResource> = [...resources];
|
||||
if (
|
||||
debouncedSearchQuery.trim().length === 0 &&
|
||||
selectedResource &&
|
||||
!allResources.find(
|
||||
(resource) =>
|
||||
resource.resourceId === selectedResource?.resourceId
|
||||
)
|
||||
) {
|
||||
allResources.unshift(selectedResource);
|
||||
}
|
||||
return allResources;
|
||||
}, [debouncedSearchQuery, resources, selectedResource]);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("resourceSearch")}
|
||||
value={resourceSearchQuery}
|
||||
onValueChange={setResourceSearchQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("resourcesNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{resourcesShown.map((r) => (
|
||||
<CommandItem
|
||||
value={`${r.name}:${r.resourceId}`}
|
||||
key={r.resourceId}
|
||||
onSelect={() => {
|
||||
onSelectResource(r);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
r.resourceId ===
|
||||
selectedResource?.resourceId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{`${r.name}`}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { cn } from "@app/lib/cn";
|
||||
import type { DockerState } from "@app/lib/docker";
|
||||
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
import type { ListSitesResponse } from "@server/routers/site";
|
||||
import { type ListTargetsResponse } from "@server/routers/target";
|
||||
import type { ArrayElement } from "@server/types/ArrayElement";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ContainersSelector } from "./ContainersSelector";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
@@ -20,7 +23,7 @@ import {
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
|
||||
import { useEffect } from "react";
|
||||
import { SitesSelector } from "./site-selector";
|
||||
|
||||
type SiteWithUpdateAvailable = ListSitesResponse["sites"][number];
|
||||
|
||||
@@ -36,14 +39,14 @@ export type LocalTarget = Omit<
|
||||
export type ResourceTargetAddressItemProps = {
|
||||
getDockerStateForSite: (siteId: number) => DockerState;
|
||||
updateTarget: (targetId: number, data: Partial<LocalTarget>) => void;
|
||||
sites: SiteWithUpdateAvailable[];
|
||||
orgId: string;
|
||||
proxyTarget: LocalTarget;
|
||||
isHttp: boolean;
|
||||
refreshContainersForSite: (siteId: number) => void;
|
||||
};
|
||||
|
||||
export function ResourceTargetAddressItem({
|
||||
sites,
|
||||
orgId,
|
||||
getDockerStateForSite,
|
||||
updateTarget,
|
||||
proxyTarget,
|
||||
@@ -52,9 +55,23 @@ export function ResourceTargetAddressItem({
|
||||
}: ResourceTargetAddressItemProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const selectedSite = sites.find(
|
||||
(site) => site.siteId === proxyTarget.siteId
|
||||
);
|
||||
const [selectedSite, setSelectedSite] = useState<Pick<
|
||||
SiteWithUpdateAvailable,
|
||||
"name" | "siteId" | "type"
|
||||
> | null>(() => {
|
||||
if (
|
||||
proxyTarget.siteName &&
|
||||
proxyTarget.siteType &&
|
||||
proxyTarget.siteId
|
||||
) {
|
||||
return {
|
||||
name: proxyTarget.siteName,
|
||||
siteId: proxyTarget.siteId,
|
||||
type: proxyTarget.siteType
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const handleContainerSelectForTarget = (
|
||||
hostname: string,
|
||||
@@ -70,28 +87,23 @@ export function ResourceTargetAddressItem({
|
||||
return (
|
||||
<div className="flex items-center w-full" key={proxyTarget.targetId}>
|
||||
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
|
||||
{selectedSite &&
|
||||
selectedSite.type === "newt" &&
|
||||
(() => {
|
||||
const dockerState = getDockerStateForSite(
|
||||
selectedSite.siteId
|
||||
);
|
||||
return (
|
||||
<ContainersSelector
|
||||
site={selectedSite}
|
||||
containers={dockerState.containers}
|
||||
isAvailable={dockerState.isAvailable}
|
||||
onContainerSelect={
|
||||
handleContainerSelectForTarget
|
||||
}
|
||||
onRefresh={() =>
|
||||
refreshContainersForSite(
|
||||
selectedSite.siteId
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{selectedSite && selectedSite.type === "newt" && (
|
||||
<ContainersSelector
|
||||
site={selectedSite}
|
||||
containers={
|
||||
getDockerStateForSite(selectedSite.siteId)
|
||||
.containers
|
||||
}
|
||||
isAvailable={
|
||||
getDockerStateForSite(selectedSite.siteId)
|
||||
.isAvailable
|
||||
}
|
||||
onContainerSelect={handleContainerSelectForTarget}
|
||||
onRefresh={() =>
|
||||
refreshContainersForSite(selectedSite.siteId)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -113,39 +125,18 @@ export function ResourceTargetAddressItem({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-45">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("siteSearch")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map((site) => (
|
||||
<CommandItem
|
||||
key={site.siteId}
|
||||
value={`${site.siteId}:${site.name}`}
|
||||
onSelect={() =>
|
||||
updateTarget(
|
||||
proxyTarget.targetId,
|
||||
{
|
||||
siteId: site.siteId
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
site.siteId ===
|
||||
proxyTarget.siteId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{site.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSite}
|
||||
onSelectSite={(site) => {
|
||||
updateTarget(proxyTarget.targetId, {
|
||||
siteId: site.siteId,
|
||||
siteType: site.type,
|
||||
siteName: site.name
|
||||
});
|
||||
setSelectedSite(site);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
|
||||
92
src/components/site-selector.tsx
Normal file
92
src/components/site-selector.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import type { ListSitesResponse } from "@server/routers/site";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "./ui/command";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
export type Selectedsite = Pick<
|
||||
ListSitesResponse["sites"][number],
|
||||
"name" | "siteId" | "type"
|
||||
>;
|
||||
|
||||
export type SitesSelectorProps = {
|
||||
orgId: string;
|
||||
selectedSite?: Selectedsite | null;
|
||||
onSelectSite: (selected: Selectedsite) => void;
|
||||
};
|
||||
|
||||
export function SitesSelector({
|
||||
orgId,
|
||||
selectedSite,
|
||||
onSelectSite
|
||||
}: SitesSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
||||
const [debouncedQuery] = useDebounce(siteSearchQuery, 150);
|
||||
|
||||
const { data: sites = [] } = useQuery(
|
||||
orgQueries.sites({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 10
|
||||
})
|
||||
);
|
||||
|
||||
// always include the selected site in the list of sites shown
|
||||
const sitesShown = useMemo(() => {
|
||||
const allSites: Array<Selectedsite> = [...sites];
|
||||
if (
|
||||
debouncedQuery.trim().length === 0 &&
|
||||
selectedSite &&
|
||||
!allSites.find((site) => site.siteId === selectedSite?.siteId)
|
||||
) {
|
||||
allSites.unshift(selectedSite);
|
||||
}
|
||||
return allSites;
|
||||
}, [debouncedQuery, sites, selectedSite]);
|
||||
|
||||
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={() => {
|
||||
onSelectSite(site);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
site.siteId === selectedSite?.siteId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{site.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
@@ -92,14 +92,26 @@ export const productUpdatesQueries = {
|
||||
};
|
||||
|
||||
export const orgQueries = {
|
||||
clients: ({ orgId }: { orgId: string }) =>
|
||||
machineClients: ({
|
||||
orgId,
|
||||
query,
|
||||
perPage = 10_000
|
||||
}: {
|
||||
orgId: string;
|
||||
query?: string;
|
||||
perPage?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "CLIENTS"] as const,
|
||||
queryKey: ["ORG", orgId, "CLIENTS", { query, perPage }] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams({
|
||||
pageSize: "10000"
|
||||
pageSize: perPage.toString()
|
||||
});
|
||||
|
||||
if (query?.trim()) {
|
||||
sp.set("query", query);
|
||||
}
|
||||
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListClientsResponse>
|
||||
>(`/org/${orgId}/clients?${sp.toString()}`, { signal });
|
||||
@@ -130,14 +142,26 @@ export const orgQueries = {
|
||||
}
|
||||
}),
|
||||
|
||||
sites: ({ orgId }: { orgId: string }) =>
|
||||
sites: ({
|
||||
orgId,
|
||||
query,
|
||||
perPage = 10_000
|
||||
}: {
|
||||
orgId: string;
|
||||
query?: string;
|
||||
perPage?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "SITES"] as const,
|
||||
queryKey: ["ORG", orgId, "SITES", { query, perPage }] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams({
|
||||
pageSize: "10000"
|
||||
pageSize: perPage.toString()
|
||||
});
|
||||
|
||||
if (query?.trim()) {
|
||||
sp.set("query", query);
|
||||
}
|
||||
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListSitesResponse>
|
||||
>(`/org/${orgId}/sites?${sp.toString()}`, { signal });
|
||||
@@ -179,14 +203,26 @@ export const orgQueries = {
|
||||
}
|
||||
}),
|
||||
|
||||
resources: ({ orgId }: { orgId: string }) =>
|
||||
resources: ({
|
||||
orgId,
|
||||
query,
|
||||
perPage = 10_000
|
||||
}: {
|
||||
orgId: string;
|
||||
query?: string;
|
||||
perPage?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "RESOURCES"] as const,
|
||||
queryKey: ["ORG", orgId, "RESOURCES", { query, perPage }] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams({
|
||||
pageSize: "10000"
|
||||
pageSize: perPage.toString()
|
||||
});
|
||||
|
||||
if (query?.trim()) {
|
||||
sp.set("query", query);
|
||||
}
|
||||
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListResourcesResponse>
|
||||
>(`/org/${orgId}/resources?${sp.toString()}`, { signal });
|
||||
|
||||
Reference in New Issue
Block a user