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",
|
"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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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 { 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>
|
||||||
|
|
||||||
|
|||||||
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 = {
|
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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user