Add support for configuring load balancing strategy

This commit is contained in:
Owen
2026-10-12 16:47:12 -04:00
parent cccc2d1200
commit b43709903d
12 changed files with 149 additions and 0 deletions
+9
View File
@@ -793,6 +793,15 @@
"targetsDescription": "Set up targets to route traffic to backend services", "targetsDescription": "Set up targets to route traffic to backend services",
"targetStickySessions": "Enable Sticky Sessions", "targetStickySessions": "Enable Sticky Sessions",
"targetStickySessionsDescription": "Keep connections on the same backend target for their entire session.", "targetStickySessionsDescription": "Keep connections on the same backend target for their entire session.",
"loadBalancingStrategy": "Load Balancing Strategy",
"loadBalancingStrategy_wrr": "Round Robin",
"loadBalancingStrategy_wrr_description": "Distribute requests evenly across targets in rotation.",
"loadBalancingStrategy_p2c": "Power of Two Choices",
"loadBalancingStrategy_p2c_description": "Pick two random targets and send the request to the one with fewer active connections. Requires Traefik v3.4 or newer.",
"loadBalancingStrategy_hrw": "Client IP Hash",
"loadBalancingStrategy_hrw_description": "Hash the client IP so the same client is consistently routed to the same target. Requires Traefik v3.6 or newer.",
"loadBalancingStrategy_leasttime": "Least Response Time",
"loadBalancingStrategy_leasttime_description": "Send requests to the target with the fastest response time and fewest active connections. Requires Traefik v3.6 or newer.",
"methodSelect": "Select method", "methodSelect": "Select method",
"targetSubmit": "Add Target", "targetSubmit": "Add Target",
"targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to the backend.", "targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to the backend.",
+4
View File
@@ -181,6 +181,10 @@ export const resources = pgTable(
applyRules: boolean("applyRules"), applyRules: boolean("applyRules"),
enabled: boolean("enabled").notNull().default(true), enabled: boolean("enabled").notNull().default(true),
stickySession: boolean("stickySession").notNull().default(false), stickySession: boolean("stickySession").notNull().default(false),
loadBalancingStrategy: varchar("loadBalancingStrategy", { length: 32 })
.$type<"wrr" | "p2c" | "hrw" | "leasttime">()
.notNull()
.default("wrr"), // traefik http loadBalancer.strategy
tlsServerName: varchar("tlsServerName"), tlsServerName: varchar("tlsServerName"),
setHostHeader: varchar("setHostHeader"), setHostHeader: varchar("setHostHeader"),
enableProxy: boolean("enableProxy").default(true), enableProxy: boolean("enableProxy").default(true),
+4
View File
@@ -197,6 +197,10 @@ export const resources = sqliteTable(
stickySession: integer("stickySession", { mode: "boolean" }) stickySession: integer("stickySession", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
loadBalancingStrategy: text("loadBalancingStrategy")
.$type<"wrr" | "p2c" | "hrw" | "leasttime">()
.notNull()
.default("wrr"), // traefik http loadBalancer.strategy
tlsServerName: text("tlsServerName"), tlsServerName: text("tlsServerName"),
setHostHeader: text("setHostHeader"), setHostHeader: text("setHostHeader"),
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true), enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
@@ -410,6 +410,13 @@ export async function generatePublicResourceBlueprintYaml(
if (resource.tlsServerName) { if (resource.tlsServerName) {
resourceConfig["tls-server-name"] = resource.tlsServerName; resourceConfig["tls-server-name"] = resource.tlsServerName;
} }
if (
resource.loadBalancingStrategy &&
resource.loadBalancingStrategy !== "wrr"
) {
resourceConfig["load-balancing-strategy"] =
resource.loadBalancingStrategy;
}
if (resource.requestHeaders) { if (resource.requestHeaders) {
try { try {
resourceConfig.requestHeaders = JSON.parse(resource.requestHeaders); resourceConfig.requestHeaders = JSON.parse(resource.requestHeaders);
+8
View File
@@ -424,6 +424,9 @@ export async function updatePublicResources(
setHostHeader: resourceData["host-header"] || null, setHostHeader: resourceData["host-header"] || null,
tlsServerName: tlsServerName:
resourceData["tls-server-name"] || null, resourceData["tls-server-name"] || null,
loadBalancingStrategy:
resourceData["load-balancing-strategy"] ??
"wrr",
emailWhitelistEnabled: resourceData.auth?.[ emailWhitelistEnabled: resourceData.auth?.[
"whitelist-users" "whitelist-users"
] ]
@@ -628,6 +631,9 @@ export async function updatePublicResources(
setHostHeader: resourceData["host-header"] || null, setHostHeader: resourceData["host-header"] || null,
tlsServerName: tlsServerName:
resourceData["tls-server-name"] || null, resourceData["tls-server-name"] || null,
loadBalancingStrategy:
resourceData["load-balancing-strategy"] ??
"wrr",
requestHeaders, requestHeaders,
responseHeaders, responseHeaders,
maintenanceModeEnabled: maintenanceModeEnabled:
@@ -1260,6 +1266,8 @@ export async function updatePublicResources(
enabled: resourceEnabled, enabled: resourceEnabled,
setHostHeader: resourceData["host-header"] || null, setHostHeader: resourceData["host-header"] || null,
tlsServerName: resourceData["tls-server-name"] || null, tlsServerName: resourceData["tls-server-name"] || null,
loadBalancingStrategy:
resourceData["load-balancing-strategy"] ?? "wrr",
ssl: resourceSsl, ssl: resourceSsl,
requestHeaders, requestHeaders,
responseHeaders, responseHeaders,
+3
View File
@@ -327,6 +327,9 @@ export const PublicResourceSchema = z
auth: AuthSchema.optional(), auth: AuthSchema.optional(),
"host-header": z.string().optional(), "host-header": z.string().optional(),
"tls-server-name": z.string().optional(), "tls-server-name": z.string().optional(),
"load-balancing-strategy": z
.enum(["wrr", "p2c", "hrw", "leasttime"])
.optional(),
headers: z.array(HeaderSchema).optional(), // deprecated alias for requestHeaders headers: z.array(HeaderSchema).optional(), // deprecated alias for requestHeaders
requestHeaders: z.array(HeaderSchema).optional(), requestHeaders: z.array(HeaderSchema).optional(),
responseHeaders: z.array(HeaderSchema).optional(), responseHeaders: z.array(HeaderSchema).optional(),
+6
View File
@@ -30,6 +30,7 @@ import { buildWildcardTls } from "./certResolver";
import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule"; import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule";
import { import {
buildHttpLoadBalancerServers, buildHttpLoadBalancerServers,
buildLoadBalancingStrategy,
buildStickySessionCookie, buildStickySessionCookie,
buildTcpUdpLoadBalancerServers, buildTcpUdpLoadBalancerServers,
buildStickySessionIp buildStickySessionIp
@@ -93,6 +94,7 @@ export async function getTraefikConfig(
domainId: resources.domainId, domainId: resources.domainId,
enabled: resources.enabled, enabled: resources.enabled,
stickySession: resources.stickySession, stickySession: resources.stickySession,
loadBalancingStrategy: resources.loadBalancingStrategy,
tlsServerName: resources.tlsServerName, tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader, setHostHeader: resources.setHostHeader,
enableProxy: resources.enableProxy, enableProxy: resources.enableProxy,
@@ -222,6 +224,7 @@ export async function getTraefikConfig(
domainId: row.domainId, domainId: row.domainId,
enabled: row.enabled, enabled: row.enabled,
stickySession: row.stickySession, stickySession: row.stickySession,
loadBalancingStrategy: row.loadBalancingStrategy,
tlsServerName: row.tlsServerName, tlsServerName: row.tlsServerName,
setHostHeader: row.setHostHeader, setHostHeader: row.setHostHeader,
enableProxy: row.enableProxy, enableProxy: row.enableProxy,
@@ -458,6 +461,9 @@ export async function getTraefikConfig(
config_output.http.services![serviceName] = { config_output.http.services![serviceName] = {
loadBalancer: { loadBalancer: {
servers: buildHttpLoadBalancerServers(targets), servers: buildHttpLoadBalancerServers(targets),
...buildLoadBalancingStrategy(
resource.loadBalancingStrategy
),
...(resource.stickySession ...(resource.stickySession
? buildStickySessionCookie(resource.ssl) ? buildStickySessionCookie(resource.ssl)
: {}) : {})
+19
View File
@@ -2,6 +2,7 @@ import "@server/extendZod";
import { assertEqualsObj } from "@test/assert"; import { assertEqualsObj } from "@test/assert";
import { import {
buildHttpLoadBalancerServers, buildHttpLoadBalancerServers,
buildLoadBalancingStrategy,
buildTcpUdpLoadBalancerServers buildTcpUdpLoadBalancerServers
} from "./loadBalancer"; } from "./loadBalancer";
import type { TargetWithSite } from "./types"; import type { TargetWithSite } from "./types";
@@ -75,6 +76,24 @@ function runTests() {
); );
} }
assertEqualsObj(
buildLoadBalancingStrategy("wrr"),
{},
"Default wrr strategy should be omitted"
);
assertEqualsObj(
buildLoadBalancingStrategy(null),
{},
"Missing strategy should be omitted"
);
for (const strategy of ["p2c", "hrw", "leasttime"]) {
assertEqualsObj(
buildLoadBalancingStrategy(strategy),
{ strategy },
`${strategy} strategy should be emitted`
);
}
console.log("All load balancer endpoint tests passed!"); console.log("All load balancer endpoint tests passed!");
} }
+12
View File
@@ -63,6 +63,18 @@ export function buildHttpLoadBalancerServers(targets: TargetWithSite[]) {
); );
} }
/**
* Build the loadBalancer.strategy for an HTTP-mode resource. Traefik defaults
* to wrr when strategy is omitted, so only emit it for the non-default
* strategies to keep the generated config unchanged for existing resources.
*/
export function buildLoadBalancingStrategy(strategy: string | null) {
if (!strategy || strategy === "wrr") {
return {};
}
return { strategy };
}
export function buildStickySessionCookie(ssl: boolean | null) { export function buildStickySessionCookie(ssl: boolean | null) {
return { return {
sticky: { sticky: {
@@ -66,6 +66,7 @@ import {
} from "@server/lib/traefik/rule"; } from "@server/lib/traefik/rule";
import { import {
buildHttpLoadBalancerServers, buildHttpLoadBalancerServers,
buildLoadBalancingStrategy,
buildStickySessionCookie, buildStickySessionCookie,
buildTcpUdpLoadBalancerServers, buildTcpUdpLoadBalancerServers,
buildStickySessionIp buildStickySessionIp
@@ -131,6 +132,7 @@ export async function getTraefikConfig(
domainId: resources.domainId, domainId: resources.domainId,
enabled: resources.enabled, enabled: resources.enabled,
stickySession: resources.stickySession, stickySession: resources.stickySession,
loadBalancingStrategy: resources.loadBalancingStrategy,
tlsServerName: resources.tlsServerName, tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader, setHostHeader: resources.setHostHeader,
enableProxy: resources.enableProxy, enableProxy: resources.enableProxy,
@@ -275,6 +277,7 @@ export async function getTraefikConfig(
domainId: row.domainId, domainId: row.domainId,
enabled: row.enabled, enabled: row.enabled,
stickySession: row.stickySession, stickySession: row.stickySession,
loadBalancingStrategy: row.loadBalancingStrategy,
tlsServerName: row.tlsServerName, tlsServerName: row.tlsServerName,
setHostHeader: row.setHostHeader, setHostHeader: row.setHostHeader,
enableProxy: row.enableProxy, enableProxy: row.enableProxy,
@@ -639,6 +642,9 @@ export async function getTraefikConfig(
config_output.http.services![serviceName] = { config_output.http.services![serviceName] = {
loadBalancer: { loadBalancer: {
servers: buildHttpLoadBalancerServers(targets), servers: buildHttpLoadBalancerServers(targets),
...buildLoadBalancingStrategy(
resource.loadBalancingStrategy
),
...(resource.stickySession ...(resource.stickySession
? buildStickySessionCookie(resource.ssl) ? buildStickySessionCookie(resource.ssl)
: {}) : {})
@@ -90,6 +90,12 @@ const updateHttpResourceBodySchema = z
domainId: z.string().optional(), domainId: z.string().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
stickySession: z.boolean().optional(), stickySession: z.boolean().optional(),
loadBalancingStrategy: z
.enum(["wrr", "p2c", "hrw", "leasttime"])
.optional()
.describe(
"How requests are distributed across targets: wrr (weighted round robin, default), p2c (power of two choices), hrw (highest random weight / client IP hashing), leasttime (lowest response time)."
),
tlsServerName: z.string().nullable().optional(), tlsServerName: z.string().nullable().optional(),
setHostHeader: z.string().nullable().optional(), setHostHeader: z.string().nullable().optional(),
skipToIdpId: z skipToIdpId: z
@@ -2,6 +2,13 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { HeadersInput } from "@app/components/HeadersInput"; import { HeadersInput } from "@app/components/HeadersInput";
import { import {
SettingsContainer, SettingsContainer,
@@ -45,6 +52,8 @@ import { useActionState, useState, startTransition } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
const LOAD_BALANCING_STRATEGIES = ["wrr", "p2c", "hrw", "leasttime"] as const;
export default function ReverseProxyTargetsPage() { export default function ReverseProxyTargetsPage() {
const params = useParams(); const params = useParams();
const { resource, updateResource } = useResourceContext(); const { resource, updateResource } = useResourceContext();
@@ -90,6 +99,7 @@ function ProxyResourceHttpForm({
const httpSettingsSchema = z.object({ const httpSettingsSchema = z.object({
stickySession: z.boolean(), stickySession: z.boolean(),
loadBalancingStrategy: z.enum(LOAD_BALANCING_STRATEGIES),
ssl: z.boolean(), ssl: z.boolean(),
tlsServerName: z tlsServerName: z
.string() .string()
@@ -131,6 +141,7 @@ function ProxyResourceHttpForm({
resolver: zodResolver(httpSettingsSchema), resolver: zodResolver(httpSettingsSchema),
defaultValues: { defaultValues: {
stickySession: resource.stickySession, stickySession: resource.stickySession,
loadBalancingStrategy: resource.loadBalancingStrategy,
ssl: resource.ssl, ssl: resource.ssl,
tlsServerName: resource.tlsServerName || "", tlsServerName: resource.tlsServerName || "",
setHostHeader: resource.setHostHeader || "", setHostHeader: resource.setHostHeader || "",
@@ -164,6 +175,7 @@ function ProxyResourceHttpForm({
`/resource/${resource.resourceId}`, `/resource/${resource.resourceId}`,
{ {
stickySession: data.stickySession, stickySession: data.stickySession,
loadBalancingStrategy: data.loadBalancingStrategy,
ssl: data.ssl, ssl: data.ssl,
tlsServerName: data.tlsServerName || null, tlsServerName: data.tlsServerName || null,
setHostHeader: data.setHostHeader || null, setHostHeader: data.setHostHeader || null,
@@ -186,6 +198,7 @@ function ProxyResourceHttpForm({
updateResource({ updateResource({
...resource, ...resource,
stickySession: data.stickySession, stickySession: data.stickySession,
loadBalancingStrategy: data.loadBalancingStrategy,
ssl: data.ssl, ssl: data.ssl,
tlsServerName: data.tlsServerName || null, tlsServerName: data.tlsServerName || null,
setHostHeader: data.setHostHeader || null, setHostHeader: data.setHostHeader || null,
@@ -305,6 +318,58 @@ function ProxyResourceHttpForm({
/> />
</SettingsFormCell> </SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="loadBalancingStrategy"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"loadBalancingStrategy"
)}
</FormLabel>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{LOAD_BALANCING_STRATEGIES.map(
(strategy) => (
<SelectItem
key={
strategy
}
value={
strategy
}
>
{t(
`loadBalancingStrategy_${strategy}`
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormDescription>
{t(
`loadBalancingStrategy_${field.value}_description`
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half"> <SettingsFormCell span="half">
<FormField <FormField
control={form.control} control={form.control}