Compare commits

...
4 Commits
Author SHA1 Message Date
DenozordecandCursor 3d0ea33baf 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 <cursoragent@cursor.com>
2026-08-20 17:35:13 +07:00
DenozordecandCursor 8a13888db7 fix(services): вернуть вес и приоритет в режиме балансировки
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
CD / quality (push) Successful in 1m5s
CD / publish (push) Successful in 1m43s
После объединения адресов в форме пропали per-IP поля. Weighted снова задаёт вес, failover — основной и запасной.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 17:06:29 +07:00
DenozordecandCursor f5d97e463a fix(services): показывать живой health и историю выхода из пула
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m14s
CD / publish (push) Successful in 2m3s
Таблица IP брала hysteresis unknown вместо последней пробы. Failover теперь показывает выход и возврат в пул, а не только DNS add.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:43:22 +07:00
DenozordecandCursor be8f94143f fix(health): не затирать график uptime пробой down в том же бакете
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
CD / quality (push) Successful in 1m7s
CD / publish (push) Successful in 1m38s
Если в минутном бакете есть живой IP, линия строится по его пингу, а не по null от Down.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:07:24 +07:00
21 changed files with 923 additions and 103 deletions

No files matched your search

+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",
+8 -12
View File
@@ -16,8 +16,8 @@ import { formatDate, formatRelative, sqliteUtcToIso } from '@/lib/format'
import { import {
failoverEventCopy, failoverEventCopy,
type FailoverEvent, type FailoverEvent,
type FailoverHistoryItem,
} from '@/lib/failover-events' } from '@/lib/failover-events'
import type { FailoverLogEntry } from '@/lib/schemas'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
import type { ComponentProps } from 'react' import type { ComponentProps } from 'react'
@@ -45,12 +45,6 @@ function separatorClass(tone: 'down' | 'added' | 'removed'): string {
return 'bg-destructive/25' return 'bg-destructive/25'
} }
function failoverHistoryCopy(item: FailoverLogEntry): string {
return item.action === 'removed'
? `${item.ip} убрана с ${item.fqdn}`
: `${item.ip} добавлена на ${item.fqdn}`
}
/** /**
* Failover как sibling «Смены статуса»: ReUI Timeline + Badge. * Failover как sibling «Смены статуса»: ReUI Timeline + Badge.
* Preview: https://reui.io/preview/base/components/c-timeline-10 * Preview: https://reui.io/preview/base/components/c-timeline-10
@@ -63,14 +57,14 @@ export function FailoverTimeline({
history = [], history = [],
}: { }: {
events: FailoverEvent[] events: FailoverEvent[]
history?: readonly FailoverLogEntry[] history?: readonly FailoverHistoryItem[]
}) { }) {
if (events.length === 0 && history.length === 0) { if (events.length === 0 && history.length === 0) {
return ( return (
<EmptyState <EmptyState
icon={ShieldCheckIcon} icon={ShieldCheckIcon}
title="Нет инцидентов Failover" title="Нет инцидентов Failover"
description="Нет Down и нет смен A-записей по FQDN" description="Нет Down и нет выходов из пула"
stackedIcon={false} stackedIcon={false}
centered={false} centered={false}
/> />
@@ -142,6 +136,10 @@ export function FailoverTimeline({
<TimelineHeader> <TimelineHeader>
<TimelineTitle className="flex flex-wrap items-center gap-2"> <TimelineTitle className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm">{item.ip}</span> <span className="font-mono text-sm">{item.ip}</span>
<HealthCheckBadge
status={item.action === 'added' ? 'up' : 'down'}
size="xs"
/>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{item.fqdn} {item.fqdn}
</span> </span>
@@ -151,9 +149,7 @@ export function FailoverTimeline({
</TimelineDate> </TimelineDate>
</TimelineHeader> </TimelineHeader>
<TimelineContent> <TimelineContent>
<p className="text-foreground text-sm"> <p className="text-foreground text-sm">{item.copy}</p>
{failoverHistoryCopy(item)}
</p>
</TimelineContent> </TimelineContent>
</TimelineItem> </TimelineItem>
) )
@@ -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,15 +10,8 @@ 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 { FieldGroup } 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'
import { ButtonGroup } from '@cfdm/ui/components/button-group' import { ButtonGroup } from '@cfdm/ui/components/button-group'
import { CableIcon, GlobeIcon } from 'lucide-react' import { CableIcon, GlobeIcon } from 'lucide-react'
@@ -59,9 +53,14 @@ 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 {
weight?: number
priority?: number
}
function CompactNumberField({ function CompactNumberField({
id, id,
value, value,
@@ -95,6 +94,89 @@ function CompactNumberField({
) )
} }
function PoolLbMetaFields({
idPrefix,
mode,
ips,
weights,
priorities,
onMetaChange,
}: {
idPrefix: string
mode: Exclude<LbMode, 'round_robin'>
ips: readonly string[]
weights: Record<string, number>
priorities: Record<string, number>
onMetaChange?: (ip: string, meta: LbPoolMetaChange) => void
}) {
const isWeighted = mode === 'weighted'
const minPriority =
ips.length === 0
? 1
: Math.min(...ips.map((ip) => priorities[ip] ?? 1))
return (
<SettingRow
title={isWeighted ? 'Вес IP' : 'Приоритет IP'}
description={
isWeighted
? 'Доля времени на общем FQDN: 1 и 3 = ¼ и ¾ цикла (слот 60 с)'
: '1 — основной, больше — запасной'
}
compact
stacked
className="gap-3 px-0 py-3"
contentClassName="min-w-0"
>
{ips.length === 0 ? (
<p className="text-muted-foreground text-sm">Сначала добавьте IP выше</p>
) : (
<div className="flex w-full flex-col gap-2">
{ips.map((ip) => {
const isPrimary = (priorities[ip] ?? 1) === minPriority
const fieldId = isWeighted
? `${idPrefix}-weight-${ip}`
: `${idPrefix}-priority-${ip}`
return (
<div key={ip} className="flex items-center gap-3">
<span className="min-w-0 flex-1 truncate font-mono text-sm">{ip}</span>
{!isWeighted ? (
<Badge
variant={isPrimary ? 'success-light' : 'outline'}
size="xs"
radius="full"
>
{isPrimary ? 'Основной' : 'Запасной'}
</Badge>
) : null}
<Field className="w-28 gap-0">
<FieldLabel htmlFor={fieldId} className="sr-only">
{isWeighted ? `Вес ${ip}` : `Приоритет ${ip}`}
</FieldLabel>
<CompactNumberField
id={fieldId}
value={isWeighted ? (weights[ip] ?? 1) : (priorities[ip] ?? 1)}
min={1}
max={100}
onValueChange={(next) =>
onMetaChange?.(
ip,
isWeighted
? { weight: next ?? 1 }
: { priority: next ?? 1 },
)
}
/>
</Field>
</div>
)
})}
</div>
)}
</SettingRow>
)
}
export function HealthCheckConfigFields({ export function HealthCheckConfigFields({
value, value,
onChange, onChange,
@@ -102,6 +184,10 @@ export function HealthCheckConfigFields({
lbModeOptions = defaultLbModeOptions, lbModeOptions = defaultLbModeOptions,
idPrefix = 'health', idPrefix = 'health',
showLbMode = true, showLbMode = true,
ips = [],
weights = {},
priorities = {},
onMetaChange,
className, className,
}: { }: {
value: LbAndHealthConfig value: LbAndHealthConfig
@@ -110,6 +196,10 @@ export function HealthCheckConfigFields({
lbModeOptions?: { value: string; label: string }[] lbModeOptions?: { value: string; label: string }[]
idPrefix?: string idPrefix?: string
showLbMode?: boolean showLbMode?: boolean
ips?: readonly string[]
weights?: Record<string, number>
priorities?: Record<string, number>
onMetaChange?: (ip: string, meta: LbPoolMetaChange) => void
className?: string className?: string
}) { }) {
function patch(next: Partial<LbAndHealthConfig>) { function patch(next: Partial<LbAndHealthConfig>) {
@@ -134,25 +224,28 @@ 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}
{showLbMode && value.lb_mode !== 'round_robin' ? (
<PoolLbMetaFields
idPrefix={idPrefix}
mode={value.lb_mode}
ips={ips}
weights={weights}
priorities={priorities}
onMetaChange={onMetaChange}
/>
) : null}
<SettingRow <SettingRow
title="Провайдер health-check" title="Провайдер health-check"
description="Кто пробирует цель. Можно выбрать несколько источников." description="Кто пробирует цель. Можно выбрать несколько источников."
@@ -2,10 +2,17 @@ import { UnplugIcon } from 'lucide-react'
import { FailoverTimeline } from '@/components/failover-timeline' import { FailoverTimeline } from '@/components/failover-timeline'
import { import {
mergeFailoverHistory,
toFailoverEvents, toFailoverEvents,
type FailoverBindingPool, type FailoverBindingPool,
type FailoverHealthInput, type FailoverHealthInput,
} from '@/lib/failover-events' } from '@/lib/failover-events'
import {
latestHealthByIp,
resolveIpDisplayHealth,
type HealthLogProbe,
type HealthLogStatus,
} from '@/lib/health-log'
import type { FailoverLogEntry } from '@/lib/schemas' import type { FailoverLogEntry } from '@/lib/schemas'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { import {
@@ -32,7 +39,7 @@ function failoverCountLabel(count: number): string {
} }
/** /**
* Failover — текущие Down + журнал add/remove по FQDN. * Failover — текущие 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
@@ -44,12 +51,32 @@ export function ServiceFailoverPanel({
ipHealth, ipHealth,
bindings, bindings,
history, history,
probes = [],
}: { }: {
ipHealth: readonly FailoverHealthInput[] ipHealth: readonly FailoverHealthInput[]
bindings: readonly FailoverBindingPool[] bindings: readonly FailoverBindingPool[]
history: readonly FailoverLogEntry[] history: readonly FailoverLogEntry[]
probes?: readonly HealthLogProbe[]
}) { }) {
const events = toFailoverEvents(ipHealth, bindings) const liveByIp = latestHealthByIp(probes)
const overlayHealth = ipHealth.map((row) => {
const live = liveByIp.get(row.ip)
return {
...row,
status: resolveIpDisplayHealth(
row.status as HealthLogStatus,
live?.status,
),
last_error:
live && live.status !== 'unknown' ? live.last_error : row.last_error,
last_checked_at:
live && live.status !== 'unknown'
? live.last_checked_at
: row.last_checked_at,
}
})
const events = toFailoverEvents(overlayHealth, bindings)
const mergedHistory = mergeFailoverHistory(history, probes, bindings)
const removedCount = events.filter((event) => event.kind === 'removed').length const removedCount = events.filter((event) => event.kind === 'removed').length
return ( return (
@@ -69,7 +96,7 @@ export function ServiceFailoverPanel({
)} )}
</FrameTitle> </FrameTitle>
<FrameDescription> <FrameDescription>
Текущие Down и история A-записей по FQDN Текущие Down и история: кто вышел из пула и кто вернулся
</FrameDescription> </FrameDescription>
</FrameHeader> </FrameHeader>
@@ -85,7 +112,7 @@ export function ServiceFailoverPanel({
</Alert> </Alert>
) : null} ) : null}
<FailoverTimeline events={events} history={history} /> <FailoverTimeline events={events} history={mergedHistory} />
</FramePanel> </FramePanel>
</Frame> </Frame>
) )
@@ -45,6 +45,28 @@ describe('toAlignedSeries', () => {
expect(keys).toEqual(['local', 'cloudflare', 'globalping']) expect(keys).toEqual(['local', 'cloudflare', 'globalping'])
}) })
it('keeps live latency when another IP in the same bucket is down', () => {
const { points } = toAlignedSeries([
probe({
id: 1,
latency_ms: 18,
checked_at: '2026-01-01T00:00:10.000Z',
}),
probe({
id: 2,
status: 'down',
ok: false,
latency_ms: null,
checked_at: '2026-01-01T00:00:12.000Z',
}),
])
expect(points).toHaveLength(1)
expect(points[0]?.local).toBe(18)
expect(points[0]?.localOk).toBe(true)
expect(points[0]?.ok).toBe(true)
})
it('does not plot down probes as latency 0', () => { it('does not plot down probes as latency 0', () => {
const { points } = toAlignedSeries([ const { points } = toAlignedSeries([
probe({ probe({
@@ -124,8 +124,13 @@ export function toAlignedSeries(items: UptimeProbe[]): {
} }
const ok = probeOk(item) const ok = probeOk(item)
row[`${key}Ok`] = ok const prevOk = row[`${key}Ok`]
row[key] = ok ? item.latency_ms : null row[`${key}Ok`] = prevOk === true || ok
if (ok && item.latency_ms != null) {
row[key] = item.latency_ms
} else if (row[key] === undefined) {
row[key] = null
}
} }
const points = [...buckets.entries()] const points = [...buckets.entries()]
@@ -17,6 +17,7 @@ import {
DEFAULT_BINDING_HEALTH, DEFAULT_BINDING_HEALTH,
emptyAddressBlock, emptyAddressBlock,
hydrateAddressBlock, hydrateAddressBlock,
patchAddressIpMeta,
toBindingDrafts, toBindingDrafts,
toDomainsPayload, toDomainsPayload,
type AddressBlockState, type AddressBlockState,
@@ -282,6 +283,12 @@ export function ServiceEditSheet({
idPrefix="service-health" idPrefix="service-health"
value={primaryHealthValue} value={primaryHealthValue}
onChange={handlePrimaryHealthChange} onChange={handlePrimaryHealthChange}
ips={address.nodes.map((node) => node.ip)}
weights={address.target_ip_weights}
priorities={address.target_ip_priorities}
onMetaChange={(ip, meta) =>
setAddress((current) => patchAddressIpMeta(current, ip, meta))
}
/> />
</section> </section>
</div> </div>
@@ -18,6 +18,11 @@ import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-colu
import { IconTile } from '@/components/reui/icon-tile' import { IconTile } from '@/components/reui/icon-tile'
import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters' import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
import { ResourcePage } from '@/components/reui-kit' import { ResourcePage } from '@/components/reui-kit'
import {
latestHealthByIp,
resolveIpDisplayHealth,
type HealthLogProbe,
} from '@/lib/health-log'
import { certRelativeBadge } from '@/components/columns/certificates-columns' import { certRelativeBadge } from '@/components/columns/certificates-columns'
import { certMonitoringOptions } from '@/lib/cert-monitoring' import { certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatDate } from '@/lib/format' import { formatDate } from '@/lib/format'
@@ -99,8 +104,12 @@ function mapNodeHealth(status: string): HealthStatus {
return 'unknown' return 'unknown'
} }
function buildIpRows(service: ServiceView): ServiceIpRow[] { function buildIpRows(
service: ServiceView,
probes: readonly HealthLogProbe[] = [],
): ServiceIpRow[] {
const healthByIp = new Map(service.ip_health.map((row) => [row.ip, row])) const healthByIp = new Map(service.ip_health.map((row) => [row.ip, row]))
const liveByIp = latestHealthByIp(probes)
const weights = Object.assign( const weights = Object.assign(
{}, {},
...service.domains.map((domain) => domain.target_ip_weights ?? {}), ...service.domains.map((domain) => domain.target_ip_weights ?? {}),
@@ -113,19 +122,25 @@ function buildIpRows(service: ServiceView): ServiceIpRow[] {
return service.ips.map((ip) => { return service.ips.map((ip) => {
const health = healthByIp.get(ip) const health = healthByIp.get(ip)
const live = liveByIp.get(ip)
const status = resolveIpDisplayHealth(health?.status, live?.status)
const extras = live && live.status !== 'unknown' ? live : health
return { return {
id: ip, id: ip,
ip, ip,
status: health?.status ?? 'unknown', status,
enabled: service.ip_enabled[ip] !== false, enabled: service.ip_enabled[ip] !== false,
active: activeSet.has(ip), active: activeSet.has(ip),
weight: weights[ip] ?? 1, weight: weights[ip] ?? 1,
priority: priorities[ip] ?? 1, priority: priorities[ip] ?? 1,
latency_ms: health?.latency_ms ?? null, latency_ms: extras?.latency_ms ?? null,
last_checked_at: health?.last_checked_at ?? null, last_checked_at: extras?.last_checked_at ?? null,
last_error: health?.last_error ?? null, last_error:
colo: health?.colo ?? null, live && live.status !== 'unknown'
provider: health?.provider ?? null, ? live.last_error
: (health?.last_error ?? null),
colo: extras?.colo ?? null,
provider: extras?.provider ?? null,
} }
}) })
} }
@@ -151,17 +166,23 @@ function buildNodeRows(
weight: number weight: number
priority: number priority: number
}>, }>,
probes: readonly HealthLogProbe[] = [],
): ServiceNodeRow[] { ): ServiceNodeRow[] {
return nodes.map((node) => ({ const liveByIp = latestHealthByIp(probes)
id: String(node.id), return nodes.map((node) => {
nodeId: node.id, const stored = mapNodeHealth(node.health_status)
address: node.address, const live = liveByIp.get(node.address)
protocol: node.protocol, return {
port: node.port, id: String(node.id),
health_status: mapNodeHealth(node.health_status), nodeId: node.id,
weight: node.weight, address: node.address,
priority: node.priority, protocol: node.protocol,
})) port: node.port,
health_status: resolveIpDisplayHealth(stored, live?.status),
weight: node.weight,
priority: node.priority,
}
})
} }
function NameCell({ function NameCell({
@@ -199,6 +220,7 @@ interface ServiceDetailGridProps {
weight: number weight: number
priority: number priority: number
}> }>
probes?: readonly HealthLogProbe[]
togglingIp: string | null togglingIp: string | null
onToggleIp: (ip: string, enabled: boolean) => void onToggleIp: (ip: string, enabled: boolean) => void
onChangeIp: (row: ServiceFqdnRow) => void onChangeIp: (row: ServiceFqdnRow) => void
@@ -211,6 +233,7 @@ interface ServiceDetailGridProps {
export function ServiceDetailGrid({ export function ServiceDetailGrid({
service, service,
nodes, nodes,
probes = [],
togglingIp, togglingIp,
onToggleIp, onToggleIp,
onChangeIp, onChangeIp,
@@ -283,9 +306,9 @@ export function ServiceDetailGrid({
}, },
}) })
const ipRows = useMemo(() => buildIpRows(service), [service]) const ipRows = useMemo(() => buildIpRows(service, probes), [service, probes])
const fqdnRows = useMemo(() => buildFqdnRows(service), [service]) const fqdnRows = useMemo(() => buildFqdnRows(service), [service])
const nodeRows = useMemo(() => buildNodeRows(nodes), [nodes]) const nodeRows = useMemo(() => buildNodeRows(nodes, probes), [nodes, probes])
const markActive = service.lb_mode === 'failover' || service.lb_mode === 'weighted' const markActive = service.lb_mode === 'failover' || service.lb_mode === 'weighted'
const tabs = TABS.map((entry) => ({ const tabs = TABS.map((entry) => ({
@@ -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)',
}, },
} }
+106
View File
@@ -3,10 +3,14 @@ import { describe, expect, it } from 'vitest'
import { import {
failoverEventCopy, failoverEventCopy,
isFailoverEventStatus, isFailoverEventStatus,
mergeFailoverHistory,
toFailoverEvents, toFailoverEvents,
toIpAliveTransitions,
type FailoverBindingPool, type FailoverBindingPool,
type FailoverHealthInput, type FailoverHealthInput,
} from '@/lib/failover-events' } from '@/lib/failover-events'
import type { FailoverLogEntry } from '@/lib/schemas'
import type { HealthLogProbe } from '@/lib/health-log'
function row( function row(
overrides: Partial<FailoverHealthInput> & Pick<FailoverHealthInput, 'ip'>, overrides: Partial<FailoverHealthInput> & Pick<FailoverHealthInput, 'ip'>,
@@ -186,3 +190,105 @@ describe('toFailoverEvents', () => {
]) ])
}) })
}) })
function probe(
overrides: Partial<HealthLogProbe> & Pick<HealthLogProbe, 'id' | 'status' | 'checked_at'>,
): HealthLogProbe {
return {
ip: '130.49.213.153',
provider: 'local',
ok: overrides.status === 'up',
latency_ms: 12,
colo: null,
error: null,
...overrides,
}
}
function dns(
overrides: Partial<FailoverLogEntry> & Pick<FailoverLogEntry, 'id' | 'action' | 'created_at'>,
): FailoverLogEntry {
return {
service_id: 13,
binding_id: 1,
fqdn: 'gt.rkns.top',
ip: '130.49.213.153',
...overrides,
}
}
describe('toIpAliveTransitions', () => {
it('emits leave then return, not the initial up', () => {
const transitions = toIpAliveTransitions([
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
probe({ id: 2, status: 'up', checked_at: '2026-08-20T09:01:00Z' }),
probe({ id: 3, status: 'down', checked_at: '2026-08-20T09:02:00Z' }),
probe({ id: 4, status: 'down', checked_at: '2026-08-20T09:03:00Z' }),
probe({ id: 5, status: 'up', checked_at: '2026-08-20T09:04:00Z' }),
])
expect(transitions).toEqual([
{ id: 'probe:3', ip: '130.49.213.153', alive: false, at: '2026-08-20T09:02:00Z' },
{ id: 'probe:5', ip: '130.49.213.153', alive: true, at: '2026-08-20T09:04:00Z' },
])
})
it('any-up across providers: leave only when every source is down', () => {
const transitions = toIpAliveTransitions([
probe({
id: 1,
provider: 'local',
status: 'up',
checked_at: '2026-08-20T09:00:00Z',
}),
probe({
id: 2,
provider: 'cloudflare',
status: 'down',
checked_at: '2026-08-20T09:01:00Z',
}),
probe({
id: 3,
provider: 'local',
status: 'down',
checked_at: '2026-08-20T09:02:00Z',
}),
probe({
id: 4,
provider: 'local',
status: 'up',
checked_at: '2026-08-20T09:03:00Z',
}),
])
expect(transitions.map((item) => item.id)).toEqual(['probe:3', 'probe:4'])
})
})
describe('mergeFailoverHistory', () => {
it('fills leave/return from probes when DNS has only the add', () => {
const history = mergeFailoverHistory(
[dns({ id: 10, action: 'added', created_at: '2026-08-20 09:26:00' })],
[
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
probe({ id: 2, status: 'down', checked_at: '2026-08-20T09:10:00Z' }),
probe({ id: 3, status: 'up', checked_at: '2026-08-20T09:26:30Z' }),
],
mskHip,
)
expect(history.map((item) => `${item.action}:${item.source}`)).toEqual([
'added:dns',
'removed:probe',
])
expect(history[0]?.copy).toBe('130.49.213.153 добавлена на gt.rkns.top')
expect(history[1]?.copy).toContain('вышла из пула')
})
it('keeps DNS over a probe return in the same 2-minute window', () => {
const history = mergeFailoverHistory(
[dns({ id: 10, action: 'added', created_at: '2026-08-20 09:26:00' })],
[probe({ id: 3, status: 'up', checked_at: '2026-08-20T09:26:30Z' })],
mskHip,
)
expect(history).toHaveLength(1)
expect(history[0]?.source).toBe('dns')
})
})
+156
View File
@@ -1,5 +1,23 @@
import {
bestAliveHealthStatus,
probeTime,
type HealthLogProbe,
type HealthLogStatus,
} from '@/lib/health-log'
import type { FailoverLogEntry } from '@/lib/schemas'
export type FailoverEventKind = 'removed' | 'last-resort' export type FailoverEventKind = 'removed' | 'last-resort'
export interface FailoverHistoryItem {
id: string
ip: string
fqdn: string
action: 'added' | 'removed'
created_at: string
copy: string
source: 'dns' | 'probe'
}
export interface FailoverEvent { export interface FailoverEvent {
id: string id: string
address: string address: string
@@ -73,3 +91,141 @@ export function toFailoverEvents(
} }
}) })
} }
const DEDUPE_WINDOW_MS = 2 * 60 * 1000
function fqdnsForIp(
ip: string,
bindings: readonly FailoverBindingPool[],
): string[] {
return bindings.filter((binding) => binding.configured.includes(ip)).map((binding) => binding.fqdn)
}
function fqdnLabel(fqdns: string[]): string {
return fqdns.join(', ') || 'пул'
}
function isAliveStatus(status: HealthLogStatus): boolean {
return status === 'up' || status === 'degraded'
}
/**
* Per-IP any-up flips: down → вышла из пула, up после down → вернулась.
* Initial state is not an event.
*/
export function toIpAliveTransitions(
probes: readonly HealthLogProbe[],
): Array<{ id: string; ip: string; alive: boolean; at: string }> {
const byIp = new Map<string, HealthLogProbe[]>()
for (const item of probes) {
const list = byIp.get(item.ip)
if (list) list.push(item)
else byIp.set(item.ip, [item])
}
const out: Array<{ id: string; ip: string; alive: boolean; at: string }> = []
for (const [ip, list] of byIp) {
list.sort(
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
)
const latestByProvider = new Map<string, HealthLogProbe>()
let prevAlive: boolean | undefined
for (const probe of list) {
latestByProvider.set(probe.provider, probe)
const status = bestAliveHealthStatus(
[...latestByProvider.values()].map((item) => item.status),
)
if (status === 'unknown') continue
const alive = isAliveStatus(status)
if (prevAlive !== undefined && alive !== prevAlive) {
out.push({
id: `probe:${probe.id}`,
ip,
alive,
at: probe.checked_at,
})
}
prevAlive = alive
}
}
return out
}
function dnsHistoryItem(item: FailoverLogEntry): FailoverHistoryItem {
return {
id: `dns:${item.id}`,
ip: item.ip,
fqdn: item.fqdn,
action: item.action,
created_at: item.created_at,
copy:
item.action === 'removed'
? `${item.ip} убрана с ${item.fqdn}`
: `${item.ip} добавлена на ${item.fqdn}`,
source: 'dns',
}
}
function probeHistoryItem(
transition: { id: string; ip: string; alive: boolean; at: string },
bindings: readonly FailoverBindingPool[],
): FailoverHistoryItem {
const fqdns = fqdnsForIp(transition.ip, bindings)
const fqdn = fqdnLabel(fqdns)
const action = transition.alive ? 'added' : 'removed'
return {
id: transition.id,
ip: transition.ip,
fqdn,
action,
created_at: transition.at,
copy:
action === 'removed'
? `${transition.ip} вышла из пула (${fqdn})`
: `${transition.ip} вернулась в пул (${fqdn})`,
source: 'probe',
}
}
function eventTime(value: string): number {
return probeTime(value)
}
/**
* DNS add/remove + health leave/return, newest first.
* Same IP+action within 2 minutes: keep the DNS row (it has a concrete FQDN).
*/
export function mergeFailoverHistory(
dns: readonly FailoverLogEntry[],
probes: readonly HealthLogProbe[],
bindings: readonly FailoverBindingPool[] = [],
): FailoverHistoryItem[] {
const merged = [
...dns.map(dnsHistoryItem),
...toIpAliveTransitions(probes).map((transition) =>
probeHistoryItem(transition, bindings),
),
]
merged.sort(
(a, b) => eventTime(b.created_at) - eventTime(a.created_at) || a.id.localeCompare(b.id),
)
const kept: FailoverHistoryItem[] = []
for (const item of merged) {
const duplicate = kept.find(
(other) =>
other.ip === item.ip &&
other.action === item.action &&
Math.abs(eventTime(other.created_at) - eventTime(item.created_at)) <=
DEDUPE_WINDOW_MS,
)
if (!duplicate) {
kept.push(item)
continue
}
if (duplicate.source === 'probe' && item.source === 'dns') {
kept[kept.indexOf(duplicate)] = item
}
}
return kept
}
+41
View File
@@ -4,7 +4,9 @@ import {
bestAliveHealthStatus, bestAliveHealthStatus,
collapseStatusChanges, collapseStatusChanges,
enabledHealthProviders, enabledHealthProviders,
latestHealthByIp,
providerHealthStatuses, providerHealthStatuses,
resolveIpDisplayHealth,
worstHealthStatus, worstHealthStatus,
type HealthLogProbe, type HealthLogProbe,
} from '@/lib/health-log' } from '@/lib/health-log'
@@ -105,3 +107,42 @@ describe('bestAliveHealthStatus', () => {
expect(bestAliveHealthStatus([])).toBe('unknown') expect(bestAliveHealthStatus([])).toBe('unknown')
}) })
}) })
describe('latestHealthByIp', () => {
it('any-up among latest-per-provider probes', () => {
const items = [
probe({
id: 1,
ip: '130.49.213.153',
provider: 'local',
status: 'up',
checked_at: '2026-08-20T09:00:00Z',
}),
probe({
id: 2,
ip: '130.49.213.153',
provider: 'cloudflare',
status: 'down',
checked_at: '2026-08-20T09:00:01Z',
}),
probe({
id: 3,
ip: '93.115.203.183',
status: 'unknown',
checked_at: '2026-08-20T08:59:00Z',
}),
]
const byIp = latestHealthByIp(items)
expect(byIp.get('130.49.213.153')?.status).toBe('up')
expect(byIp.get('93.115.203.183')?.status).toBe('unknown')
})
})
describe('resolveIpDisplayHealth', () => {
it('prefers a live probe over stored unknown', () => {
expect(resolveIpDisplayHealth('unknown', 'up')).toBe('up')
expect(resolveIpDisplayHealth('down', 'up')).toBe('up')
expect(resolveIpDisplayHealth('up', 'unknown')).toBe('up')
expect(resolveIpDisplayHealth('unknown', undefined)).toBe('unknown')
})
})
+58
View File
@@ -136,3 +136,61 @@ export function providerHealthStatuses(
return result return result
} }
export interface IpDisplayHealth {
status: HealthLogStatus
latency_ms: number | null
last_checked_at: string
last_error: string | null
colo: string | null
provider: HealthCheckProvider
}
/**
* Latest probe per provider+IP, then any-up among those providers.
* Used by the IP table so hysteresis `unknown` in ip_health does not hide a live OK.
*/
export function latestHealthByIp(
items: readonly HealthLogProbe[],
): Map<string, IpDisplayHealth> {
const latest = new Map<string, HealthLogProbe>()
const sorted = [...items].sort(
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at) || b.id - a.id,
)
for (const item of sorted) {
const key = `${item.provider}\0${item.ip}`
if (!latest.has(key)) latest.set(key, item)
}
const byIp = new Map<string, HealthLogProbe[]>()
for (const item of latest.values()) {
const list = byIp.get(item.ip)
if (list) list.push(item)
else byIp.set(item.ip, [item])
}
const result = new Map<string, IpDisplayHealth>()
for (const [ip, probes] of byIp) {
const status = bestAliveHealthStatus(probes.map((probe) => probe.status))
const preferred =
probes.find((probe) => probe.status === status) ?? probes[0]!
result.set(ip, {
status,
latency_ms: preferred.latency_ms,
last_checked_at: preferred.checked_at,
last_error: preferred.error,
colo: preferred.colo,
provider: preferred.provider,
})
}
return result
}
/** Prefer a concrete live probe over stored hysteresis `unknown`. */
export function resolveIpDisplayHealth(
stored: HealthLogStatus | undefined,
live: HealthLogStatus | undefined,
): HealthLogStatus {
if (live && live !== 'unknown') return live
return stored ?? live ?? 'unknown'
}
+50
View File
@@ -7,6 +7,7 @@ import {
emptyAddressBlock, emptyAddressBlock,
emptyBindingDraft, emptyBindingDraft,
hydrateAddressBlock, hydrateAddressBlock,
patchAddressIpMeta,
removeAddressNode, removeAddressNode,
toAddressBindings, toAddressBindings,
toDomainsPayload, toDomainsPayload,
@@ -82,6 +83,26 @@ describe('hydrateAddressBlock', () => {
expect(state.preservedBindings).toHaveLength(1) expect(state.preservedBindings).toHaveLength(1)
expect(state.preservedBindings[0]?.fqdn).toBe('edge.example.com') expect(state.preservedBindings[0]?.fqdn).toBe('edge.example.com')
}) })
it('поднимает веса и приоритеты с общего FQDN', () => {
const drafts = [
aRecord('gt.rkns.top', ['130.49.213.153', '93.115.203.183'], {
target_ip_weights: { '130.49.213.153': 3, '93.115.203.183': 1 },
target_ip_priorities: { '130.49.213.153': 2, '93.115.203.183': 1 },
}),
aRecord('nsgt.rkns.top', ['130.49.213.153']),
]
const state = hydrateAddressBlock(drafts, ['130.49.213.153', '93.115.203.183'])
expect(state.target_ip_weights).toEqual({
'130.49.213.153': 3,
'93.115.203.183': 1,
})
expect(state.target_ip_priorities).toEqual({
'130.49.213.153': 2,
'93.115.203.183': 1,
})
expect(state.nodes[0]?.extraFqdn).toBe('nsgt.rkns.top')
})
}) })
describe('toDomainsPayload', () => { describe('toDomainsPayload', () => {
@@ -161,6 +182,35 @@ describe('addAddressNode / addCommonFqdn', () => {
}) })
}) })
describe('patchAddressIpMeta', () => {
it('меняет вес одного IP и не трогает extraFqdn', () => {
const state = {
...addAddressNode(addAddressNode(emptyAddressBlock(), '1.1.1.1'), '2.2.2.2'),
nodes: [
{ ip: '1.1.1.1', extraFqdn: 'msk.example.com' },
{ ip: '2.2.2.2', extraFqdn: '' },
],
}
const next = patchAddressIpMeta(state, '1.1.1.1', { weight: 7 })
expect(next.target_ip_weights).toEqual({ '1.1.1.1': 7, '2.2.2.2': 1 })
expect(next.target_ip_priorities).toEqual(state.target_ip_priorities)
expect(next.nodes).toEqual(state.nodes)
})
it('clamp веса и приоритета в 1–100', () => {
const state = addAddressNode(emptyAddressBlock(), '10.0.0.1')
expect(patchAddressIpMeta(state, '10.0.0.1', { weight: 0 }).target_ip_weights['10.0.0.1']).toBe(1)
expect(
patchAddressIpMeta(state, '10.0.0.1', { priority: 999 }).target_ip_priorities['10.0.0.1'],
).toBe(100)
})
it('игнорирует IP вне пула', () => {
const state = addAddressNode(emptyAddressBlock(), '10.0.0.1')
expect(patchAddressIpMeta(state, '8.8.8.8', { weight: 5 })).toBe(state)
})
})
describe('CNAME / preservedBindings', () => { describe('CNAME / preservedBindings', () => {
it('сохраняет CNAME в preserved при круге hydrate → payload', () => { it('сохраняет CNAME в preserved при круге hydrate → payload', () => {
const cname: ServiceBindingDraft = { const cname: ServiceBindingDraft = {
+27
View File
@@ -243,6 +243,33 @@ export function addAddressNode(state: AddressBlockState, ip: string): AddressBlo
} }
} }
const LB_META_MIN = 1
const LB_META_MAX = 100
function clampLbMeta(value: number): number {
if (!Number.isFinite(value)) return LB_META_MIN
return Math.min(LB_META_MAX, Math.max(LB_META_MIN, Math.round(value)))
}
export function patchAddressIpMeta(
state: AddressBlockState,
ip: string,
meta: { weight?: number; priority?: number },
): AddressBlockState {
if (!state.nodes.some((node) => node.ip === ip)) return state
return {
...state,
target_ip_weights:
meta.weight === undefined
? state.target_ip_weights
: { ...state.target_ip_weights, [ip]: clampLbMeta(meta.weight) },
target_ip_priorities:
meta.priority === undefined
? state.target_ip_priorities
: { ...state.target_ip_priorities, [ip]: clampLbMeta(meta.priority) },
}
}
function fqdnKey(value: string): string { function fqdnKey(value: string): string {
return value.trim().toLowerCase() return value.trim().toLowerCase()
} }
@@ -331,6 +331,7 @@ function ServiceDetailPage() {
ipHealth={service.ip_health} ipHealth={service.ip_health}
bindings={failoverBindings} bindings={failoverBindings}
history={failoverHistory} history={failoverHistory}
probes={logItems}
/> />
</section> </section>
@@ -344,6 +345,7 @@ function ServiceDetailPage() {
<ServiceDetailGrid <ServiceDetailGrid
service={service} service={service}
nodes={nodes} nodes={nodes}
probes={logItems}
togglingIp={togglingIp} togglingIp={togglingIp}
onToggleIp={(ip, enabled) => { onToggleIp={(ip, enabled) => {
setTogglingIp(ip) setTogglingIp(ip)