fix(sync): не считать сервис с одним IP резервированием
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m20s
quality / api (push) Successful in 1m4s
CD / quality (push) Successful in 2m38s
CD / publish (push) Successful in 56s

В payload для VPS Tracker lbMode не отдаём без пула уникальных IP; в UI показываем без резервирования вместо Round robin.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-09-01 15:09:19 +07:00
co-authored by Cursor
parent 7eb195c5d7
commit de3dbe8521
5 changed files with 94 additions and 7 deletions
+15 -2
View File
@@ -3,6 +3,7 @@ import type { CfdmBindingSyncItem, LbMode, ServiceBindingView } from "@cfdm/shar
import { isIpLiteral } from "@cfdm/shared"; import { isIpLiteral } from "@cfdm/shared";
import type { Db } from "@cfdm/db"; import type { Db } from "@cfdm/db";
import { repos, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db"; import { repos, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db";
import { isSharedPool } from "./routing/pool.js";
export function isLbMode(value: unknown): value is LbMode { export function isLbMode(value: unknown): value is LbMode {
return value === "round_robin" || value === "failover" || value === "weighted"; return value === "round_robin" || value === "failover" || value === "weighted";
@@ -18,6 +19,16 @@ export function resolveLbModeForSync(
return undefined; return undefined;
} }
/** Effective HA only when the service has two or more unique origin IPs. */
export function effectiveLbModeForSync(
bindingLbMode: string | undefined | null,
groupLbMode: string | undefined | null,
serviceIps: readonly string[],
): LbMode | undefined {
if (!isSharedPool(serviceIps)) return undefined;
return resolveLbModeForSync(bindingLbMode, groupLbMode);
}
function groupLbModeForService( function groupLbModeForService(
db: Db, db: Db,
serviceId: number, serviceId: number,
@@ -166,6 +177,7 @@ export async function buildServiceSyncBindingsAsync(
const items: CfdmBindingSyncItem[] = []; const items: CfdmBindingSyncItem[] = [];
for (const binding of bindings) { for (const binding of bindings) {
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db); const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps);
items.push({ items.push({
bindingId: binding.id, bindingId: binding.id,
serviceId: service.id, serviceId: service.id,
@@ -176,7 +188,7 @@ export async function buildServiceSyncBindingsAsync(
hostname: binding.hostname, hostname: binding.hostname,
ips, ips,
cnameTarget: cnameTargetForSync(binding), cnameTarget: cnameTargetForSync(binding),
lbMode: resolveLbModeForSync(binding.lb_mode, groupLb), ...(lbMode ? { lbMode } : {}),
}); });
} }
@@ -214,6 +226,7 @@ export async function buildAllSyncBindings(
} }
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db); const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
const groupLb = groupLbModeForService(db, binding.service_id, groupLbCache); const groupLb = groupLbModeForService(db, binding.service_id, groupLbCache);
const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps);
items.push({ items.push({
bindingId: binding.id, bindingId: binding.id,
serviceId: binding.service_id, serviceId: binding.service_id,
@@ -224,7 +237,7 @@ export async function buildAllSyncBindings(
hostname: binding.hostname, hostname: binding.hostname,
ips, ips,
cnameTarget: cnameTargetForSync(binding), cnameTarget: cnameTargetForSync(binding),
lbMode: resolveLbModeForSync(binding.lb_mode, groupLb), ...(lbMode ? { lbMode } : {}),
}); });
} }
return items; return items;
+30
View File
@@ -6,6 +6,7 @@ import {
import { import {
resolveBindingIpsForSync, resolveBindingIpsForSync,
resolveLbModeForSync, resolveLbModeForSync,
effectiveLbModeForSync,
} from "../src/services/vps-tracker-sync.js"; } from "../src/services/vps-tracker-sync.js";
function binding( function binding(
@@ -183,6 +184,35 @@ describe("resolveLbModeForSync", () => {
}); });
}); });
describe("effectiveLbModeForSync", () => {
it("omits mode when unique origin IPs are below two", () => {
expect(
effectiveLbModeForSync("round_robin", "failover", ["203.0.113.10"]),
).toBeUndefined();
expect(
effectiveLbModeForSync("round_robin", "failover", [
"203.0.113.10",
"203.0.113.10",
]),
).toBeUndefined();
});
it("emits configured mode when the service has a pool", () => {
expect(
effectiveLbModeForSync("failover", "round_robin", [
"203.0.113.10",
"203.0.113.20",
]),
).toBe("failover");
expect(
effectiveLbModeForSync("off", "weighted", [
"203.0.113.10",
"198.51.100.1",
]),
).toBe("weighted");
});
});
describe("cfdmBindingSyncItemSchema lbMode", () => { describe("cfdmBindingSyncItemSchema lbMode", () => {
const base = { const base = {
bindingId: 1, bindingId: 1,
@@ -75,18 +75,25 @@ function alertDescription(events: { kind: string; fqdns: string[] }[]): string {
*/ */
export function ServiceFailoverPanel({ export function ServiceFailoverPanel({
lbMode = 'round_robin', lbMode = 'round_robin',
hasPool = true,
ipHealth, ipHealth,
bindings, bindings,
history, history,
probes = [], probes = [],
}: { }: {
lbMode?: LbMode lbMode?: LbMode
hasPool?: boolean
ipHealth: readonly FailoverHealthInput[] ipHealth: readonly FailoverHealthInput[]
bindings: readonly FailoverBindingPool[] bindings: readonly FailoverBindingPool[]
history: readonly FailoverLogEntry[] history: readonly FailoverLogEntry[]
probes?: readonly HealthLogProbe[] probes?: readonly HealthLogProbe[]
}) { }) {
const copy = PANEL_COPY[lbMode] ?? PANEL_COPY.round_robin const copy = hasPool
? (PANEL_COPY[lbMode] ?? PANEL_COPY.round_robin)
: {
title: 'без резервирования',
description: 'Один origin IP — балансировка не применяется',
}
const liveByIp = latestHealthByIp(probes) const liveByIp = latestHealthByIp(probes)
const overlayHealth = ipHealth.map((row) => { const overlayHealth = ipHealth.map((row) => {
const live = liveByIp.get(row.ip) const live = liveByIp.get(row.ip)
@@ -5,6 +5,7 @@ import {
Repeat2Icon, Repeat2Icon,
ScaleIcon, ScaleIcon,
ServerIcon, ServerIcon,
UnplugIcon,
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
@@ -21,6 +22,7 @@ import {
ServiceIpList, ServiceIpList,
} from '@/components/services/service-fqdn-list' } from '@/components/services/service-fqdn-list'
import { serviceDisplayFqdn, serviceDisplayFqdns } from '@/lib/service-utils' import { serviceDisplayFqdn, serviceDisplayFqdns } from '@/lib/service-utils'
import { uniqueIpCount } from '@/lib/failover-events'
import type { ServiceView } from '@/lib/schemas' import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { import {
@@ -75,7 +77,35 @@ const LB_MODE_META: Record<
}, },
} }
export function LbModeTile({ mode }: { mode: LbMode }) { export function LbModeTile({
mode,
hasPool = true,
}: {
mode: LbMode
hasPool?: boolean
}) {
if (!hasPool) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<IconTile
variant="elevated"
size="xs"
className="shrink-0 text-muted-foreground"
aria-label="без резервирования"
/>
}
>
<UnplugIcon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>без резервирования</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
const meta = LB_MODE_META[mode] const meta = LB_MODE_META[mode]
const Icon = meta.icon const Icon = meta.icon
@@ -148,7 +178,10 @@ export function ServiceUnitCard({
{service.name} {service.name}
</Link> </Link>
</FrameTitle> </FrameTitle>
<LbModeTile mode={service.lb_mode} /> <LbModeTile
mode={service.lb_mode}
hasPool={uniqueIpCount(service.ips) >= 2}
/>
</div> </div>
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<FrameDescription className="min-w-0 truncate font-mono text-xs"> <FrameDescription className="min-w-0 truncate font-mono text-xs">
@@ -32,7 +32,7 @@ import {
ServiceHealthMonitor, ServiceHealthMonitor,
} from '@/components/reui-kit' } from '@/components/reui-kit'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { hasSharedPool } from '@/lib/failover-events' import { hasSharedPool, uniqueIpCount } from '@/lib/failover-events'
import { import {
enabledHealthProviders, enabledHealthProviders,
providerHealthStatuses, providerHealthStatuses,
@@ -268,7 +268,10 @@ function ServiceDetailPage() {
description="Domain → Service → Node → Health → Failover" description="Domain → Service → Node → Health → Failover"
actions={ actions={
<> <>
<LbModeTile mode={service.lb_mode} /> <LbModeTile
mode={service.lb_mode}
hasPool={uniqueIpCount(service.ips) >= 2}
/>
<HealthCheckBadge status={displayHealth} /> <HealthCheckBadge status={displayHealth} />
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
@@ -360,6 +363,7 @@ function ServiceDetailPage() {
{showPoolPanel ? ( {showPoolPanel ? (
<ServiceFailoverPanel <ServiceFailoverPanel
lbMode={service.lb_mode} lbMode={service.lb_mode}
hasPool={uniqueIpCount(service.ips) >= 2}
ipHealth={service.ip_health} ipHealth={service.ip_health}
bindings={failoverBindings} bindings={failoverBindings}
history={failoverHistory} history={failoverHistory}