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",
"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",

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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}

View File

@@ -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 />

View File

@@ -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">

View File

@@ -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`)

View File

@@ -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

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 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>

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 = {
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 });