Merge branch 'dev' into feat/roles-and-user-multi-selectors

This commit is contained in:
Fred KISSIE
2026-04-30 16:55:25 +02:00
189 changed files with 6765 additions and 1954 deletions

View File

@@ -29,6 +29,7 @@ import { Label } from "@app/components/ui/label";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { UpdateResourceResponse } from "@server/routers/resource";
import { AxiosResponse } from "axios";
import { AlertCircle } from "lucide-react";
@@ -506,7 +507,7 @@ export default function GeneralForm() {
name: data.name,
niceId: data.niceId,
subdomain: data.subdomain
? toASCII(data.subdomain)
? toASCII(finalizeSubdomainSanitize(data.subdomain, true))
: undefined,
domainId: data.domainId,
proxyPort: data.proxyPort
@@ -670,6 +671,7 @@ export default function GeneralForm() {
<div className="space-y-4">
<div id="resource-domain-picker">
<DomainPicker
allowWildcard={true}
key={resource.resourceId}
orgId={orgId as string}
cols={2}

View File

@@ -62,6 +62,7 @@ import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries, resourceQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
import { tlsNameSchema } from "@server/lib/schemas";
import { type GetResourceResponse } from "@server/routers/resource";
import type { ListSitesResponse } from "@server/routers/site";
@@ -953,6 +954,18 @@ function ProxyResourceTargetsForm({
</Button>
</div>
)}
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3 flex items-start gap-1.5">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>
Round robin routing will not work between
sites that are not connected to the same
node, but failover will work.
</span>
</p>
)}
</SettingsSectionBody>
<form className="self-end mt-4" action={formAction}>

View File

@@ -488,7 +488,7 @@ export default function Page() {
const httpData = httpForm.getValues();
sanitizedSubdomain = httpData.subdomain
? finalizeSubdomainSanitize(httpData.subdomain)
? finalizeSubdomainSanitize(httpData.subdomain, true)
: undefined;
Object.assign(payload, {
@@ -694,19 +694,6 @@ export default function Page() {
header: () => <span className="p-3">{t("healthCheck")}</span>,
cell: ({ row }) => {
const status = row.original.hcHealth || "unknown";
const isEnabled = row.original.hcEnabled;
const getStatusColor = (status: string) => {
switch (status) {
case "healthy":
return "green";
case "unhealthy":
return "red";
case "unknown":
default:
return "secondary";
}
};
const getStatusText = (status: string) => {
switch (status) {
@@ -720,19 +707,7 @@ export default function Page() {
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "healthy":
return <CircleCheck className="w-3 h-3" />;
case "unhealthy":
return <CircleX className="w-3 h-3" />;
case "unknown":
default:
return null;
}
};
return (
return (
<div className="flex items-center justify-center w-full">
{row.original.siteType === "newt" ? (
<Button
@@ -742,12 +717,16 @@ export default function Page() {
openHealthCheckDialog(row.original)
}
>
<Settings className="h-4 w-4" />
<div className="flex items-center gap-1">
{getStatusIcon(status)}
<div
className={`flex items-center gap-2 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : "text-neutral-500"}`}
>
<div
className={`w-2 h-2 rounded-full ${status === "healthy" ? "bg-green-500" : status === "unhealthy" ? "bg-destructive" : "bg-neutral-500"}`}
></div>
{getStatusText(status)}
</div>
</Button>
) : (
<span>-</span>
)}
@@ -1132,6 +1111,7 @@ export default function Page() {
<SettingsSectionBody>
<SettingsSectionForm>
<DomainPicker
allowWildcard={true}
orgId={orgId as string}
warnOnProvidedDomain={
remoteExitNodes.length >= 1
@@ -1439,6 +1419,18 @@ export default function Page() {
</Button>
</div>
)}
{build === "enterprise" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3 flex items-start gap-1.5">
<InfoIcon className="h-4 w-4 shrink-0 mt-0.5" />
<span>
Round robin routing will not work between
sites that are not connected to the same
node, but failover will work.
</span>
</p>
)}
</SettingsSectionBody>
</SettingsSection>

View File

@@ -7,7 +7,8 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import OrgProvider from "@app/providers/OrgProvider";
import type { GetOrgResponse } from "@server/routers/org";
import type { ListResourcesResponse } from "@server/routers/resource";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import { GetSiteResponse } from "@server/routers/site/getSite";
import type ResponseT from "@server/types/Response";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
@@ -24,6 +25,13 @@ export interface ProxyResourcesPageProps {
searchParams: Promise<Record<string, string>>;
}
function parsePositiveInt(s: string | undefined): number | undefined {
if (!s) return undefined;
const n = Number(s);
if (!Number.isInteger(n) || n <= 0) return undefined;
return n;
}
export default async function ProxyResourcesPage(
props: ProxyResourcesPageProps
) {
@@ -47,13 +55,31 @@ export default async function ProxyResourcesPage(
pagination = responseData.pagination;
} catch (e) {}
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
try {
const res = await internal.get<
AxiosResponse<ListAllSiteResourcesByOrgResponse>
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
siteResources = res.data.data.siteResources;
} catch (e) {}
const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined);
let initialFilterSite: {
siteId: number;
name: string;
type: string;
} | null = null;
if (siteIdParam) {
try {
const siteRes = await internal.get(
`/site/${siteIdParam}`,
await authCookieHeader()
);
const s = (siteRes.data as ResponseT<GetSiteResponse>).data;
if (s && s.orgId === params.orgId) {
initialFilterSite = {
siteId: s.siteId,
name: s.name,
type: s.type
};
}
} catch {
// leave null
}
}
let org = null;
try {
@@ -94,6 +120,7 @@ export default async function ProxyResourcesPage(
: "not_protected",
enabled: resource.enabled,
domainId: resource.domainId || undefined,
fullDomain: resource.fullDomain ?? null,
ssl: resource.ssl,
targets: resource.targets?.map((target) => ({
targetId: target.targetId,
@@ -102,7 +129,9 @@ export default async function ProxyResourcesPage(
enabled: target.enabled,
healthStatus: target.healthStatus,
siteName: target.siteName
}))
})),
sites: resource.sites ?? [],
health: (resource.health as ResourceRow["health"]) ?? undefined
};
});
return (
@@ -123,6 +152,7 @@ export default async function ProxyResourcesPage(
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
initialFilterSite={initialFilterSite}
/>
</OrgProvider>
</>