mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-14 16:49:05 +02:00
Add support for configuring load balancing strategy
This commit is contained in:
@@ -793,6 +793,15 @@
|
||||
"targetsDescription": "Set up targets to route traffic to backend services",
|
||||
"targetStickySessions": "Enable Sticky Sessions",
|
||||
"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",
|
||||
"targetSubmit": "Add Target",
|
||||
"targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to the backend.",
|
||||
|
||||
@@ -181,6 +181,10 @@ export const resources = pgTable(
|
||||
applyRules: boolean("applyRules"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
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"),
|
||||
setHostHeader: varchar("setHostHeader"),
|
||||
enableProxy: boolean("enableProxy").default(true),
|
||||
|
||||
@@ -197,6 +197,10 @@ export const resources = sqliteTable(
|
||||
stickySession: integer("stickySession", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
loadBalancingStrategy: text("loadBalancingStrategy")
|
||||
.$type<"wrr" | "p2c" | "hrw" | "leasttime">()
|
||||
.notNull()
|
||||
.default("wrr"), // traefik http loadBalancer.strategy
|
||||
tlsServerName: text("tlsServerName"),
|
||||
setHostHeader: text("setHostHeader"),
|
||||
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
|
||||
|
||||
@@ -410,6 +410,13 @@ export async function generatePublicResourceBlueprintYaml(
|
||||
if (resource.tlsServerName) {
|
||||
resourceConfig["tls-server-name"] = resource.tlsServerName;
|
||||
}
|
||||
if (
|
||||
resource.loadBalancingStrategy &&
|
||||
resource.loadBalancingStrategy !== "wrr"
|
||||
) {
|
||||
resourceConfig["load-balancing-strategy"] =
|
||||
resource.loadBalancingStrategy;
|
||||
}
|
||||
if (resource.requestHeaders) {
|
||||
try {
|
||||
resourceConfig.requestHeaders = JSON.parse(resource.requestHeaders);
|
||||
|
||||
@@ -424,6 +424,9 @@ export async function updatePublicResources(
|
||||
setHostHeader: resourceData["host-header"] || null,
|
||||
tlsServerName:
|
||||
resourceData["tls-server-name"] || null,
|
||||
loadBalancingStrategy:
|
||||
resourceData["load-balancing-strategy"] ??
|
||||
"wrr",
|
||||
emailWhitelistEnabled: resourceData.auth?.[
|
||||
"whitelist-users"
|
||||
]
|
||||
@@ -628,6 +631,9 @@ export async function updatePublicResources(
|
||||
setHostHeader: resourceData["host-header"] || null,
|
||||
tlsServerName:
|
||||
resourceData["tls-server-name"] || null,
|
||||
loadBalancingStrategy:
|
||||
resourceData["load-balancing-strategy"] ??
|
||||
"wrr",
|
||||
requestHeaders,
|
||||
responseHeaders,
|
||||
maintenanceModeEnabled:
|
||||
@@ -1260,6 +1266,8 @@ export async function updatePublicResources(
|
||||
enabled: resourceEnabled,
|
||||
setHostHeader: resourceData["host-header"] || null,
|
||||
tlsServerName: resourceData["tls-server-name"] || null,
|
||||
loadBalancingStrategy:
|
||||
resourceData["load-balancing-strategy"] ?? "wrr",
|
||||
ssl: resourceSsl,
|
||||
requestHeaders,
|
||||
responseHeaders,
|
||||
|
||||
@@ -327,6 +327,9 @@ export const PublicResourceSchema = z
|
||||
auth: AuthSchema.optional(),
|
||||
"host-header": 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
|
||||
requestHeaders: z.array(HeaderSchema).optional(),
|
||||
responseHeaders: z.array(HeaderSchema).optional(),
|
||||
|
||||
@@ -30,6 +30,7 @@ import { buildWildcardTls } from "./certResolver";
|
||||
import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule";
|
||||
import {
|
||||
buildHttpLoadBalancerServers,
|
||||
buildLoadBalancingStrategy,
|
||||
buildStickySessionCookie,
|
||||
buildTcpUdpLoadBalancerServers,
|
||||
buildStickySessionIp
|
||||
@@ -93,6 +94,7 @@ export async function getTraefikConfig(
|
||||
domainId: resources.domainId,
|
||||
enabled: resources.enabled,
|
||||
stickySession: resources.stickySession,
|
||||
loadBalancingStrategy: resources.loadBalancingStrategy,
|
||||
tlsServerName: resources.tlsServerName,
|
||||
setHostHeader: resources.setHostHeader,
|
||||
enableProxy: resources.enableProxy,
|
||||
@@ -222,6 +224,7 @@ export async function getTraefikConfig(
|
||||
domainId: row.domainId,
|
||||
enabled: row.enabled,
|
||||
stickySession: row.stickySession,
|
||||
loadBalancingStrategy: row.loadBalancingStrategy,
|
||||
tlsServerName: row.tlsServerName,
|
||||
setHostHeader: row.setHostHeader,
|
||||
enableProxy: row.enableProxy,
|
||||
@@ -458,6 +461,9 @@ export async function getTraefikConfig(
|
||||
config_output.http.services![serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: buildHttpLoadBalancerServers(targets),
|
||||
...buildLoadBalancingStrategy(
|
||||
resource.loadBalancingStrategy
|
||||
),
|
||||
...(resource.stickySession
|
||||
? buildStickySessionCookie(resource.ssl)
|
||||
: {})
|
||||
|
||||
@@ -2,6 +2,7 @@ import "@server/extendZod";
|
||||
import { assertEqualsObj } from "@test/assert";
|
||||
import {
|
||||
buildHttpLoadBalancerServers,
|
||||
buildLoadBalancingStrategy,
|
||||
buildTcpUdpLoadBalancerServers
|
||||
} from "./loadBalancer";
|
||||
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!");
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
return {
|
||||
sticky: {
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
} from "@server/lib/traefik/rule";
|
||||
import {
|
||||
buildHttpLoadBalancerServers,
|
||||
buildLoadBalancingStrategy,
|
||||
buildStickySessionCookie,
|
||||
buildTcpUdpLoadBalancerServers,
|
||||
buildStickySessionIp
|
||||
@@ -131,6 +132,7 @@ export async function getTraefikConfig(
|
||||
domainId: resources.domainId,
|
||||
enabled: resources.enabled,
|
||||
stickySession: resources.stickySession,
|
||||
loadBalancingStrategy: resources.loadBalancingStrategy,
|
||||
tlsServerName: resources.tlsServerName,
|
||||
setHostHeader: resources.setHostHeader,
|
||||
enableProxy: resources.enableProxy,
|
||||
@@ -275,6 +277,7 @@ export async function getTraefikConfig(
|
||||
domainId: row.domainId,
|
||||
enabled: row.enabled,
|
||||
stickySession: row.stickySession,
|
||||
loadBalancingStrategy: row.loadBalancingStrategy,
|
||||
tlsServerName: row.tlsServerName,
|
||||
setHostHeader: row.setHostHeader,
|
||||
enableProxy: row.enableProxy,
|
||||
@@ -639,6 +642,9 @@ export async function getTraefikConfig(
|
||||
config_output.http.services![serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: buildHttpLoadBalancerServers(targets),
|
||||
...buildLoadBalancingStrategy(
|
||||
resource.loadBalancingStrategy
|
||||
),
|
||||
...(resource.stickySession
|
||||
? buildStickySessionCookie(resource.ssl)
|
||||
: {})
|
||||
|
||||
@@ -90,6 +90,12 @@ const updateHttpResourceBodySchema = z
|
||||
domainId: z.string().optional(),
|
||||
enabled: 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(),
|
||||
setHostHeader: z.string().nullable().optional(),
|
||||
skipToIdpId: z
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { HeadersInput } from "@app/components/HeadersInput";
|
||||
import {
|
||||
SettingsContainer,
|
||||
@@ -45,6 +52,8 @@ import { useActionState, useState, startTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
const LOAD_BALANCING_STRATEGIES = ["wrr", "p2c", "hrw", "leasttime"] as const;
|
||||
|
||||
export default function ReverseProxyTargetsPage() {
|
||||
const params = useParams();
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
@@ -90,6 +99,7 @@ function ProxyResourceHttpForm({
|
||||
|
||||
const httpSettingsSchema = z.object({
|
||||
stickySession: z.boolean(),
|
||||
loadBalancingStrategy: z.enum(LOAD_BALANCING_STRATEGIES),
|
||||
ssl: z.boolean(),
|
||||
tlsServerName: z
|
||||
.string()
|
||||
@@ -131,6 +141,7 @@ function ProxyResourceHttpForm({
|
||||
resolver: zodResolver(httpSettingsSchema),
|
||||
defaultValues: {
|
||||
stickySession: resource.stickySession,
|
||||
loadBalancingStrategy: resource.loadBalancingStrategy,
|
||||
ssl: resource.ssl,
|
||||
tlsServerName: resource.tlsServerName || "",
|
||||
setHostHeader: resource.setHostHeader || "",
|
||||
@@ -164,6 +175,7 @@ function ProxyResourceHttpForm({
|
||||
`/resource/${resource.resourceId}`,
|
||||
{
|
||||
stickySession: data.stickySession,
|
||||
loadBalancingStrategy: data.loadBalancingStrategy,
|
||||
ssl: data.ssl,
|
||||
tlsServerName: data.tlsServerName || null,
|
||||
setHostHeader: data.setHostHeader || null,
|
||||
@@ -186,6 +198,7 @@ function ProxyResourceHttpForm({
|
||||
updateResource({
|
||||
...resource,
|
||||
stickySession: data.stickySession,
|
||||
loadBalancingStrategy: data.loadBalancingStrategy,
|
||||
ssl: data.ssl,
|
||||
tlsServerName: data.tlsServerName || null,
|
||||
setHostHeader: data.setHostHeader || null,
|
||||
@@ -305,6 +318,58 @@ function ProxyResourceHttpForm({
|
||||
/>
|
||||
</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">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
Reference in New Issue
Block a user