♻️ filter sites server side in resource target

This commit is contained in:
Fred KISSIE
2026-03-17 04:07:02 +01:00
parent bab09dff95
commit 18ed38889f
6 changed files with 107 additions and 65 deletions

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

@@ -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 { 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,6 @@ 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";
type SiteWithUpdateAvailable = ListSitesResponse["sites"][number]; type SiteWithUpdateAvailable = ListSitesResponse["sites"][number];
@@ -36,14 +38,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,10 +54,34 @@ export function ResourceTargetAddressItem({
}: ResourceTargetAddressItemProps) { }: ResourceTargetAddressItemProps) {
const t = useTranslations(); const t = useTranslations();
const selectedSite = sites.find( const [siteSearchQuery, setSiteSearchQuery] = useState("");
(site) => site.siteId === proxyTarget.siteId
const { data: sites = [] } = useQuery(
orgQueries.sites({
orgId,
query: siteSearchQuery,
perPage: 10
})
); );
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 = ( const handleContainerSelectForTarget = (
hostname: string, hostname: string,
port?: number port?: number
@@ -70,28 +96,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,8 +134,11 @@ export function ResourceTargetAddressItem({
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0 w-45"> <PopoverContent className="p-0 w-45">
<Command> <Command shouldFilter={false}>
<CommandInput placeholder={t("siteSearch")} /> <CommandInput
placeholder={t("siteSearch")}
onValueChange={(v) => setSiteSearchQuery(v)}
/>
<CommandList> <CommandList>
<CommandEmpty>{t("siteNotFound")}</CommandEmpty> <CommandEmpty>{t("siteNotFound")}</CommandEmpty>
<CommandGroup> <CommandGroup>
@@ -122,14 +146,18 @@ export function ResourceTargetAddressItem({
<CommandItem <CommandItem
key={site.siteId} key={site.siteId}
value={`${site.siteId}:${site.name}`} value={`${site.siteId}:${site.name}`}
onSelect={() => onSelect={() => {
updateTarget( updateTarget(
proxyTarget.targetId, proxyTarget.targetId,
{ {
siteId: site.siteId siteId: site.siteId,
siteType: site.type,
siteName: site.name
} }
) );
}
setSelectedSite(site);
}}
> >
<CheckIcon <CheckIcon
className={cn( className={cn(

View File

@@ -130,14 +130,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 });