Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a228febc27 | ||
|
|
5cf39880f8 | ||
|
|
f1443f1db5 |
@@ -290,7 +290,7 @@ export interface RunAllChecksOptions {
|
||||
target: HealthCheckTarget,
|
||||
prevState: IpHealthState | null,
|
||||
nextState: IpHealthState,
|
||||
) => void;
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
@@ -326,7 +326,7 @@ function logSourceResult(
|
||||
});
|
||||
}
|
||||
|
||||
function applyAggregatedStatus(
|
||||
async function applyAggregatedStatus(
|
||||
db: Db,
|
||||
target: HealthCheckTarget,
|
||||
sources: Array<{ provider: HealthCheckProvider; result: ProbeResult }>,
|
||||
@@ -397,7 +397,7 @@ function applyAggregatedStatus(
|
||||
});
|
||||
}
|
||||
if (prevState !== state) {
|
||||
options.onStatusChange?.(target, prevState, state);
|
||||
await options.onStatusChange?.(target, prevState, state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,7 +513,7 @@ export async function runAllChecks(
|
||||
for (const source of sources) {
|
||||
logSourceResult(db, target, source.provider, source.result);
|
||||
}
|
||||
applyAggregatedStatus(db, target, sources, options);
|
||||
await applyAggregatedStatus(db, target, sources, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@ import type {
|
||||
import { AppError } from "../errors.js";
|
||||
import { isValidIpv4 } from "../lib/validators.js";
|
||||
import { getView } from "./service-config-service.js";
|
||||
import { selectActiveIpsByMode } from "./routing/index.js";
|
||||
import {
|
||||
isSharedPool,
|
||||
resolveDesiredAIps,
|
||||
} from "./routing/index.js";
|
||||
|
||||
function assertAddress(address: string): void {
|
||||
if (!isValidIpv4(address)) {
|
||||
@@ -89,8 +92,12 @@ export async function getOverview(
|
||||
: null;
|
||||
|
||||
const active = new Set<string>();
|
||||
const serviceIps = service.ips.filter(
|
||||
(ip) => service.ip_enabled[ip] !== false,
|
||||
);
|
||||
for (const binding of bindings) {
|
||||
const metas = repos.listBindingIpsWithMeta(db, binding.id);
|
||||
const targetIps = metas.map((entry) => entry.ip);
|
||||
const rows = metas.map((entry) => {
|
||||
const status = repos.getIpHealthStatusRow(db, "binding", binding.id, entry.ip);
|
||||
return {
|
||||
@@ -100,12 +107,15 @@ export async function getOverview(
|
||||
health: status ? status.status : ("unknown" as const),
|
||||
};
|
||||
});
|
||||
for (const ip of selectActiveIpsByMode(
|
||||
for (const ip of resolveDesiredAIps(
|
||||
{
|
||||
lb_mode: binding.lb_mode,
|
||||
health_check_enabled: binding.health_check_enabled,
|
||||
},
|
||||
rows,
|
||||
targetIps,
|
||||
Date.now(),
|
||||
serviceIps,
|
||||
)) {
|
||||
active.add(ip);
|
||||
}
|
||||
@@ -134,10 +144,13 @@ export function opsSummary(db: Db) {
|
||||
if (binding.lb_mode !== "failover" || !binding.health_check_enabled) {
|
||||
return false;
|
||||
}
|
||||
return binding.target_ips.some((ip) => {
|
||||
const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip);
|
||||
return row?.status === "down";
|
||||
});
|
||||
return (
|
||||
isSharedPool(binding.target_ips) &&
|
||||
binding.target_ips.some((ip) => {
|
||||
const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip);
|
||||
return row?.status === "down";
|
||||
})
|
||||
);
|
||||
}).length;
|
||||
return {
|
||||
domains: domains.length,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { LbIpRow } from "./types.js";
|
||||
import { isHealthy } from "./health.js";
|
||||
import { isPoolMember } from "./health.js";
|
||||
|
||||
export function failoverDesired(rows: LbIpRow[]): string[] {
|
||||
if (rows.length === 0) return [];
|
||||
const healthy = rows.filter((r) => isHealthy(r.health));
|
||||
const pool = healthy.length > 0 ? healthy : rows;
|
||||
const live = rows.filter((r) => isPoolMember(r.health));
|
||||
const pool = live.length > 0 ? live : rows;
|
||||
const sorted = [...pool].sort(
|
||||
(a, b) => a.priority - b.priority || a.weight - b.weight,
|
||||
);
|
||||
const minPriority = sorted[0]!.priority;
|
||||
const primaries = sorted.filter((r) => r.priority === minPriority);
|
||||
if (healthy.length > 0) {
|
||||
if (live.length > 0) {
|
||||
return primaries.map((r) => r.ip);
|
||||
}
|
||||
return [sorted[0]!.ip];
|
||||
|
||||
@@ -3,3 +3,12 @@ import type { IpHealthState, NodeHealthState } from "@cfdm/shared";
|
||||
export function isHealthy(state: IpHealthState | NodeHealthState | string): boolean {
|
||||
return state === "up" || state === "healthy";
|
||||
}
|
||||
|
||||
export function isDown(state: IpHealthState | NodeHealthState | string): boolean {
|
||||
return state === "down" || state === "unhealthy";
|
||||
}
|
||||
|
||||
/** A-pool membership: only Down is drained. Recovering (unknown/checking) and Slow return immediately. */
|
||||
export function isPoolMember(state: IpHealthState | NodeHealthState | string): boolean {
|
||||
return !isDown(state);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import type { LbMode } from "@cfdm/shared";
|
||||
import { failoverDesired } from "./failover.js";
|
||||
import { isSharedPool } from "./pool.js";
|
||||
import { canApplyLb, isSharedPool } from "./pool.js";
|
||||
import { roundRobinDesired } from "./round-robin.js";
|
||||
import type { LbIpRow, LbTargetConfig } from "./types.js";
|
||||
import { weightedDesired } from "./weighted.js";
|
||||
|
||||
export type { LbIpRow, LbTargetConfig } from "./types.js";
|
||||
export { isHealthy } from "./health.js";
|
||||
export { isDown, isHealthy, isPoolMember } from "./health.js";
|
||||
export { withBindingLock } from "./binding-lock.js";
|
||||
export { isSharedPool, shouldRecordFailoverDnsDiff } from "./pool.js";
|
||||
export {
|
||||
canApplyLb,
|
||||
isSharedPool,
|
||||
shouldRecordFailoverDnsDiff,
|
||||
uniqueIpCount,
|
||||
} from "./pool.js";
|
||||
export { WEIGHTED_DNS_TTL, WEIGHTED_SLOT_MS, weightedDesired } from "./weighted.js";
|
||||
|
||||
export function selectActiveIpsByMode(
|
||||
@@ -31,9 +36,11 @@ export function resolveDesiredAIps(
|
||||
rows: LbIpRow[],
|
||||
fallbackIps: readonly string[],
|
||||
nowMs = Date.now(),
|
||||
serviceIps: readonly string[] = fallbackIps,
|
||||
): string[] {
|
||||
const fallback = [...fallbackIps];
|
||||
if (!isSharedPool(fallback)) return fallback;
|
||||
if (!canApplyLb(serviceIps, fallback)) return fallback;
|
||||
if (!isSharedPool(rows.map((row) => row.ip))) return fallback;
|
||||
if (config.lb_mode === "weighted" || config.health_check_enabled) {
|
||||
const activeIps = selectActiveIpsByMode(config, rows, nowMs);
|
||||
if (activeIps.length > 0) return activeIps;
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import type { LbMode } from "@cfdm/shared";
|
||||
|
||||
/** Shared pool FQDN — two or more A targets. Dedicated extra-FQDN has one IP. */
|
||||
export function uniqueIpCount(ips: readonly string[]): number {
|
||||
return new Set(ips.filter(Boolean)).size;
|
||||
}
|
||||
|
||||
/** Shared pool — two or more unique IPs. One IP (even duplicated) is not a pool. */
|
||||
export function isSharedPool(ips: readonly string[]): boolean {
|
||||
return ips.length >= 2;
|
||||
return uniqueIpCount(ips) >= 2;
|
||||
}
|
||||
|
||||
/** LB / drain only when the service itself has a pool AND this FQDN is shared. */
|
||||
export function canApplyLb(
|
||||
serviceIps: readonly string[],
|
||||
bindingIps: readonly string[],
|
||||
): boolean {
|
||||
return isSharedPool(serviceIps) && isSharedPool(bindingIps);
|
||||
}
|
||||
|
||||
export function shouldRecordFailoverDnsDiff(input: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { LbIpRow } from "./types.js";
|
||||
import { isHealthy } from "./health.js";
|
||||
import { isPoolMember } from "./health.js";
|
||||
|
||||
export function roundRobinDesired(rows: LbIpRow[]): string[] {
|
||||
const healthy = rows.filter((r) => isHealthy(r.health));
|
||||
const pool = healthy.length > 0 ? healthy : rows;
|
||||
const live = rows.filter((r) => isPoolMember(r.health));
|
||||
const pool = live.length > 0 ? live : rows;
|
||||
return pool.map((r) => r.ip);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LbIpRow } from "./types.js";
|
||||
import { isHealthy } from "./health.js";
|
||||
import { isPoolMember } from "./health.js";
|
||||
|
||||
/** Slot length for time-sliced weighted DNS (one A at a time). */
|
||||
export const WEIGHTED_SLOT_MS = 60_000;
|
||||
@@ -9,8 +9,8 @@ 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;
|
||||
const live = rows.filter((r) => isPoolMember(r.health));
|
||||
const pool = live.length > 0 ? live : rows;
|
||||
if (pool.length === 1) return [pool[0]!.ip];
|
||||
|
||||
const sorted = [...pool].sort((a, b) => a.ip.localeCompare(b.ip));
|
||||
|
||||
@@ -29,7 +29,8 @@ import * as domainService from "./domain-service.js";
|
||||
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
||||
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
|
||||
import {
|
||||
isHealthy,
|
||||
canApplyLb,
|
||||
isPoolMember,
|
||||
isSharedPool,
|
||||
resolveDesiredAIps,
|
||||
selectActiveIpsByMode,
|
||||
@@ -41,12 +42,24 @@ import {
|
||||
} from "./routing/index.js";
|
||||
|
||||
export type { LbIpRow, LbTargetConfig };
|
||||
export { resolveDesiredAIps, selectActiveIpsByMode, shouldRecordFailoverDnsDiff };
|
||||
export {
|
||||
canApplyLb,
|
||||
resolveDesiredAIps,
|
||||
selectActiveIpsByMode,
|
||||
shouldRecordFailoverDnsDiff,
|
||||
};
|
||||
|
||||
const AUTO_DNS_TTL = 1;
|
||||
|
||||
function ttlForBinding(mode: LbMode, ipCount: number): number {
|
||||
return mode === "weighted" && ipCount >= 2 ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL;
|
||||
function ttlForBinding(mode: LbMode, ips: readonly string[]): number {
|
||||
return mode === "weighted" && isSharedPool(ips) ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL;
|
||||
}
|
||||
|
||||
function enabledServiceIps(db: Db, serviceId: number): string[] {
|
||||
return repos
|
||||
.listServiceIpRows(db, serviceId)
|
||||
.filter((row) => row.enabled)
|
||||
.map((row) => row.ip);
|
||||
}
|
||||
|
||||
export function failoverARecordDiff(
|
||||
@@ -274,7 +287,7 @@ function getGroupLbState(
|
||||
} else {
|
||||
existing.weight += weight;
|
||||
existing.priority = Math.min(existing.priority, priority);
|
||||
if (isHealthy(existing.health) && status && !isHealthy(status.status as IpHealthState)) {
|
||||
if (isPoolMember(existing.health) && status && !isPoolMember(status.status as IpHealthState)) {
|
||||
existing.health = status.status as IpHealthState;
|
||||
}
|
||||
}
|
||||
@@ -300,7 +313,17 @@ function desiredAIps(
|
||||
scope === "binding"
|
||||
? getBindingLbState(db, refId)
|
||||
: getGroupLbState(db, refId);
|
||||
return resolveDesiredAIps(state.config, state.rows, fallbackIps);
|
||||
const serviceIps =
|
||||
scope === "binding"
|
||||
? enabledServiceIps(db, repos.getBinding(db, refId).service_id)
|
||||
: fallbackIps;
|
||||
return resolveDesiredAIps(
|
||||
state.config,
|
||||
state.rows,
|
||||
fallbackIps,
|
||||
Date.now(),
|
||||
serviceIps,
|
||||
);
|
||||
}
|
||||
|
||||
async function collectKnownZones(
|
||||
@@ -352,7 +375,7 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
const { config, rows } = getBindingLbState(db, binding.id);
|
||||
const bindingActiveIps = targetCname
|
||||
? []
|
||||
: resolveDesiredAIps(config, rows, targetIps);
|
||||
: resolveDesiredAIps(config, rows, targetIps, Date.now(), ips);
|
||||
|
||||
return {
|
||||
binding_id: binding.id,
|
||||
@@ -473,6 +496,21 @@ function fallbackCnameHealth(
|
||||
);
|
||||
}
|
||||
|
||||
function overlayLiveHealth(
|
||||
stored: IpHealthState | undefined,
|
||||
live: IpHealthState | undefined,
|
||||
): IpHealthState {
|
||||
if (live && live !== "unknown") return live;
|
||||
return stored ?? live ?? "unknown";
|
||||
}
|
||||
|
||||
function bestAliveDisplayStatus(statuses: readonly string[]): IpHealthState {
|
||||
if (statuses.some((status) => status === "up")) return "up";
|
||||
if (statuses.some((status) => status === "degraded")) return "degraded";
|
||||
if (statuses.some((status) => status === "down")) return "down";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function attachServiceHealth(
|
||||
db: Db,
|
||||
views: ServiceView[],
|
||||
@@ -480,10 +518,13 @@ function attachServiceHealth(
|
||||
const ids = views.map((v) => v.id);
|
||||
const healthByService = repos.aggregateIpHealthByServiceIds(db, ids);
|
||||
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
|
||||
const liveByService = repos.listLatestLiveHealthByServiceIds(db, ids);
|
||||
return views.map((view) => {
|
||||
const health = healthByService.get(view.id);
|
||||
const rows = ipHealthByService.get(view.id) ?? [];
|
||||
const liveRows = liveByService.get(view.id) ?? [];
|
||||
const byIp = new Map(rows.map((row) => [row.ip, row]));
|
||||
const liveByIp = new Map(liveRows.map((row) => [row.ip, row]));
|
||||
const cnameFallback = fallbackCnameHealth(rows, view);
|
||||
const aRecordIps = new Set(
|
||||
(view.domains ?? []).flatMap((domain) =>
|
||||
@@ -492,20 +533,33 @@ function attachServiceHealth(
|
||||
);
|
||||
const ip_health = (view.ips ?? []).map((ip) => {
|
||||
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
|
||||
const live = liveByIp.get(ip);
|
||||
const status = overlayLiveHealth(row?.status, live?.status);
|
||||
const extras = live && live.status !== "unknown" ? live : row;
|
||||
return {
|
||||
ip,
|
||||
status: row?.status ?? ("unknown" as const),
|
||||
latency_ms: row?.latency_ms ?? null,
|
||||
last_checked_at: row?.last_checked_at ?? null,
|
||||
last_error: row?.last_error ?? null,
|
||||
provider: row?.provider ?? "local",
|
||||
colo: row?.colo ?? null,
|
||||
status,
|
||||
latency_ms: extras?.latency_ms ?? null,
|
||||
last_checked_at: extras?.last_checked_at ?? null,
|
||||
last_error:
|
||||
live && live.status !== "unknown"
|
||||
? live.last_error
|
||||
: (row?.last_error ?? null),
|
||||
provider: extras?.provider ?? "local",
|
||||
colo: extras?.colo ?? null,
|
||||
};
|
||||
});
|
||||
const displayStatus = bestAliveDisplayStatus(ip_health.map((row) => row.status));
|
||||
const latencyRow =
|
||||
ip_health.find((row) => row.status === displayStatus && row.latency_ms != null) ??
|
||||
ip_health.find((row) => row.latency_ms != null);
|
||||
return {
|
||||
...view,
|
||||
health_status: health?.health_status ?? "unknown",
|
||||
health_latency_ms: health?.health_latency_ms ?? null,
|
||||
health_status: overlayLiveHealth(health?.health_status, displayStatus),
|
||||
health_latency_ms:
|
||||
displayStatus !== "unknown"
|
||||
? (latencyRow?.latency_ms ?? null)
|
||||
: (health?.health_latency_ms ?? null),
|
||||
ip_health,
|
||||
};
|
||||
});
|
||||
@@ -649,7 +703,7 @@ async function syncBindingDns(
|
||||
domainId,
|
||||
hostname,
|
||||
desiredIps,
|
||||
ttlForBinding(binding.lb_mode, configuredIps.length),
|
||||
ttlForBinding(binding.lb_mode, configuredIps),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1212,7 +1266,7 @@ async function syncGroupDomainDns(
|
||||
domainId,
|
||||
hostname,
|
||||
desiredIps,
|
||||
ttlForBinding(group.lb_mode, fallbackIps.length),
|
||||
ttlForBinding(group.lb_mode, fallbackIps),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1705,10 +1759,12 @@ export async function reconcileDnsForTarget(
|
||||
const cnameTarget = binding.cname_target?.trim() || null;
|
||||
if (cnameTarget) return;
|
||||
const ips = repos.listServiceIps(db, service.id);
|
||||
const poolIps = enabledServiceIps(db, service.id);
|
||||
const targetIps = repos.listBindingIps(db, binding.id);
|
||||
if (!isSharedPool(targetIps)) return;
|
||||
validateTargetIpsInPool(targetIps, ips);
|
||||
const desiredIps = desiredAIps(db, "binding", refId, targetIps);
|
||||
const desiredIps = canApplyLb(poolIps, targetIps)
|
||||
? desiredAIps(db, "binding", refId, targetIps)
|
||||
: targetIps;
|
||||
await syncBindingDns(
|
||||
db,
|
||||
cf,
|
||||
@@ -1749,7 +1805,8 @@ export async function reconcileWeightedDns(
|
||||
const service = repos.getService(db, latest.service_id);
|
||||
if (!shouldPushDns(db, service)) return;
|
||||
const targetIps = repos.listBindingIps(db, latest.id);
|
||||
if (!isSharedPool(targetIps)) return;
|
||||
const poolIps = enabledServiceIps(db, service.id);
|
||||
if (!canApplyLb(poolIps, targetIps)) return;
|
||||
const ips = repos.listServiceIps(db, service.id);
|
||||
validateTargetIpsInPool(targetIps, ips);
|
||||
const desiredIps = desiredAIps(db, "binding", latest.id, targetIps);
|
||||
|
||||
@@ -436,4 +436,46 @@ describe("CNAME health mapped onto service IPs", () => {
|
||||
const view = await getView(db, service.id);
|
||||
expect(view.health_status).toBe("up");
|
||||
});
|
||||
|
||||
it("getView shows live OK over hysteresis unknown", async () => {
|
||||
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||
const { getView } = await import("../src/services/service-config-service.js");
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||
const service = repos.createService(db, "RW Panel", "rw-panel");
|
||||
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "c", null);
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||
{ ip: "2.59.161.102", weight: 1, priority: 1 },
|
||||
]);
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"2.59.161.102",
|
||||
"unknown",
|
||||
63,
|
||||
0,
|
||||
null,
|
||||
1,
|
||||
);
|
||||
repos.insertHealthProbeLog(db, {
|
||||
scope: "binding",
|
||||
refId: binding.id,
|
||||
ip: "2.59.161.102",
|
||||
provider: "local",
|
||||
status: "up",
|
||||
ok: true,
|
||||
latencyMs: 63,
|
||||
colo: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const view = await getView(db, service.id);
|
||||
expect(view.health_status).toBe("up");
|
||||
expect(view.ip_health[0]?.status).toBe("up");
|
||||
expect(view.ip_health[0]?.latency_ms).toBe(63);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,18 @@ describe("selectActiveIpsByMode", () => {
|
||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("failover returns recovering unknown primary immediately", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "failover",
|
||||
health_check_enabled: true,
|
||||
};
|
||||
const rows = [
|
||||
row("1.1.1.1", { priority: 1, health: "unknown" }),
|
||||
row("2.2.2.2", { priority: 2, health: "up" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("failover falls back to min-priority ip among all when none healthy", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "failover",
|
||||
@@ -105,6 +117,17 @@ describe("selectActiveIpsByMode", () => {
|
||||
).toEqual(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("weighted includes recovering unknown in the cycle", () => {
|
||||
const rows = [
|
||||
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||
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 with one ip always returns that ip", () => {
|
||||
expect(
|
||||
selectActiveIpsByMode(weightedConfig, [row("1.1.1.1", { weight: 5 })], 0),
|
||||
@@ -126,7 +149,7 @@ describe("selectActiveIpsByMode", () => {
|
||||
expect(selectActiveIpsByMode(weightedConfig, [], 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it("round_robin excludes unknown when another ip is up", () => {
|
||||
it("round_robin puts recovering unknown back with live ips", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "round_robin",
|
||||
health_check_enabled: true,
|
||||
@@ -135,7 +158,25 @@ describe("selectActiveIpsByMode", () => {
|
||||
row("1.1.1.1", { health: "up" }),
|
||||
row("2.2.2.2", { health: "unknown" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
|
||||
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
||||
"1.1.1.1",
|
||||
"2.2.2.2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("round_robin keeps degraded in the pool with live ips", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "round_robin",
|
||||
health_check_enabled: true,
|
||||
};
|
||||
const rows = [
|
||||
row("1.1.1.1", { health: "up" }),
|
||||
row("2.2.2.2", { health: "degraded" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
||||
"1.1.1.1",
|
||||
"2.2.2.2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array for no rows", () => {
|
||||
@@ -158,6 +199,34 @@ describe("resolveDesiredAIps", () => {
|
||||
).toEqual(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("does not drain when the service has only one unique IP", () => {
|
||||
expect(
|
||||
resolveDesiredAIps(
|
||||
weightedConfig,
|
||||
[row("1.1.1.1", { health: "down" })],
|
||||
["1.1.1.1", "1.1.1.1"],
|
||||
0,
|
||||
["1.1.1.1"],
|
||||
),
|
||||
).toEqual(["1.1.1.1", "1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("does not apply overlay when service pool is a single IP", () => {
|
||||
const rows = [
|
||||
row("1.1.1.1", { health: "up" }),
|
||||
row("2.2.2.2", { health: "down" }),
|
||||
];
|
||||
expect(
|
||||
resolveDesiredAIps(
|
||||
weightedConfig,
|
||||
rows,
|
||||
["1.1.1.1", "2.2.2.2"],
|
||||
0,
|
||||
["1.1.1.1"],
|
||||
),
|
||||
).toEqual(["1.1.1.1", "2.2.2.2"]);
|
||||
});
|
||||
|
||||
it("applies weighted overlay on a shared pool", () => {
|
||||
const rows = [
|
||||
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||
@@ -170,6 +239,18 @@ describe("resolveDesiredAIps", () => {
|
||||
});
|
||||
|
||||
describe("shouldRecordFailoverDnsDiff", () => {
|
||||
it("skips duplicate listings of the same IP", () => {
|
||||
expect(
|
||||
shouldRecordFailoverDnsDiff({
|
||||
configuredIps: ["1.1.1.1", "1.1.1.1"],
|
||||
lbMode: "failover",
|
||||
added: [],
|
||||
removed: ["1.1.1.1"],
|
||||
downIps: new Set(["1.1.1.1"]),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("skips dedicated extra-FQDN", () => {
|
||||
expect(
|
||||
shouldRecordFailoverDnsDiff({
|
||||
|
||||
@@ -51,7 +51,28 @@ describe('toFailoverEvents', () => {
|
||||
expect(isFailoverEventStatus('unhealthy')).toBe(false)
|
||||
})
|
||||
|
||||
it('Down всегда виден, даже без A-записей', () => {
|
||||
it('один IP у сервиса — не инцидент пула', () => {
|
||||
const events = toFailoverEvents(
|
||||
[
|
||||
row({
|
||||
ip: '10.0.0.1',
|
||||
status: 'down',
|
||||
consecutive_failures: 9,
|
||||
last_error: 'fetch failed',
|
||||
}),
|
||||
],
|
||||
[
|
||||
{
|
||||
fqdn: 'solo.example.com',
|
||||
configured: ['10.0.0.1'],
|
||||
active: [],
|
||||
},
|
||||
],
|
||||
)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('Down без shared pool не событие балансировки', () => {
|
||||
const events = toFailoverEvents([
|
||||
row({
|
||||
ip: '130.49.213.153',
|
||||
@@ -60,19 +81,7 @@ describe('toFailoverEvents', () => {
|
||||
last_error: 'fetch failed',
|
||||
}),
|
||||
])
|
||||
expect(events).toEqual([
|
||||
{
|
||||
id: '130.49.213.153',
|
||||
address: '130.49.213.153',
|
||||
status: 'down',
|
||||
kind: 'last-resort',
|
||||
fqdns: [],
|
||||
consecutiveFailures: 9,
|
||||
lastFailureReason: 'fetch failed',
|
||||
lastCheckAt: null,
|
||||
},
|
||||
])
|
||||
expect(failoverEventCopy(events[0]!)).toBe('Down, в A-записях last-resort')
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('OK вне пула не инцидент (standby)', () => {
|
||||
@@ -151,9 +160,7 @@ describe('toFailoverEvents', () => {
|
||||
},
|
||||
],
|
||||
)
|
||||
expect(events[0]?.kind).toBe('last-resort')
|
||||
expect(events[0]?.fqdns).toEqual([])
|
||||
expect(failoverEventCopy(events[0]!)).toBe('Down, в A-записях last-resort')
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('down без A-записи на configured FQDN — снятие', () => {
|
||||
@@ -293,6 +300,24 @@ describe('mergeFailoverHistory', () => {
|
||||
expect(history[0]?.source).toBe('dns')
|
||||
})
|
||||
|
||||
it('drops probe leave/return when the service has no shared pool', () => {
|
||||
const history = mergeFailoverHistory(
|
||||
[],
|
||||
[
|
||||
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
|
||||
probe({ id: 2, status: 'down', checked_at: '2026-08-20T09:10:00Z' }),
|
||||
],
|
||||
[
|
||||
{
|
||||
fqdn: 'solo.example.com',
|
||||
configured: ['130.49.213.153'],
|
||||
active: ['130.49.213.153'],
|
||||
},
|
||||
],
|
||||
)
|
||||
expect(history).toEqual([])
|
||||
})
|
||||
|
||||
it('drops dedicated extra-FQDN DNS rows', () => {
|
||||
const history = mergeFailoverHistory(
|
||||
[
|
||||
|
||||
@@ -44,9 +44,20 @@ export interface FailoverBindingPool {
|
||||
active: readonly string[]
|
||||
}
|
||||
|
||||
/** Shared pool FQDN — two or more A targets. Dedicated extra-FQDN has one IP. */
|
||||
/** Unique A targets. Duplicates of the same IP are not a pool. */
|
||||
export function uniqueIpCount(ips: readonly string[]): number {
|
||||
return new Set(ips.filter(Boolean)).size
|
||||
}
|
||||
|
||||
/** Shared pool FQDN — two or more unique A targets. */
|
||||
export function isSharedPoolBinding(binding: FailoverBindingPool): boolean {
|
||||
return binding.configured.length >= 2
|
||||
return uniqueIpCount(binding.configured) >= 2
|
||||
}
|
||||
|
||||
export function hasSharedPool(
|
||||
bindings: readonly FailoverBindingPool[],
|
||||
): boolean {
|
||||
return bindings.some(isSharedPoolBinding)
|
||||
}
|
||||
|
||||
/** Инцидент только при down. up / degraded / unknown — не вывод из пула. */
|
||||
@@ -66,13 +77,14 @@ export function failoverEventCopy(event: FailoverEvent): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Текущие Down всегда в панели. Per-FQDN: снята с hostname или last-resort.
|
||||
* Standby (up / degraded / unknown) — не инцидент.
|
||||
* Инциденты пула только если у сервиса есть shared FQDN (2+ уникальных IP).
|
||||
* Один IP — не балансировка и не вывод из пула; Down смотрит health-монитор.
|
||||
*/
|
||||
export function toFailoverEvents(
|
||||
ipHealth: readonly FailoverHealthInput[],
|
||||
bindings: readonly FailoverBindingPool[] = [],
|
||||
): FailoverEvent[] {
|
||||
if (!hasSharedPool(bindings)) return []
|
||||
return ipHealth.filter((row) => isFailoverEventStatus(row.status)).map((row) => {
|
||||
const removedFqdns: string[] = []
|
||||
const lastResortFqdns: string[] = []
|
||||
@@ -221,9 +233,9 @@ export function mergeFailoverHistory(
|
||||
...dns
|
||||
.filter((item) => isPoolFqdn(item.fqdn, bindings))
|
||||
.map(dnsHistoryItem),
|
||||
...toIpAliveTransitions(probes).map((transition) =>
|
||||
probeHistoryItem(transition, bindings),
|
||||
),
|
||||
...toIpAliveTransitions(probes)
|
||||
.filter((transition) => fqdnsForIp(transition.ip, bindings).length > 0)
|
||||
.map((transition) => probeHistoryItem(transition, bindings)),
|
||||
]
|
||||
merged.sort(
|
||||
(a, b) => eventTime(b.created_at) - eventTime(a.created_at) || a.id.localeCompare(b.id),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
latestHealthByIp,
|
||||
providerHealthStatuses,
|
||||
resolveIpDisplayHealth,
|
||||
resolveServiceDisplayHealth,
|
||||
worstHealthStatus,
|
||||
type HealthLogProbe,
|
||||
} from '@/lib/health-log'
|
||||
@@ -146,3 +147,22 @@ describe('resolveIpDisplayHealth', () => {
|
||||
expect(resolveIpDisplayHealth('unknown', undefined)).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveServiceDisplayHealth', () => {
|
||||
it('shows OK when live probes recovered and stored is still unknown', () => {
|
||||
expect(
|
||||
resolveServiceDisplayHealth(
|
||||
'unknown',
|
||||
[{ ip: '2.59.161.102', status: 'unknown' }],
|
||||
[
|
||||
probe({
|
||||
id: 1,
|
||||
ip: '2.59.161.102',
|
||||
status: 'up',
|
||||
checked_at: '2026-08-20T18:00:00Z',
|
||||
}),
|
||||
],
|
||||
),
|
||||
).toBe('up')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -194,3 +194,22 @@ export function resolveIpDisplayHealth(
|
||||
if (live && live !== 'unknown') return live
|
||||
return stored ?? live ?? 'unknown'
|
||||
}
|
||||
|
||||
/** Service KPI / card: any-up of per-IP overlay (live probes beat hysteresis). */
|
||||
export function resolveServiceDisplayHealth(
|
||||
stored: HealthLogStatus | undefined,
|
||||
ipHealth: readonly { ip: string; status: string }[],
|
||||
probes: readonly HealthLogProbe[] = [],
|
||||
): HealthLogStatus {
|
||||
const liveByIp = latestHealthByIp(probes)
|
||||
const statuses =
|
||||
ipHealth.length > 0
|
||||
? ipHealth.map((row) =>
|
||||
resolveIpDisplayHealth(
|
||||
row.status as HealthLogStatus,
|
||||
liveByIp.get(row.ip)?.status,
|
||||
),
|
||||
)
|
||||
: [...liveByIp.values()].map((row) => row.status)
|
||||
return resolveIpDisplayHealth(stored, bestAliveHealthStatus(statuses))
|
||||
}
|
||||
|
||||
@@ -84,6 +84,21 @@ describe('hydrateAddressBlock', () => {
|
||||
expect(state.preservedBindings[0]?.fqdn).toBe('edge.example.com')
|
||||
})
|
||||
|
||||
it('при одном IP пула отделяет второй A в extraFqdn узла', () => {
|
||||
const drafts = [
|
||||
aRecord('dns.shnt.top', ['130.49.213.176']),
|
||||
aRecord('ndns.shnt.top', ['130.49.213.176']),
|
||||
]
|
||||
|
||||
const state = hydrateAddressBlock(drafts, ['130.49.213.176'])
|
||||
|
||||
expect(state.commonFqdns).toEqual(['dns.shnt.top'])
|
||||
expect(state.nodes).toEqual([
|
||||
{ ip: '130.49.213.176', extraFqdn: 'ndns.shnt.top' },
|
||||
])
|
||||
expect(state.preservedBindings).toEqual([])
|
||||
})
|
||||
|
||||
it('поднимает веса и приоритеты с общего FQDN', () => {
|
||||
const drafts = [
|
||||
aRecord('gt.rkns.top', ['130.49.213.153', '93.115.203.183'], {
|
||||
@@ -143,6 +158,24 @@ describe('toDomainsPayload', () => {
|
||||
expect(second.nodes).toEqual(first.nodes)
|
||||
expect(second.preservedBindings).toEqual([])
|
||||
})
|
||||
|
||||
it('круг hydrate → payload → hydrate сохраняет extra FQDN при одном IP', () => {
|
||||
const drafts = [
|
||||
aRecord('dns.shnt.top', ['130.49.213.176']),
|
||||
aRecord('ndns.shnt.top', ['130.49.213.176']),
|
||||
]
|
||||
const first = hydrateAddressBlock(drafts, ['130.49.213.176'])
|
||||
expect(first.commonFqdns).toEqual(['dns.shnt.top'])
|
||||
expect(first.nodes).toEqual([
|
||||
{ ip: '130.49.213.176', extraFqdn: 'ndns.shnt.top' },
|
||||
])
|
||||
const rebound = toAddressBindings(first, primaryMeta)
|
||||
const second = hydrateAddressBlock(rebound, ['130.49.213.176'])
|
||||
|
||||
expect(second.commonFqdns).toEqual(first.commonFqdns)
|
||||
expect(second.nodes).toEqual(first.nodes)
|
||||
expect(second.preservedBindings).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeAddressNode', () => {
|
||||
|
||||
@@ -145,6 +145,23 @@ function isFullPoolA(draft: ServiceBindingDraft, pool: string[]): boolean {
|
||||
return draft.record_type === 'A' && sameIpSet(draft.target_ips, pool)
|
||||
}
|
||||
|
||||
function takeAsCommon(
|
||||
draft: ServiceBindingDraft,
|
||||
fqdn: string,
|
||||
commonFqdns: string[],
|
||||
weights: Record<string, number>,
|
||||
priorities: Record<string, number>,
|
||||
): { weights: Record<string, number>; priorities: Record<string, number> } {
|
||||
if (fqdn) commonFqdns.push(draft.fqdn)
|
||||
if (Object.keys(weights).length === 0) {
|
||||
return {
|
||||
weights: { ...draft.target_ip_weights },
|
||||
priorities: { ...draft.target_ip_priorities },
|
||||
}
|
||||
}
|
||||
return { weights, priorities }
|
||||
}
|
||||
|
||||
export function hydrateAddressBlock(
|
||||
drafts: ServiceBindingDraft[],
|
||||
pool: string[] = [],
|
||||
@@ -166,15 +183,36 @@ export function hydrateAddressBlock(
|
||||
const preservedBindings: ServiceBindingDraft[] = []
|
||||
let weights: Record<string, number> = {}
|
||||
let priorities: Record<string, number> = {}
|
||||
const splitSinglePool =
|
||||
ips.length === 1 &&
|
||||
drafts.filter((draft) => isFullPoolA(draft, ips)).length > 1
|
||||
let assignedFirstSinglePoolCommon = false
|
||||
|
||||
for (const draft of drafts) {
|
||||
const fqdn = draft.fqdn.trim()
|
||||
if (isFullPoolA(draft, ips)) {
|
||||
if (fqdn) commonFqdns.push(draft.fqdn)
|
||||
if (Object.keys(weights).length === 0) {
|
||||
weights = { ...draft.target_ip_weights }
|
||||
priorities = { ...draft.target_ip_priorities }
|
||||
if (splitSinglePool && isFullPoolA(draft, ips)) {
|
||||
if (!assignedFirstSinglePoolCommon) {
|
||||
assignedFirstSinglePoolCommon = true
|
||||
const next = takeAsCommon(draft, fqdn, commonFqdns, weights, priorities)
|
||||
weights = next.weights
|
||||
priorities = next.priorities
|
||||
continue
|
||||
}
|
||||
const ip = draft.target_ips[0]?.trim() ?? ''
|
||||
if (ip && poolSet.has(ip) && fqdn && !claimed.has(ip)) {
|
||||
claimed.add(ip)
|
||||
extraByIp.set(ip, draft.fqdn)
|
||||
continue
|
||||
}
|
||||
const overflow = takeAsCommon(draft, fqdn, commonFqdns, weights, priorities)
|
||||
weights = overflow.weights
|
||||
priorities = overflow.priorities
|
||||
continue
|
||||
}
|
||||
if (isFullPoolA(draft, ips)) {
|
||||
const next = takeAsCommon(draft, fqdn, commonFqdns, weights, priorities)
|
||||
weights = next.weights
|
||||
priorities = next.priorities
|
||||
continue
|
||||
}
|
||||
if (draft.record_type === 'A' && draft.target_ips.length === 1) {
|
||||
|
||||
@@ -36,6 +36,8 @@ export const serviceGroupsQueryOptions = () =>
|
||||
}
|
||||
return parsed.data
|
||||
},
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
export const servicesQueryOptions = () =>
|
||||
@@ -101,6 +103,8 @@ export const serviceViewQueryOptions = (id: number) =>
|
||||
const data = await api.get<unknown>(`/api/v1/services/${id}`)
|
||||
return serviceViewSchema.parse(data)
|
||||
},
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
export const serviceHealthLogQueryOptions = (id: number) =>
|
||||
@@ -110,6 +114,8 @@ export const serviceHealthLogQueryOptions = (id: number) =>
|
||||
const data = await api.get<unknown>(`/api/v1/services/${id}/health-log`)
|
||||
return z.object({ items: z.array(healthProbeLogSchema) }).parse(data)
|
||||
},
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
export const serviceFailoverLogQueryOptions = (id: number) =>
|
||||
|
||||
@@ -32,9 +32,11 @@ import {
|
||||
ServiceHealthMonitor,
|
||||
} from '@/components/reui-kit'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { hasSharedPool } from '@/lib/failover-events'
|
||||
import {
|
||||
enabledHealthProviders,
|
||||
providerHealthStatuses,
|
||||
resolveServiceDisplayHealth,
|
||||
} from '@/lib/health-log'
|
||||
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
||||
import {
|
||||
@@ -115,6 +117,22 @@ function ServiceDetailPage() {
|
||||
})),
|
||||
[service?.domains],
|
||||
)
|
||||
const uniqueEnabledIps = useMemo(
|
||||
() =>
|
||||
(service?.ips ?? []).filter((ip) => service?.ip_enabled[ip] !== false),
|
||||
[service?.ips, service?.ip_enabled],
|
||||
)
|
||||
const displayHealth = useMemo(
|
||||
() =>
|
||||
resolveServiceDisplayHealth(
|
||||
service?.health_status,
|
||||
service?.ip_health ?? [],
|
||||
logItems,
|
||||
),
|
||||
[service?.health_status, service?.ip_health, logItems],
|
||||
)
|
||||
const showPoolPanel =
|
||||
uniqueEnabledIps.length >= 2 && hasSharedPool(failoverBindings)
|
||||
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
@@ -251,7 +269,7 @@ function ServiceDetailPage() {
|
||||
actions={
|
||||
<>
|
||||
<LbModeTile mode={service.lb_mode} />
|
||||
<HealthCheckBadge status={service.health_status} />
|
||||
<HealthCheckBadge status={displayHealth} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
@@ -278,20 +296,20 @@ function ServiceDetailPage() {
|
||||
id: 'status',
|
||||
icon: <ActivityIcon />,
|
||||
label: 'Статус',
|
||||
value: service.health_status === 'up' ? 'OK' : service.health_status,
|
||||
value: displayHealth === 'up' ? 'OK' : displayHealth,
|
||||
variant:
|
||||
service.health_status === 'down'
|
||||
displayHealth === 'down'
|
||||
? 'destructive'
|
||||
: service.health_status === 'degraded'
|
||||
: displayHealth === 'degraded'
|
||||
? 'warning'
|
||||
: 'default',
|
||||
iconClassName:
|
||||
service.health_status === 'down'
|
||||
displayHealth === 'down'
|
||||
? 'text-destructive'
|
||||
: service.health_status === 'degraded'
|
||||
: displayHealth === 'degraded'
|
||||
? 'text-warning'
|
||||
: 'text-success',
|
||||
hint: <HealthCheckBadge status={service.health_status} size="xs" />,
|
||||
hint: <HealthCheckBadge status={displayHealth} size="xs" />,
|
||||
},
|
||||
{
|
||||
id: 'fqdn',
|
||||
@@ -305,21 +323,33 @@ function ServiceDetailPage() {
|
||||
icon: <NetworkIcon />,
|
||||
label: 'IP',
|
||||
value: String(service.ips.length),
|
||||
hint: `${service.active_ips.length} в пуле`,
|
||||
hint: showPoolPanel
|
||||
? `${service.active_ips.length} в пуле`
|
||||
: uniqueEnabledIps.length <= 1
|
||||
? 'без балансировки'
|
||||
: service.ips.join(', ') || 'нет',
|
||||
},
|
||||
{
|
||||
id: 'pool',
|
||||
icon: <ServerIcon />,
|
||||
label: 'Активный пул',
|
||||
value: String((overview?.active_addresses ?? service.active_ips).length),
|
||||
hint: (overview?.active_addresses ?? service.active_ips).join(', ') || 'нет',
|
||||
label: showPoolPanel ? 'Активный пул' : 'Адреса',
|
||||
value: String(
|
||||
(overview?.active_addresses ?? service.active_ips).length,
|
||||
),
|
||||
hint:
|
||||
(overview?.active_addresses ?? service.active_ips).join(', ') ||
|
||||
'нет',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<section
|
||||
aria-label="Мониторинг"
|
||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||
className={
|
||||
showPoolPanel
|
||||
? 'grid min-w-0 items-start gap-2 @3xl:grid-cols-2'
|
||||
: 'grid min-w-0 items-start gap-2'
|
||||
}
|
||||
>
|
||||
<ServiceHealthMonitor
|
||||
items={logItems}
|
||||
@@ -327,13 +357,15 @@ function ServiceDetailPage() {
|
||||
statuses={providerStatuses}
|
||||
isLoading={logQuery.isLoading}
|
||||
/>
|
||||
<ServiceFailoverPanel
|
||||
lbMode={service.lb_mode}
|
||||
ipHealth={service.ip_health}
|
||||
bindings={failoverBindings}
|
||||
history={failoverHistory}
|
||||
probes={logItems}
|
||||
/>
|
||||
{showPoolPanel ? (
|
||||
<ServiceFailoverPanel
|
||||
lbMode={service.lb_mode}
|
||||
ipHealth={service.ip_health}
|
||||
bindings={failoverBindings}
|
||||
history={failoverHistory}
|
||||
probes={logItems}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{service.ips.length === 0 && service.domains.length === 0 ? (
|
||||
|
||||
+4
-3
@@ -44,9 +44,10 @@ health-check работают на двух уровнях:
|
||||
- **Change Domain** — перенос привязок между зонами `POST /api/v1/services/:id/change-domain`.
|
||||
|
||||
Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted`
|
||||
работает как `round_robin` (одна A на IP). `unknown` **не** считается healthy и
|
||||
не попадает в пул, пока нет успешных проб; восстановление — `UNHEALTHY → CHECKING → HEALTHY`
|
||||
после `HEALTH_SUCCESS_RECOVERIES` (default 2). Пороги и cron движка задаются в
|
||||
работает как `round_robin` (одна A на IP). В A-пул попадает всё, кроме **Down**
|
||||
(`unknown` / checking / Slow возвращаются в DNS на первой успешной пробе).
|
||||
Бейдж Healthy — `UNHEALTHY → CHECKING → HEALTHY` после `HEALTH_SUCCESS_RECOVERIES`
|
||||
(default 2). Пороги и cron движка задаются в
|
||||
**Настройки → Health-check** (env — fallback, пока значения не сохранены в UI).
|
||||
|
||||
### Источники проб: Local, Cloudflare Worker, Globalping
|
||||
|
||||
@@ -2341,6 +2341,113 @@ export function listIpHealthByServiceIds(
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseIpHealthState(status: string | null): IpHealthState | undefined {
|
||||
if (
|
||||
status === "up" ||
|
||||
status === "down" ||
|
||||
status === "degraded" ||
|
||||
status === "unknown"
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function bestAliveIpState(statuses: readonly IpHealthState[]): IpHealthState {
|
||||
if (statuses.some((status) => status === "up")) return "up";
|
||||
if (statuses.some((status) => status === "degraded")) return "degraded";
|
||||
if (statuses.some((status) => status === "down")) return "down";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest probe per service+IP+provider from health_probe_log, then any-up per IP.
|
||||
* Display overlay — hysteresis in ip_health_status is unchanged (DNS).
|
||||
*/
|
||||
export function listLatestLiveHealthByServiceIds(
|
||||
db: Db,
|
||||
serviceIds: number[],
|
||||
): Map<number, ServiceIpHealthRow[]> {
|
||||
const result = new Map<number, ServiceIpHealthRow[]>();
|
||||
if (serviceIds.length === 0) return result;
|
||||
const idList = sql.join(
|
||||
serviceIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
);
|
||||
const rows = db.all<{
|
||||
service_id: number;
|
||||
ip: string;
|
||||
provider: string | null;
|
||||
status: string | null;
|
||||
latency_ms: number | null;
|
||||
last_error: string | null;
|
||||
colo: string | null;
|
||||
checked_at: string | null;
|
||||
}>(sql`
|
||||
SELECT sb.service_id AS service_id,
|
||||
l.ip AS ip,
|
||||
l.provider AS provider,
|
||||
l.status AS status,
|
||||
l.latency_ms AS latency_ms,
|
||||
l.error AS last_error,
|
||||
l.colo AS colo,
|
||||
l.checked_at AS checked_at
|
||||
FROM health_probe_log l
|
||||
INNER JOIN service_bindings sb
|
||||
ON l.scope = 'binding' AND l.ref_id = sb.id
|
||||
INNER JOIN (
|
||||
SELECT sb2.service_id AS service_id,
|
||||
l2.ip AS ip,
|
||||
l2.provider AS provider,
|
||||
MAX(l2.id) AS max_id
|
||||
FROM health_probe_log l2
|
||||
INNER JOIN service_bindings sb2
|
||||
ON l2.scope = 'binding' AND l2.ref_id = sb2.id
|
||||
WHERE sb2.service_id IN (${idList})
|
||||
GROUP BY sb2.service_id, l2.ip, l2.provider
|
||||
) latest
|
||||
ON latest.max_id = l.id
|
||||
`);
|
||||
|
||||
const byServiceIp = new Map<
|
||||
string,
|
||||
{ serviceId: number; ip: string; probes: ServiceIpHealthRow[] }
|
||||
>();
|
||||
for (const row of rows) {
|
||||
const status = parseIpHealthState(row.status);
|
||||
if (!status) continue;
|
||||
const key = `${row.service_id}\0${row.ip}`;
|
||||
const probe: ServiceIpHealthRow = {
|
||||
ip: row.ip,
|
||||
status,
|
||||
latency_ms: row.latency_ms,
|
||||
last_checked_at: row.checked_at,
|
||||
last_error: row.last_error,
|
||||
provider: normalizeStatusProvider(row.provider),
|
||||
colo: row.colo,
|
||||
};
|
||||
const bucket = byServiceIp.get(key);
|
||||
if (bucket) bucket.probes.push(probe);
|
||||
else {
|
||||
byServiceIp.set(key, {
|
||||
serviceId: row.service_id,
|
||||
ip: row.ip,
|
||||
probes: [probe],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { serviceId, ip, probes } of byServiceIp.values()) {
|
||||
const status = bestAliveIpState(probes.map((probe) => probe.status));
|
||||
const preferred =
|
||||
probes.find((probe) => probe.status === status) ?? probes[0]!;
|
||||
const list = result.get(serviceId) ?? [];
|
||||
list.push({ ...preferred, ip, status });
|
||||
result.set(serviceId, list);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mergeHealthAggregates(
|
||||
parts: Array<HealthAggregate | undefined | null>,
|
||||
): HealthAggregate {
|
||||
|
||||
Reference in New Issue
Block a user