Wrap in transactions

This commit is contained in:
Owen
2026-04-27 13:46:01 -07:00
parent 512ba2150b
commit 61aaa5a832
7 changed files with 337 additions and 272 deletions

View File

@@ -17,16 +17,21 @@ export async function addTargets(
}:${target.port}`; }:${target.port}`;
}); });
await sendToClient( if (payloadTargets.length > 0) {
newtId, await sendToClient(
{ newtId,
type: `newt/${protocol}/add`, {
data: { type: `newt/${protocol}/add`,
targets: payloadTargets data: {
targets: payloadTargets
}
},
{
incrementConfigVersion: true,
compress: canCompress(version, "newt")
} }
}, );
{ incrementConfigVersion: true, compress: canCompress(version, "newt") } }
);
const healthCheckTargets = healthCheckData.map((hc) => { const healthCheckTargets = healthCheckData.map((hc) => {
// Ensure all necessary fields are present // Ensure all necessary fields are present
@@ -206,16 +211,18 @@ export async function removeTargets(
}:${target.port}`; }:${target.port}`;
}); });
await sendToClient( if (payloadTargets.length > 0) {
newtId, await sendToClient(
{ newtId,
type: `newt/${protocol}/remove`, {
data: { type: `newt/${protocol}/remove`,
targets: payloadTargets data: {
} targets: payloadTargets
}, }
{ incrementConfigVersion: true } },
); { incrementConfigVersion: true }
);
}
const healthCheckTargets = healthCheckData.map((hc) => { const healthCheckTargets = healthCheckData.map((hc) => {
return hc.targetHealthCheckId; return hc.targetHealthCheckId;

View File

@@ -98,7 +98,8 @@ export async function deleteResource(
await removeTargets( await removeTargets(
newt.newtId, newt.newtId,
[target], // [target],
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
[healthCheck], [healthCheck],
deletedResource.protocol, deletedResource.protocol,
newt.version newt.version

View File

@@ -19,7 +19,11 @@ import { eq } from "drizzle-orm";
import { pickPort } from "./helpers"; import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators"; import { isTargetValid } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { fireHealthCheckHealthyAlert, fireHealthCheckUnhealthyAlert, fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; import {
fireHealthCheckHealthyAlert,
fireHealthCheckUnhealthyAlert,
fireHealthCheckUnknownAlert
} from "#dynamic/lib/alerts";
const createTargetParamsSchema = z.strictObject({ const createTargetParamsSchema = z.strictObject({
resourceId: z.string().transform(Number).pipe(z.int().positive()) resourceId: z.string().transform(Number).pipe(z.int().positive())
@@ -142,151 +146,155 @@ export async function createTarget(
); );
} }
const existingTargets = await db
.select()
.from(targets)
.where(eq(targets.resourceId, resourceId));
const existingTarget = existingTargets.find(
(target) =>
target.ip === targetData.ip &&
target.port === targetData.port &&
target.method === targetData.method &&
target.siteId === targetData.siteId
);
if (existingTarget) {
// log a warning
logger.warn(
`Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}`
);
}
let newTarget: Target[] = []; let newTarget: Target[] = [];
let healthCheck: TargetHealthCheck[] = [];
let targetIps: string[] = []; let targetIps: string[] = [];
if (site.type == "local") { let healthCheck: TargetHealthCheck[] = [];
newTarget = await db await db.transaction(async (trx) => {
.insert(targets) const existingTargets = await trx
.values({ .select()
resourceId, .from(targets)
...targetData, .where(eq(targets.resourceId, resourceId));
priority: targetData.priority || 100
}) const existingTarget = existingTargets.find(
.returning(); (target) =>
} else { target.ip === targetData.ip &&
// make sure the target is within the site subnet target.port === targetData.port &&
if ( target.method === targetData.method &&
site.type == "wireguard" && target.siteId === targetData.siteId
!isIpInCidr(targetData.ip, site.subnet!) );
) {
return next( if (existingTarget) {
createHttpError( // log a warning
HttpCode.BAD_REQUEST, logger.warn(
`Target IP is not within the site subnet` `Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}`
)
); );
} }
const { internalPort, targetIps: newTargetIps } = await pickPort( if (site.type == "local") {
site.siteId!, newTarget = await trx
db .insert(targets)
); .values({
resourceId,
...targetData,
priority: targetData.priority || 100
})
.returning();
} else {
// make sure the target is within the site subnet
if (
site.type == "wireguard" &&
!isIpInCidr(targetData.ip, site.subnet!)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Target IP is not within the site subnet`
)
);
}
if (!internalPort) { const { internalPort, targetIps: newTargetIps } =
return next( await pickPort(site.siteId!, trx);
createHttpError(
HttpCode.BAD_REQUEST, if (!internalPort) {
`No available internal port` return next(
) createHttpError(
); HttpCode.BAD_REQUEST,
`No available internal port`
)
);
}
newTarget = await trx
.insert(targets)
.values({
resourceId,
siteId: site.siteId,
ip: targetData.ip,
method: targetData.method,
port: targetData.port,
internalPort,
enabled: targetData.enabled,
path: targetData.path,
pathMatchType: targetData.pathMatchType,
rewritePath: targetData.rewritePath,
rewritePathType: targetData.rewritePathType,
priority: targetData.priority || 100
})
.returning();
// add the new target to the targetIps array
newTargetIps.push(`${targetData.ip}/32`);
targetIps = newTargetIps;
} }
newTarget = await db let hcHeaders = null;
.insert(targets) if (targetData.hcHeaders) {
hcHeaders = JSON.stringify(targetData.hcHeaders);
}
healthCheck = await trx
.insert(targetHealthCheck)
.values({ .values({
resourceId, orgId: resource.orgId,
siteId: site.siteId, targetId: newTarget[0].targetId,
ip: targetData.ip, siteId: targetData.siteId,
method: targetData.method, name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`,
port: targetData.port, hcEnabled: targetData.hcEnabled ?? false,
internalPort, hcPath: targetData.hcPath ?? null,
enabled: targetData.enabled, hcScheme: targetData.hcScheme ?? null,
path: targetData.path, hcMode: targetData.hcMode ?? null,
pathMatchType: targetData.pathMatchType, hcHostname: targetData.hcHostname ?? null,
rewritePath: targetData.rewritePath, hcPort: targetData.hcPort ?? null,
rewritePathType: targetData.rewritePathType, hcInterval: targetData.hcInterval ?? null,
priority: targetData.priority || 100 hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null,
hcTimeout: targetData.hcTimeout ?? null,
hcHeaders: hcHeaders,
hcFollowRedirects: targetData.hcFollowRedirects ?? null,
hcMethod: targetData.hcMethod ?? null,
hcStatus: targetData.hcStatus ?? null,
hcHealth: targetData.hcEnabled ? "unhealthy" : "unknown",
hcTlsServerName: targetData.hcTlsServerName ?? null,
hcHealthyThreshold: targetData.hcHealthyThreshold ?? null,
hcUnhealthyThreshold:
targetData.hcUnhealthyThreshold ?? null
}) })
.returning(); .returning();
// add the new target to the targetIps array if (healthCheck[0].hcHealth === "unhealthy") {
newTargetIps.push(`${targetData.ip}/32`); await fireHealthCheckUnhealthyAlert(
healthCheck[0].orgId,
targetIps = newTargetIps; healthCheck[0].targetHealthCheckId,
} healthCheck[0].name,
undefined,
let hcHeaders = null; undefined,
if (targetData.hcHeaders) { false, // dont send the alert because we just want to create the alert, not notify users yet
hcHeaders = JSON.stringify(targetData.hcHeaders); trx
} );
} else if (healthCheck[0].hcHealth === "unknown") {
healthCheck = await db // if the health is unknown, we want to fire an alert to notify users to enable health checks
.insert(targetHealthCheck) await fireHealthCheckUnknownAlert(
.values({ healthCheck[0].orgId,
orgId: resource.orgId, healthCheck[0].targetHealthCheckId,
targetId: newTarget[0].targetId, healthCheck[0].name,
siteId: targetData.siteId, undefined,
name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`, undefined,
hcEnabled: targetData.hcEnabled ?? false, false, // dont send the alert because we just want to create the alert, not notify users yet
hcPath: targetData.hcPath ?? null, trx
hcScheme: targetData.hcScheme ?? null, );
hcMode: targetData.hcMode ?? null, } else if (healthCheck[0].hcHealth === "healthy") {
hcHostname: targetData.hcHostname ?? null, await fireHealthCheckHealthyAlert(
hcPort: targetData.hcPort ?? null, healthCheck[0].orgId,
hcInterval: targetData.hcInterval ?? null, healthCheck[0].targetHealthCheckId,
hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null, healthCheck[0].name,
hcTimeout: targetData.hcTimeout ?? null, undefined,
hcHeaders: hcHeaders, undefined,
hcFollowRedirects: targetData.hcFollowRedirects ?? null, false, // dont send the alert because we just want to create the alert, not notify users yet
hcMethod: targetData.hcMethod ?? null, trx
hcStatus: targetData.hcStatus ?? null, );
hcHealth: targetData.hcEnabled ? "unhealthy" : "unknown", }
hcTlsServerName: targetData.hcTlsServerName ?? null, });
hcHealthyThreshold: targetData.hcHealthyThreshold ?? null,
hcUnhealthyThreshold: targetData.hcUnhealthyThreshold ?? null
})
.returning();
if (healthCheck[0].hcHealth === "unhealthy") {
await fireHealthCheckUnhealthyAlert(
healthCheck[0].orgId,
healthCheck[0].targetHealthCheckId,
healthCheck[0].name,
undefined,
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
} else if (healthCheck[0].hcHealth === "unknown") {
// if the health is unknown, we want to fire an alert to notify users to enable health checks
await fireHealthCheckUnknownAlert(
healthCheck[0].orgId,
healthCheck[0].targetHealthCheckId,
healthCheck[0].name,
undefined,
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
} else if (healthCheck[0].hcHealth === "healthy") {
await fireHealthCheckHealthyAlert(
healthCheck[0].orgId,
healthCheck[0].targetHealthCheckId,
healthCheck[0].name,
undefined,
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
}
if (site.pubKey) { if (site.pubKey) {
if (site.type == "wireguard") { if (site.type == "wireguard") {

View File

@@ -2,15 +2,13 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { newts, resources, sites, targets } from "@server/db"; import { newts, resources, sites, targets } from "@server/db";
import { eq } from "drizzle-orm"; import { eq, ne, and } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { addPeer } from "../gerbil/peers";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { removeTargets } from "../newt/targets"; import { removeTargets } from "../newt/targets";
import { getAllowedIps } from "./helpers";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { targetHealthCheck } from "@server/db/pg"; import { targetHealthCheck } from "@server/db/pg";
@@ -80,10 +78,29 @@ export async function deleteTarget(
); );
} }
// check if there are other targets on the resource
const otherTargets = await db
.select()
.from(targets)
.where(
and(
eq(targets.resourceId, resource.resourceId),
ne(targets.targetId, targetId)
)
);
if (otherTargets.length == 0) {
// set the resource status
await db
.update(resources)
.set({ health: "unknown" })
.where(eq(resources.resourceId, resource.resourceId));
}
const [site] = await db const [site] = await db
.select() .select()
.from(sites) .from(sites)
.where(eq(sites.siteId, targets.siteId)) .where(eq(sites.siteId, deletedTarget.siteId))
.limit(1); .limit(1);
if (!site) { if (!site) {
@@ -106,7 +123,8 @@ export async function deleteTarget(
await removeTargets( await removeTargets(
newt.newtId, newt.newtId,
[deletedTarget], // [deletedTarget],
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
[deletedHealthCheck], [deletedHealthCheck],
resource.protocol, resource.protocol,
newt.version newt.version

View File

@@ -10,12 +10,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers"; import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets"; import { addTargets } from "../newt/targets";
import { fireHealthCheckHealthyAlert, fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; import { fireHealthCheckHealthyAlert, fireHealthCheckUnknownAlert, fireHealthCheckUnhealthyAlert } from "#dynamic/lib/alerts";
import { pickPort } from "./helpers"; import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators"; import { isTargetValid } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { fireHealthCheckUnhealthyAlert } from "@server/lib/alerts";
const updateTargetParamsSchema = z.strictObject({ const updateTargetParamsSchema = z.strictObject({
targetId: z.string().transform(Number).pipe(z.int().positive()) targetId: z.string().transform(Number).pipe(z.int().positive())
@@ -168,124 +166,131 @@ export async function updateTarget(
const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null;
const [updatedTarget] = await db let updatedTarget: any;
.update(targets) let updatedHc: any;
.set({ await db.transaction(async (trx) => {
siteId: parsedBody.data.siteId, [updatedTarget] = await trx
ip: parsedBody.data.ip, .update(targets)
method: parsedBody.data.method, .set({
port: parsedBody.data.port, siteId: parsedBody.data.siteId,
internalPort, ip: parsedBody.data.ip,
enabled: parsedBody.data.enabled, method: parsedBody.data.method,
path: parsedBody.data.path, port: parsedBody.data.port,
pathMatchType: parsedBody.data.pathMatchType, internalPort,
priority: parsedBody.data.priority, enabled: parsedBody.data.enabled,
rewritePath: pathMatchTypeRemoved ? null : parsedBody.data.rewritePath, path: parsedBody.data.path,
rewritePathType: pathMatchTypeRemoved ? null : parsedBody.data.rewritePathType pathMatchType: parsedBody.data.pathMatchType,
}) priority: parsedBody.data.priority,
.where(eq(targets.targetId, targetId)) rewritePath: pathMatchTypeRemoved ? null : parsedBody.data.rewritePath,
.returning(); rewritePathType: pathMatchTypeRemoved ? null : parsedBody.data.rewritePathType
})
.where(eq(targets.targetId, targetId))
.returning();
const [existingHc] = await db const [existingHc] = await trx
.select() .select()
.from(targetHealthCheck) .from(targetHealthCheck)
.where(eq(targetHealthCheck.targetId, targetId)) .where(eq(targetHealthCheck.targetId, targetId))
.limit(1); .limit(1);
if (!existingHc) { if (!existingHc) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
`Health check for target with ID ${targetId} not found` `Health check for target with ID ${targetId} not found`
) )
); );
} }
let hcHeaders = null; let hcHeaders = null;
if (parsedBody.data.hcHeaders) { if (parsedBody.data.hcHeaders) {
hcHeaders = JSON.stringify(parsedBody.data.hcHeaders); hcHeaders = JSON.stringify(parsedBody.data.hcHeaders);
} }
// When health check is disabled, reset hcHealth to "unknown" // When health check is disabled, reset hcHealth to "unknown"
// to prevent previously unhealthy targets from being excluded. // to prevent previously unhealthy targets from being excluded.
// Also when the site is not a newt, set hcHealth to "unknown". // Also when the site is not a newt, set hcHealth to "unknown".
// If hcEnabled is being turned on (was false, now true), set to "unhealthy" // If hcEnabled is being turned on (was false, now true), set to "unhealthy"
// so the target must pass a health check before being considered healthy. // so the target must pass a health check before being considered healthy.
const hcEnabledTurnedOn = const hcEnabledTurnedOn =
parsedBody.data.hcEnabled === true && existingHc.hcEnabled === false; parsedBody.data.hcEnabled === true && existingHc.hcEnabled === false;
let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined; let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined;
if ( if (
parsedBody.data.hcEnabled === false || parsedBody.data.hcEnabled === false ||
parsedBody.data.hcEnabled === null || parsedBody.data.hcEnabled === null ||
site.type !== "newt" site.type !== "newt"
) { ) {
hcHealthValue = "unknown"; hcHealthValue = "unknown";
} else if (hcEnabledTurnedOn) { } else if (hcEnabledTurnedOn) {
hcHealthValue = "unhealthy"; hcHealthValue = "unhealthy";
} else { } else {
hcHealthValue = undefined; hcHealthValue = undefined;
} }
const isDisablingHc = const isDisablingHc =
(parsedBody.data.hcEnabled === false || (parsedBody.data.hcEnabled === false ||
parsedBody.data.hcEnabled === null) && parsedBody.data.hcEnabled === null) &&
existingHc.hcEnabled === true; existingHc.hcEnabled === true;
const [updatedHc] = await db const [updatedHc] = await trx
.update(targetHealthCheck) .update(targetHealthCheck)
.set({ .set({
siteId: parsedBody.data.siteId, siteId: parsedBody.data.siteId,
hcEnabled: parsedBody.data.hcEnabled || false, hcEnabled: parsedBody.data.hcEnabled || false,
hcPath: parsedBody.data.hcPath, hcPath: parsedBody.data.hcPath,
hcScheme: parsedBody.data.hcScheme, hcScheme: parsedBody.data.hcScheme,
hcMode: parsedBody.data.hcMode, hcMode: parsedBody.data.hcMode,
hcHostname: parsedBody.data.hcHostname, hcHostname: parsedBody.data.hcHostname,
hcPort: parsedBody.data.hcPort, hcPort: parsedBody.data.hcPort,
hcInterval: parsedBody.data.hcInterval, hcInterval: parsedBody.data.hcInterval,
hcUnhealthyInterval: parsedBody.data.hcUnhealthyInterval, hcUnhealthyInterval: parsedBody.data.hcUnhealthyInterval,
hcTimeout: parsedBody.data.hcTimeout, hcTimeout: parsedBody.data.hcTimeout,
hcHeaders: hcHeaders, hcHeaders: hcHeaders,
hcFollowRedirects: parsedBody.data.hcFollowRedirects, hcFollowRedirects: parsedBody.data.hcFollowRedirects,
hcMethod: parsedBody.data.hcMethod, hcMethod: parsedBody.data.hcMethod,
hcStatus: parsedBody.data.hcStatus, hcStatus: parsedBody.data.hcStatus,
hcTlsServerName: parsedBody.data.hcTlsServerName, hcTlsServerName: parsedBody.data.hcTlsServerName,
hcHealthyThreshold: parsedBody.data.hcHealthyThreshold, hcHealthyThreshold: parsedBody.data.hcHealthyThreshold,
hcUnhealthyThreshold: parsedBody.data.hcUnhealthyThreshold, hcUnhealthyThreshold: parsedBody.data.hcUnhealthyThreshold,
hcHealth: hcHealthValue hcHealth: hcHealthValue
}) })
.where(eq(targetHealthCheck.targetId, targetId)) .where(eq(targetHealthCheck.targetId, targetId))
.returning(); .returning();
if (updatedHc.hcHealth === "unhealthy" && existingHc.hcHealth !== "unhealthy") { if (updatedHc.hcHealth === "unhealthy" && existingHc.hcHealth !== "unhealthy") {
await fireHealthCheckUnhealthyAlert( await fireHealthCheckUnhealthyAlert(
updatedHc.orgId, updatedHc.orgId,
updatedHc.targetHealthCheckId, updatedHc.targetHealthCheckId,
updatedHc.name || "", updatedHc.name || "",
undefined, undefined,
undefined, undefined,
false // dont send the alert because we just want to create the alert, not notify users yet false, // dont send the alert because we just want to create the alert, not notify users yet
); trx
} else if (updatedHc.hcHealth === "unknown" && existingHc.hcHealth !== "unknown") { );
// if the health is unknown, we want to fire an alert to notify users to enable health checks } else if (updatedHc.hcHealth === "unknown" && existingHc.hcHealth !== "unknown") {
await fireHealthCheckUnknownAlert( // if the health is unknown, we want to fire an alert to notify users to enable health checks
updatedHc.orgId, await fireHealthCheckUnknownAlert(
updatedHc.targetHealthCheckId, updatedHc.orgId,
updatedHc.name, updatedHc.targetHealthCheckId,
undefined, updatedHc.name,
undefined, undefined,
false // dont send the alert because we just want to create the alert, not notify users yet undefined,
); false, // dont send the alert because we just want to create the alert, not notify users yet
} else if (updatedHc.hcHealth === "healthy" && existingHc.hcHealth !== "healthy") { trx
await fireHealthCheckHealthyAlert( );
updatedHc.orgId, } else if (updatedHc.hcHealth === "healthy" && existingHc.hcHealth !== "healthy") {
updatedHc.targetHealthCheckId, await fireHealthCheckHealthyAlert(
updatedHc.name, updatedHc.orgId,
undefined, updatedHc.targetHealthCheckId,
undefined, updatedHc.name,
false // dont send the alert because we just want to create the alert, not notify users yet undefined,
); undefined,
} false, // dont send the alert because we just want to create the alert, not notify users yet
trx
);
}
});
if (site.pubKey) { if (site.pubKey) {
if (site.type == "wireguard") { if (site.type == "wireguard") {
@@ -310,6 +315,7 @@ export async function updateTarget(
); );
} }
} }
return response(res, { return response(res, {
data: { data: {
...updatedTarget, ...updatedTarget,

View File

@@ -62,6 +62,7 @@ import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { DockerManager, DockerState } from "@app/lib/docker"; import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries, resourceQueries } from "@app/lib/queries"; import { orgQueries, resourceQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
import { tlsNameSchema } from "@server/lib/schemas"; import { tlsNameSchema } from "@server/lib/schemas";
import { type GetResourceResponse } from "@server/routers/resource"; import { type GetResourceResponse } from "@server/routers/resource";
import type { ListSitesResponse } from "@server/routers/site"; import type { ListSitesResponse } from "@server/routers/site";
@@ -953,6 +954,18 @@ function ProxyResourceTargetsForm({
</Button> </Button>
</div> </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> </SettingsSectionBody>
<form className="self-end mt-4" action={formAction}> <form className="self-end mt-4" action={formAction}>

View File

@@ -1419,6 +1419,18 @@ export default function Page() {
</Button> </Button>
</div> </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> </SettingsSectionBody>
</SettingsSection> </SettingsSection>