feat(health-check): introduce health probe gap configuration and enhance health check logic
Added `healthProbeGapMs` configuration to control the minimum pause between probes to different physical targets. Updated health check service to utilize this configuration, ensuring efficient probing without overwhelming the targets. Enhanced the `runAllChecks` function to group probes by physical IP and implement the new gap logic. Updated related tests to validate the new functionality.
This commit is contained in:
@@ -131,6 +131,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
};
|
};
|
||||||
const n = await healthCheckService.runAllChecks(app.db, {
|
const n = await healthCheckService.runAllChecks(app.db, {
|
||||||
thresholds,
|
thresholds,
|
||||||
|
probeGapMs: config.healthProbeGapMs,
|
||||||
onStatusChange: async (target, prev, next) => {
|
onStatusChange: async (target, prev, next) => {
|
||||||
try {
|
try {
|
||||||
const label =
|
const label =
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface AppConfig {
|
|||||||
healthDegradedFailures: number;
|
healthDegradedFailures: number;
|
||||||
healthDownFailures: number;
|
healthDownFailures: number;
|
||||||
healthLatencyWarnMs: number;
|
healthLatencyWarnMs: number;
|
||||||
|
/** Min pause between probes to different physical targets (same IP is probed once). */
|
||||||
|
healthProbeGapMs: number;
|
||||||
logLevel: string;
|
logLevel: string;
|
||||||
/** Portal SSO — when true, require portal JWT with apps includes cfdm */
|
/** Portal SSO — when true, require portal JWT with apps includes cfdm */
|
||||||
authRequired: boolean;
|
authRequired: boolean;
|
||||||
@@ -46,12 +48,14 @@ export function loadConfig(): AppConfig {
|
|||||||
? resolve(process.env.STATIC_DIR)
|
? resolve(process.env.STATIC_DIR)
|
||||||
: null,
|
: null,
|
||||||
certCheckCron: process.env.CERT_CHECK_CRON ?? "0 0 */6 * * *",
|
certCheckCron: process.env.CERT_CHECK_CRON ?? "0 0 */6 * * *",
|
||||||
healthCheckCron: process.env.HEALTH_CHECK_CRON ?? "*/30 * * * * *",
|
// Default: every 2 minutes (was every 30s — hammered origins / anti-bot).
|
||||||
|
healthCheckCron: process.env.HEALTH_CHECK_CRON ?? "0 */2 * * * *",
|
||||||
healthDegradedFailures:
|
healthDegradedFailures:
|
||||||
Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1,
|
Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1,
|
||||||
healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2,
|
healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2,
|
||||||
healthLatencyWarnMs:
|
healthLatencyWarnMs:
|
||||||
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
|
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
|
||||||
|
healthProbeGapMs: Number(process.env.HEALTH_PROBE_GAP_MS ?? "2000") || 2000,
|
||||||
logLevel: process.env.LOG_LEVEL ?? "info",
|
logLevel: process.env.LOG_LEVEL ?? "info",
|
||||||
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||||
authIssuer:
|
authIssuer:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export async function healthCheckRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
const checked = await healthCheckService.runAllChecks(request.server.db, {
|
const checked = await healthCheckService.runAllChecks(request.server.db, {
|
||||||
thresholds,
|
thresholds,
|
||||||
|
probeGapMs: config.healthProbeGapMs,
|
||||||
onStatusChange: async (target, prev, next) => {
|
onStatusChange: async (target, prev, next) => {
|
||||||
try {
|
try {
|
||||||
const label =
|
const label =
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { connect, isIP } from "node:net";
|
import { connect, isIP } from "node:net";
|
||||||
import { resolve4, resolve6 } from "node:dns/promises";
|
import { resolve4, resolve6 } from "node:dns/promises";
|
||||||
import { Agent, fetch as undiciFetch, interceptors } from "undici";
|
import { Agent, buildConnector, fetch as undiciFetch } from "undici";
|
||||||
import type { Db } from "@cfdm/db";
|
import type { Db } from "@cfdm/db";
|
||||||
import { repos } from "@cfdm/db";
|
import { repos } from "@cfdm/db";
|
||||||
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
|
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
|
||||||
@@ -25,7 +25,7 @@ export function hostForUrl(ipOrHost: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build http(s) URL authority for the probe.
|
* Build http(s) URL authority for the probe.
|
||||||
* Prefer FQDN in the URL (correct Host/SNI); IP is pinned via DNS interceptor.
|
* Prefer FQDN in the URL (correct Host/SNI); TCP dial goes to configured IP via custom connector.
|
||||||
*/
|
*/
|
||||||
export function buildHttpProbeUrl(
|
export function buildHttpProbeUrl(
|
||||||
urlHost: string,
|
urlHost: string,
|
||||||
@@ -79,6 +79,36 @@ function tcpProbe(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dial `connectAddr` for TCP/TLS while URL Host/SNI stay on the FQDN. */
|
||||||
|
function createIpPinnedAgent(
|
||||||
|
connectAddr: string,
|
||||||
|
sniHost: string,
|
||||||
|
useTls: boolean,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Agent {
|
||||||
|
const connector = buildConnector({
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
timeout: timeoutMs,
|
||||||
|
});
|
||||||
|
return new Agent({
|
||||||
|
connect(opts, callback) {
|
||||||
|
connector(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
// Force socket to configured IP (or CNAME target), not public DNS of FQDN.
|
||||||
|
hostname: connectAddr,
|
||||||
|
host: connectAddr,
|
||||||
|
servername:
|
||||||
|
useTls && isIP(sniHost) === 0
|
||||||
|
? sniHost
|
||||||
|
: (opts.servername as string | undefined),
|
||||||
|
},
|
||||||
|
callback,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP(S) probe: URL/Host/SNI use hostname (vhost), TCP connects to configured IP when numeric.
|
* HTTP(S) probe: URL/Host/SNI use hostname (vhost), TCP connects to configured IP when numeric.
|
||||||
* Avoids re-resolving FQDN via public DNS (which skewed group vs binding latency for the same IP).
|
* Avoids re-resolving FQDN via public DNS (which skewed group vs binding latency for the same IP).
|
||||||
@@ -95,37 +125,16 @@ async function httpProbe(
|
|||||||
const useTls = port === 443;
|
const useTls = port === 443;
|
||||||
const connectAddr = String(ip || "").trim();
|
const connectAddr = String(ip || "").trim();
|
||||||
const headerHost = (target.hostname || "").trim() || connectAddr;
|
const headerHost = (target.hostname || "").trim() || connectAddr;
|
||||||
const urlHost = headerHost;
|
const url = buildHttpProbeUrl(headerHost, port, pathWithSlash, useTls);
|
||||||
const url = buildHttpProbeUrl(urlHost, port, pathWithSlash, useTls);
|
|
||||||
|
|
||||||
const family = isIP(connectAddr);
|
const family = isIP(connectAddr);
|
||||||
const pinToIp = family === 4 || family === 6;
|
const pinToIp = family === 4 || family === 6;
|
||||||
|
|
||||||
let dispatcher: Agent | undefined;
|
const dispatcher = pinToIp
|
||||||
if (pinToIp) {
|
? createIpPinnedAgent(connectAddr, headerHost, useTls, timeoutMs)
|
||||||
// URL stays on FQDN (Host + SNI), lookup always returns the configured IP.
|
: useTls
|
||||||
dispatcher = new Agent({
|
? createIpPinnedAgent(connectAddr, headerHost, useTls, timeoutMs)
|
||||||
connect: {
|
: undefined;
|
||||||
...(useTls && isIP(headerHost) === 0 ? { servername: headerHost } : {}),
|
|
||||||
rejectUnauthorized: false,
|
|
||||||
},
|
|
||||||
}).compose(
|
|
||||||
interceptors.dns({
|
|
||||||
dualStack: false,
|
|
||||||
affinity: family === 6 ? 6 : 4,
|
|
||||||
lookup: (_origin, _opts, cb) => {
|
|
||||||
cb(null, [{ address: connectAddr, family: family === 6 ? 6 : 4 }]);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
) as Agent;
|
|
||||||
} else if (useTls) {
|
|
||||||
dispatcher = new Agent({
|
|
||||||
connect: {
|
|
||||||
...(isIP(headerHost) === 0 ? { servername: headerHost } : {}),
|
|
||||||
rejectUnauthorized: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await undiciFetch(url, {
|
const response = await undiciFetch(url, {
|
||||||
@@ -244,6 +253,8 @@ function deriveState(
|
|||||||
|
|
||||||
export interface RunAllChecksOptions {
|
export interface RunAllChecksOptions {
|
||||||
thresholds: HealthCheckThresholds;
|
thresholds: HealthCheckThresholds;
|
||||||
|
/** Pause between unique physical probes (default 2000). Same IP is only probed once. */
|
||||||
|
probeGapMs?: number;
|
||||||
onStatusChange?: (
|
onStatusChange?: (
|
||||||
target: HealthCheckTarget,
|
target: HealthCheckTarget,
|
||||||
prevState: IpHealthState | null,
|
prevState: IpHealthState | null,
|
||||||
@@ -251,47 +262,97 @@ export interface RunAllChecksOptions {
|
|||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One network hit per key. Group+binding on the same IP share a single TCP/HTTP probe
|
||||||
|
* so anti-bot / rate-limit on the origin is not tripped by back-to-back checks.
|
||||||
|
*/
|
||||||
|
export function physicalProbeKey(target: HealthCheckTarget): string {
|
||||||
|
const port = target.port ?? (target.type === "http" ? 80 : 80);
|
||||||
|
const ip = String(target.ip || "").trim().toLowerCase();
|
||||||
|
if (target.type === "http") {
|
||||||
|
const path = (target.path?.trim() || "/") || "/";
|
||||||
|
const expected = target.expected_status ?? "";
|
||||||
|
return `http|${ip}|${port}|${path}|${expected}`;
|
||||||
|
}
|
||||||
|
if (target.type === "tcp") return `tcp|${ip}|${port}`;
|
||||||
|
if (target.type === "ping") {
|
||||||
|
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
if (target.type === "dns") {
|
||||||
|
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
return `${target.type}|${ip}|${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runAllChecks(
|
export async function runAllChecks(
|
||||||
db: Db,
|
db: Db,
|
||||||
options: RunAllChecksOptions,
|
options: RunAllChecksOptions,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const targets = repos.listHealthCheckTargets(db);
|
const targets = repos.listHealthCheckTargets(db);
|
||||||
|
const gapMs = Math.max(0, options.probeGapMs ?? 2000);
|
||||||
|
|
||||||
|
const byPhysical = new Map<string, HealthCheckTarget[]>();
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const prev = repos.getIpHealthStatusRow(
|
const key = physicalProbeKey(target);
|
||||||
db,
|
const list = byPhysical.get(key);
|
||||||
target.scope,
|
if (list) list.push(target);
|
||||||
target.ref_id,
|
else byPhysical.set(key, [target]);
|
||||||
target.ip,
|
}
|
||||||
);
|
|
||||||
const result = await probeTarget(target);
|
let probeIndex = 0;
|
||||||
const { state, failures } = deriveState(
|
for (const group of byPhysical.values()) {
|
||||||
result.ok,
|
if (probeIndex > 0 && gapMs > 0) {
|
||||||
result.latencyMs,
|
await sleep(gapMs);
|
||||||
prev
|
}
|
||||||
? {
|
probeIndex += 1;
|
||||||
consecutive_failures: prev.consecutive_failures,
|
|
||||||
status: prev.status,
|
// Prefer binding hostname for SNI when several scopes share one IP.
|
||||||
}
|
const representative =
|
||||||
: null,
|
group.find((t) => t.scope === "binding") ?? group[0]!;
|
||||||
options.thresholds,
|
const result = await probeTarget(representative);
|
||||||
);
|
|
||||||
const prevState: IpHealthState | null = prev
|
for (const target of group) {
|
||||||
? (prev.status as IpHealthState)
|
const prev = repos.getIpHealthStatusRow(
|
||||||
: null;
|
db,
|
||||||
repos.upsertIpHealthStatus(
|
target.scope,
|
||||||
db,
|
target.ref_id,
|
||||||
target.scope,
|
target.ip,
|
||||||
target.ref_id,
|
);
|
||||||
target.ip,
|
const { state, failures } = deriveState(
|
||||||
state,
|
result.ok,
|
||||||
result.latencyMs,
|
result.latencyMs,
|
||||||
failures,
|
prev
|
||||||
result.error,
|
? {
|
||||||
);
|
consecutive_failures: prev.consecutive_failures,
|
||||||
if (prevState !== state) {
|
status: prev.status,
|
||||||
options.onStatusChange?.(target, prevState, state);
|
}
|
||||||
|
: null,
|
||||||
|
options.thresholds,
|
||||||
|
);
|
||||||
|
const prevState: IpHealthState | null = prev
|
||||||
|
? (prev.status as IpHealthState)
|
||||||
|
: null;
|
||||||
|
repos.upsertIpHealthStatus(
|
||||||
|
db,
|
||||||
|
target.scope,
|
||||||
|
target.ref_id,
|
||||||
|
target.ip,
|
||||||
|
state,
|
||||||
|
result.latencyMs,
|
||||||
|
failures,
|
||||||
|
result.error,
|
||||||
|
);
|
||||||
|
if (prevState !== state) {
|
||||||
|
options.onStatusChange?.(target, prevState, state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Orphan rows (old IPs / hostname keys) still feed MAX latency on group badge.
|
||||||
|
repos.pruneStaleIpHealthStatus(db, targets);
|
||||||
return targets.length;
|
return targets.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function startTcpServer(): Promise<{ server: Server; port: number }> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("health-check URL helpers", () => {
|
describe("health-check URL helpers", () => {
|
||||||
it("buildHttpProbeUrl uses FQDN in URL (IP pinned via DNS interceptor)", () => {
|
it("buildHttpProbeUrl uses FQDN in URL (IP pinned via connector)", () => {
|
||||||
expect(
|
expect(
|
||||||
healthCheckService.buildHttpProbeUrl("gt.rkns.top", 443, "/", true),
|
healthCheckService.buildHttpProbeUrl("gt.rkns.top", 443, "/", true),
|
||||||
).toBe("https://gt.rkns.top/");
|
).toBe("https://gt.rkns.top/");
|
||||||
@@ -125,6 +125,28 @@ describe("health-check probeTarget", () => {
|
|||||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("physicalProbeKey collapses group+binding on same IP for tcp", async () => {
|
||||||
|
const { physicalProbeKey } = await import("../src/services/health-check-service.js");
|
||||||
|
const group: HealthCheckTarget = {
|
||||||
|
scope: "group",
|
||||||
|
ref_id: 1,
|
||||||
|
ip: "93.115.203.183",
|
||||||
|
hostname: "gt.rkns.top",
|
||||||
|
type: "tcp",
|
||||||
|
port: 443,
|
||||||
|
path: null,
|
||||||
|
expected_status: null,
|
||||||
|
timeout_ms: 3000,
|
||||||
|
};
|
||||||
|
const binding: HealthCheckTarget = {
|
||||||
|
...group,
|
||||||
|
scope: "binding",
|
||||||
|
ref_id: 2,
|
||||||
|
hostname: "rutg.rkns.top",
|
||||||
|
};
|
||||||
|
expect(physicalProbeKey(group)).toBe(physicalProbeKey(binding));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("health-check state derivation via runAllChecks", () => {
|
describe("health-check state derivation via runAllChecks", () => {
|
||||||
|
|||||||
Vendored
+44
-1
File diff suppressed because one or more lines are too long
Vendored
+41
-1
@@ -194,6 +194,9 @@ var appSettings = sqliteTable("app_settings", {
|
|||||||
mode: "boolean"
|
mode: "boolean"
|
||||||
}).notNull().default(false),
|
}).notNull().default(false),
|
||||||
vps_tracker_last_sync_at: text("vps_tracker_last_sync_at"),
|
vps_tracker_last_sync_at: text("vps_tracker_last_sync_at"),
|
||||||
|
show_quick_actions: integer("show_quick_actions", {
|
||||||
|
mode: "boolean"
|
||||||
|
}).notNull().default(true),
|
||||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||||
});
|
});
|
||||||
@@ -345,6 +348,14 @@ var DEFAULT_APP_SWITCHER = {
|
|||||||
url: "http://192.168.100.67:6363",
|
url: "http://192.168.100.67:6363",
|
||||||
icon: "cloud",
|
icon: "cloud",
|
||||||
shortcut: "\u23182"
|
shortcut: "\u23182"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "evobgp",
|
||||||
|
name: "EvoBGP",
|
||||||
|
subtitle: "BGP \u043C\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0446\u0438\u044F",
|
||||||
|
url: "http://192.168.100.67:3000",
|
||||||
|
icon: "globe",
|
||||||
|
shortcut: "\u23183"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -365,7 +376,8 @@ function toDto(row) {
|
|||||||
row.vps_tracker_integration_token?.trim()
|
row.vps_tracker_integration_token?.trim()
|
||||||
),
|
),
|
||||||
vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled),
|
vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled),
|
||||||
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at
|
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at,
|
||||||
|
showQuickActions: row.show_quick_actions == null ? true : Boolean(row.show_quick_actions)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function getAppSettings(db) {
|
function getAppSettings(db) {
|
||||||
@@ -397,6 +409,7 @@ function updateAppSettings(db, patch) {
|
|||||||
vps_tracker_url: patch.vpsTrackerUrl !== void 0 ? patch.vpsTrackerUrl : current.vps_tracker_url,
|
vps_tracker_url: patch.vpsTrackerUrl !== void 0 ? patch.vpsTrackerUrl : current.vps_tracker_url,
|
||||||
vps_tracker_integration_token: patch.vpsTrackerIntegrationToken !== void 0 && patch.vpsTrackerIntegrationToken.trim() !== "" ? patch.vpsTrackerIntegrationToken : current.vps_tracker_integration_token,
|
vps_tracker_integration_token: patch.vpsTrackerIntegrationToken !== void 0 && patch.vpsTrackerIntegrationToken.trim() !== "" ? patch.vpsTrackerIntegrationToken : current.vps_tracker_integration_token,
|
||||||
vps_tracker_sync_enabled: patch.vpsTrackerSyncEnabled !== void 0 ? patch.vpsTrackerSyncEnabled : current.vps_tracker_sync_enabled,
|
vps_tracker_sync_enabled: patch.vpsTrackerSyncEnabled !== void 0 ? patch.vpsTrackerSyncEnabled : current.vps_tracker_sync_enabled,
|
||||||
|
show_quick_actions: patch.showQuickActions !== void 0 ? patch.showQuickActions : current.show_quick_actions,
|
||||||
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
||||||
}).where(eq(appSettings.id, SETTINGS_ID)).run();
|
}).where(eq(appSettings.id, SETTINGS_ID)).run();
|
||||||
return getAppSettings(db);
|
return getAppSettings(db);
|
||||||
@@ -492,6 +505,7 @@ __export(repos_exports, {
|
|||||||
listUngroupedServices: () => listUngroupedServices,
|
listUngroupedServices: () => listUngroupedServices,
|
||||||
markDnsPendingDelete: () => markDnsPendingDelete,
|
markDnsPendingDelete: () => markDnsPendingDelete,
|
||||||
mergeHealthAggregates: () => mergeHealthAggregates,
|
mergeHealthAggregates: () => mergeHealthAggregates,
|
||||||
|
pruneStaleIpHealthStatus: () => pruneStaleIpHealthStatus,
|
||||||
reorderServices: () => reorderServices,
|
reorderServices: () => reorderServices,
|
||||||
replaceBindingIps: () => replaceBindingIps,
|
replaceBindingIps: () => replaceBindingIps,
|
||||||
replaceBindingIpsWithMeta: () => replaceBindingIpsWithMeta,
|
replaceBindingIpsWithMeta: () => replaceBindingIpsWithMeta,
|
||||||
@@ -1443,6 +1457,32 @@ function deleteIpHealthStatusForIp(db, scope, refId, ip) {
|
|||||||
)
|
)
|
||||||
).run();
|
).run();
|
||||||
}
|
}
|
||||||
|
function pruneStaleIpHealthStatus(db, activeTargets) {
|
||||||
|
const byRef = /* @__PURE__ */ new Map();
|
||||||
|
for (const t of activeTargets) {
|
||||||
|
const key = `${t.scope}:${t.ref_id}`;
|
||||||
|
let ips = byRef.get(key);
|
||||||
|
if (!ips) {
|
||||||
|
ips = /* @__PURE__ */ new Set();
|
||||||
|
byRef.set(key, ips);
|
||||||
|
}
|
||||||
|
ips.add(t.ip);
|
||||||
|
}
|
||||||
|
let deleted = 0;
|
||||||
|
for (const [key, ips] of byRef) {
|
||||||
|
const sep = key.indexOf(":");
|
||||||
|
const scope = key.slice(0, sep);
|
||||||
|
const refId = Number(key.slice(sep + 1));
|
||||||
|
if (!Number.isFinite(refId)) continue;
|
||||||
|
for (const row of listIpHealthStatus(db, scope, refId)) {
|
||||||
|
if (!ips.has(row.ip)) {
|
||||||
|
deleteIpHealthStatusForIp(db, scope, refId, row.ip);
|
||||||
|
deleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
function listHealthCheckTargets(db) {
|
function listHealthCheckTargets(db) {
|
||||||
const fqdnExpr = sql2`CASE WHEN sb.hostname = '@' OR sb.hostname IS NULL THEN d.zone_name ELSE sb.hostname || '.' || d.zone_name END`;
|
const fqdnExpr = sql2`CASE WHEN sb.hostname = '@' OR sb.hostname IS NULL THEN d.zone_name ELSE sb.hostname || '.' || d.zone_name END`;
|
||||||
const bindingTargets = db.all(sql2`
|
const bindingTargets = db.all(sql2`
|
||||||
|
|||||||
@@ -1687,6 +1687,38 @@ export function deleteIpHealthStatusForIp(
|
|||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drop health rows whose IP is no longer a live probe target for that scope/ref. */
|
||||||
|
export function pruneStaleIpHealthStatus(
|
||||||
|
db: Db,
|
||||||
|
activeTargets: Array<{ scope: HealthCheckScope; ref_id: number; ip: string }>,
|
||||||
|
): number {
|
||||||
|
const byRef = new Map<string, Set<string>>();
|
||||||
|
for (const t of activeTargets) {
|
||||||
|
const key = `${t.scope}:${t.ref_id}`;
|
||||||
|
let ips = byRef.get(key);
|
||||||
|
if (!ips) {
|
||||||
|
ips = new Set();
|
||||||
|
byRef.set(key, ips);
|
||||||
|
}
|
||||||
|
ips.add(t.ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
let deleted = 0;
|
||||||
|
for (const [key, ips] of byRef) {
|
||||||
|
const sep = key.indexOf(":");
|
||||||
|
const scope = key.slice(0, sep) as HealthCheckScope;
|
||||||
|
const refId = Number(key.slice(sep + 1));
|
||||||
|
if (!Number.isFinite(refId)) continue;
|
||||||
|
for (const row of listIpHealthStatus(db, scope, refId)) {
|
||||||
|
if (!ips.has(row.ip)) {
|
||||||
|
deleteIpHealthStatusForIp(db, scope, refId, row.ip);
|
||||||
|
deleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Health Check Targets ---
|
// --- Health Check Targets ---
|
||||||
|
|
||||||
export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user