Merge pull request #2670 from Fredkiss3/feat/selector-filtering

Feat: selector filtering
This commit is contained in:
Milo Schwartz
2026-03-29 12:26:51 -07:00
committed by GitHub
14 changed files with 597 additions and 297 deletions

View File

@@ -148,6 +148,11 @@
"createLink": "Create Link", "createLink": "Create Link",
"resourcesNotFound": "No resources found", "resourcesNotFound": "No resources found",
"resourceSearch": "Search resources", "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", "openMenu": "Open menu",
"resource": "Resource", "resource": "Resource",
"title": "Title", "title": "Title",

View File

@@ -1,15 +1,14 @@
import { Request, Response, NextFunction } from "express"; import { db, resources } from "@server/db";
import { z } from "zod";
import { db } from "@server/db";
import { Resource, resources, sites } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response"; 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 stoi from "@server/lib/stoi";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; 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({ const getResourceSchema = z.strictObject({
resourceId: z resourceId: z

View File

@@ -40,6 +40,7 @@ function queryTargets(resourceId: number) {
resourceId: targets.resourceId, resourceId: targets.resourceId,
siteId: targets.siteId, siteId: targets.siteId,
siteType: sites.type, siteType: sites.type,
siteName: sites.name,
hcEnabled: targetHealthCheck.hcEnabled, hcEnabled: targetHealthCheck.hcEnabled,
hcPath: targetHealthCheck.hcPath, hcPath: targetHealthCheck.hcPath,
hcScheme: targetHealthCheck.hcScheme, hcScheme: targetHealthCheck.hcScheme,

View File

@@ -124,20 +124,15 @@ export default function ReverseProxyTargetsPage(props: {
resourceId: resource.resourceId resourceId: resource.resourceId
}) })
); );
const { data: sites = [], isLoading: isLoadingSites } = useQuery(
orgQueries.sites({
orgId: params.orgId
})
);
if (isLoadingSites || isLoadingTargets) { if (isLoadingTargets) {
return null; return null;
} }
return ( return (
<SettingsContainer> <SettingsContainer>
<ProxyResourceTargetsForm <ProxyResourceTargetsForm
sites={sites} orgId={params.orgId}
initialTargets={remoteTargets} initialTargets={remoteTargets}
resource={resource} resource={resource}
/> />
@@ -160,12 +155,12 @@ export default function ReverseProxyTargetsPage(props: {
} }
function ProxyResourceTargetsForm({ function ProxyResourceTargetsForm({
sites, orgId,
initialTargets, initialTargets,
resource resource
}: { }: {
initialTargets: LocalTarget[]; initialTargets: LocalTarget[];
sites: ListSitesResponse["sites"]; orgId: string;
resource: GetResourceResponse; resource: GetResourceResponse;
}) { }) {
const t = useTranslations(); const t = useTranslations();
@@ -243,17 +238,21 @@ function ProxyResourceTargetsForm({
}); });
}, []); }, []);
const { data: sites = [] } = useQuery(
orgQueries.sites({
orgId
})
);
const updateTarget = useCallback( const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => { (targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => { setTargets((prevTargets) => {
const site = sites.find((site) => site.siteId === data.siteId);
return prevTargets.map((target) => return prevTargets.map((target) =>
target.targetId === targetId target.targetId === targetId
? { ? {
...target, ...target,
...data, ...data,
updated: true, updated: true
siteType: site ? site.type : target.siteType
} }
: target : target
); );
@@ -453,7 +452,7 @@ function ProxyResourceTargetsForm({
return ( return (
<ResourceTargetAddressItem <ResourceTargetAddressItem
isHttp={isHttp} isHttp={isHttp}
sites={sites} orgId={orgId}
getDockerStateForSite={getDockerStateForSite} getDockerStateForSite={getDockerStateForSite}
proxyTarget={row.original} proxyTarget={row.original}
refreshContainersForSite={refreshContainersForSite} refreshContainersForSite={refreshContainersForSite}
@@ -619,6 +618,7 @@ function ProxyResourceTargetsForm({
method: isHttp ? "http" : null, method: isHttp ? "http" : null,
port: 0, port: 0,
siteId: sites.length > 0 ? sites[0].siteId : 0, siteId: sites.length > 0 ? sites[0].siteId : 0,
siteName: sites.length > 0 ? sites[0].name : "",
path: isHttp ? null : null, path: isHttp ? null : null,
pathMatchType: isHttp ? null : null, pathMatchType: isHttp ? null : null,
rewritePath: isHttp ? null : null, rewritePath: isHttp ? null : null,

View File

@@ -216,9 +216,7 @@ export default function Page() {
const [remoteExitNodes, setRemoteExitNodes] = useState< const [remoteExitNodes, setRemoteExitNodes] = useState<
ListRemoteExitNodesResponse["remoteExitNodes"] ListRemoteExitNodesResponse["remoteExitNodes"]
>([]); >([]);
const [loadingExitNodes, setLoadingExitNodes] = useState( const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas");
build === "saas"
);
const [createLoading, setCreateLoading] = useState(false); const [createLoading, setCreateLoading] = useState(false);
const [showSnippets, setShowSnippets] = useState(false); const [showSnippets, setShowSnippets] = useState(false);
@@ -282,6 +280,7 @@ export default function Page() {
method: isHttp ? "http" : null, method: isHttp ? "http" : null,
port: 0, port: 0,
siteId: sites.length > 0 ? sites[0].siteId : 0, siteId: sites.length > 0 ? sites[0].siteId : 0,
siteName: sites.length > 0 ? sites[0].name : "",
path: isHttp ? null : null, path: isHttp ? null : null,
pathMatchType: isHttp ? null : null, pathMatchType: isHttp ? null : null,
rewritePath: isHttp ? null : null, rewritePath: isHttp ? null : null,
@@ -336,8 +335,7 @@ export default function Page() {
// In saas mode with no exit nodes, force HTTP // In saas mode with no exit nodes, force HTTP
const showTypeSelector = const showTypeSelector =
build !== "saas" || build !== "saas" || (!loadingExitNodes && remoteExitNodes.length > 0);
(!loadingExitNodes && remoteExitNodes.length > 0);
const baseForm = useForm({ const baseForm = useForm({
resolver: zodResolver(baseResourceFormSchema), resolver: zodResolver(baseResourceFormSchema),
@@ -600,7 +598,10 @@ export default function Page() {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("resourceErrorCreate"), title: t("resourceErrorCreate"),
description: formatAxiosError(e, t("resourceErrorCreateMessageDescription")) description: formatAxiosError(
e,
t("resourceErrorCreateMessageDescription")
)
}); });
} }
@@ -826,7 +827,8 @@ export default function Page() {
cell: ({ row }) => ( cell: ({ row }) => (
<ResourceTargetAddressItem <ResourceTargetAddressItem
isHttp={isHttp} isHttp={isHttp}
sites={sites} orgId={orgId!.toString()}
// sites={sites}
getDockerStateForSite={getDockerStateForSite} getDockerStateForSite={getDockerStateForSite}
proxyTarget={row.original} proxyTarget={row.original}
refreshContainersForSite={refreshContainersForSite} refreshContainersForSite={refreshContainersForSite}

View File

@@ -69,6 +69,7 @@ import {
import AccessTokenSection from "@app/components/AccessTokenUsage"; import AccessTokenSection from "@app/components/AccessTokenUsage";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toUnicode } from "punycode"; import { toUnicode } from "punycode";
import { ResourceSelector, type SelectedResource } from "./resource-selector";
type FormProps = { type FormProps = {
open: boolean; open: boolean;
@@ -99,18 +100,21 @@ export default function CreateShareLinkForm({
orgQueries.resources({ orgId: org?.org.orgId ?? "" }) orgQueries.resources({ orgId: org?.org.orgId ?? "" })
); );
const resources = useMemo( const [selectedResource, setSelectedResource] =
() => useState<SelectedResource | null>(null);
allResources
.filter((r) => r.http) // const resources = useMemo(
.map((r) => ({ // () =>
resourceId: r.resourceId, // allResources
name: r.name, // .filter((r) => r.http)
niceId: r.niceId, // .map((r) => ({
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/` // resourceId: r.resourceId,
})), // name: r.name,
[allResources] // niceId: r.niceId,
); // resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
// })),
// [allResources]
// );
const formSchema = z.object({ const formSchema = z.object({
resourceId: z.number({ message: t("shareErrorSelectResource") }), resourceId: z.number({ message: t("shareErrorSelectResource") }),
@@ -199,15 +203,11 @@ export default function CreateShareLinkForm({
setAccessToken(token.accessToken); setAccessToken(token.accessToken);
setAccessTokenId(token.accessTokenId); setAccessTokenId(token.accessTokenId);
const resource = resources.find(
(r) => r.resourceId === values.resourceId
);
onCreated?.({ onCreated?.({
accessTokenId: token.accessTokenId, accessTokenId: token.accessTokenId,
resourceId: token.resourceId, resourceId: token.resourceId,
resourceName: values.resourceName, resourceName: values.resourceName,
resourceNiceId: resource ? resource.niceId : "", resourceNiceId: selectedResource ? selectedResource.niceId : "",
title: token.title, title: token.title,
createdAt: token.createdAt, createdAt: token.createdAt,
expiresAt: token.expiresAt expiresAt: token.expiresAt
@@ -217,11 +217,6 @@ export default function CreateShareLinkForm({
setLoading(false); setLoading(false);
} }
function getSelectedResourceName(id: number) {
const resource = resources.find((r) => r.resourceId === id);
return `${resource?.name}`;
}
return ( return (
<> <>
<Credenza <Credenza
@@ -241,7 +236,7 @@ export default function CreateShareLinkForm({
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<div className="space-y-4"> <div className="flex flex-col gap-y-4 px-1">
{!link && ( {!link && (
<Form {...form}> <Form {...form}>
<form <form
@@ -269,10 +264,8 @@ export default function CreateShareLinkForm({
"text-muted-foreground" "text-muted-foreground"
)} )}
> >
{field.value {selectedResource?.name
? getSelectedResourceName( ? selectedResource.name
field.value
)
: t( : t(
"resourceSelect" "resourceSelect"
)} )}
@@ -281,59 +274,34 @@ export default function CreateShareLinkForm({
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0"> <PopoverContent className="p-0">
<Command> <ResourceSelector
<CommandInput orgId={
placeholder={t( org.org
"resourceSearch" .orgId
)} }
/> selectedResource={
<CommandList> selectedResource
<CommandEmpty> }
{t( onSelectResource={(
"resourcesNotFound" r
)} ) => {
</CommandEmpty> form.setValue(
<CommandGroup> "resourceId",
{resources.map( r.resourceId
( );
r form.setValue(
) => ( "resourceName",
<CommandItem r.name
value={`${r.name}:${r.resourceId}`} );
key={ form.setValue(
r.resourceId "resourceUrl",
} `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
onSelect={() => { );
form.setValue( setSelectedResource(
"resourceId", r
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>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<FormMessage /> <FormMessage />

View File

@@ -1,15 +1,10 @@
"use client"; "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 { Tag, TagInput } from "@app/components/tags/tag-input";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import { import {
Form, Form,
FormControl, FormControl,
@@ -32,24 +27,24 @@ import {
SelectValue SelectValue
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { Switch } from "@app/components/ui/switch"; 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 { cn } from "@app/lib/cn";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { orgQueries, resourceQueries } from "@app/lib/queries"; 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 { ListSitesResponse } from "@server/routers/site";
import { UserType } from "@server/types/UserTypes"; 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 { 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 { zodResolver } from "@hookform/resolvers/zod"; import { SitesSelector, type Selectedsite } from "./site-selector";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { CaretSortIcon } from "@radix-ui/react-icons";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { MachinesSelector } from "./machines-selector";
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";
// --- Helpers (shared) --- // --- Helpers (shared) ---
@@ -258,7 +253,14 @@ export function InternalResourceForm({
authDaemonPort: z.number().int().positive().optional().nullable(), authDaemonPort: z.number().int().positive().optional().nullable(),
roles: z.array(tagSchema).optional(), roles: z.array(tagSchema).optional(),
users: 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>; type FormData = z.infer<typeof formSchema>;
@@ -267,7 +269,7 @@ export function InternalResourceForm({
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.clients({ orgId })); const clientsQuery = useQuery(orgQueries.machineClients({ orgId }));
const resourceRolesQuery = useQuery({ const resourceRolesQuery = useQuery({
...resourceQueries.siteResourceRoles({ ...resourceQueries.siteResourceRoles({
siteResourceId: siteResourceId ?? 0 siteResourceId: siteResourceId ?? 0
@@ -325,12 +327,9 @@ export function InternalResourceForm({
})); }));
} }
if (clientsData) { if (clientsData) {
existingClients = ( existingClients = [
clientsData as { clientId: number; name: string }[] ...(clientsData as { clientId: number; name: string }[])
).map((c) => ({ ];
id: c.clientId.toString(),
text: c.name
}));
} }
} }
@@ -416,6 +415,10 @@ export function InternalResourceForm({
clients: [] clients: []
}; };
const [selectedSite, setSelectedSite] = useState<Selectedsite>(
availableSites[0]
);
const form = useForm<FormData>({ const form = useForm<FormData>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues defaultValues
@@ -537,9 +540,15 @@ export function InternalResourceForm({
return ( return (
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit((values) => onSubmit={form.handleSubmit((values) => {
onSubmit(values as InternalResourceFormValues) onSubmit({
)} ...values,
clients: (values.clients ?? []).map((c) => ({
id: c.clientId.toString(),
text: c.name
}))
});
})}
className="space-y-6" className="space-y-6"
id={formId} id={formId}
> >
@@ -600,46 +609,14 @@ export function InternalResourceForm({
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-full p-0"> <PopoverContent className="w-full p-0">
<Command> <SitesSelector
<CommandInput orgId={orgId}
placeholder={t("searchSites")} selectedSite={selectedSite}
/> onSelectSite={(site) => {
<CommandList> setSelectedSite(site);
<CommandEmpty> field.onChange(site.siteId);
{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>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<FormMessage /> <FormMessage />
@@ -649,8 +626,7 @@ export function InternalResourceForm({
</div> </div>
<HorizontalTabs <HorizontalTabs
clientSide={true} clientSide
defaultTab={0}
items={[ items={[
{ {
title: t( title: t(
@@ -667,7 +643,7 @@ export function InternalResourceForm({
: [{ title: t("sshAccess"), href: "#" }]) : [{ title: t("sshAccess"), href: "#" }])
]} ]}
> >
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4 p-1">
<div> <div>
<div className="mb-8"> <div className="mb-8">
<label className="font-medium block"> <label className="font-medium block">
@@ -1038,7 +1014,7 @@ export function InternalResourceForm({
</div> </div>
</div> </div>
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4 p-1">
<div className="mb-8"> <div className="mb-8">
<label className="font-medium block"> <label className="font-medium block">
{t("editInternalResourceDialogAccessControl")} {t("editInternalResourceDialogAccessControl")}
@@ -1158,48 +1134,73 @@ export function InternalResourceForm({
<FormLabel> <FormLabel>
{t("machineClients")} {t("machineClients")}
</FormLabel> </FormLabel>
<FormControl> <Popover>
<TagInput <PopoverTrigger asChild>
{...field} <FormControl>
activeTagIndex={ <Button
activeClientsTagIndex variant="outline"
} role="combobox"
setActiveTagIndex={ className={cn(
setActiveClientsTagIndex "justify-between w-full",
} "text-muted-foreground pl-1.5"
placeholder={ )}
t( >
"accessClientSelect" <span
) || className={cn(
"Select machine clients" "inline-flex items-center gap-1",
} "overflow-x-auto"
size="sm" )}
tags={ >
form.getValues() {(
.clients ?? [] field.value ??
} []
setTags={(newClients) => ).map(
form.setValue( (
"clients", client
newClients as [ ) => (
Tag, <span
...Tag[] key={
] client.clientId
) }
} className={cn(
enableAutocomplete={ "bg-muted-foreground/20 font-normal text-foreground rounded-sm",
true "py-1 px-1.5 text-xs"
} )}
autocompleteOptions={ >
allClients {
} client.name
allowDuplicates={false} }
restrictTagsToAutocompleteOptions={ </span>
true )
} )}
sortTags={true} <span className="pl-1">
/> {t(
</FormControl> "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 /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -1211,7 +1212,7 @@ export function InternalResourceForm({
{/* SSH Access tab */} {/* SSH Access tab */}
{!disableEnterpriseFeatures && mode !== "cidr" && ( {!disableEnterpriseFeatures && mode !== "cidr" && (
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4 p-1">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} /> <PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<div className="mb-8"> <div className="mb-8">
<label className="font-medium block"> <label className="font-medium block">

View File

@@ -540,7 +540,7 @@ export default function MachineClientsTable({
columns={columns} columns={columns}
rows={machineClients} rows={machineClients}
tableId="machine-clients" tableId="machine-clients"
searchPlaceholder={t("resourcesSearch")} searchPlaceholder={t("machinesSearch")}
onAdd={() => onAdd={() =>
startNavigation(() => startNavigation(() =>
router.push(`/${orgId}/settings/clients/machine/create`) router.push(`/${orgId}/settings/clients/machine/create`)

View File

@@ -770,7 +770,7 @@ export default function UserDevicesTable({
columns={columns} columns={columns}
rows={userClients || []} rows={userClients || []}
tableId="user-clients" tableId="user-clients"
searchPlaceholder={t("resourcesSearch")} searchPlaceholder={t("userDevicesSearch")}
onRefresh={refreshData} onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering} isRefreshing={isRefreshing || isFiltering}
enableColumnVisibility enableColumnVisibility

View 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>
);
}

View 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>
);
}

View File

@@ -1,12 +1,15 @@
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import type { DockerState } from "@app/lib/docker"; import type { DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget"; import { parseHostTarget } from "@app/lib/parseHostTarget";
import { orgQueries } from "@app/lib/queries";
import { CaretSortIcon } from "@radix-ui/react-icons"; import { CaretSortIcon } from "@radix-ui/react-icons";
import type { ListSitesResponse } from "@server/routers/site"; import type { ListSitesResponse } from "@server/routers/site";
import { type ListTargetsResponse } from "@server/routers/target"; import { type ListTargetsResponse } from "@server/routers/target";
import type { ArrayElement } from "@server/types/ArrayElement"; import type { ArrayElement } from "@server/types/ArrayElement";
import { useQuery } from "@tanstack/react-query";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import { ContainersSelector } from "./ContainersSelector"; import { ContainersSelector } from "./ContainersSelector";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { import {
@@ -20,7 +23,7 @@ import {
import { Input } from "./ui/input"; import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
import { useEffect } from "react"; import { SitesSelector } from "./site-selector";
type SiteWithUpdateAvailable = ListSitesResponse["sites"][number]; type SiteWithUpdateAvailable = ListSitesResponse["sites"][number];
@@ -36,14 +39,14 @@ export type LocalTarget = Omit<
export type ResourceTargetAddressItemProps = { export type ResourceTargetAddressItemProps = {
getDockerStateForSite: (siteId: number) => DockerState; getDockerStateForSite: (siteId: number) => DockerState;
updateTarget: (targetId: number, data: Partial<LocalTarget>) => void; updateTarget: (targetId: number, data: Partial<LocalTarget>) => void;
sites: SiteWithUpdateAvailable[]; orgId: string;
proxyTarget: LocalTarget; proxyTarget: LocalTarget;
isHttp: boolean; isHttp: boolean;
refreshContainersForSite: (siteId: number) => void; refreshContainersForSite: (siteId: number) => void;
}; };
export function ResourceTargetAddressItem({ export function ResourceTargetAddressItem({
sites, orgId,
getDockerStateForSite, getDockerStateForSite,
updateTarget, updateTarget,
proxyTarget, proxyTarget,
@@ -52,9 +55,23 @@ export function ResourceTargetAddressItem({
}: ResourceTargetAddressItemProps) { }: ResourceTargetAddressItemProps) {
const t = useTranslations(); const t = useTranslations();
const selectedSite = sites.find( const [selectedSite, setSelectedSite] = useState<Pick<
(site) => site.siteId === proxyTarget.siteId 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 = ( const handleContainerSelectForTarget = (
hostname: string, hostname: string,
@@ -70,28 +87,23 @@ export function ResourceTargetAddressItem({
return ( return (
<div className="flex items-center w-full" key={proxyTarget.targetId}> <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"> <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 && selectedSite.type === "newt" && (
selectedSite.type === "newt" && <ContainersSelector
(() => { site={selectedSite}
const dockerState = getDockerStateForSite( containers={
selectedSite.siteId getDockerStateForSite(selectedSite.siteId)
); .containers
return ( }
<ContainersSelector isAvailable={
site={selectedSite} getDockerStateForSite(selectedSite.siteId)
containers={dockerState.containers} .isAvailable
isAvailable={dockerState.isAvailable} }
onContainerSelect={ onContainerSelect={handleContainerSelectForTarget}
handleContainerSelectForTarget onRefresh={() =>
} refreshContainersForSite(selectedSite.siteId)
onRefresh={() => }
refreshContainersForSite( />
selectedSite.siteId )}
)
}
/>
);
})()}
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -113,39 +125,18 @@ export function ResourceTargetAddressItem({
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0 w-45"> <PopoverContent className="p-0 w-45">
<Command> <SitesSelector
<CommandInput placeholder={t("siteSearch")} /> orgId={orgId}
<CommandList> selectedSite={selectedSite}
<CommandEmpty>{t("siteNotFound")}</CommandEmpty> onSelectSite={(site) => {
<CommandGroup> updateTarget(proxyTarget.targetId, {
{sites.map((site) => ( siteId: site.siteId,
<CommandItem siteType: site.type,
key={site.siteId} siteName: site.name
value={`${site.siteId}:${site.name}`} });
onSelect={() => setSelectedSite(site);
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>
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View 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>
);
}

View File

@@ -92,14 +92,26 @@ export const productUpdatesQueries = {
}; };
export const orgQueries = { export const orgQueries = {
clients: ({ orgId }: { orgId: string }) => machineClients: ({
orgId,
query,
perPage = 10_000
}: {
orgId: string;
query?: string;
perPage?: number;
}) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "CLIENTS"] as const, queryKey: ["ORG", orgId, "CLIENTS", { query, perPage }] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({ const sp = new URLSearchParams({
pageSize: "10000" pageSize: perPage.toString()
}); });
if (query?.trim()) {
sp.set("query", query);
}
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListClientsResponse> AxiosResponse<ListClientsResponse>
>(`/org/${orgId}/clients?${sp.toString()}`, { signal }); >(`/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({ queryOptions({
queryKey: ["ORG", orgId, "SITES"] as const, queryKey: ["ORG", orgId, "SITES", { query, perPage }] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({ const sp = new URLSearchParams({
pageSize: "10000" pageSize: perPage.toString()
}); });
if (query?.trim()) {
sp.set("query", query);
}
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListSitesResponse> AxiosResponse<ListSitesResponse>
>(`/org/${orgId}/sites?${sp.toString()}`, { signal }); >(`/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({ queryOptions({
queryKey: ["ORG", orgId, "RESOURCES"] as const, queryKey: ["ORG", orgId, "RESOURCES", { query, perPage }] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({ const sp = new URLSearchParams({
pageSize: "10000" pageSize: perPage.toString()
}); });
if (query?.trim()) {
sp.set("query", query);
}
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListResourcesResponse> AxiosResponse<ListResourcesResponse>
>(`/org/${orgId}/resources?${sp.toString()}`, { signal }); >(`/org/${orgId}/resources?${sp.toString()}`, { signal });