feat(services): крутить weighted как доли времени на одном IP
quality / changes (push) Successful in 9s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / web (push) Successful in 55s
quality / api (push) Successful in 45s
CD / quality (push) Successful in 1m57s
CD / publish (push) Successful in 1m36s

Веса задают долю слотов на общем FQDN (1 к 3 = ¼ и ¾ цикла), TTL 60.
В селекте режима показывать текстовое имя, не ID.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-20 17:35:13 +07:00
co-authored by Cursor
parent 8a13888db7
commit 3d0ea33baf
8 changed files with 259 additions and 66 deletions
+5
View File
@@ -38,6 +38,10 @@ import {
healthEngineFallbacksFromConfig, healthEngineFallbacksFromConfig,
scheduleHealthCheckJob, scheduleHealthCheckJob,
} from "./services/health-check-scheduler.js"; } from "./services/health-check-scheduler.js";
import {
createWeightedDnsTask,
scheduleWeightedDnsJob,
} from "./services/weighted-dns-scheduler.js";
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js"; import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
import { AsyncTask, CronJob } from "toad-scheduler"; import { AsyncTask, CronJob } from "toad-scheduler";
@@ -133,6 +137,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
app.decorate("reloadHealthCheckJob", () => { app.decorate("reloadHealthCheckJob", () => {
scheduleHealthCheckJob(app, config, healthTask); scheduleHealthCheckJob(app, config, healthTask);
}); });
scheduleWeightedDnsJob(app, createWeightedDnsTask(app));
if (config.cloudflareApiToken) { if (config.cloudflareApiToken) {
fireEnsureHealthWorker( fireEnsureHealthWorker(
app.db, app.db,
+7 -2
View File
@@ -2,25 +2,30 @@ import type { LbMode } from "@cfdm/shared";
import { failoverDesired } from "./failover.js"; import { failoverDesired } from "./failover.js";
import { roundRobinDesired } from "./round-robin.js"; import { roundRobinDesired } from "./round-robin.js";
import type { LbIpRow, LbTargetConfig } from "./types.js"; import type { LbIpRow, LbTargetConfig } from "./types.js";
import { weightedDesired } from "./weighted.js";
export type { LbIpRow, LbTargetConfig } from "./types.js"; export type { LbIpRow, LbTargetConfig } from "./types.js";
export { isHealthy } from "./health.js"; export { isHealthy } from "./health.js";
export { withBindingLock } from "./binding-lock.js"; export { withBindingLock } from "./binding-lock.js";
export { WEIGHTED_DNS_TTL, WEIGHTED_SLOT_MS, weightedDesired } from "./weighted.js";
export function selectActiveIpsByMode( export function selectActiveIpsByMode(
config: LbTargetConfig, config: LbTargetConfig,
rows: LbIpRow[], rows: LbIpRow[],
nowMs = Date.now(),
): string[] { ): string[] {
if (rows.length === 0) return []; if (rows.length === 0) return [];
if (config.lb_mode === "failover") { if (config.lb_mode === "failover") {
return failoverDesired(rows); return failoverDesired(rows);
} }
// weighted = round_robin on DNS (one A per IP) if (config.lb_mode === "weighted") {
return weightedDesired(rows, nowMs);
}
return roundRobinDesired(rows); return roundRobinDesired(rows);
} }
export function strategyLabel(mode: LbMode): string { export function strategyLabel(mode: LbMode): string {
if (mode === "failover") return "Failover"; if (mode === "failover") return "Failover";
if (mode === "weighted") return "Round Robin (weighted alias)"; if (mode === "weighted") return "Weighted";
return "Round Robin"; return "Round Robin";
} }
+24
View File
@@ -0,0 +1,24 @@
import type { LbIpRow } from "./types.js";
import { isHealthy } from "./health.js";
/** Slot length for time-sliced weighted DNS (one A at a time). */
export const WEIGHTED_SLOT_MS = 60_000;
/** Cloudflare DNS-only minimum TTL; Auto (1) is ~300s and would smear ratios. */
export const WEIGHTED_DNS_TTL = 60;
export function weightedDesired(rows: LbIpRow[], nowMs = Date.now()): string[] {
if (rows.length === 0) return [];
const healthy = rows.filter((r) => isHealthy(r.health));
const pool = healthy.length > 0 ? healthy : rows;
if (pool.length === 1) return [pool[0]!.ip];
const sorted = [...pool].sort((a, b) => a.ip.localeCompare(b.ip));
const cycle: string[] = [];
for (const row of sorted) {
const weight = Math.max(1, Math.round(row.weight));
for (let i = 0; i < weight; i++) cycle.push(row.ip);
}
const slot = Math.floor(nowMs / WEIGHTED_SLOT_MS) % cycle.length;
return [cycle[slot]!];
}
+132 -31
View File
@@ -32,6 +32,7 @@ import {
isHealthy, isHealthy,
selectActiveIpsByMode, selectActiveIpsByMode,
withBindingLock, withBindingLock,
WEIGHTED_DNS_TTL,
type LbIpRow, type LbIpRow,
type LbTargetConfig, type LbTargetConfig,
} from "./routing/index.js"; } from "./routing/index.js";
@@ -39,6 +40,12 @@ import {
export type { LbIpRow, LbTargetConfig }; export type { LbIpRow, LbTargetConfig };
export { selectActiveIpsByMode }; export { selectActiveIpsByMode };
const AUTO_DNS_TTL = 1;
function ttlForLbMode(mode: LbMode): number {
return mode === "weighted" ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL;
}
export function failoverARecordDiff( export function failoverARecordDiff(
existingA: readonly string[], existingA: readonly string[],
desiredIps: readonly string[], desiredIps: readonly string[],
@@ -276,6 +283,23 @@ function computeActiveIps(
return selectActiveIpsByMode(state.config, state.rows); return selectActiveIpsByMode(state.config, state.rows);
} }
function desiredAIps(
db: Db,
scope: HealthCheckScope,
refId: number,
fallbackIps: string[],
): string[] {
const config =
scope === "binding"
? getBindingLbState(db, refId).config
: getGroupLbState(db, refId).config;
if (config.lb_mode === "weighted" || config.health_check_enabled) {
const activeIps = computeActiveIps(db, scope, refId);
if (activeIps.length > 0) return activeIps;
}
return fallbackIps;
}
async function collectKnownZones( async function collectKnownZones(
db: Db, db: Db,
cf: CloudflareClient, cf: CloudflareClient,
@@ -613,7 +637,16 @@ async function syncBindingDns(
return; return;
} }
await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps); const binding = repos.getBinding(db, bindingId);
await syncBindingADns(
db,
cf,
bindingId,
domainId,
hostname,
desiredIps,
ttlForLbMode(binding.lb_mode),
);
} }
async function syncBindingCnameDns( async function syncBindingCnameDns(
@@ -700,6 +733,7 @@ async function syncBindingADns(
domainId: number, domainId: number,
hostname: string, hostname: string,
desiredIps: string[], desiredIps: string[],
ttl: number,
): Promise<void> { ): Promise<void> {
const domain = repos.getDomain(db, domainId); const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name; const zoneName = domain.zone_name;
@@ -735,13 +769,15 @@ async function syncBindingADns(
for (const ip of desiredIps) { for (const ip of desiredIps) {
const existing = refreshed.find((r) => r.content === ip); const existing = refreshed.find((r) => r.content === ip);
const recordName = dnsNameForBinding(hostname, zoneName);
let recordId: number; let recordId: number;
if (existing) { if (existing) {
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) { if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) {
await dnsService.update(db, cf, domainId, existing.id, { await dnsService.update(db, cf, domainId, existing.id, {
record_type: "A", record_type: "A",
name: dnsNameForBinding(hostname, zoneName), name: recordName,
content: ip, content: ip,
ttl,
proxied: false, proxied: false,
}); });
} }
@@ -759,12 +795,24 @@ async function syncBindingADns(
if (adopted) { if (adopted) {
repos.linkBindingRecord(db, bindingId, adopted.id); repos.linkBindingRecord(db, bindingId, adopted.id);
recordId = adopted.id; recordId = adopted.id;
if (
!dnsRecordNamesMatch(adopted.name, hostname, zoneName) ||
adopted.ttl !== ttl
) {
await dnsService.update(db, cf, domainId, adopted.id, {
record_type: "A",
name: recordName,
content: ip,
ttl,
proxied: false,
});
}
} else { } else {
const record = await dnsService.create(db, cf, domainId, { const record = await dnsService.create(db, cf, domainId, {
record_type: "A", record_type: "A",
name: dnsNameForBinding(hostname, zoneName), name: recordName,
content: ip, content: ip,
ttl: 1, ttl,
proxied: false, proxied: false,
}); });
repos.linkBindingRecord(db, bindingId, record.id); repos.linkBindingRecord(db, bindingId, record.id);
@@ -1001,12 +1049,7 @@ async function syncServiceBindingsToDns(
} }
validateTargetIpsInPool(targetIps, ips); validateTargetIpsInPool(targetIps, ips);
if (binding.health_check_enabled) { const desiredIps = desiredAIps(db, "binding", binding.id, targetIps);
const activeIps = computeActiveIps(db, "binding", binding.id);
if (activeIps.length > 0) {
targetIps = activeIps;
}
}
await syncBindingDns( await syncBindingDns(
db, db,
@@ -1014,7 +1057,7 @@ async function syncServiceBindingsToDns(
binding.id, binding.id,
binding.domain_id, binding.domain_id,
binding.hostname, binding.hostname,
targetIps, desiredIps,
null, null,
); );
} }
@@ -1046,6 +1089,7 @@ async function syncGroupDomainDnsRecords(
domainId: number, domainId: number,
hostname: string, hostname: string,
desiredIps: string[], desiredIps: string[],
ttl: number = AUTO_DNS_TTL,
): Promise<void> { ): Promise<void> {
const domain = repos.getDomain(db, domainId); const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name; const zoneName = domain.zone_name;
@@ -1061,14 +1105,16 @@ async function syncGroupDomainDnsRecords(
if (desiredIps.length === 0) return; if (desiredIps.length === 0) return;
const refreshed = repos.listGroupDnsRecords(db, groupId); const refreshed = repos.listGroupDnsRecords(db, groupId);
const recordName = dnsNameForBinding(hostname, zoneName);
for (const ip of desiredIps) { for (const ip of desiredIps) {
const existing = refreshed.find((r) => r.content === ip); const existing = refreshed.find((r) => r.content === ip);
if (existing) { if (existing) {
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) { if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) {
await dnsService.update(db, cf, domainId, existing.id, { await dnsService.update(db, cf, domainId, existing.id, {
record_type: "A", record_type: "A",
name: dnsNameForBinding(hostname, zoneName), name: recordName,
content: ip, content: ip,
ttl,
proxied: false, proxied: false,
}); });
} }
@@ -1084,13 +1130,25 @@ async function syncGroupDomainDnsRecords(
); );
if (adopted) { if (adopted) {
repos.linkGroupDnsRecord(db, groupId, adopted.id); repos.linkGroupDnsRecord(db, groupId, adopted.id);
if (
!dnsRecordNamesMatch(adopted.name, hostname, zoneName) ||
adopted.ttl !== ttl
) {
await dnsService.update(db, cf, domainId, adopted.id, {
record_type: "A",
name: recordName,
content: ip,
ttl,
proxied: false,
});
}
continue; continue;
} }
const record = await dnsService.create(db, cf, domainId, { const record = await dnsService.create(db, cf, domainId, {
record_type: "A", record_type: "A",
name: dnsNameForBinding(hostname, zoneName), name: recordName,
content: ip, content: ip,
ttl: 1, ttl,
proxied: false, proxied: false,
}); });
repos.linkGroupDnsRecord(db, groupId, record.id); repos.linkGroupDnsRecord(db, groupId, record.id);
@@ -1141,9 +1199,8 @@ async function syncGroupDomainDns(
const knownZones = await collectKnownZones(db, cf); const knownZones = await collectKnownZones(db, cf);
const { zoneName, hostname } = parseFqdn(domainValue, knownZones); const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
const domainId = await resolveDomainId(db, cf, zoneName); const domainId = await resolveDomainId(db, cf, zoneName);
const desiredIps = group.health_check_enabled const fallbackIps = await collectGroupDnsIps(db, groupId);
? computeActiveIps(db, "group", groupId) const desiredIps = desiredAIps(db, "group", groupId, fallbackIps);
: await collectGroupDnsIps(db, groupId);
await syncGroupDomainDnsRecords( await syncGroupDomainDnsRecords(
db, db,
cf, cf,
@@ -1151,6 +1208,7 @@ async function syncGroupDomainDns(
domainId, domainId,
hostname, hostname,
desiredIps, desiredIps,
ttlForLbMode(group.lb_mode),
); );
} }
@@ -1331,14 +1389,7 @@ export async function updateConfig(
} }
if (pushDns) { if (pushDns) {
let effectiveIps = targetIps; const effectiveIps = desiredAIps(db, "binding", binding.id, targetIps);
const refreshedBinding = repos.getBinding(db, binding.id);
if (refreshedBinding.health_check_enabled) {
const activeIps = computeActiveIps(db, "binding", binding.id);
if (activeIps.length > 0) {
effectiveIps = activeIps;
}
}
await syncBindingDns( await syncBindingDns(
db, db,
cf, cf,
@@ -1644,7 +1695,7 @@ export async function reconcileDnsForTarget(
if (scope === "binding") { if (scope === "binding") {
await withBindingLock(refId, async () => { await withBindingLock(refId, async () => {
const binding = repos.getBinding(db, refId); const binding = repos.getBinding(db, refId);
if (!binding.health_check_enabled) return; if (!binding.health_check_enabled && binding.lb_mode !== "weighted") return;
const service = repos.getService(db, binding.service_id); const service = repos.getService(db, binding.service_id);
if (!shouldPushDns(db, service)) return; if (!shouldPushDns(db, service)) return;
const cnameTarget = binding.cname_target?.trim() || null; const cnameTarget = binding.cname_target?.trim() || null;
@@ -1652,8 +1703,7 @@ export async function reconcileDnsForTarget(
const ips = repos.listServiceIps(db, service.id); const ips = repos.listServiceIps(db, service.id);
const targetIps = repos.listBindingIps(db, binding.id); const targetIps = repos.listBindingIps(db, binding.id);
validateTargetIpsInPool(targetIps, ips); validateTargetIpsInPool(targetIps, ips);
const activeIps = computeActiveIps(db, "binding", refId); const desiredIps = desiredAIps(db, "binding", refId, targetIps);
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
await syncBindingDns( await syncBindingDns(
db, db,
cf, cf,
@@ -1668,8 +1718,59 @@ export async function reconcileDnsForTarget(
} }
const group = repos.getServiceGroup(db, refId); const group = repos.getServiceGroup(db, refId);
if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) { if (!group.enabled || !group.domain?.trim()) {
return;
}
if (!group.health_check_enabled && group.lb_mode !== "weighted") {
return; return;
} }
await syncGroupDomainDns(db, cf, refId); await syncGroupDomainDns(db, cf, refId);
} }
export async function reconcileWeightedDns(
db: Db,
cf: CloudflareClient,
): Promise<number> {
let n = 0;
for (const binding of repos.listAllBindings(db)) {
if (binding.lb_mode !== "weighted") continue;
if (binding.cname_target?.trim()) continue;
try {
await withBindingLock(binding.id, async () => {
const latest = repos.getBinding(db, binding.id);
if (latest.lb_mode !== "weighted") return;
if (latest.cname_target?.trim()) return;
const service = repos.getService(db, latest.service_id);
if (!shouldPushDns(db, service)) return;
const targetIps = repos.listBindingIps(db, latest.id);
if (targetIps.length === 0) return;
const ips = repos.listServiceIps(db, service.id);
validateTargetIpsInPool(targetIps, ips);
const desiredIps = desiredAIps(db, "binding", latest.id, targetIps);
await syncBindingDns(
db,
cf,
latest.id,
latest.domain_id,
latest.hostname,
desiredIps,
null,
);
n += 1;
});
} catch {
continue;
}
}
for (const group of repos.listServiceGroups(db)) {
if (group.lb_mode !== "weighted") continue;
if (!group.enabled || !group.domain?.trim()) continue;
try {
await syncGroupDomainDns(db, cf, group.id);
n += 1;
} catch {
continue;
}
}
return n;
}
@@ -0,0 +1,39 @@
import type { FastifyInstance } from "fastify";
import { AsyncTask, SimpleIntervalJob } from "toad-scheduler";
import * as serviceConfigService from "./service-config-service.js";
import { WEIGHTED_SLOT_MS } from "./routing/weighted.js";
export const WEIGHTED_DNS_JOB_ID = "weighted-dns";
export function createWeightedDnsTask(app: FastifyInstance): AsyncTask {
return new AsyncTask(
WEIGHTED_DNS_JOB_ID,
async () => {
const n = await serviceConfigService.reconcileWeightedDns(app.db, app.cf);
if (n > 0) {
app.log.info({ reconciled: n }, "weighted dns rotated");
}
},
(err) => {
app.log.warn({ err }, "weighted dns rotate failed");
},
);
}
export function scheduleWeightedDnsJob(
app: FastifyInstance,
task: AsyncTask,
): void {
const scheduler = app.scheduler;
if (!scheduler) return;
if (scheduler.existsById(WEIGHTED_DNS_JOB_ID)) {
scheduler.removeById(WEIGHTED_DNS_JOB_ID);
}
scheduler.addSimpleIntervalJob(
new SimpleIntervalJob(
{ seconds: WEIGHTED_SLOT_MS / 1000, runImmediately: true },
task,
{ id: WEIGHTED_DNS_JOB_ID, preventOverrun: true },
),
);
}
+43 -10
View File
@@ -4,6 +4,7 @@ import {
type LbIpRow, type LbIpRow,
type LbTargetConfig, type LbTargetConfig,
} from "../src/services/service-config-service.js"; } from "../src/services/service-config-service.js";
import { WEIGHTED_SLOT_MS } from "../src/services/routing/weighted.js";
function row( function row(
ip: string, ip: string,
@@ -17,6 +18,11 @@ function row(
}; };
} }
const weightedConfig: LbTargetConfig = {
lb_mode: "weighted",
health_check_enabled: true,
};
describe("selectActiveIpsByMode", () => { describe("selectActiveIpsByMode", () => {
it("round_robin returns all healthy ips, falls back to all if none healthy", () => { it("round_robin returns all healthy ips, falls back to all if none healthy", () => {
const config: LbTargetConfig = { const config: LbTargetConfig = {
@@ -75,22 +81,49 @@ describe("selectActiveIpsByMode", () => {
expect(selectActiveIpsByMode(config, rows)).toEqual(["2.2.2.2"]); expect(selectActiveIpsByMode(config, rows)).toEqual(["2.2.2.2"]);
}); });
it("weighted returns all healthy ips (one A per ip; weights stored for display)", () => { it("weighted 1:3 picks the lighter ip on slot 0 and the heavier on slot 1", () => {
const config: LbTargetConfig = {
lb_mode: "weighted",
health_check_enabled: true,
};
const rows = [ const rows = [
row("1.1.1.1", { weight: 3, health: "up" }), row("1.1.1.1", { weight: 1, health: "up" }),
row("2.2.2.2", { weight: 1, health: "up" }), row("2.2.2.2", { weight: 3, health: "up" }),
row("3.3.3.3", { weight: 2, health: "down" }),
]; ];
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([ expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
"1.1.1.1", expect(selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS)).toEqual([
"2.2.2.2", "2.2.2.2",
]); ]);
}); });
it("weighted excludes down ips from the cycle", () => {
const rows = [
row("1.1.1.1", { weight: 1, health: "up" }),
row("2.2.2.2", { weight: 3, health: "down" }),
];
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
expect(
selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS),
).toEqual(["1.1.1.1"]);
});
it("weighted with one ip always returns that ip", () => {
expect(
selectActiveIpsByMode(weightedConfig, [row("1.1.1.1", { weight: 5 })], 0),
).toEqual(["1.1.1.1"]);
});
it("weighted with all unknown rotates across every ip", () => {
const rows = [
row("1.1.1.1", { weight: 1, health: "unknown" }),
row("2.2.2.2", { weight: 3, health: "unknown" }),
];
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
expect(selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS)).toEqual([
"2.2.2.2",
]);
});
it("weighted returns empty array for no rows", () => {
expect(selectActiveIpsByMode(weightedConfig, [], 0)).toEqual([]);
});
it("round_robin excludes unknown when another ip is up", () => { it("round_robin excludes unknown when another ip is up", () => {
const config: LbTargetConfig = { const config: LbTargetConfig = {
lb_mode: "round_robin", lb_mode: "round_robin",
@@ -1,6 +1,7 @@
import { FormFieldSimple } from '@/components/form-field' import { FormFieldSimple } from '@/components/form-field'
import { AppInput } from '@/components/app-input' import { AppInput } from '@/components/app-input'
import { SettingRow } from '@/components/setting-row' import { SettingRow } from '@/components/setting-row'
import { SelectField } from '@/components/select-field'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { import {
NumberField, NumberField,
@@ -9,13 +10,6 @@ import {
NumberFieldIncrement, NumberFieldIncrement,
NumberFieldInput, NumberFieldInput,
} from '@/components/reui/number-field' } from '@/components/reui/number-field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Switch } from '@cfdm/ui/components/switch' import { Switch } from '@cfdm/ui/components/switch'
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field' import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
@@ -59,7 +53,7 @@ export interface LbAndHealthConfig extends HealthCheckConfig {
const defaultLbModeOptions = [ const defaultLbModeOptions = [
{ value: 'round_robin', label: 'Round Robin' }, { value: 'round_robin', label: 'Round Robin' },
{ value: 'failover', label: 'Failover (приоритет)' }, { value: 'failover', label: 'Failover (приоритет)' },
{ value: 'weighted', label: 'Weighted (веса)' }, { value: 'weighted', label: 'Веса (подмена IP)' },
] ]
export interface LbPoolMetaChange { export interface LbPoolMetaChange {
@@ -126,7 +120,7 @@ function PoolLbMetaFields({
title={isWeighted ? 'Вес IP' : 'Приоритет IP'} title={isWeighted ? 'Вес IP' : 'Приоритет IP'}
description={ description={
isWeighted isWeighted
? 'Больше — чаще в пуле' ? 'Доля времени на общем FQDN: 1 и 3 = ¼ и ¾ цикла (слот 60 с)'
: '1 — основной, больше — запасной' : '1 — основной, больше — запасной'
} }
compact compact
@@ -230,22 +224,14 @@ export function HealthCheckConfigFields({
compact compact
className={rowClass} className={rowClass}
> >
<Select <SelectField
modal={false} modal={false}
value={value.lb_mode} value={value.lb_mode}
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })} onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
> triggerId={`${idPrefix}-lb-mode`}
<SelectTrigger id={`${idPrefix}-lb-mode`} className="w-full"> placeholder="Выберите режим"
<SelectValue placeholder="Выберите режим" /> options={lbModeOptions}
</SelectTrigger> />
<SelectContent>
{lbModeOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow> </SettingRow>
) : null} ) : null}
@@ -71,7 +71,7 @@ const LB_MODE_META: Record<
weighted: { weighted: {
icon: ScaleIcon, icon: ScaleIcon,
className: 'text-info', className: 'text-info',
label: 'Weighted (веса)', label: 'Веса (подмена IP)',
}, },
} }