fix(services): снимать Down только с общего FQDN
Персональные A не крутить и не трогать при падении. Панель называется по режиму балансировки. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import type { LbMode } from "@cfdm/shared";
|
import type { LbMode } from "@cfdm/shared";
|
||||||
import { failoverDesired } from "./failover.js";
|
import { failoverDesired } from "./failover.js";
|
||||||
|
import { isSharedPool } from "./pool.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";
|
import { weightedDesired } from "./weighted.js";
|
||||||
@@ -7,6 +8,7 @@ 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 { isSharedPool, shouldRecordFailoverDnsDiff } from "./pool.js";
|
||||||
export { WEIGHTED_DNS_TTL, WEIGHTED_SLOT_MS, weightedDesired } from "./weighted.js";
|
export { WEIGHTED_DNS_TTL, WEIGHTED_SLOT_MS, weightedDesired } from "./weighted.js";
|
||||||
|
|
||||||
export function selectActiveIpsByMode(
|
export function selectActiveIpsByMode(
|
||||||
@@ -24,6 +26,21 @@ export function selectActiveIpsByMode(
|
|||||||
return roundRobinDesired(rows);
|
return roundRobinDesired(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveDesiredAIps(
|
||||||
|
config: LbTargetConfig,
|
||||||
|
rows: LbIpRow[],
|
||||||
|
fallbackIps: readonly string[],
|
||||||
|
nowMs = Date.now(),
|
||||||
|
): string[] {
|
||||||
|
const fallback = [...fallbackIps];
|
||||||
|
if (!isSharedPool(fallback)) return fallback;
|
||||||
|
if (config.lb_mode === "weighted" || config.health_check_enabled) {
|
||||||
|
const activeIps = selectActiveIpsByMode(config, rows, nowMs);
|
||||||
|
if (activeIps.length > 0) return activeIps;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
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 "Weighted";
|
if (mode === "weighted") return "Weighted";
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { LbMode } from "@cfdm/shared";
|
||||||
|
|
||||||
|
/** Shared pool FQDN — two or more A targets. Dedicated extra-FQDN has one IP. */
|
||||||
|
export function isSharedPool(ips: readonly string[]): boolean {
|
||||||
|
return ips.length >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRecordFailoverDnsDiff(input: {
|
||||||
|
configuredIps: readonly string[];
|
||||||
|
lbMode: LbMode;
|
||||||
|
added: readonly string[];
|
||||||
|
removed: readonly string[];
|
||||||
|
downIps: ReadonlySet<string>;
|
||||||
|
}): boolean {
|
||||||
|
if (!isSharedPool(input.configuredIps)) return false;
|
||||||
|
if (input.lbMode !== "weighted") return true;
|
||||||
|
return [...input.added, ...input.removed].some((ip) => input.downIps.has(ip));
|
||||||
|
}
|
||||||
@@ -30,7 +30,10 @@ import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
|||||||
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
|
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
|
||||||
import {
|
import {
|
||||||
isHealthy,
|
isHealthy,
|
||||||
|
isSharedPool,
|
||||||
|
resolveDesiredAIps,
|
||||||
selectActiveIpsByMode,
|
selectActiveIpsByMode,
|
||||||
|
shouldRecordFailoverDnsDiff,
|
||||||
withBindingLock,
|
withBindingLock,
|
||||||
WEIGHTED_DNS_TTL,
|
WEIGHTED_DNS_TTL,
|
||||||
type LbIpRow,
|
type LbIpRow,
|
||||||
@@ -38,12 +41,12 @@ import {
|
|||||||
} from "./routing/index.js";
|
} from "./routing/index.js";
|
||||||
|
|
||||||
export type { LbIpRow, LbTargetConfig };
|
export type { LbIpRow, LbTargetConfig };
|
||||||
export { selectActiveIpsByMode };
|
export { resolveDesiredAIps, selectActiveIpsByMode, shouldRecordFailoverDnsDiff };
|
||||||
|
|
||||||
const AUTO_DNS_TTL = 1;
|
const AUTO_DNS_TTL = 1;
|
||||||
|
|
||||||
function ttlForLbMode(mode: LbMode): number {
|
function ttlForBinding(mode: LbMode, ipCount: number): number {
|
||||||
return mode === "weighted" ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL;
|
return mode === "weighted" && ipCount >= 2 ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function failoverARecordDiff(
|
export function failoverARecordDiff(
|
||||||
@@ -72,6 +75,22 @@ function recordFailoverDnsDiff(
|
|||||||
const { added, removed } = failoverARecordDiff(existingA, desiredIps);
|
const { added, removed } = failoverARecordDiff(existingA, desiredIps);
|
||||||
if (added.length === 0 && removed.length === 0) return;
|
if (added.length === 0 && removed.length === 0) return;
|
||||||
const binding = repos.getBinding(db, bindingId);
|
const binding = repos.getBinding(db, bindingId);
|
||||||
|
const configuredIps = repos.listBindingIps(db, bindingId);
|
||||||
|
const { config, rows } = getBindingLbState(db, bindingId);
|
||||||
|
const downIps = new Set(
|
||||||
|
rows.filter((row) => row.health === "down").map((row) => row.ip),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!shouldRecordFailoverDnsDiff({
|
||||||
|
configuredIps,
|
||||||
|
lbMode: config.lb_mode,
|
||||||
|
added,
|
||||||
|
removed,
|
||||||
|
downIps,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
repos.insertFailoverLog(db, {
|
repos.insertFailoverLog(db, {
|
||||||
serviceId: binding.service_id,
|
serviceId: binding.service_id,
|
||||||
bindingId,
|
bindingId,
|
||||||
@@ -271,33 +290,17 @@ function getGroupLbState(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeActiveIps(
|
|
||||||
db: Db,
|
|
||||||
scope: HealthCheckScope,
|
|
||||||
refId: number,
|
|
||||||
): string[] {
|
|
||||||
const state =
|
|
||||||
scope === "binding"
|
|
||||||
? getBindingLbState(db, refId)
|
|
||||||
: getGroupLbState(db, refId);
|
|
||||||
return selectActiveIpsByMode(state.config, state.rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
function desiredAIps(
|
function desiredAIps(
|
||||||
db: Db,
|
db: Db,
|
||||||
scope: HealthCheckScope,
|
scope: HealthCheckScope,
|
||||||
refId: number,
|
refId: number,
|
||||||
fallbackIps: string[],
|
fallbackIps: string[],
|
||||||
): string[] {
|
): string[] {
|
||||||
const config =
|
const state =
|
||||||
scope === "binding"
|
scope === "binding"
|
||||||
? getBindingLbState(db, refId).config
|
? getBindingLbState(db, refId)
|
||||||
: getGroupLbState(db, refId).config;
|
: getGroupLbState(db, refId);
|
||||||
if (config.lb_mode === "weighted" || config.health_check_enabled) {
|
return resolveDesiredAIps(state.config, state.rows, fallbackIps);
|
||||||
const activeIps = computeActiveIps(db, scope, refId);
|
|
||||||
if (activeIps.length > 0) return activeIps;
|
|
||||||
}
|
|
||||||
return fallbackIps;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectKnownZones(
|
async function collectKnownZones(
|
||||||
@@ -349,7 +352,7 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
|||||||
const { config, rows } = getBindingLbState(db, binding.id);
|
const { config, rows } = getBindingLbState(db, binding.id);
|
||||||
const bindingActiveIps = targetCname
|
const bindingActiveIps = targetCname
|
||||||
? []
|
? []
|
||||||
: selectActiveIpsByMode(config, rows);
|
: resolveDesiredAIps(config, rows, targetIps);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
binding_id: binding.id,
|
binding_id: binding.id,
|
||||||
@@ -638,6 +641,7 @@ async function syncBindingDns(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const binding = repos.getBinding(db, bindingId);
|
const binding = repos.getBinding(db, bindingId);
|
||||||
|
const configuredIps = repos.listBindingIps(db, bindingId);
|
||||||
await syncBindingADns(
|
await syncBindingADns(
|
||||||
db,
|
db,
|
||||||
cf,
|
cf,
|
||||||
@@ -645,7 +649,7 @@ async function syncBindingDns(
|
|||||||
domainId,
|
domainId,
|
||||||
hostname,
|
hostname,
|
||||||
desiredIps,
|
desiredIps,
|
||||||
ttlForLbMode(binding.lb_mode),
|
ttlForBinding(binding.lb_mode, configuredIps.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,7 +1212,7 @@ async function syncGroupDomainDns(
|
|||||||
domainId,
|
domainId,
|
||||||
hostname,
|
hostname,
|
||||||
desiredIps,
|
desiredIps,
|
||||||
ttlForLbMode(group.lb_mode),
|
ttlForBinding(group.lb_mode, fallbackIps.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1702,6 +1706,7 @@ export async function reconcileDnsForTarget(
|
|||||||
if (cnameTarget) return;
|
if (cnameTarget) return;
|
||||||
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);
|
||||||
|
if (!isSharedPool(targetIps)) return;
|
||||||
validateTargetIpsInPool(targetIps, ips);
|
validateTargetIpsInPool(targetIps, ips);
|
||||||
const desiredIps = desiredAIps(db, "binding", refId, targetIps);
|
const desiredIps = desiredAIps(db, "binding", refId, targetIps);
|
||||||
await syncBindingDns(
|
await syncBindingDns(
|
||||||
@@ -1735,6 +1740,7 @@ export async function reconcileWeightedDns(
|
|||||||
for (const binding of repos.listAllBindings(db)) {
|
for (const binding of repos.listAllBindings(db)) {
|
||||||
if (binding.lb_mode !== "weighted") continue;
|
if (binding.lb_mode !== "weighted") continue;
|
||||||
if (binding.cname_target?.trim()) continue;
|
if (binding.cname_target?.trim()) continue;
|
||||||
|
if (!isSharedPool(binding.target_ips ?? [])) continue;
|
||||||
try {
|
try {
|
||||||
await withBindingLock(binding.id, async () => {
|
await withBindingLock(binding.id, async () => {
|
||||||
const latest = repos.getBinding(db, binding.id);
|
const latest = repos.getBinding(db, binding.id);
|
||||||
@@ -1743,7 +1749,7 @@ export async function reconcileWeightedDns(
|
|||||||
const service = repos.getService(db, latest.service_id);
|
const service = repos.getService(db, latest.service_id);
|
||||||
if (!shouldPushDns(db, service)) return;
|
if (!shouldPushDns(db, service)) return;
|
||||||
const targetIps = repos.listBindingIps(db, latest.id);
|
const targetIps = repos.listBindingIps(db, latest.id);
|
||||||
if (targetIps.length === 0) return;
|
if (!isSharedPool(targetIps)) return;
|
||||||
const ips = repos.listServiceIps(db, service.id);
|
const ips = repos.listServiceIps(db, service.id);
|
||||||
validateTargetIpsInPool(targetIps, ips);
|
validateTargetIpsInPool(targetIps, ips);
|
||||||
const desiredIps = desiredAIps(db, "binding", latest.id, targetIps);
|
const desiredIps = desiredAIps(db, "binding", latest.id, targetIps);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
resolveDesiredAIps,
|
||||||
selectActiveIpsByMode,
|
selectActiveIpsByMode,
|
||||||
|
shouldRecordFailoverDnsDiff,
|
||||||
type LbIpRow,
|
type LbIpRow,
|
||||||
type LbTargetConfig,
|
type LbTargetConfig,
|
||||||
} from "../src/services/service-config-service.js";
|
} from "../src/services/service-config-service.js";
|
||||||
@@ -144,3 +146,75 @@ describe("selectActiveIpsByMode", () => {
|
|||||||
expect(selectActiveIpsByMode(config, [])).toEqual([]);
|
expect(selectActiveIpsByMode(config, [])).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveDesiredAIps", () => {
|
||||||
|
it("keeps a dedicated single IP even when down", () => {
|
||||||
|
expect(
|
||||||
|
resolveDesiredAIps(
|
||||||
|
weightedConfig,
|
||||||
|
[row("1.1.1.1", { health: "down" })],
|
||||||
|
["1.1.1.1"],
|
||||||
|
),
|
||||||
|
).toEqual(["1.1.1.1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies weighted overlay on a shared pool", () => {
|
||||||
|
const rows = [
|
||||||
|
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||||
|
row("2.2.2.2", { weight: 3, health: "up" }),
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
resolveDesiredAIps(weightedConfig, rows, ["1.1.1.1", "2.2.2.2"], 0),
|
||||||
|
).toEqual(selectActiveIpsByMode(weightedConfig, rows, 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldRecordFailoverDnsDiff", () => {
|
||||||
|
it("skips dedicated extra-FQDN", () => {
|
||||||
|
expect(
|
||||||
|
shouldRecordFailoverDnsDiff({
|
||||||
|
configuredIps: ["1.1.1.1"],
|
||||||
|
lbMode: "failover",
|
||||||
|
added: [],
|
||||||
|
removed: ["1.1.1.1"],
|
||||||
|
downIps: new Set(["1.1.1.1"]),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips weighted live-to-live slot swap", () => {
|
||||||
|
expect(
|
||||||
|
shouldRecordFailoverDnsDiff({
|
||||||
|
configuredIps: ["1.1.1.1", "2.2.2.2"],
|
||||||
|
lbMode: "weighted",
|
||||||
|
added: ["2.2.2.2"],
|
||||||
|
removed: ["1.1.1.1"],
|
||||||
|
downIps: new Set(),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs weighted swap when a down ip leaves the pool", () => {
|
||||||
|
expect(
|
||||||
|
shouldRecordFailoverDnsDiff({
|
||||||
|
configuredIps: ["1.1.1.1", "2.2.2.2"],
|
||||||
|
lbMode: "weighted",
|
||||||
|
added: ["2.2.2.2"],
|
||||||
|
removed: ["1.1.1.1"],
|
||||||
|
downIps: new Set(["1.1.1.1"]),
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs failover diffs on a shared pool", () => {
|
||||||
|
expect(
|
||||||
|
shouldRecordFailoverDnsDiff({
|
||||||
|
configuredIps: ["1.1.1.1", "2.2.2.2"],
|
||||||
|
lbMode: "failover",
|
||||||
|
added: ["2.2.2.2"],
|
||||||
|
removed: ["1.1.1.1"],
|
||||||
|
downIps: new Set(["1.1.1.1"]),
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ export function FailoverTimeline({
|
|||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ShieldCheckIcon}
|
icon={ShieldCheckIcon}
|
||||||
title="Нет инцидентов Failover"
|
title="Нет инцидентов"
|
||||||
description="Нет Down и нет выходов из пула"
|
description="Нет Down и нет выходов из общего пула"
|
||||||
stackedIcon={false}
|
stackedIcon={false}
|
||||||
centered={false}
|
centered={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type HealthLogProbe,
|
type HealthLogProbe,
|
||||||
type HealthLogStatus,
|
type HealthLogStatus,
|
||||||
} from '@/lib/health-log'
|
} from '@/lib/health-log'
|
||||||
import type { FailoverLogEntry } from '@/lib/schemas'
|
import type { FailoverLogEntry, ServiceView } from '@/lib/schemas'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
@@ -28,6 +28,23 @@ import {
|
|||||||
AlertTitle,
|
AlertTitle,
|
||||||
} from '@/components/reui/alert'
|
} from '@/components/reui/alert'
|
||||||
|
|
||||||
|
type LbMode = ServiceView['lb_mode']
|
||||||
|
|
||||||
|
const PANEL_COPY: Record<LbMode, { title: string; description: string }> = {
|
||||||
|
failover: {
|
||||||
|
title: 'Failover (приоритет)',
|
||||||
|
description: 'Down снимается с общего FQDN. История: кто вышел из пула и кто вернулся',
|
||||||
|
},
|
||||||
|
weighted: {
|
||||||
|
title: 'Веса (подмена IP)',
|
||||||
|
description: 'На общем FQDN один IP по весам. Down выводится из цикла',
|
||||||
|
},
|
||||||
|
round_robin: {
|
||||||
|
title: 'Round Robin',
|
||||||
|
description: 'На общем FQDN все живые A. Down снимается с пула',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
function failoverCountLabel(count: number): string {
|
function failoverCountLabel(count: number): string {
|
||||||
const mod10 = count % 10
|
const mod10 = count % 10
|
||||||
const mod100 = count % 100
|
const mod100 = count % 100
|
||||||
@@ -38,8 +55,17 @@ function failoverCountLabel(count: number): string {
|
|||||||
return `${count} адресов Down`
|
return `${count} адресов Down`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function alertDescription(events: { kind: string; fqdns: string[] }[]): string {
|
||||||
|
const removedCount = events.filter((event) => event.kind === 'removed').length
|
||||||
|
if (removedCount > 0) return 'Сняты с общего FQDN'
|
||||||
|
if (events.some((event) => event.fqdns.length > 0)) {
|
||||||
|
return 'Остались в A-записях общего FQDN как last-resort'
|
||||||
|
}
|
||||||
|
return 'Down: персональные FQDN не меняются'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Failover — текущие Down + кто вышел из пула и кто вернулся.
|
* Балансировка — текущие Down + кто вышел из общего пула.
|
||||||
* Preview: https://reui.io/preview/base/components/c-timeline-10
|
* Preview: https://reui.io/preview/base/components/c-timeline-10
|
||||||
* Preview: https://reui.io/preview/base/empty-state-12
|
* Preview: https://reui.io/preview/base/empty-state-12
|
||||||
* Docs: https://reui.io/docs/components/base/frame
|
* Docs: https://reui.io/docs/components/base/frame
|
||||||
@@ -48,16 +74,19 @@ function failoverCountLabel(count: number): string {
|
|||||||
* Docs: https://reui.io/docs/components/base/alert
|
* Docs: https://reui.io/docs/components/base/alert
|
||||||
*/
|
*/
|
||||||
export function ServiceFailoverPanel({
|
export function ServiceFailoverPanel({
|
||||||
|
lbMode = 'round_robin',
|
||||||
ipHealth,
|
ipHealth,
|
||||||
bindings,
|
bindings,
|
||||||
history,
|
history,
|
||||||
probes = [],
|
probes = [],
|
||||||
}: {
|
}: {
|
||||||
|
lbMode?: LbMode
|
||||||
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 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)
|
||||||
@@ -77,14 +106,13 @@ export function ServiceFailoverPanel({
|
|||||||
})
|
})
|
||||||
const events = toFailoverEvents(overlayHealth, bindings)
|
const events = toFailoverEvents(overlayHealth, bindings)
|
||||||
const mergedHistory = mergeFailoverHistory(history, probes, bindings)
|
const mergedHistory = mergeFailoverHistory(history, probes, bindings)
|
||||||
const removedCount = events.filter((event) => event.kind === 'removed').length
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
||||||
<FramePanel className="flex flex-col gap-3">
|
<FramePanel className="flex flex-col gap-3">
|
||||||
<FrameHeader className="gap-1 px-0 py-0">
|
<FrameHeader className="gap-1 px-0 py-0">
|
||||||
<FrameTitle className="flex flex-wrap items-center gap-2">
|
<FrameTitle className="flex flex-wrap items-center gap-2">
|
||||||
Failover
|
{copy.title}
|
||||||
{events.length > 0 ? (
|
{events.length > 0 ? (
|
||||||
<Badge variant="destructive-light" size="xs" radius="full">
|
<Badge variant="destructive-light" size="xs" radius="full">
|
||||||
{events.length}
|
{events.length}
|
||||||
@@ -95,20 +123,14 @@ export function ServiceFailoverPanel({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</FrameTitle>
|
</FrameTitle>
|
||||||
<FrameDescription>
|
<FrameDescription>{copy.description}</FrameDescription>
|
||||||
Текущие Down и история: кто вышел из пула и кто вернулся
|
|
||||||
</FrameDescription>
|
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
|
|
||||||
{events.length > 0 ? (
|
{events.length > 0 ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<UnplugIcon aria-hidden="true" />
|
<UnplugIcon aria-hidden="true" />
|
||||||
<AlertTitle>{failoverCountLabel(events.length)}</AlertTitle>
|
<AlertTitle>{failoverCountLabel(events.length)}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>{alertDescription(events)}</AlertDescription>
|
||||||
{removedCount > 0
|
|
||||||
? 'Сняты с FQDN или остались last-resort'
|
|
||||||
: 'Остались в A-записях как last-resort'}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ describe('toFailoverEvents', () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
expect(failoverEventCopy(events[0]!)).toBe('Снята с gt.rkns.top')
|
expect(failoverEventCopy(events[0]!)).toBe('Снята с gt.rkns.top')
|
||||||
|
expect(events[0]?.fqdns).not.toContain('nsgt.rkns.top')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Down только last-resort на своём FQDN', () => {
|
it('Down только last-resort на своём FQDN', () => {
|
||||||
@@ -151,10 +152,8 @@ describe('toFailoverEvents', () => {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
expect(events[0]?.kind).toBe('last-resort')
|
expect(events[0]?.kind).toBe('last-resort')
|
||||||
expect(events[0]?.fqdns).toEqual(['nsgt.rkns.top'])
|
expect(events[0]?.fqdns).toEqual([])
|
||||||
expect(failoverEventCopy(events[0]!)).toBe(
|
expect(failoverEventCopy(events[0]!)).toBe('Down, в A-записях last-resort')
|
||||||
'Down, в A-записях last-resort на nsgt.rkns.top',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('down без A-записи на configured FQDN — снятие', () => {
|
it('down без A-записи на configured FQDN — снятие', () => {
|
||||||
@@ -279,7 +278,9 @@ describe('mergeFailoverHistory', () => {
|
|||||||
'removed:probe',
|
'removed:probe',
|
||||||
])
|
])
|
||||||
expect(history[0]?.copy).toBe('130.49.213.153 добавлена на gt.rkns.top')
|
expect(history[0]?.copy).toBe('130.49.213.153 добавлена на gt.rkns.top')
|
||||||
expect(history[1]?.copy).toContain('вышла из пула')
|
expect(history[1]?.copy).toBe(
|
||||||
|
'130.49.213.153 вышла из пула (gt.rkns.top)',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps DNS over a probe return in the same 2-minute window', () => {
|
it('keeps DNS over a probe return in the same 2-minute window', () => {
|
||||||
@@ -291,4 +292,20 @@ describe('mergeFailoverHistory', () => {
|
|||||||
expect(history).toHaveLength(1)
|
expect(history).toHaveLength(1)
|
||||||
expect(history[0]?.source).toBe('dns')
|
expect(history[0]?.source).toBe('dns')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('drops dedicated extra-FQDN DNS rows', () => {
|
||||||
|
const history = mergeFailoverHistory(
|
||||||
|
[
|
||||||
|
dns({
|
||||||
|
id: 11,
|
||||||
|
fqdn: 'nsgt.rkns.top',
|
||||||
|
action: 'added',
|
||||||
|
created_at: '2026-08-20 09:26:00',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
mskHip,
|
||||||
|
)
|
||||||
|
expect(history).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ export interface FailoverBindingPool {
|
|||||||
active: readonly string[]
|
active: readonly string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared pool FQDN — two or more A targets. Dedicated extra-FQDN has one IP. */
|
||||||
|
export function isSharedPoolBinding(binding: FailoverBindingPool): boolean {
|
||||||
|
return binding.configured.length >= 2
|
||||||
|
}
|
||||||
|
|
||||||
/** Инцидент только при down. up / degraded / unknown — не вывод из пула. */
|
/** Инцидент только при down. up / degraded / unknown — не вывод из пула. */
|
||||||
export function isFailoverEventStatus(status: string): boolean {
|
export function isFailoverEventStatus(status: string): boolean {
|
||||||
return status === 'down'
|
return status === 'down'
|
||||||
@@ -72,6 +77,7 @@ export function toFailoverEvents(
|
|||||||
const removedFqdns: string[] = []
|
const removedFqdns: string[] = []
|
||||||
const lastResortFqdns: string[] = []
|
const lastResortFqdns: string[] = []
|
||||||
for (const binding of bindings) {
|
for (const binding of bindings) {
|
||||||
|
if (!isSharedPoolBinding(binding)) continue
|
||||||
const configured = binding.configured.includes(row.ip)
|
const configured = binding.configured.includes(row.ip)
|
||||||
const active = binding.active.includes(row.ip)
|
const active = binding.active.includes(row.ip)
|
||||||
if (configured && !active) removedFqdns.push(binding.fqdn)
|
if (configured && !active) removedFqdns.push(binding.fqdn)
|
||||||
@@ -98,7 +104,18 @@ function fqdnsForIp(
|
|||||||
ip: string,
|
ip: string,
|
||||||
bindings: readonly FailoverBindingPool[],
|
bindings: readonly FailoverBindingPool[],
|
||||||
): string[] {
|
): string[] {
|
||||||
return bindings.filter((binding) => binding.configured.includes(ip)).map((binding) => binding.fqdn)
|
return bindings
|
||||||
|
.filter((binding) => isSharedPoolBinding(binding) && binding.configured.includes(ip))
|
||||||
|
.map((binding) => binding.fqdn)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPoolFqdn(
|
||||||
|
fqdn: string,
|
||||||
|
bindings: readonly FailoverBindingPool[],
|
||||||
|
): boolean {
|
||||||
|
const binding = bindings.find((item) => item.fqdn === fqdn)
|
||||||
|
if (!binding) return true
|
||||||
|
return isSharedPoolBinding(binding)
|
||||||
}
|
}
|
||||||
|
|
||||||
function fqdnLabel(fqdns: string[]): string {
|
function fqdnLabel(fqdns: string[]): string {
|
||||||
@@ -201,7 +218,9 @@ export function mergeFailoverHistory(
|
|||||||
bindings: readonly FailoverBindingPool[] = [],
|
bindings: readonly FailoverBindingPool[] = [],
|
||||||
): FailoverHistoryItem[] {
|
): FailoverHistoryItem[] {
|
||||||
const merged = [
|
const merged = [
|
||||||
...dns.map(dnsHistoryItem),
|
...dns
|
||||||
|
.filter((item) => isPoolFqdn(item.fqdn, bindings))
|
||||||
|
.map(dnsHistoryItem),
|
||||||
...toIpAliveTransitions(probes).map((transition) =>
|
...toIpAliveTransitions(probes).map((transition) =>
|
||||||
probeHistoryItem(transition, bindings),
|
probeHistoryItem(transition, bindings),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user