From b9bea44dce833d0806adbbaca7da64ab3f9c251e Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 19 Aug 2026 18:32:18 +0700 Subject: [PATCH] =?UTF-8?q?feat(health):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20Globalping=20=D0=B8=20=D0=BC=D1=83=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D0=B8=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Несколько источников проб сразу и правило агрегации на сервисе вместо XOR Local/Cloudflare. Co-authored-by: Cursor --- apps/api/src/lib/globalping-client.ts | 277 +++++++++++++++++ .../src/services/health-check-scheduler.ts | 7 + apps/api/src/services/health-check-service.ts | 206 ++++++++----- apps/api/src/services/health/globalping.ts | 40 +++ .../services/health/health-worker-deploy.ts | 6 +- apps/api/src/services/health/mailbox.ts | 4 +- .../src/services/service-config-service.ts | 28 +- apps/api/test/health-globalping.test.ts | 273 +++++++++++++++++ apps/api/test/settings-health.test.ts | 47 +++ .../components/health-check-config-fields.tsx | 177 ++++++----- .../reui-kit/health-source-tiles.tsx | 202 +++++++++++++ apps/web/src/components/reui-kit/index.ts | 6 + .../web/src/components/service-edit-sheet.tsx | 21 +- apps/web/src/lib/schemas.ts | 20 +- apps/web/src/routes/_auth/settings/health.tsx | 176 ++++++++++- docs/Home.md | 36 ++- packages/db/dist/index.d.ts | 281 +++++++++++++++++- packages/db/dist/index.js | 148 +++++++-- .../db/migrations/023_health_providers.sql | 21 ++ packages/db/src/repos.ts | 165 ++++++++-- packages/db/src/schema.ts | 11 + packages/db/src/settings-repo.ts | 37 +++ packages/shared/dist/index.d.ts | 277 +++++++++++++++-- packages/shared/dist/index.js | 137 ++++++++- packages/shared/src/health-providers.ts | 143 +++++++++ packages/shared/src/index.ts | 2 +- .../shared/src/integration-vps-tracker.ts | 3 + packages/shared/src/schemas.ts | 31 +- packages/shared/src/types.ts | 28 +- packages/ui/src/components/toggle-group.tsx | 89 ++++++ packages/ui/src/components/toggle.tsx | 43 +++ 31 files changed, 2671 insertions(+), 271 deletions(-) create mode 100644 apps/api/src/lib/globalping-client.ts create mode 100644 apps/api/src/services/health/globalping.ts create mode 100644 apps/api/test/health-globalping.test.ts create mode 100644 apps/web/src/components/reui-kit/health-source-tiles.tsx create mode 100644 packages/db/migrations/023_health_providers.sql create mode 100644 packages/shared/src/health-providers.ts create mode 100644 packages/ui/src/components/toggle-group.tsx create mode 100644 packages/ui/src/components/toggle.tsx diff --git a/apps/api/src/lib/globalping-client.ts b/apps/api/src/lib/globalping-client.ts new file mode 100644 index 0000000..6d3b19a --- /dev/null +++ b/apps/api/src/lib/globalping-client.ts @@ -0,0 +1,277 @@ +import type { HealthCheckTarget } from "@cfdm/shared"; +import { + clampGlobalpingLimit, + parseGlobalpingLocations, +} from "@cfdm/shared"; + +export const GLOBALPING_API_ROOT = "https://api.globalping.io"; +export const GLOBALPING_MIN_POLL_MS = 500; +export const GLOBALPING_UA = "CFDM-health/1.0"; + +export interface GlobalpingClientOptions { + token?: string | null; + locations?: string; + limit?: number; + pollIntervalMs?: number; + maxWaitMs?: number; + fetchImpl?: typeof fetch; +} + +export interface GlobalpingProbeResult { + ok: boolean; + latencyMs: number; + error: string | null; + colo: string | null; +} + +interface MeasurementCreateBody { + type: "ping" | "http"; + target: string; + inProgressUpdates: false; + limit: number; + locations: Array<{ magic: string }>; + measurementOptions: Record; +} + +interface MeasurementProbe { + continent?: string; + country?: string; + city?: string; + network?: string; +} + +interface MeasurementResultRow { + probe?: MeasurementProbe; + result?: { + status?: string; + statusCode?: number; + timings?: { total?: number }; + stats?: { avg?: number; loss?: number }; + }; +} + +interface MeasurementResponse { + id?: string; + status?: string; + results?: MeasurementResultRow[]; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function locationLabel(probe: MeasurementProbe | undefined): string | null { + if (!probe) return null; + const city = probe.city?.trim(); + const country = probe.country?.trim(); + if (city && country) return `${city}, ${country}`; + return city || country || null; +} + +export function buildMeasurementBody( + target: HealthCheckTarget, + options: GlobalpingClientOptions, +): MeasurementCreateBody { + const limit = clampGlobalpingLimit(options.limit, 3); + const locations = parseGlobalpingLocations(options.locations).map((magic) => ({ + magic, + })); + const port = target.port ?? (target.type === "http" ? 80 : 80); + const ip = String(target.ip || "").trim(); + const hostname = (target.hostname || ip).trim(); + + if (target.type === "http") { + const path = target.path?.trim() || "/"; + const protocol = port === 443 ? "HTTPS" : "HTTP"; + return { + type: "http", + target: ip, + inProgressUpdates: false, + limit, + locations, + measurementOptions: { + protocol, + port, + request: { + method: "GET", + host: hostname, + path: path.startsWith("/") ? path : `/${path}`, + }, + }, + }; + } + + return { + type: "ping", + target: ip, + inProgressUpdates: false, + limit, + locations, + measurementOptions: { + protocol: "TCP", + port, + }, + }; +} + +function rowOk(target: HealthCheckTarget, row: MeasurementResultRow): boolean { + const result = row.result; + if (!result) return false; + const status = String(result.status ?? "").toLowerCase(); + if (status && status !== "finished") return false; + if (target.type === "http") { + const code = result.statusCode; + if (code == null) return false; + if (target.expected_status != null) return code === target.expected_status; + return code >= 200 && code < 400; + } + const loss = result.stats?.loss; + if (loss != null && loss >= 100) return false; + return status === "finished" || status === ""; +} + +function rowLatency(row: MeasurementResultRow): number { + const total = row.result?.timings?.total; + if (typeof total === "number" && Number.isFinite(total)) return Math.round(total); + const avg = row.result?.stats?.avg; + if (typeof avg === "number" && Number.isFinite(avg)) return Math.round(avg); + return 0; +} + +export function summarizeMeasurement( + target: HealthCheckTarget, + doc: MeasurementResponse, +): GlobalpingProbeResult { + const rows = doc.results ?? []; + if (rows.length === 0) { + return { + ok: false, + latencyMs: 0, + error: "Globalping: пустой результат", + colo: null, + }; + } + const oks = rows.map((row) => rowOk(target, row)); + const okCount = oks.filter(Boolean).length; + const ok = okCount > rows.length / 2; + const latencies = rows.map(rowLatency); + const latencyMs = Math.round( + latencies.reduce((sum, n) => sum + n, 0) / latencies.length, + ); + const colo = + locationLabel(rows.find((_, i) => oks[i])?.probe) ?? + locationLabel(rows[0]?.probe); + if (ok) { + return { ok: true, latencyMs, error: null, colo }; + } + const expected = + target.type === "http" && target.expected_status != null + ? `ожидали HTTP ${target.expected_status}` + : target.type === "http" + ? "ожидали HTTP 2xx/3xx" + : "TCP ping с packet loss < 100%"; + return { + ok: false, + latencyMs, + error: `Globalping: ${okCount}/${rows.length} проб успешны (${expected})`, + colo, + }; +} + +async function parseJson(response: Response): Promise { + try { + return (await response.json()) as MeasurementResponse; + } catch { + return {}; + } +} + +export async function runGlobalpingMeasurement( + target: HealthCheckTarget, + options: GlobalpingClientOptions = {}, +): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const pollMs = + options.pollIntervalMs === undefined + ? GLOBALPING_MIN_POLL_MS + : Math.max(0, options.pollIntervalMs); + const maxWaitMs = options.maxWaitMs ?? Math.max(target.timeout_ms ?? 3000, 3000) + 15_000; + const headers: Record = { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": GLOBALPING_UA, + }; + const token = options.token?.trim(); + if (token) headers.Authorization = `Bearer ${token}`; + + const created = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements`, { + method: "POST", + headers, + body: JSON.stringify(buildMeasurementBody(target, options)), + }); + if (created.status === 429) { + return { + ok: false, + latencyMs: 0, + error: "Globalping: 429 rate limit", + colo: null, + }; + } + if (created.status !== 202 && created.status !== 200) { + const body = await parseJson(created); + return { + ok: false, + latencyMs: 0, + error: `Globalping: HTTP ${created.status}${body.status ? ` (${body.status})` : ""}`, + colo: null, + }; + } + const createdBody = await parseJson(created); + const id = createdBody.id?.trim(); + if (!id) { + return { + ok: false, + latencyMs: 0, + error: "Globalping: нет id измерения", + colo: null, + }; + } + + const started = Date.now(); + while (Date.now() - started < maxWaitMs) { + await sleep(pollMs); + const polled = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements/${id}`, { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": GLOBALPING_UA, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + if (polled.status === 429) { + return { + ok: false, + latencyMs: 0, + error: "Globalping: 429 rate limit", + colo: null, + }; + } + if (!polled.ok) { + return { + ok: false, + latencyMs: 0, + error: `Globalping: HTTP ${polled.status} при опросе`, + colo: null, + }; + } + const doc = await parseJson(polled); + if (String(doc.status ?? "").toLowerCase() === "in-progress") continue; + return summarizeMeasurement(target, doc); + } + return { + ok: false, + latencyMs: Date.now() - started, + error: "Globalping: timeout ожидания measurement", + colo: null, + }; +} diff --git a/apps/api/src/services/health-check-scheduler.ts b/apps/api/src/services/health-check-scheduler.ts index df6cd7b..299b8ec 100644 --- a/apps/api/src/services/health-check-scheduler.ts +++ b/apps/api/src/services/health-check-scheduler.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify"; import { AsyncTask, CronJob } from "toad-scheduler"; import { getAppSettings, + getAppSettingsSecrets, updateAppSettings, type HealthEngineFallbacks, } from "@cfdm/db"; @@ -71,11 +72,17 @@ export function createHealthCheckTask( successRecoveries: settings.healthSuccessRecoveries, }; const mailbox = mailboxFromSettings(app.db, app.cf, fallbacks); + const secrets = getAppSettingsSecrets(app.db); const n = await healthCheckService.runAllChecks(app.db, { thresholds, probeGapMs: config.healthProbeGapMs, mailbox, staleAfterMs: cronStaleAfterMs(settings.healthCheckCron), + globalping: { + token: secrets.globalpingToken, + locations: secrets.globalpingLocations, + limit: secrets.globalpingLimit, + }, onStatusChange: async (target, prev, next) => { try { const label = diff --git a/apps/api/src/services/health-check-service.ts b/apps/api/src/services/health-check-service.ts index a09b662..bb45e4c 100644 --- a/apps/api/src/services/health-check-service.ts +++ b/apps/api/src/services/health-check-service.ts @@ -3,11 +3,20 @@ import { resolve4, resolve6 } from "node:dns/promises"; import { Agent, buildConnector, fetch as undiciFetch } from "undici"; import type { Db } from "@cfdm/db"; import { repos } from "@cfdm/db"; -import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared"; +import type { HealthCheckTarget, IpHealthState, HealthCheckProvider } from "@cfdm/shared"; +import { + aggregateHealthOk, + parseHealthAggregate, + targetProviders, +} from "@cfdm/shared"; import { AppError } from "../errors.js"; import { nextHealthState } from "./health/state-machine.js"; import { LocalHealthCheckProvider } from "./health/local.js"; import { workerNotConfiguredResult } from "./health/worker.js"; +import { + globalpingNotConfiguredResult, + probeWithGlobalping, +} from "./health/globalping.js"; import { buildTargetsDoc, indexResults, @@ -15,6 +24,7 @@ import { originProbeKey, type HealthMailbox, } from "./health/mailbox.js"; +import type { GlobalpingClientOptions } from "../lib/globalping-client.js"; export interface HealthCheckThresholds { degradedFailures: number; @@ -275,6 +285,7 @@ export interface RunAllChecksOptions { mailbox?: HealthMailbox | null; /** Results older than this are stale (default 10 min). */ staleAfterMs?: number; + globalping?: GlobalpingClientOptions | null; onStatusChange?: ( target: HealthCheckTarget, prevState: IpHealthState | null, @@ -287,20 +298,60 @@ function sleep(ms: number): Promise { } /** - * 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. + * One network hit per origin+provider. Group+binding on the same IP share a probe. */ -export function physicalProbeKey(target: HealthCheckTarget): string { - const kind = target.provider === "cloudflare" ? "cloudflare" : "local"; - return `${kind}|${originProbeKey(target)}`; +export function physicalProbeKey( + target: HealthCheckTarget, + provider: HealthCheckProvider = target.provider, +): string { + return `${provider}|${originProbeKey(target)}`; } -function applyProbeResult( +function logSourceResult( db: Db, target: HealthCheckTarget, + provider: HealthCheckProvider, result: ProbeResult, +): void { + repos.insertHealthProbeLog(db, { + scope: target.scope, + refId: target.ref_id, + ip: target.ip, + provider, + status: result.ok ? "up" : "down", + ok: result.ok, + latencyMs: result.latencyMs, + colo: result.colo ?? null, + error: result.error, + }); +} + +function applyAggregatedStatus( + db: Db, + target: HealthCheckTarget, + sources: Array<{ provider: HealthCheckProvider; result: ProbeResult }>, options: RunAllChecksOptions, ): void { + const policy = parseHealthAggregate(target.aggregate); + const oks = sources.map((s) => s.result.ok); + const aggregatedOk = aggregateHealthOk(oks, policy); + const latencies = sources.map((s) => s.result.latencyMs); + const latencyMs = latencies.length + ? Math.round(latencies.reduce((sum, n) => sum + n, 0) / latencies.length) + : 0; + const colo = + sources.find((s) => s.result.colo)?.result.colo ?? + sources[0]?.result.colo ?? + null; + const error = aggregatedOk + ? null + : sources + .map((s) => s.result.error) + .filter((msg): msg is string => Boolean(msg)) + .join("; ") || "health aggregate down"; + const statusProvider = + sources.length > 1 ? "aggregate" : (sources[0]?.provider ?? target.provider); + const prev = repos.getIpHealthStatusRow( db, target.scope, @@ -308,8 +359,8 @@ function applyProbeResult( target.ip, ); const { state, failures, successes, node } = deriveState( - result.ok, - result.latencyMs, + aggregatedOk, + latencyMs, prev ? { consecutive_failures: prev.consecutive_failures, @@ -322,30 +373,18 @@ function applyProbeResult( const prevState: IpHealthState | null = prev ? (prev.status as IpHealthState) : null; - const provider = target.provider === "cloudflare" ? "cloudflare" : "local"; repos.upsertIpHealthStatus( db, target.scope, target.ref_id, target.ip, state, - result.latencyMs, + latencyMs, failures, - result.error, + error, successes, - { colo: result.colo ?? null, provider }, + { colo, provider: statusProvider }, ); - repos.insertHealthProbeLog(db, { - scope: target.scope, - refId: target.ref_id, - ip: target.ip, - provider, - status: state, - ok: result.ok, - latencyMs: result.latencyMs, - colo: result.colo ?? null, - error: result.error, - }); const matchedNode = repos.findNodeByIp(db, target.ip); if (matchedNode && matchedNode.enabled) { repos.updateNode(db, matchedNode.id, { @@ -353,7 +392,7 @@ function applyProbeResult( consecutive_failures: failures, consecutive_successes: successes, last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19), - last_failure_reason: result.error, + last_failure_reason: error, }); } if (prevState !== state) { @@ -379,42 +418,26 @@ export async function runAllChecks( const local = new LocalHealthCheckProvider(); const staleAfterMs = options.staleAfterMs ?? 10 * 60_000; - const byPhysical = new Map(); + const byOrigin = new Map(); for (const target of targets) { - const key = physicalProbeKey(target); - const list = byPhysical.get(key); + const key = originProbeKey(target); + const list = byOrigin.get(key); if (list) list.push(target); - else byPhysical.set(key, [target]); + else byOrigin.set(key, [target]); } - const localGroups: HealthCheckTarget[][] = []; - const cloudflareGroups: HealthCheckTarget[][] = []; - for (const group of byPhysical.values()) { - if (group[0]?.provider === "cloudflare") cloudflareGroups.push(group); - else localGroups.push(group); - } - - let probeIndex = 0; - for (const group of localGroups) { - if (probeIndex > 0 && gapMs > 0) { - await sleep(gapMs); - } - probeIndex += 1; - const representative = - group.find((t) => t.scope === "binding") ?? group[0]!; - const result = await local.probe(representative); - for (const target of group) { - applyProbeResult(db, target, result, options); - } - } - - if (cloudflareGroups.length > 0) { - const mailbox = options.mailbox ?? null; + const needsCloudflare = targets.some((t) => + targetProviders(t).includes("cloudflare"), + ); + let mailboxResults = new Map(); + let mailboxColo: string | null = null; + let mailboxStale = true; + const mailbox = options.mailbox ?? null; + if (needsCloudflare) { const resultsDoc = mailbox ? await mailbox.getResults() : null; - const byKey = indexResults(resultsDoc); - const stale = !mailbox || isResultsStale(resultsDoc, staleAfterMs); - const colo = resultsDoc?.colo ?? null; - + mailboxResults = indexResults(resultsDoc); + mailboxStale = !mailbox || isResultsStale(resultsDoc, staleAfterMs); + mailboxColo = resultsDoc?.colo ?? null; if (mailbox) { try { const next = buildTargetsDoc(targets); @@ -426,28 +449,71 @@ export async function runAllChecks( // ingest still proceeds } } + } - for (const group of cloudflareGroups) { - const representative = - group.find((t) => t.scope === "binding") ?? group[0]!; - const item = byKey.get(originProbeKey(representative)); - let result: ProbeResult; - if (!mailbox) { - result = workerNotConfiguredResult(); - } else if (stale || !item) { - result = staleWorkerResult(colo); - } else { + const probeCache = new Map(); + let probeIndex = 0; + + async function resolveProvider( + provider: HealthCheckProvider, + representative: HealthCheckTarget, + originKey: string, + ): Promise { + const cacheKey = `${provider}|${originKey}`; + const cached = probeCache.get(cacheKey); + if (cached) return cached; + + let result: ProbeResult; + if (provider === "local") { + if (probeIndex > 0 && gapMs > 0) await sleep(gapMs); + probeIndex += 1; + result = await local.probe(representative); + } else if (provider === "cloudflare") { + const item = mailboxResults.get(originKey); + if (!mailbox) result = workerNotConfiguredResult(); + else if (mailboxStale || !item) result = staleWorkerResult(mailboxColo); + else { result = { ok: item.ok, latencyMs: item.latencyMs, error: item.error, - colo, + colo: mailboxColo, }; } - for (const target of group) { - applyProbeResult(db, target, result, options); + } else { + if (!options.globalping?.token?.trim()) { + result = globalpingNotConfiguredResult(); + } else { + if (probeIndex > 0 && gapMs > 0) await sleep(gapMs); + probeIndex += 1; + result = await probeWithGlobalping(representative, options.globalping); } } + probeCache.set(cacheKey, result); + return result; + } + + for (const [originKey, group] of byOrigin) { + const representative = + group.find((t) => t.scope === "binding") ?? group[0]!; + const needed = new Set(); + for (const target of group) { + for (const provider of targetProviders(target)) needed.add(provider); + } + for (const provider of needed) { + await resolveProvider(provider, representative, originKey); + } + for (const target of group) { + const providers = targetProviders(target); + const sources = providers.map((provider) => ({ + provider, + result: probeCache.get(`${provider}|${originKey}`)!, + })); + for (const source of sources) { + logSourceResult(db, target, source.provider, source.result); + } + applyAggregatedStatus(db, target, sources, options); + } } repos.pruneStaleIpHealthStatus(db, targets); @@ -473,6 +539,8 @@ export async function runDomainMonitors( timeout_ms: monitor.timeout_ms, verify_tls: false, provider: "local", + providers: ["local"], + aggregate: "majority", }; let result: ProbeResult; if (monitor.type === "http") { diff --git a/apps/api/src/services/health/globalping.ts b/apps/api/src/services/health/globalping.ts new file mode 100644 index 0000000..556ac54 --- /dev/null +++ b/apps/api/src/services/health/globalping.ts @@ -0,0 +1,40 @@ +import type { HealthCheckTarget } from "@cfdm/shared"; +import { + runGlobalpingMeasurement, + type GlobalpingClientOptions, +} from "../../lib/globalping-client.js"; +import type { ProbeResult } from "../health-check-service.js"; + +export function globalpingNotConfiguredResult(): ProbeResult { + return { + ok: false, + latencyMs: 0, + error: "Globalping: токен не задан", + colo: null, + }; +} + +export async function probeWithGlobalping( + target: HealthCheckTarget, + options: GlobalpingClientOptions, +): Promise { + if (!options.token?.trim()) { + return globalpingNotConfiguredResult(); + } + try { + const result = await runGlobalpingMeasurement(target, options); + return { + ok: result.ok, + latencyMs: result.latencyMs, + error: result.error, + colo: result.colo, + }; + } catch (err) { + return { + ok: false, + latencyMs: 0, + error: err instanceof Error ? err.message : "Globalping: ошибка запроса", + colo: null, + }; + } +} diff --git a/apps/api/src/services/health/health-worker-deploy.ts b/apps/api/src/services/health/health-worker-deploy.ts index f399553..93a7678 100644 --- a/apps/api/src/services/health/health-worker-deploy.ts +++ b/apps/api/src/services/health/health-worker-deploy.ts @@ -1,6 +1,6 @@ import { getAppSettings, repos, updateAppSettings, type HealthEngineFallbacks } from "@cfdm/db"; import type { Db } from "@cfdm/db"; -import { HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME } from "@cfdm/shared"; +import { HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, targetHasProvider } from "@cfdm/shared"; import type { CloudflareClient } from "../../lib/cf-client.js"; import { AppError } from "../../errors.js"; import { loadHealthProbeWorkerSource } from "./health-probe-script.js"; @@ -126,7 +126,7 @@ export async function maybeEnsureHealthWorker( ): Promise { const hasCloudflare = repos .listHealthCheckTargets(db) - .some((target) => target.provider === "cloudflare"); + .some((target) => targetHasProvider(target, "cloudflare")); if (!hasCloudflare) return; const settings = getAppSettings(db, fallbacks); if (settings.healthWorkerKvNamespaceId.trim() && !settings.healthWorkerError) { @@ -172,7 +172,7 @@ export function fireEnsureHealthWorker( if (!cf.isConfigured) return; const hasCloudflare = repos .listHealthCheckTargets(db) - .some((target) => target.provider === "cloudflare"); + .some((target) => targetHasProvider(target, "cloudflare")); if (!hasCloudflare) { void syncCloudflareTargetsToKv(db, cf, fallbacks).catch((err) => { log?.warn({ err }, "health worker KV sync failed"); diff --git a/apps/api/src/services/health/mailbox.ts b/apps/api/src/services/health/mailbox.ts index 7a177c9..2e13620 100644 --- a/apps/api/src/services/health/mailbox.ts +++ b/apps/api/src/services/health/mailbox.ts @@ -5,7 +5,7 @@ import type { HealthProbeTargetItem, HealthProbeTargetsDoc, } from "@cfdm/shared"; -import { HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY } from "@cfdm/shared"; +import { HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, targetHasProvider } from "@cfdm/shared"; import type { CloudflareClient } from "../../lib/cf-client.js"; export interface HealthMailbox { @@ -70,7 +70,7 @@ export function cloudflareMailboxTargets( ): HealthProbeTargetItem[] { const unique = new Map(); for (const target of targets) { - if (target.provider !== "cloudflare") continue; + if (!targetHasProvider(target, "cloudflare")) continue; if (target.type !== "tcp" && target.type !== "http") continue; const key = originProbeKey(target); if (unique.has(key)) continue; diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index 05a5a7f..17d0c23 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -2,6 +2,8 @@ import type { Db } from "@cfdm/db"; import { repos } from "@cfdm/db"; import type { DnsRecord, + HealthCheckAggregate, + HealthCheckProvider, HealthCheckScope, HealthCheckType, IpHealthState, @@ -51,7 +53,9 @@ export interface ServiceDomainInput { health_check_interval_sec?: number; health_check_timeout_ms?: number; health_check_verify_tls?: boolean; - health_check_provider?: "local" | "cloudflare"; + health_check_provider?: HealthCheckProvider; + health_check_providers?: HealthCheckProvider[]; + health_check_aggregate?: HealthCheckAggregate; } export interface ToggleRequest { @@ -72,7 +76,9 @@ export interface ServiceGroupBody { health_check_interval_sec?: number; health_check_timeout_ms?: number; health_check_verify_tls?: boolean; - health_check_provider?: "local" | "cloudflare"; + health_check_provider?: HealthCheckProvider; + health_check_providers?: HealthCheckProvider[]; + health_check_aggregate?: HealthCheckAggregate; } export interface UpdateServiceGroupBody { @@ -89,7 +95,9 @@ export interface UpdateServiceGroupBody { health_check_interval_sec?: number; health_check_timeout_ms?: number; health_check_verify_tls?: boolean; - health_check_provider?: "local" | "cloudflare"; + health_check_provider?: HealthCheckProvider; + health_check_providers?: HealthCheckProvider[]; + health_check_aggregate?: HealthCheckAggregate; } export interface UpdateServiceConfigRequest { @@ -296,6 +304,10 @@ async function buildView(db: Db, serviceId: number): Promise { health_check_timeout_ms: binding.health_check_timeout_ms, health_check_verify_tls: binding.health_check_verify_tls, health_check_provider: binding.health_check_provider ?? "local", + health_check_providers: binding.health_check_providers ?? [ + binding.health_check_provider ?? "local", + ], + health_check_aggregate: binding.health_check_aggregate ?? "majority", sync_status: aggregateSyncStatus(statuses), }; }); @@ -1164,7 +1176,9 @@ export async function updateConfig( input.health_check_interval_sec !== undefined || input.health_check_timeout_ms !== undefined || input.health_check_verify_tls !== undefined || - input.health_check_provider !== undefined + input.health_check_provider !== undefined || + input.health_check_providers !== undefined || + input.health_check_aggregate !== undefined ) { repos.updateBindingLbConfig(db, binding.id, { lb_mode: input.lb_mode, @@ -1177,6 +1191,8 @@ export async function updateConfig( health_check_timeout_ms: input.health_check_timeout_ms, health_check_verify_tls: input.health_check_verify_tls, health_check_provider: input.health_check_provider, + health_check_providers: input.health_check_providers, + health_check_aggregate: input.health_check_aggregate, }); } @@ -1281,6 +1297,8 @@ export async function createGroup( health_check_timeout_ms: body.health_check_timeout_ms, health_check_verify_tls: body.health_check_verify_tls, health_check_provider: body.health_check_provider, + health_check_providers: body.health_check_providers, + health_check_aggregate: body.health_check_aggregate, }, ); fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS); @@ -1320,6 +1338,8 @@ export async function updateGroup( health_check_timeout_ms: body.health_check_timeout_ms, health_check_verify_tls: body.health_check_verify_tls, health_check_provider: body.health_check_provider, + health_check_providers: body.health_check_providers, + health_check_aggregate: body.health_check_aggregate, }, ); if (!domain && group.enabled) { diff --git a/apps/api/test/health-globalping.test.ts b/apps/api/test/health-globalping.test.ts new file mode 100644 index 0000000..6eb0b19 --- /dev/null +++ b/apps/api/test/health-globalping.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from "vitest"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; +import { repos, type Db } from "@cfdm/db"; +import * as healthCheckService from "../src/services/health-check-service.js"; +import { + buildMeasurementBody, + summarizeMeasurement, +} from "../src/lib/globalping-client.js"; +import { aggregateHealthOk } from "@cfdm/shared"; +import type { HealthCheckTarget } from "@cfdm/shared"; + +const thresholds = { + degradedFailures: 1, + downFailures: 2, + latencyWarnMs: 1000, + successRecoveries: 2, +}; + +function tcpTarget(overrides?: Partial): HealthCheckTarget { + return { + scope: "binding", + ref_id: 1, + ip: "203.0.113.10", + hostname: "panel.example.com", + type: "tcp", + port: 443, + path: null, + expected_status: null, + timeout_ms: 400, + verify_tls: false, + provider: "globalping", + providers: ["globalping"], + aggregate: "majority", + ...overrides, + }; +} + +async function seedBinding( + db: Db, + opts: { + ip: string; + providers: Array<"local" | "cloudflare" | "globalping">; + aggregate?: "any" | "all" | "majority"; + port?: number; + }, +) { + const domain = repos.createDomain(db, null, "example.com", "zone-1"); + const service = repos.createService(db, "Panel", "panel"); + repos.setServiceEnabled(db, service.id, true); + repos.replaceServiceIps(db, service.id, [opts.ip]); + const binding = repos.insertBinding(db, domain.id, service.id, "panel", null); + repos.replaceBindingIpsWithMeta(db, binding.id, [ + { ip: opts.ip, weight: 1, priority: 1 }, + ]); + repos.updateBindingLbConfig(db, binding.id, { + health_check_enabled: true, + health_check_type: "tcp", + health_check_port: opts.port ?? 1, + health_check_timeout_ms: 400, + health_check_providers: opts.providers, + health_check_aggregate: opts.aggregate ?? "majority", + }); + return { service, binding, domain }; +} + +function mockFetch(handler: (url: string, init?: RequestInit) => Response): typeof fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + return handler(url, init); + }) as typeof fetch; +} + +describe("aggregateHealthOk", () => { + it("any / all / majority", () => { + expect(aggregateHealthOk([true, false], "any")).toBe(false); + expect(aggregateHealthOk([true, false], "all")).toBe(true); + expect(aggregateHealthOk([true, false], "majority")).toBe(true); + expect(aggregateHealthOk([false, false], "majority")).toBe(false); + expect(aggregateHealthOk([true, false, false], "majority")).toBe(false); + expect(aggregateHealthOk([true, true, false], "majority")).toBe(true); + expect(aggregateHealthOk([true], "majority")).toBe(true); + }); +}); + +describe("globalping mapping", () => { + it("maps CFDM TCP to ping+TCP and HTTP to http+host", () => { + const tcp = buildMeasurementBody(tcpTarget(), { limit: 3, locations: "World" }); + expect(tcp.type).toBe("ping"); + expect(tcp.measurementOptions.protocol).toBe("TCP"); + expect(tcp.measurementOptions.port).toBe(443); + expect(tcp.inProgressUpdates).toBe(false); + + const http = buildMeasurementBody( + tcpTarget({ type: "http", port: 443, path: "/health", expected_status: 200 }), + { limit: 2, locations: "EU,US" }, + ); + expect(http.type).toBe("http"); + expect(http.locations).toEqual([{ magic: "EU" }, { magic: "US" }]); + expect(http.measurementOptions.request).toMatchObject({ + host: "panel.example.com", + path: "/health", + method: "GET", + }); + }); + + it("summarizes HTTP majority and TCP packet loss", () => { + const httpOk = summarizeMeasurement( + tcpTarget({ type: "http", expected_status: 200 }), + { + status: "finished", + results: [ + { probe: { city: "Frankfurt", country: "DE" }, result: { status: "finished", statusCode: 200, timings: { total: 40 } } }, + { probe: { city: "London", country: "GB" }, result: { status: "finished", statusCode: 200, timings: { total: 50 } } }, + { probe: { city: "Paris", country: "FR" }, result: { status: "finished", statusCode: 500, timings: { total: 20 } } }, + ], + }, + ); + expect(httpOk.ok).toBe(true); + expect(httpOk.colo).toBe("Frankfurt, DE"); + + const tcpFail = summarizeMeasurement(tcpTarget(), { + status: "finished", + results: [ + { result: { status: "finished", stats: { avg: 12, loss: 100 } } }, + { result: { status: "finished", stats: { avg: 11, loss: 100 } } }, + ], + }); + expect(tcpFail.ok).toBe(false); + }); +}); + +describe("globalping engine", () => { + it("POST 202 + GET finished writes colo from probe city", async () => { + const app = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const { binding } = await seedBinding(app.db, { + ip: "203.0.113.40", + providers: ["globalping"], + port: 443, + }); + const fetchImpl = mockFetch((url) => { + if (url.endsWith("/v1/measurements")) { + return new Response(JSON.stringify({ id: "meas-1" }), { status: 202 }); + } + return new Response( + JSON.stringify({ + id: "meas-1", + status: "finished", + results: [ + { + probe: { city: "Amsterdam", country: "NL" }, + result: { status: "finished", stats: { avg: 18, loss: 0 } }, + }, + { + probe: { city: "Frankfurt", country: "DE" }, + result: { status: "finished", stats: { avg: 22, loss: 0 } }, + }, + ], + }), + { status: 200 }, + ); + }); + await healthCheckService.runAllChecks(app.db, { + thresholds, + probeGapMs: 0, + globalping: { + token: "gp_test", + locations: "World", + limit: 2, + pollIntervalMs: 0, + fetchImpl, + }, + }); + const row = repos.getIpHealthStatusRow( + app.db, + "binding", + binding.id, + "203.0.113.40", + ); + expect(row?.status).toBe("up"); + expect(row?.provider).toBe("globalping"); + expect(row?.colo).toMatch(/Amsterdam/); + await app.close(); + }); + + it("429 fails the source and does not fall back to local", async () => { + const app = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const { binding } = await seedBinding(app.db, { + ip: "127.0.0.1", + providers: ["globalping"], + port: 1, + }); + const fetchImpl = mockFetch(() => new Response("rate limited", { status: 429 })); + await healthCheckService.runAllChecks(app.db, { + thresholds, + probeGapMs: 0, + globalping: { + token: "gp_test", + locations: "World", + limit: 1, + pollIntervalMs: 0, + fetchImpl, + }, + }); + const row = repos.getIpHealthStatusRow( + app.db, + "binding", + binding.id, + "127.0.0.1", + ); + expect(row?.last_error).toMatch(/429/i); + expect(row?.provider).toBe("globalping"); + await app.close(); + }); + + it("local+globalping all keeps IP up if Globalping is ok", async () => { + const app = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const { binding, service } = await seedBinding(app.db, { + ip: "127.0.0.1", + providers: ["local", "globalping"], + aggregate: "all", + port: 1, + }); + const fetchImpl = mockFetch((url) => { + if (url.endsWith("/v1/measurements")) { + return new Response(JSON.stringify({ id: "meas-2" }), { status: 202 }); + } + return new Response( + JSON.stringify({ + status: "finished", + results: [ + { probe: { city: "Vienna", country: "AT" }, result: { status: "finished", stats: { avg: 9, loss: 0 } } }, + ], + }), + { status: 200 }, + ); + }); + await healthCheckService.runAllChecks(app.db, { + thresholds, + probeGapMs: 0, + globalping: { + token: "gp_test", + locations: "World", + limit: 1, + pollIntervalMs: 0, + fetchImpl, + }, + }); + const row = repos.getIpHealthStatusRow( + app.db, + "binding", + binding.id, + "127.0.0.1", + ); + expect(row?.status).toBe("up"); + expect(row?.provider).toBe("aggregate"); + const logs = repos.listHealthProbeLogForService(app.db, service.id); + expect(logs.map((row) => row.provider).sort()).toEqual([ + "globalping", + "local", + ]); + await app.close(); + }); +}); diff --git a/apps/api/test/settings-health.test.ts b/apps/api/test/settings-health.test.ts index 341db42..0ceb7b7 100644 --- a/apps/api/test/settings-health.test.ts +++ b/apps/api/test/settings-health.test.ts @@ -164,4 +164,51 @@ describe("settings health engine", () => { expect(body.healthWorkerToken).toBeUndefined(); await app.close(); }); + + it("GET exposes Globalping flags without the token; PATCH persists locations/limit", async () => { + const app = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(app); + const initial = await app.inject({ + method: "GET", + url: "/api/v1/settings", + headers, + }); + expect(initial.statusCode).toBe(200); + const before = initial.json() as { + globalpingTokenSet?: boolean; + globalpingLocations?: string; + globalpingLimit?: number; + globalpingToken?: string; + }; + expect(before.globalpingTokenSet).toBe(false); + expect(before.globalpingLocations).toBe("World"); + expect(before.globalpingLimit).toBe(3); + expect(before.globalpingToken).toBeUndefined(); + + const patched = await app.inject({ + method: "PATCH", + url: "/api/v1/settings", + headers, + payload: { + globalpingToken: "gp_secret", + globalpingLocations: "EU,US", + globalpingLimit: 5, + }, + }); + expect(patched.statusCode).toBe(200); + const body = patched.json() as { + globalpingTokenSet: boolean; + globalpingLocations: string; + globalpingLimit: number; + globalpingToken?: string; + }; + expect(body.globalpingTokenSet).toBe(true); + expect(body.globalpingLocations).toBe("EU,US"); + expect(body.globalpingLimit).toBe(5); + expect(body.globalpingToken).toBeUndefined(); + await app.close(); + }); }); diff --git a/apps/web/src/components/health-check-config-fields.tsx b/apps/web/src/components/health-check-config-fields.tsx index 0102645..bd2cd78 100644 --- a/apps/web/src/components/health-check-config-fields.tsx +++ b/apps/web/src/components/health-check-config-fields.tsx @@ -17,16 +17,22 @@ import { SelectValue, } from '@cfdm/ui/components/select' import { Switch } from '@cfdm/ui/components/switch' -import { Button } from '@cfdm/ui/components/button' -import { ButtonGroup } from '@cfdm/ui/components/button-group' import { FieldGroup } from '@cfdm/ui/components/field' +import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group' import { Link } from '@tanstack/react-router' import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert' import { cn } from '@cfdm/ui/lib/utils' +import { + HealthAggregateTiles, + HealthSourceTiles, + type HealthAggregate, + type HealthProvider, +} from '@/components/reui-kit/health-source-tiles' +import { uniqueHealthProviders } from '@cfdm/shared' export type LbMode = 'round_robin' | 'failover' | 'weighted' export type HealthCheckType = 'tcp' | 'http' -export type HealthProvider = 'local' | 'cloudflare' +export type { HealthProvider, HealthAggregate } export interface HealthCheckConfig { enabled: boolean @@ -38,6 +44,8 @@ export interface HealthCheckConfig { timeout_ms: number verify_tls: boolean provider: HealthProvider + providers: HealthProvider[] + aggregate: HealthAggregate method?: string | null retries?: number consecutive_fails?: number @@ -54,62 +62,6 @@ const defaultLbModeOptions = [ { value: 'weighted', label: 'Weighted (веса)' }, ] -const healthCheckTypes = [ - { value: 'tcp', label: 'TCP connect' }, - { value: 'http', label: 'HTTP' }, -] as const - -const cloudflareTypes = [ - { value: 'tcp', label: 'TCP' }, - { value: 'http', label: 'HTTP' }, -] as const - -export function HealthProviderToggle({ - value, - onChange, - id, -}: { - value: HealthProvider - onChange: (next: HealthProvider) => void - id?: string -}) { - const provider = value || 'local' - return ( - - - - - ) -} - -interface HealthCheckConfigFieldsProps { - value: LbAndHealthConfig - onChange: (next: LbAndHealthConfig) => void - lbModeLabel?: string - lbModeOptions?: { value: string; label: string }[] - idPrefix?: string - showLbMode?: boolean - className?: string -} - function CompactNumberField({ id, value, @@ -151,13 +103,29 @@ export function HealthCheckConfigFields({ idPrefix = 'health', showLbMode = true, className, -}: HealthCheckConfigFieldsProps) { +}: { + value: LbAndHealthConfig + onChange: (next: LbAndHealthConfig) => void + lbModeLabel?: string + lbModeOptions?: { value: string; label: string }[] + idPrefix?: string + showLbMode?: boolean + className?: string +}) { function patch(next: Partial) { onChange({ ...value, ...next }) } + const providers = + value.providers?.length > 0 + ? uniqueHealthProviders(value.providers) + : uniqueHealthProviders([value.provider ?? 'local']) + const aggregate = value.aggregate ?? 'majority' const isHttp = value.type === 'http' const rowClass = 'gap-3 px-0 py-3' + const hasCloudflare = providers.includes('cloudflare') + const hasGlobalping = providers.includes('globalping') + const hasLocal = providers.includes('local') return ( @@ -190,23 +158,25 @@ export function HealthCheckConfigFields({ - + patch({ - provider, - enabled: provider === 'cloudflare' ? true : value.enabled, + providers: next, + provider: next[0] ?? 'local', + enabled: next.includes('cloudflare') ? true : value.enabled, }) } /> - {value.provider === 'cloudflare' ? ( + + {hasCloudflare ? ( Cloudflare Worker @@ -216,10 +186,24 @@ export function HealthCheckConfigFields({ Настройках → Health-check - . Если Worker не создан, цель не пробируется как Local. + . Если Worker не создан, этот источник не пробируется как Local. - ) : ( + ) : null} + {hasGlobalping ? ( + + Globalping + + Пробы из сети globalping.io (TCP ping / HTTP). Токен, локации и лимит — + в{' '} + + Настройках → Health-check + + . Без токена или при 429 этот источник = fail, без fallback на Local. + + + ) : null} + {hasLocal ? ( Local health-check @@ -230,7 +214,22 @@ export function HealthCheckConfigFields({ . Интервал в карточке не используется. - )} + ) : null} + + {providers.length > 1 ? ( + + patch({ aggregate: next })} + /> + + ) : null}
- + + TCP + + + HTTP + + diff --git a/apps/web/src/components/reui-kit/health-source-tiles.tsx b/apps/web/src/components/reui-kit/health-source-tiles.tsx new file mode 100644 index 0000000..5712ccc --- /dev/null +++ b/apps/web/src/components/reui-kit/health-source-tiles.tsx @@ -0,0 +1,202 @@ +import type { KeyboardEvent, ReactNode } from 'react' +import { CheckIcon, GlobeIcon, ServerIcon, CloudIcon, LayersIcon, ShieldAlertIcon, ScaleIcon } from 'lucide-react' + +import { Frame, FramePanel } from '@/components/reui/frame' +import { IconTile } from '@/components/reui/icon-tile' +import { cn } from '@cfdm/ui/lib/utils' +import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared' + +export type HealthProvider = HealthCheckProvider +export type HealthAggregate = HealthCheckAggregate + +const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current' + +const PROVIDER_ITEMS: Array<{ + id: HealthProvider + title: string + description: string + icon: ReactNode + iconClassName: string +}> = [ + { + id: 'local', + title: 'Local', + description: 'TCP/HTTP с сервера API', + icon: , + iconClassName: 'text-info [&_svg]:text-current', + }, + { + id: 'cloudflare', + title: 'Cloudflare', + description: 'Worker на edge, KV mailbox', + icon: , + iconClassName: 'text-warning [&_svg]:text-current', + }, + { + id: 'globalping', + title: 'Globalping', + description: 'Пробы из сети globalping.io', + icon: , + iconClassName: 'text-success [&_svg]:text-current', + }, +] + +const AGGREGATE_ITEMS: Array<{ + id: HealthAggregate + title: string + description: string + icon: ReactNode +}> = [ + { + id: 'any', + title: 'Any', + description: 'Down, если хотя бы один источник Down', + icon: , + }, + { + id: 'all', + title: 'All', + description: 'Down, только если все выбранные Down', + icon: , + }, + { + id: 'majority', + title: 'Majority', + description: 'Down по большинству (2 → оба, 3 → ≥2)', + icon: , + }, +] + +function handleTileKeyDown(onActivate: () => void, event: KeyboardEvent) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onActivate() + } +} + +function TilePanel({ + selected, + title, + description, + icon, + iconClassName, + role, + onActivate, +}: { + selected: boolean + title: string + description: string + icon: ReactNode + iconClassName?: string + role: 'checkbox' | 'radio' + onActivate: () => void +}) { + return ( + handleTileKeyDown(onActivate, event)} + > +
+ +
+
+ {title} + {selected ? ( + + ) : null} +
+

{description}

+
+
+
+ ) +} + +/** + * Мультивыбор источников проб (Local / Cloudflare / Globalping). + * Preview: https://reui.io/preview/base/card-12 + * Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile + */ +export function HealthSourceTiles({ + value, + onChange, +}: { + value: HealthProvider[] + onChange: (next: HealthProvider[]) => void +}) { + const selected = value.length > 0 ? value : (['local'] as HealthProvider[]) + + function toggle(id: HealthProvider) { + if (selected.includes(id)) { + if (selected.length === 1) return + onChange(selected.filter((item) => item !== id)) + return + } + onChange([...selected, id]) + } + + return ( + +
+ {PROVIDER_ITEMS.map((item) => ( + toggle(item.id)} + /> + ))} +
+ + ) +} + +/** + * Правило агрегации (ровно одно): any / all / majority. + * Preview: https://reui.io/preview/base/card-12 · https://reui.io/preview/base/settings-5 + */ +export function HealthAggregateTiles({ + value, + onChange, +}: { + value: HealthAggregate + onChange: (next: HealthAggregate) => void +}) { + const selected = value || 'majority' + return ( + +
+ {AGGREGATE_ITEMS.map((item) => ( + onChange(item.id)} + /> + ))} +
+ + ) +} diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index d2febb4..ac23a49 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -18,3 +18,9 @@ export { OpsDashboard } from './ops-dashboard' export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board' export { DetailPanel, type DetailMetricCard } from './detail-panel' export { SettingsShell, type SettingsTabConfig } from './settings-shell' +export { + HealthSourceTiles, + HealthAggregateTiles, + type HealthProvider, + type HealthAggregate, +} from './health-source-tiles' diff --git a/apps/web/src/components/service-edit-sheet.tsx b/apps/web/src/components/service-edit-sheet.tsx index d875779..1a9d4af 100644 --- a/apps/web/src/components/service-edit-sheet.tsx +++ b/apps/web/src/components/service-edit-sheet.tsx @@ -8,6 +8,8 @@ import { type LbAndHealthConfig, type LbMode, type HealthCheckType, + type HealthProvider, + type HealthAggregate, } from '@/components/health-check-config-fields' import type { CreateServiceWithConfigInput, @@ -53,7 +55,9 @@ interface BindingHealthConfig { interval_sec: number timeout_ms: number verify_tls: boolean - provider: 'local' | 'cloudflare' + provider: HealthProvider + providers: HealthProvider[] + aggregate: HealthAggregate } export interface ServiceBindingDraft { @@ -77,6 +81,8 @@ const defaultHealth: BindingHealthConfig = { timeout_ms: 3000, verify_tls: false, provider: 'local', + providers: ['local'], + aggregate: 'majority', } interface ServiceEditSheetProps { @@ -110,7 +116,12 @@ function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] { interval_sec: binding.health_check_interval_sec, timeout_ms: binding.health_check_timeout_ms, verify_tls: binding.health_check_verify_tls ?? false, - provider: binding.health_check_provider === 'cloudflare' ? 'cloudflare' : 'local', + provider: binding.health_check_provider ?? 'local', + providers: + binding.health_check_providers?.length > 0 + ? binding.health_check_providers + : [binding.health_check_provider ?? 'local'], + aggregate: binding.health_check_aggregate ?? 'majority', }, target_ip_weights: binding.target_ip_weights ?? {}, target_ip_priorities: binding.target_ip_priorities ?? {}, @@ -139,6 +150,8 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) { health_check_timeout_ms: binding.health.timeout_ms, health_check_verify_tls: binding.health.verify_tls, health_check_provider: binding.health.provider, + health_check_providers: binding.health.providers, + health_check_aggregate: binding.health.aggregate, } : { fqdn: binding.fqdn.trim(), @@ -155,6 +168,8 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) { health_check_timeout_ms: binding.health.timeout_ms, health_check_verify_tls: binding.health.verify_tls, health_check_provider: binding.health.provider, + health_check_providers: binding.health.providers, + health_check_aggregate: binding.health.aggregate, }, ) } @@ -334,6 +349,8 @@ export function ServiceEditSheet({ timeout_ms: next.timeout_ms, verify_tls: next.verify_tls, provider: next.provider, + providers: next.providers, + aggregate: next.aggregate, } } diff --git a/apps/web/src/lib/schemas.ts b/apps/web/src/lib/schemas.ts index d38a0b7..8c20bc0 100644 --- a/apps/web/src/lib/schemas.ts +++ b/apps/web/src/lib/schemas.ts @@ -36,7 +36,9 @@ export const serviceGroupSchema = z.object({ health_check_interval_sec: z.number().default(30), health_check_timeout_ms: z.number().default(3000), health_check_verify_tls: z.coerce.boolean().default(false), - health_check_provider: z.enum(['local', 'cloudflare']).catch('local'), + health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'), + health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']), + health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'), created_at: z.string(), updated_at: z.string(), }) @@ -77,7 +79,9 @@ export const serviceDomainBindingSchema = z health_check_interval_sec: z.number().default(30), health_check_timeout_ms: z.number().default(3000), health_check_verify_tls: z.coerce.boolean().default(false), - health_check_provider: z.enum(['local', 'cloudflare']).catch('local'), + health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'), + health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']), + health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'), sync_status: z.string().nullable().default(null), }) .transform((binding) => ({ @@ -102,7 +106,7 @@ export const serviceIpHealthSchema = z.object({ latency_ms: z.number().nullable(), last_checked_at: z.string().nullable().optional(), last_error: z.string().nullable().optional(), - provider: z.enum(['local', 'cloudflare']).optional(), + provider: z.enum(['local', 'cloudflare', 'globalping', 'aggregate']).optional(), colo: z.string().nullable().optional(), }) @@ -111,7 +115,7 @@ export const healthProbeLogSchema = z.object({ scope: z.string(), ref_id: z.number(), ip: z.string(), - provider: z.enum(['local', 'cloudflare']), + provider: z.enum(['local', 'cloudflare', 'globalping']), status: z.enum(['up', 'down', 'degraded', 'unknown']), ok: z.coerce.boolean(), latency_ms: z.number().nullable(), @@ -188,7 +192,9 @@ export const serviceBindingSchema = z health_check_interval_sec: z.number().default(30), health_check_timeout_ms: z.number().default(3000), health_check_verify_tls: z.coerce.boolean().default(false), - health_check_provider: z.enum(['local', 'cloudflare']).catch('local'), + health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'), + health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']), + health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'), sync_status: z.string().nullable().default(null), created_at: z.string(), updated_at: z.string(), @@ -273,7 +279,9 @@ const healthCheckConfigFields = { health_check_interval_sec: z.number().int().min(5).max(3600).optional(), health_check_timeout_ms: z.number().int().min(100).max(30000).optional(), health_check_verify_tls: z.boolean().optional(), - health_check_provider: z.enum(['local', 'cloudflare']).optional(), + health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).optional(), + health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).optional(), + health_check_aggregate: z.enum(['any', 'all', 'majority']).optional(), } const serviceDomainInputSchema = z diff --git a/apps/web/src/routes/_auth/settings/health.tsx b/apps/web/src/routes/_auth/settings/health.tsx index 53edfa1..95dc9db 100644 --- a/apps/web/src/routes/_auth/settings/health.tsx +++ b/apps/web/src/routes/_auth/settings/health.tsx @@ -5,7 +5,7 @@ import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { toast } from 'sonner' -import { HeartPulseIcon } from 'lucide-react' +import { HeartPulseIcon, GlobeIcon } from 'lucide-react' import { api } from '@/lib/api-client' import { SettingRow } from '@/components/setting-row' @@ -47,7 +47,14 @@ const formSchema = z.object({ } }) +const globalpingSchema = z.object({ + globalpingToken: z.string().optional(), + globalpingLocations: z.string().trim().min(1).max(200), + globalpingLimit: z.number().int().min(1).max(10), +}) + type FormValues = z.infer +type GlobalpingValues = z.infer type HealthWorkerStatus = 'missing' | 'ready' | 'error' type SettingsResponse = FormValues & { @@ -58,6 +65,9 @@ type SettingsResponse = FormValues & { healthWorkerDeployedAt?: string | null healthWorkerLastIngestAt?: string | null healthWorkerKvNamespaceId?: string + globalpingTokenSet?: boolean + globalpingLocations?: string + globalpingLimit?: number } export const Route = createFileRoute('/_auth/settings/health')({ @@ -140,6 +150,15 @@ function HealthSettingsPage() { }, }) + const gpForm = useForm({ + resolver: zodResolver(globalpingSchema), + defaultValues: { + globalpingToken: '', + globalpingLocations: 'World', + globalpingLimit: 3, + }, + }) + useEffect(() => { if (!data) return form.reset({ @@ -149,7 +168,12 @@ function HealthSettingsPage() { healthLatencyWarnMs: data.healthLatencyWarnMs, healthSuccessRecoveries: data.healthSuccessRecoveries, }) - }, [data, form]) + gpForm.reset({ + globalpingToken: '', + globalpingLocations: data.globalpingLocations || 'World', + globalpingLimit: data.globalpingLimit ?? 3, + }) + }, [data, form, gpForm]) const saveMut = useMutation({ mutationFn: (values: FormValues) => @@ -168,6 +192,27 @@ function HealthSettingsPage() { toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'), }) + const saveGpMut = useMutation({ + mutationFn: (values: GlobalpingValues) => + api.patch('/api/v1/settings', { + globalpingLocations: values.globalpingLocations, + globalpingLimit: values.globalpingLimit, + ...(values.globalpingToken?.trim() + ? { globalpingToken: values.globalpingToken.trim() } + : {}), + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['app-settings'] }) + toast.success('Настройки Globalping сохранены') + gpForm.reset({ + ...gpForm.getValues(), + globalpingToken: '', + }) + }, + onError: (e: unknown) => + toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'), + }) + const ensureMut = useMutation({ mutationFn: () => api.post('/api/v1/settings/health/worker/ensure'), @@ -180,6 +225,7 @@ function HealthSettingsPage() { }) return ( +
@@ -193,8 +239,8 @@ function HealthSettingsPage() { Local health-check - Расписание и пороги движка — общие для Local и Cloudflare Worker. - Тип/порт/path задаются в карточке сервиса. + Расписание и пороги движка — общие для Local, Cloudflare Worker и Globalping. + Тип/порт/path и правило агрегации задаются в карточке сервиса. @@ -416,5 +462,127 @@ function HealthSettingsPage() {
+ +
+ void gpForm.handleSubmit((values) => saveGpMut.mutate(values))(event) + } + > + + + + + Globalping + + {data?.globalpingTokenSet ? 'Токен задан' : 'Нет токена'} + + + + Пробы из сети globalping.io. Poll ≥ 500 мс.{' '} + + settings-16 + + . + + + +
+ + Лимиты и credits + + Без токена — 250 tests/hour, с токеном — 500 +{' '} + + credits + + . Токен: dash.globalping.io/tokens. Один measurement на + уникальный IP/порт за тик cron. При десятках IP следите за + hourly credits. + + +
+ + + + + + + + + ( + + )} + /> + + + + + Сохранить + + +
+ +
+
) } diff --git a/docs/Home.md b/docs/Home.md index b00f150..6537f49 100644 --- a/docs/Home.md +++ b/docs/Home.md @@ -49,18 +49,26 @@ health-check работают на двух уровнях: после `HEALTH_SUCCESS_RECOVERIES` (default 2). Пороги и cron движка задаются в **Настройки → Health-check** (env — fallback, пока значения не сохранены в UI). -### Local XOR Cloudflare Worker +### Источники проб: Local, Cloudflare Worker, Globalping -Провайдер задаётся на привязке (`service_bindings.health_check_provider`): **local** -или **cloudflare**. Одновременно оба не работают. +На привязке/группе задаётся **мультивыбор** источников (`health_check_providers` JSON) +и **правило агрегации** (`health_check_aggregate`: `any` | `all` | `majority`). +Failover читает одну строку `ip_health_status` (агрегат). Журнал `health_probe_log` — +строка на каждый источник. -| | Local | Cloudflare Worker | -|---|---|---| -| Кто пробирует | процесс API CFDM | Worker на edge (Cron Trigger) | -| Планировщик | глобальный cron CFDM | cron Worker + ingest KV в CFDM | -| Пороги Slow/Down | Настройки → Health-check | те же | -| Результат | SQLite `ip_health_status` | та же SQLite + `colo` из KV | -| Регионы Health Checks | нет | нет (на Free продукта нет) | +| | Local | Cloudflare Worker | Globalping | +|---|---|---|---| +| Кто пробирует | процесс API CFDM | Worker на edge (Cron Trigger) | [globalping.io](https://globalping.io) | +| Планировщик | глобальный cron CFDM | cron Worker + ingest KV | тот же cron CFDM (POST/GET measurements) | +| Пороги Slow/Down | Настройки → Health-check | те же | те же (по агрегату) | +| Результат | SQLite `ip_health_status` | та же SQLite + `colo` из KV | та же SQLite, colo = city/country пробы | +| Fallback | — | нет (не Local) | нет (нет токена / 429 / timeout = fail) | + +**Агрегация (на сервисе/группе):** + +- `any` — Down, если хотя бы один выбранный источник Down +- `all` — Down, только если все выбранные Down +- `majority` — Down по большинству (2 источника → оба; 3 → ≥2) **Cloudflare в CFDM — это Worker**, не [Health Checks API](https://developers.cloudflare.com/api/resources/healthchecks). Продукт Health Checks на Free-плане недоступен и **не используется**. @@ -75,7 +83,13 @@ Account `Workers Scripts Write` + `Workers KV Storage Write`. Zone DNS недо Free: 5 Cron Triggers на аккаунт; KV 1000 writes/сутки (интервал ≥ 2 мин); ≤ 48 целей за тик. Исходник: [`workers/health-probe/`](../workers/health-probe/). -Reconcile DNS запускается cron-задачей `health-check` после ingest KV. +**Globalping:** `POST /v1/measurements` → poll `GET` каждые ≥ 500 мс. +CFDM TCP → `type: ping` + `protocol: TCP`; HTTP → `type: http`, `target` = IP, `request.host` = hostname. +Токен: [dash.globalping.io/tokens](https://dash.globalping.io/tokens). Без токена 250 tests/hour, с токеном 500 + [credits](https://globalping.io/credits). +Локации (magic CSV, default `World`) и `limit` (1–10, default 3) — **Настройки → Health-check**. +Один measurement на уникальный origin (IP/порт/path) за тик. + +Reconcile DNS запускается cron-задачей `health-check` после ingest KV и агрегации. ## Docker diff --git a/packages/db/dist/index.d.ts b/packages/db/dist/index.d.ts index 23de8d5..fd8d9d9 100644 --- a/packages/db/dist/index.d.ts +++ b/packages/db/dist/index.d.ts @@ -1,7 +1,7 @@ import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core'; import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; -import { AuditSourceApp, AuditSeverity, AuditTargetType, AuditLogEntry, HealthWorkerStatus, LbMode, HealthCheckType, HealthCheckProvider, IpHealthState, HealthCheckScope, ServiceBinding, Domain, Group, OriginHealthCheck, ServiceNode, Service, ServiceGroup, Subdomain, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared'; +import { AuditSourceApp, AuditSeverity, AuditTargetType, AuditLogEntry, HealthWorkerStatus, LbMode, HealthCheckType, HealthCheckProvider, HealthCheckAggregate, IpHealthState, HealthStatusProvider, HealthCheckScope, ServiceBinding, Domain, Group, OriginHealthCheck, ServiceNode, Service, ServiceGroup, Subdomain, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared'; declare const groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: "groups"; @@ -599,6 +599,44 @@ declare const serviceGroups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ }, {}, { length: number | undefined; }>; + health_check_providers: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_providers"; + tableName: "service_groups"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_aggregate: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_aggregate"; + tableName: "service_groups"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "service_groups"; @@ -1537,6 +1575,44 @@ declare const serviceBindings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ }, {}, { length: number | undefined; }>; + health_check_providers: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_providers"; + tableName: "service_bindings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_aggregate: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_aggregate"; + tableName: "service_bindings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; routing_strategy: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "routing_strategy"; tableName: "service_bindings"; @@ -3460,6 +3536,61 @@ declare const appSettings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ }, {}, { length: number | undefined; }>; + globalping_token: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "globalping_token"; + tableName: "app_settings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + globalping_locations: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "globalping_locations"; + tableName: "app_settings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + globalping_limit: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "globalping_limit"; + tableName: "app_settings"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "app_settings"; @@ -5151,6 +5282,44 @@ declare const schema: { }, {}, { length: number | undefined; }>; + health_check_providers: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_providers"; + tableName: "service_groups"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_aggregate: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_aggregate"; + tableName: "service_groups"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "service_groups"; @@ -6089,6 +6258,44 @@ declare const schema: { }, {}, { length: number | undefined; }>; + health_check_providers: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_providers"; + tableName: "service_bindings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_aggregate: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_aggregate"; + tableName: "service_bindings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; routing_strategy: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "routing_strategy"; tableName: "service_bindings"; @@ -8012,6 +8219,61 @@ declare const schema: { }, {}, { length: number | undefined; }>; + globalping_token: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "globalping_token"; + tableName: "app_settings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + globalping_locations: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "globalping_locations"; + tableName: "app_settings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + globalping_limit: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "globalping_limit"; + tableName: "app_settings"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "app_settings"; @@ -9176,6 +9438,9 @@ type AppSettingsDto = { healthWorkerError: string | null; healthWorkerDeployedAt: string | null; healthWorkerLastIngestAt: string | null; + globalpingTokenSet: boolean; + globalpingLocations: string; + globalpingLimit: number; } & HealthEngineSettings; type AppSettingsPatch = { vpsTrackerUrl?: string; @@ -9194,6 +9459,9 @@ type AppSettingsPatch = { healthWorkerError?: string | null; healthWorkerDeployedAt?: string | null; healthWorkerLastIngestAt?: string | null; + globalpingToken?: string; + globalpingLocations?: string; + globalpingLimit?: number; }; type HealthEngineFallbacks = HealthEngineSettings & { healthWorkerUrl: string; @@ -9206,6 +9474,9 @@ declare function getAppSettingsSecrets(db: Db): { vpsTrackerSyncEnabled: boolean; healthWorkerUrl: string; healthWorkerToken: string; + globalpingToken: string; + globalpingLocations: string; + globalpingLimit: number; }; declare function updateAppSettings(db: Db, patch: AppSettingsPatch, fallbacks?: HealthEngineFallbacks): AppSettingsDto; declare function touchVpsTrackerSync(db: Db): void; @@ -9294,6 +9565,8 @@ interface ServiceGroupLbPatch { health_check_timeout_ms?: number; health_check_verify_tls?: boolean; health_check_provider?: HealthCheckProvider; + health_check_providers?: HealthCheckProvider[]; + health_check_aggregate?: HealthCheckAggregate; } declare function createServiceGroup(db: Db, name: string, groupType: string, icon: string | null, domain: string | null, lbPatch?: ServiceGroupLbPatch): ServiceGroup; declare function updateServiceGroup(db: Db, id: number, name: string, groupType: string, icon: string | null, domain: string | null, lbPatch?: ServiceGroupLbPatch): ServiceGroup; @@ -9402,6 +9675,8 @@ interface BindingLbPatch { health_check_timeout_ms?: number; health_check_verify_tls?: boolean; health_check_provider?: HealthCheckProvider; + health_check_providers?: HealthCheckProvider[]; + health_check_aggregate?: HealthCheckAggregate; } declare function updateBindingLbConfig(db: Db, bindingId: number, patch: BindingLbPatch): void; declare function setBindingCnameTarget(db: Db, bindingId: number, target: string | null): void; @@ -9453,7 +9728,7 @@ type ServiceIpHealthRow = { latency_ms: number | null; last_checked_at: string | null; last_error: string | null; - provider: HealthCheckProvider; + provider: HealthStatusProvider; colo: string | null; }; /** Per-IP binding-scope health, worst status if the same IP is on several bindings. */ @@ -9462,7 +9737,7 @@ declare function mergeHealthAggregates(parts: Array upsertIpHealthStatus, upsertSubdomain: () => upsertSubdomain }); -import { dnsRecordNamesMatch, isIpLiteral } from "@cfdm/shared"; +import { + derivePrimaryProvider, + dnsRecordNamesMatch, + isIpLiteral, + parseHealthAggregate, + parseHealthProviders, + serializeHealthProviders, + normalizeStatusProvider +} from "@cfdm/shared"; import { and as and2, asc, count, eq as eq3, isNull, like, notInArray, or as or2, sql as sql2 } from "drizzle-orm"; function listGroups(db) { return db.select().from(groups).orderBy(asc(groups.name)).all(); @@ -1158,8 +1183,58 @@ function deleteService(db, id) { const result = db.delete(services).where(eq3(services.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`service ${id}`); } -function normalizeHealthProvider(value) { - return value === "cloudflare" ? "cloudflare" : "local"; +function healthProviderColumns(patch) { + const out = {}; + if (patch.health_check_providers !== void 0) { + const list = parseHealthProviders(patch.health_check_providers); + out.health_check_providers = serializeHealthProviders(list); + out.health_check_provider = derivePrimaryProvider(list); + } else if (patch.health_check_provider !== void 0) { + const list = parseHealthProviders(null, patch.health_check_provider); + out.health_check_providers = serializeHealthProviders(list); + out.health_check_provider = derivePrimaryProvider(list); + } + if (patch.health_check_aggregate !== void 0) { + out.health_check_aggregate = parseHealthAggregate( + patch.health_check_aggregate + ); + } + return out; +} +function mapHealthFields(row) { + const providers = parseHealthProviders( + row.health_check_providers, + row.health_check_provider + ); + return { + health_check_providers: providers, + health_check_provider: derivePrimaryProvider(providers), + health_check_aggregate: parseHealthAggregate(row.health_check_aggregate) + }; +} +function mapServiceBinding(row) { + return { + id: row.id, + domain_id: row.domain_id, + service_id: row.service_id, + hostname: row.hostname, + cname_target: row.cname_target, + dns_record_id: row.dns_record_id, + lb_mode: row.lb_mode, + health_check_enabled: row.health_check_enabled, + health_check_type: row.health_check_type, + health_check_port: row.health_check_port, + health_check_path: row.health_check_path, + health_check_expected_status: row.health_check_expected_status, + health_check_interval_sec: row.health_check_interval_sec, + health_check_timeout_ms: row.health_check_timeout_ms, + health_check_verify_tls: row.health_check_verify_tls, + ...mapHealthFields(row), + routing_strategy: row.routing_strategy, + operation_version: row.operation_version, + created_at: row.created_at, + updated_at: row.updated_at + }; } function mapServiceGroup(row) { return { @@ -1178,7 +1253,7 @@ function mapServiceGroup(row) { health_check_interval_sec: row.health_check_interval_sec, health_check_timeout_ms: row.health_check_timeout_ms, health_check_verify_tls: row.health_check_verify_tls, - health_check_provider: normalizeHealthProvider(row.health_check_provider), + ...mapHealthFields(row), created_at: row.created_at, updated_at: row.updated_at }; @@ -1206,7 +1281,11 @@ function createServiceGroup(db, name, groupType, icon, domain, lbPatch) { health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30, health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3e3, health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false, - health_check_provider: lbPatch?.health_check_provider ?? "local" + ...healthProviderColumns({ + health_check_provider: lbPatch?.health_check_provider ?? "local", + health_check_providers: lbPatch?.health_check_providers, + health_check_aggregate: lbPatch?.health_check_aggregate ?? "majority" + }) }).returning({ id: serviceGroups.id }).get().id; return getServiceGroup(db, id); } @@ -1236,8 +1315,7 @@ function updateServiceGroup(db, id, name, groupType, icon, domain, lbPatch) { update.health_check_timeout_ms = lbPatch.health_check_timeout_ms; if (lbPatch.health_check_verify_tls !== void 0) update.health_check_verify_tls = lbPatch.health_check_verify_tls; - if (lbPatch.health_check_provider !== void 0) - update.health_check_provider = lbPatch.health_check_provider; + Object.assign(update, healthProviderColumns(lbPatch)); } const result = db.update(serviceGroups).set(update).where(eq3(serviceGroups.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`service group ${id}`); @@ -1547,8 +1625,7 @@ function updateBindingLbConfig(db, bindingId, patch) { update.health_check_timeout_ms = patch.health_check_timeout_ms; if (patch.health_check_verify_tls !== void 0) update.health_check_verify_tls = patch.health_check_verify_tls; - if (patch.health_check_provider !== void 0) - update.health_check_provider = patch.health_check_provider; + Object.assign(update, healthProviderColumns(patch)); db.update(serviceBindings).set(update).where(eq3(serviceBindings.id, bindingId)).run(); } function setBindingCnameTarget(db, bindingId, target) { @@ -1607,7 +1684,8 @@ function dnsRecordMatchesHostname(recordName, hostname, zoneName) { var SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port, sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec, - sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider, sb.cname_target, + sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider, + sb.health_check_providers, sb.health_check_aggregate, sb.cname_target, d.zone_name, d.group_id, g.name AS group_name, s.name AS service_name, s.slug AS service_slug, dr.content AS target_ip, dr.sync_status, @@ -1655,6 +1733,7 @@ function enrichServiceBindingView(db, row) { return { ...row, cname_target: row.cname_target ?? null, + ...mapHealthFields(row), target_ips, target_ip: target_ips[0] ?? null, target_ip_weights, @@ -1695,7 +1774,7 @@ function listBindingsByService(db, serviceId) { function getBinding(db, id) { const row = db.select().from(serviceBindings).where(eq3(serviceBindings.id, id)).get(); if (!row) throw new NotFoundError(`service binding ${id}`); - return row; + return mapServiceBinding(row); } function getBindingView(db, id) { const rows = db.all(sql2` @@ -1718,7 +1797,8 @@ function findBinding(db, serviceId, domainId, hostname) { eq3(serviceBindings.hostname, hostname) ) ).get(); - return row ?? null; + if (!row) return null; + return mapServiceBinding(row); } function insertBinding(db, domainId, serviceId, hostname, dnsRecordId) { const id = db.insert(serviceBindings).values({ @@ -1744,7 +1824,7 @@ function setBindingDnsRecordId(db, bindingId, dnsRecordId) { }).where(eq3(serviceBindings.id, bindingId)).run(); } function bindingsToRemove(db, serviceId, keepIds) { - const all = db.select().from(serviceBindings).where(eq3(serviceBindings.service_id, serviceId)).all(); + const all = db.select().from(serviceBindings).where(eq3(serviceBindings.service_id, serviceId)).all().map(mapServiceBinding); return all.filter((b) => !keepIds.includes(b.id)); } function deleteBindingsExcept(db, serviceId, keepIds) { @@ -1975,7 +2055,7 @@ function listIpHealthByServiceIds(db, serviceIds) { latency_ms: parsed.health_latency_ms, last_checked_at: row.last_checked_at, last_error: row.last_error, - provider: normalizeHealthProvider(row.provider), + provider: normalizeStatusProvider(row.provider), colo: row.colo }); result.set(row.service_id, list); @@ -2101,6 +2181,8 @@ function listHealthCheckTargets(db) { sb.health_check_expected_status AS expected_status, sb.health_check_timeout_ms AS timeout_ms, sb.health_check_verify_tls AS verify_tls, + sb.health_check_providers AS providers_json, + COALESCE(sb.health_check_aggregate, 'majority') AS aggregate, COALESCE(sb.health_check_provider, 'local') AS provider FROM service_binding_ips sbi JOIN service_bindings sb ON sb.id = sbi.binding_id @@ -2116,6 +2198,8 @@ function listHealthCheckTargets(db) { sg.health_check_expected_status AS expected_status, sg.health_check_timeout_ms AS timeout_ms, sg.health_check_verify_tls AS verify_tls, + sg.health_check_providers AS providers_json, + COALESCE(sg.health_check_aggregate, 'majority') AS aggregate, COALESCE(sg.health_check_provider, 'local') AS provider FROM service_binding_ips sbi JOIN service_bindings sb ON sb.id = sbi.binding_id @@ -2137,6 +2221,8 @@ function listHealthCheckTargets(db) { sg.health_check_expected_status AS expected_status, sg.health_check_timeout_ms AS timeout_ms, sg.health_check_verify_tls AS verify_tls, + sg.health_check_providers AS providers_json, + COALESCE(sg.health_check_aggregate, 'majority') AS aggregate, COALESCE(sg.health_check_provider, 'local') AS provider FROM service_binding_ips sbi JOIN service_bindings sb ON sb.id = sbi.binding_id @@ -2158,6 +2244,8 @@ function listHealthCheckTargets(db) { sb.health_check_expected_status AS expected_status, sb.health_check_timeout_ms AS timeout_ms, sb.health_check_verify_tls AS verify_tls, + sb.health_check_providers AS providers_json, + COALESCE(sb.health_check_aggregate, 'majority') AS aggregate, COALESCE(sb.health_check_provider, 'local') AS provider FROM service_bindings sb JOIN domains d ON d.id = sb.domain_id @@ -2176,6 +2264,8 @@ function listHealthCheckTargets(db) { sg.health_check_expected_status AS expected_status, sg.health_check_timeout_ms AS timeout_ms, sg.health_check_verify_tls AS verify_tls, + sg.health_check_providers AS providers_json, + COALESCE(sg.health_check_aggregate, 'majority') AS aggregate, COALESCE(sg.health_check_provider, 'local') AS provider FROM service_bindings sb JOIN domains d ON d.id = sb.domain_id @@ -2195,11 +2285,25 @@ function listHealthCheckTargets(db) { ...groupInheritedBindingTargets, ...cnameBindingTargets, ...groupInheritedCnameBindingTargets - ].map((t) => ({ - ...t, - verify_tls: Boolean(t.verify_tls), - provider: normalizeHealthProvider(t.provider) - })); + ].map((t) => { + const row = t; + const providers = parseHealthProviders(row.providers_json, row.provider); + return { + scope: row.scope, + ref_id: row.ref_id, + ip: row.ip, + hostname: row.hostname, + type: row.type, + port: row.port, + path: row.path, + expected_status: row.expected_status, + timeout_ms: row.timeout_ms, + verify_tls: Boolean(row.verify_tls), + providers, + aggregate: parseHealthAggregate(row.aggregate), + provider: derivePrimaryProvider(providers) + }; + }); } function listDomainTags(db, domainId) { return db.select({ tag: domainTags.tag }).from(domainTags).where(eq3(domainTags.domain_id, domainId)).all().map((r) => r.tag); @@ -2329,7 +2433,7 @@ function listHealthProbeLogForService(db, serviceId, limit = 50) { `); return rows.map((row) => ({ ...row, - provider: normalizeHealthProvider(row.provider), + provider: parseHealthProviders(null, row.provider)[0] ?? "local", ok: Boolean(row.ok) })); } diff --git a/packages/db/migrations/023_health_providers.sql b/packages/db/migrations/023_health_providers.sql new file mode 100644 index 0000000..7e7a71b --- /dev/null +++ b/packages/db/migrations/023_health_providers.sql @@ -0,0 +1,21 @@ +ALTER TABLE service_bindings ADD COLUMN health_check_providers TEXT; +ALTER TABLE service_bindings ADD COLUMN health_check_aggregate TEXT NOT NULL DEFAULT 'majority'; +UPDATE service_bindings +SET health_check_providers = CASE + WHEN health_check_provider = 'cloudflare' THEN '["cloudflare"]' + ELSE '["local"]' +END +WHERE health_check_providers IS NULL; + +ALTER TABLE service_groups ADD COLUMN health_check_providers TEXT; +ALTER TABLE service_groups ADD COLUMN health_check_aggregate TEXT NOT NULL DEFAULT 'majority'; +UPDATE service_groups +SET health_check_providers = CASE + WHEN health_check_provider = 'cloudflare' THEN '["cloudflare"]' + ELSE '["local"]' +END +WHERE health_check_providers IS NULL; + +ALTER TABLE app_settings ADD COLUMN globalping_token TEXT; +ALTER TABLE app_settings ADD COLUMN globalping_locations TEXT; +ALTER TABLE app_settings ADD COLUMN globalping_limit INTEGER; diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index 7eaee10..b5f84d5 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -5,10 +5,12 @@ import type { DomainListItem, Group, GroupWithStats, + HealthCheckAggregate, HealthCheckProvider, HealthCheckScope, HealthCheckTarget, HealthCheckType, + HealthStatusProvider, IpHealthState, IpHealthStatus, LbMode, @@ -21,7 +23,15 @@ import type { Subdomain, SyncJob, } from "@cfdm/shared"; -import { dnsRecordNamesMatch, isIpLiteral } from "@cfdm/shared"; +import { + derivePrimaryProvider, + dnsRecordNamesMatch, + isIpLiteral, + parseHealthAggregate, + parseHealthProviders, + serializeHealthProviders, + normalizeStatusProvider, +} from "@cfdm/shared"; import { and, asc, count, eq, isNull, like, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "./client.js"; import { ConflictError, NotFoundError } from "./errors.js"; @@ -770,8 +780,82 @@ export function deleteService(db: Db, id: number): void { // --- Service Groups --- -function normalizeHealthProvider(value: unknown): HealthCheckProvider { - return value === "cloudflare" ? "cloudflare" : "local"; +function healthProviderColumns(patch: { + health_check_provider?: HealthCheckProvider; + health_check_providers?: HealthCheckProvider[]; + health_check_aggregate?: HealthCheckAggregate; +}): { + health_check_provider?: string; + health_check_providers?: string; + health_check_aggregate?: string; +} { + const out: { + health_check_provider?: string; + health_check_providers?: string; + health_check_aggregate?: string; + } = {}; + if (patch.health_check_providers !== undefined) { + const list = parseHealthProviders(patch.health_check_providers); + out.health_check_providers = serializeHealthProviders(list); + out.health_check_provider = derivePrimaryProvider(list); + } else if (patch.health_check_provider !== undefined) { + const list = parseHealthProviders(null, patch.health_check_provider); + out.health_check_providers = serializeHealthProviders(list); + out.health_check_provider = derivePrimaryProvider(list); + } + if (patch.health_check_aggregate !== undefined) { + out.health_check_aggregate = parseHealthAggregate( + patch.health_check_aggregate, + ); + } + return out; +} + +function mapHealthFields(row: { + health_check_provider?: unknown; + health_check_providers?: unknown; + health_check_aggregate?: unknown; +}): { + health_check_provider: HealthCheckProvider; + health_check_providers: HealthCheckProvider[]; + health_check_aggregate: HealthCheckAggregate; +} { + const providers = parseHealthProviders( + row.health_check_providers, + row.health_check_provider, + ); + return { + health_check_providers: providers, + health_check_provider: derivePrimaryProvider(providers), + health_check_aggregate: parseHealthAggregate(row.health_check_aggregate), + }; +} + +function mapServiceBinding( + row: typeof serviceBindings.$inferSelect, +): ServiceBinding { + return { + id: row.id, + domain_id: row.domain_id, + service_id: row.service_id, + hostname: row.hostname, + cname_target: row.cname_target, + dns_record_id: row.dns_record_id, + lb_mode: row.lb_mode as LbMode, + health_check_enabled: row.health_check_enabled, + health_check_type: row.health_check_type as HealthCheckType, + health_check_port: row.health_check_port, + health_check_path: row.health_check_path, + health_check_expected_status: row.health_check_expected_status, + health_check_interval_sec: row.health_check_interval_sec, + health_check_timeout_ms: row.health_check_timeout_ms, + health_check_verify_tls: row.health_check_verify_tls, + ...mapHealthFields(row), + routing_strategy: row.routing_strategy as LbMode, + operation_version: row.operation_version, + created_at: row.created_at, + updated_at: row.updated_at, + }; } function mapServiceGroup(row: typeof serviceGroups.$inferSelect): ServiceGroup { @@ -791,7 +875,7 @@ function mapServiceGroup(row: typeof serviceGroups.$inferSelect): ServiceGroup { health_check_interval_sec: row.health_check_interval_sec, health_check_timeout_ms: row.health_check_timeout_ms, health_check_verify_tls: row.health_check_verify_tls, - health_check_provider: normalizeHealthProvider(row.health_check_provider), + ...mapHealthFields(row), created_at: row.created_at, updated_at: row.updated_at, }; @@ -827,6 +911,8 @@ export interface ServiceGroupLbPatch { health_check_timeout_ms?: number; health_check_verify_tls?: boolean; health_check_provider?: HealthCheckProvider; + health_check_providers?: HealthCheckProvider[]; + health_check_aggregate?: HealthCheckAggregate; } export function createServiceGroup( @@ -853,7 +939,11 @@ export function createServiceGroup( health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30, health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3000, health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false, - health_check_provider: lbPatch?.health_check_provider ?? "local", + ...healthProviderColumns({ + health_check_provider: lbPatch?.health_check_provider ?? "local", + health_check_providers: lbPatch?.health_check_providers, + health_check_aggregate: lbPatch?.health_check_aggregate ?? "majority", + }), }) .returning({ id: serviceGroups.id }) .get()!.id; @@ -894,8 +984,7 @@ export function updateServiceGroup( update.health_check_timeout_ms = lbPatch.health_check_timeout_ms; if (lbPatch.health_check_verify_tls !== undefined) update.health_check_verify_tls = lbPatch.health_check_verify_tls; - if (lbPatch.health_check_provider !== undefined) - update.health_check_provider = lbPatch.health_check_provider; + Object.assign(update, healthProviderColumns(lbPatch)); } const result = db .update(serviceGroups) @@ -1439,6 +1528,8 @@ export interface BindingLbPatch { health_check_timeout_ms?: number; health_check_verify_tls?: boolean; health_check_provider?: HealthCheckProvider; + health_check_providers?: HealthCheckProvider[]; + health_check_aggregate?: HealthCheckAggregate; } export function updateBindingLbConfig( @@ -1469,8 +1560,7 @@ export function updateBindingLbConfig( update.health_check_timeout_ms = patch.health_check_timeout_ms; if (patch.health_check_verify_tls !== undefined) update.health_check_verify_tls = patch.health_check_verify_tls; - if (patch.health_check_provider !== undefined) - update.health_check_provider = patch.health_check_provider; + Object.assign(update, healthProviderColumns(patch)); db.update(serviceBindings) .set(update) .where(eq(serviceBindings.id, bindingId)) @@ -1578,7 +1668,8 @@ function dnsRecordMatchesHostname( const SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port, sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec, - sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider, sb.cname_target, + sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider, + sb.health_check_providers, sb.health_check_aggregate, sb.cname_target, d.zone_name, d.group_id, g.name AS group_name, s.name AS service_name, s.slug AS service_slug, dr.content AS target_ip, dr.sync_status, @@ -1642,6 +1733,7 @@ function enrichServiceBindingView( return { ...row, cname_target: row.cname_target ?? null, + ...mapHealthFields(row), target_ips, target_ip: target_ips[0] ?? null, target_ip_weights, @@ -1694,7 +1786,7 @@ export function getBinding(db: Db, id: number): ServiceBinding { .where(eq(serviceBindings.id, id)) .get(); if (!row) throw new NotFoundError(`service binding ${id}`); - return row as ServiceBinding; + return mapServiceBinding(row); } export function getBindingView(db: Db, id: number): ServiceBindingView { @@ -1728,7 +1820,8 @@ export function findBinding( ), ) .get(); - return (row as ServiceBinding) ?? null; + if (!row) return null; + return mapServiceBinding(row); } export function insertBinding( @@ -1792,7 +1885,8 @@ export function bindingsToRemove( .select() .from(serviceBindings) .where(eq(serviceBindings.service_id, serviceId)) - .all() as ServiceBinding[]; + .all() + .map(mapServiceBinding); return all.filter((b) => !keepIds.includes(b.id)); } @@ -2124,7 +2218,7 @@ export type ServiceIpHealthRow = { latency_ms: number | null; last_checked_at: string | null; last_error: string | null; - provider: HealthCheckProvider; + provider: HealthStatusProvider; colo: string | null; }; @@ -2175,7 +2269,7 @@ export function listIpHealthByServiceIds( latency_ms: parsed.health_latency_ms, last_checked_at: row.last_checked_at, last_error: row.last_error, - provider: normalizeHealthProvider(row.provider), + provider: normalizeStatusProvider(row.provider), colo: row.colo, }); result.set(row.service_id, list); @@ -2243,7 +2337,7 @@ export function upsertIpHealthStatus( consecutiveFailures: number, lastError: string | null, consecutiveSuccesses = 0, - extras?: { colo?: string | null; provider?: HealthCheckProvider }, + extras?: { colo?: string | null; provider?: HealthStatusProvider }, ): void { const colo = extras?.colo ?? null; const provider = extras?.provider ?? "local"; @@ -2359,6 +2453,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] { sb.health_check_expected_status AS expected_status, sb.health_check_timeout_ms AS timeout_ms, sb.health_check_verify_tls AS verify_tls, + sb.health_check_providers AS providers_json, + COALESCE(sb.health_check_aggregate, 'majority') AS aggregate, COALESCE(sb.health_check_provider, 'local') AS provider FROM service_binding_ips sbi JOIN service_bindings sb ON sb.id = sbi.binding_id @@ -2378,6 +2474,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] { sg.health_check_expected_status AS expected_status, sg.health_check_timeout_ms AS timeout_ms, sg.health_check_verify_tls AS verify_tls, + sg.health_check_providers AS providers_json, + COALESCE(sg.health_check_aggregate, 'majority') AS aggregate, COALESCE(sg.health_check_provider, 'local') AS provider FROM service_binding_ips sbi JOIN service_bindings sb ON sb.id = sbi.binding_id @@ -2404,6 +2502,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] { sg.health_check_expected_status AS expected_status, sg.health_check_timeout_ms AS timeout_ms, sg.health_check_verify_tls AS verify_tls, + sg.health_check_providers AS providers_json, + COALESCE(sg.health_check_aggregate, 'majority') AS aggregate, COALESCE(sg.health_check_provider, 'local') AS provider FROM service_binding_ips sbi JOIN service_bindings sb ON sb.id = sbi.binding_id @@ -2427,6 +2527,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] { sb.health_check_expected_status AS expected_status, sb.health_check_timeout_ms AS timeout_ms, sb.health_check_verify_tls AS verify_tls, + sb.health_check_providers AS providers_json, + COALESCE(sb.health_check_aggregate, 'majority') AS aggregate, COALESCE(sb.health_check_provider, 'local') AS provider FROM service_bindings sb JOIN domains d ON d.id = sb.domain_id @@ -2448,6 +2550,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] { sg.health_check_expected_status AS expected_status, sg.health_check_timeout_ms AS timeout_ms, sg.health_check_verify_tls AS verify_tls, + sg.health_check_providers AS providers_json, + COALESCE(sg.health_check_aggregate, 'majority') AS aggregate, COALESCE(sg.health_check_provider, 'local') AS provider FROM service_bindings sb JOIN domains d ON d.id = sb.domain_id @@ -2468,11 +2572,28 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] { ...groupInheritedBindingTargets, ...cnameBindingTargets, ...groupInheritedCnameBindingTargets, - ].map((t) => ({ - ...t, - verify_tls: Boolean(t.verify_tls), - provider: normalizeHealthProvider(t.provider), - })); + ].map((t) => { + const row = t as HealthCheckTarget & { + providers_json?: string | null; + aggregate?: string | null; + }; + const providers = parseHealthProviders(row.providers_json, row.provider); + return { + scope: row.scope, + ref_id: row.ref_id, + ip: row.ip, + hostname: row.hostname, + type: row.type, + port: row.port, + path: row.path, + expected_status: row.expected_status, + timeout_ms: row.timeout_ms, + verify_tls: Boolean(row.verify_tls), + providers, + aggregate: parseHealthAggregate(row.aggregate), + provider: derivePrimaryProvider(providers), + }; + }); } // --- Domain tags --- @@ -2759,7 +2880,7 @@ export function listHealthProbeLogForService( `); return rows.map((row) => ({ ...row, - provider: normalizeHealthProvider(row.provider), + provider: parseHealthProviders(null, row.provider)[0] ?? "local", ok: Boolean(row.ok), })); } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 6d45bdc..8399f1c 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -65,6 +65,8 @@ export const serviceGroups = sqliteTable("service_groups", { .notNull() .default(false), health_check_provider: text("health_check_provider").notNull().default("local"), + health_check_providers: text("health_check_providers").notNull().default('["local"]'), + health_check_aggregate: text("health_check_aggregate").notNull().default("majority"), created_at: text("created_at") .notNull() .default(sql`datetime('now')`), @@ -169,6 +171,12 @@ export const serviceBindings = sqliteTable( health_check_provider: text("health_check_provider") .notNull() .default("local"), + health_check_providers: text("health_check_providers") + .notNull() + .default('["local"]'), + health_check_aggregate: text("health_check_aggregate") + .notNull() + .default("majority"), routing_strategy: text("routing_strategy").notNull().default("round_robin"), operation_version: integer("operation_version").notNull().default(0), created_at: text("created_at") @@ -398,6 +406,9 @@ export const appSettings = sqliteTable("app_settings", { health_worker_error: text("health_worker_error"), health_worker_deployed_at: text("health_worker_deployed_at"), health_worker_last_ingest_at: text("health_worker_last_ingest_at"), + globalping_token: text("globalping_token"), + globalping_locations: text("globalping_locations"), + globalping_limit: integer("globalping_limit"), created_at: text("created_at") .notNull() .default(sql`datetime('now')`), diff --git a/packages/db/src/settings-repo.ts b/packages/db/src/settings-repo.ts index f87f102..c199df1 100644 --- a/packages/db/src/settings-repo.ts +++ b/packages/db/src/settings-repo.ts @@ -28,6 +28,9 @@ export type AppSettingsDto = { healthWorkerError: string | null; healthWorkerDeployedAt: string | null; healthWorkerLastIngestAt: string | null; + globalpingTokenSet: boolean; + globalpingLocations: string; + globalpingLimit: number; } & HealthEngineSettings; export type AppSettingsPatch = { @@ -47,6 +50,9 @@ export type AppSettingsPatch = { healthWorkerError?: string | null; healthWorkerDeployedAt?: string | null; healthWorkerLastIngestAt?: string | null; + globalpingToken?: string; + globalpingLocations?: string; + globalpingLimit?: number; }; export type HealthEngineFallbacks = HealthEngineSettings & { @@ -118,6 +124,14 @@ function toDto( healthWorkerDeployedAt: row.health_worker_deployed_at ?? null, healthWorkerLastIngestAt: row.health_worker_last_ingest_at ?? null, healthWorkerStatus: workerStatus(row, env.healthWorkerUrl), + globalpingTokenSet: Boolean(row.globalping_token?.trim()), + globalpingLocations: row.globalping_locations?.trim() || "World", + globalpingLimit: + row.globalping_limit == null || + Number.isNaN(row.globalping_limit) || + row.globalping_limit < 1 + ? 3 + : Math.min(10, row.globalping_limit), }; } @@ -146,12 +160,16 @@ export function getAppSettingsSecrets(db: Db): { vpsTrackerSyncEnabled: boolean; healthWorkerUrl: string; healthWorkerToken: string; + globalpingToken: string; + globalpingLocations: string; + globalpingLimit: number; } { const row = db .select() .from(appSettings) .where(eq(appSettings.id, SETTINGS_ID)) .get(); + const limit = row?.globalping_limit; return { vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "", vpsTrackerIntegrationToken: @@ -159,6 +177,12 @@ export function getAppSettingsSecrets(db: Db): { vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled), healthWorkerUrl: row?.health_worker_url?.trim() ?? "", healthWorkerToken: row?.health_worker_token?.trim() ?? "", + globalpingToken: row?.globalping_token?.trim() ?? "", + globalpingLocations: row?.globalping_locations?.trim() || "World", + globalpingLimit: + limit == null || Number.isNaN(limit) || limit < 1 + ? 3 + : Math.min(10, limit), }; } @@ -249,6 +273,19 @@ export function updateAppSettings( patch.healthWorkerLastIngestAt !== undefined ? patch.healthWorkerLastIngestAt : current.health_worker_last_ingest_at, + globalping_token: + patch.globalpingToken !== undefined && + patch.globalpingToken.trim() !== "" + ? patch.globalpingToken + : current.globalping_token, + globalping_locations: + patch.globalpingLocations !== undefined + ? patch.globalpingLocations.trim() || "World" + : current.globalping_locations, + globalping_limit: + patch.globalpingLimit !== undefined + ? Math.min(10, Math.max(1, patch.globalpingLimit)) + : current.globalping_limit, updated_at: new Date().toISOString(), }) .where(eq(appSettings.id, SETTINGS_ID)) diff --git a/packages/shared/dist/index.d.ts b/packages/shared/dist/index.d.ts index 9864bce..d3097d4 100644 --- a/packages/shared/dist/index.d.ts +++ b/packages/shared/dist/index.d.ts @@ -15,6 +15,36 @@ declare const CERT_MONITOR_REQUIRED = "required"; declare const CERT_MONITOR_SKIPPED = "skipped"; declare const CERT_MONITORING_VALUES: readonly ["auto", "required", "skipped"]; +declare const HEALTH_CHECK_PROVIDERS: readonly ["local", "cloudflare", "globalping"]; +type HealthCheckProvider = (typeof HEALTH_CHECK_PROVIDERS)[number]; +declare const HEALTH_STATUS_PROVIDERS: readonly ["local", "cloudflare", "globalping", "aggregate"]; +type HealthStatusProvider = (typeof HEALTH_STATUS_PROVIDERS)[number]; +declare const HEALTH_CHECK_AGGREGATES: readonly ["any", "all", "majority"]; +type HealthCheckAggregate = (typeof HEALTH_CHECK_AGGREGATES)[number]; +declare function normalizeProbeProvider(value: unknown): HealthCheckProvider; +declare function normalizeStatusProvider(value: unknown): HealthStatusProvider; +declare function uniqueHealthProviders(values: readonly unknown[]): HealthCheckProvider[]; +declare function parseHealthProviders(json: unknown, fallback?: unknown): HealthCheckProvider[]; +declare function serializeHealthProviders(providers: readonly HealthCheckProvider[]): string; +declare function parseHealthAggregate(value: unknown): HealthCheckAggregate; +declare function derivePrimaryProvider(providers: readonly HealthCheckProvider[]): HealthCheckProvider; +declare function targetProviders(target: { + providers?: readonly HealthCheckProvider[] | null; + provider?: HealthCheckProvider | null; +}): HealthCheckProvider[]; +declare function targetHasProvider(target: { + providers?: readonly HealthCheckProvider[] | null; + provider?: HealthCheckProvider | null; +}, provider: HealthCheckProvider): boolean; +/** + * any — Down if at least one source is Down (ok only if all ok). + * all — Down only if every source is Down (ok if any ok). + * majority — Down if a strict majority of sources are Down (2 → both, 3 → ≥2). + */ +declare function aggregateHealthOk(oks: readonly boolean[], policy: HealthCheckAggregate): boolean; +declare function clampGlobalpingLimit(value: unknown, fallback?: number): number; +declare function parseGlobalpingLocations(value: unknown): string[]; + interface ServiceGroup$1 { id: number; name: string; @@ -32,6 +62,8 @@ interface ServiceGroup$1 { health_check_timeout_ms: number; health_check_verify_tls: boolean; health_check_provider: HealthCheckProvider; + health_check_providers: HealthCheckProvider[]; + health_check_aggregate: HealthCheckAggregate; created_at: string; updated_at: string; } @@ -62,6 +94,8 @@ interface ServiceBinding { health_check_timeout_ms: number; health_check_verify_tls: boolean; health_check_provider: HealthCheckProvider; + health_check_providers: HealthCheckProvider[]; + health_check_aggregate: HealthCheckAggregate; routing_strategy: LbMode; operation_version: number; created_at: string; @@ -93,6 +127,8 @@ interface ServiceBindingView { health_check_timeout_ms: number; health_check_verify_tls: boolean; health_check_provider: HealthCheckProvider; + health_check_providers: HealthCheckProvider[]; + health_check_aggregate: HealthCheckAggregate; sync_status: string | null; created_at: string; updated_at: string; @@ -118,6 +154,8 @@ interface ServiceDomainBindingView { health_check_timeout_ms: number; health_check_verify_tls: boolean; health_check_provider: HealthCheckProvider; + health_check_providers: HealthCheckProvider[]; + health_check_aggregate: HealthCheckAggregate; sync_status: string | null; } interface ServiceView$1 { @@ -189,7 +227,6 @@ type LbMode = "round_robin" | "failover" | "weighted"; type HealthCheckType = "tcp" | "http" | "ping" | "dns"; type IpHealthState = "up" | "down" | "degraded" | "unknown"; type NodeHealthState = "unknown" | "checking" | "healthy" | "degraded" | "unhealthy" | "disabled"; -type HealthCheckProvider = "local" | "cloudflare"; type HealthCheckScope = "binding" | "group"; interface IpHealthStatus { scope: HealthCheckScope; @@ -202,7 +239,7 @@ interface IpHealthStatus { last_checked_at: string | null; last_error: string | null; colo?: string | null; - provider?: HealthCheckProvider; + provider?: HealthStatusProvider; } interface ServiceIpHealth$1 { ip: string; @@ -210,7 +247,7 @@ interface ServiceIpHealth$1 { latency_ms: number | null; last_checked_at?: string | null; last_error?: string | null; - provider?: HealthCheckProvider; + provider?: HealthStatusProvider; colo?: string | null; } interface ServiceNode { @@ -290,6 +327,8 @@ interface HealthCheckTarget { timeout_ms: number; verify_tls: boolean; provider: HealthCheckProvider; + providers?: HealthCheckProvider[]; + aggregate?: HealthCheckAggregate; } declare class ValidationError extends Error { @@ -370,7 +409,24 @@ declare const nodeHealthStateSchema: z.ZodEnum<{ declare const healthCheckProviderSchema: z.ZodEnum<{ local: "local"; cloudflare: "cloudflare"; + globalping: "globalping"; }>; +declare const healthStatusProviderSchema: z.ZodEnum<{ + local: "local"; + cloudflare: "cloudflare"; + globalping: "globalping"; + aggregate: "aggregate"; +}>; +declare const healthCheckAggregateSchema: z.ZodEnum<{ + any: "any"; + all: "all"; + majority: "majority"; +}>; +declare const healthCheckProvidersSchema: z.ZodPipe>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>; declare const healthCheckScopeSchema: z.ZodEnum<{ binding: "binding"; group: "group"; @@ -397,6 +453,8 @@ declare const ipHealthStatusSchema: z.ZodObject<{ provider: z.ZodOptional>; }, z.core.$strip>; declare const serviceIpHealthSchema: z.ZodObject<{ @@ -413,6 +471,8 @@ declare const serviceIpHealthSchema: z.ZodObject<{ provider: z.ZodOptional>; colo: z.ZodOptional>; }, z.core.$strip>; @@ -425,6 +485,7 @@ declare const healthProbeLogSchema: z.ZodObject<{ provider: z.ZodEnum<{ local: "local"; cloudflare: "cloudflare"; + globalping: "globalping"; }>; status: z.ZodEnum<{ unknown: "unknown"; @@ -455,21 +516,21 @@ declare const groupWithStatsSchema: z.ZodObject<{ domain_count: z.ZodNumber; }, z.core.$strip>; declare const serviceGroupTypeSchema: z.ZodEnum<{ + custom: "custom"; vpn: "vpn"; network: "network"; internet: "internet"; bgp: "bgp"; - custom: "custom"; }>; declare const serviceGroupSchema: z.ZodObject<{ id: z.ZodNumber; name: z.ZodString; type: z.ZodCatch>; icon: z.ZodDefault>; domain: z.ZodDefault>; @@ -495,6 +556,17 @@ declare const serviceGroupSchema: z.ZodObject<{ health_check_provider: z.ZodCatch>; + health_check_providers: z.ZodCatch>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>; + health_check_aggregate: z.ZodCatch>; created_at: z.ZodString; updated_at: z.ZodString; @@ -548,6 +620,17 @@ declare const serviceDomainBindingSchema: z.ZodPipe>; + health_check_providers: z.ZodCatch>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>; + health_check_aggregate: z.ZodCatch>; sync_status: z.ZodDefault>; }, z.core.$strip>, z.ZodTransform<{ @@ -570,7 +653,9 @@ declare const serviceDomainBindingSchema: z.ZodPipe>; + health_check_providers: z.ZodCatch>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>; + health_check_aggregate: z.ZodCatch>; sync_status: z.ZodDefault>; }, z.core.$strip>, z.ZodTransform<{ @@ -668,7 +766,9 @@ declare const serviceViewSchema: z.ZodObject<{ health_check_interval_sec: number; health_check_timeout_ms: number; health_check_verify_tls: boolean; - health_check_provider: "local" | "cloudflare"; + health_check_provider: "local" | "cloudflare" | "globalping"; + health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"]; + health_check_aggregate: "any" | "all" | "majority"; sync_status: string | null; target_ip?: string | null | undefined; }, { @@ -687,7 +787,9 @@ declare const serviceViewSchema: z.ZodObject<{ health_check_interval_sec: number; health_check_timeout_ms: number; health_check_verify_tls: boolean; - health_check_provider: "local" | "cloudflare"; + health_check_provider: "local" | "cloudflare" | "globalping"; + health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"]; + health_check_aggregate: "any" | "all" | "majority"; sync_status: string | null; target_ips?: string[] | undefined; target_ip?: string | null | undefined; @@ -716,6 +818,8 @@ declare const serviceViewSchema: z.ZodObject<{ provider: z.ZodOptional>; colo: z.ZodOptional>; }, z.core.$strip>>>; @@ -725,11 +829,11 @@ declare const serviceGroupViewSchema: z.ZodObject<{ id: z.ZodNumber; name: z.ZodString; type: z.ZodCatch>; icon: z.ZodDefault>; domain: z.ZodDefault>; @@ -755,6 +859,17 @@ declare const serviceGroupViewSchema: z.ZodObject<{ health_check_provider: z.ZodCatch>; + health_check_providers: z.ZodCatch>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>; + health_check_aggregate: z.ZodCatch>; created_at: z.ZodString; updated_at: z.ZodString; @@ -807,6 +922,17 @@ declare const serviceGroupViewSchema: z.ZodObject<{ health_check_provider: z.ZodCatch>; + health_check_providers: z.ZodCatch>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>; + health_check_aggregate: z.ZodCatch>; sync_status: z.ZodDefault>; }, z.core.$strip>, z.ZodTransform<{ @@ -829,7 +955,9 @@ declare const serviceGroupViewSchema: z.ZodObject<{ health_check_interval_sec: number; health_check_timeout_ms: number; health_check_verify_tls: boolean; - health_check_provider: "local" | "cloudflare"; + health_check_provider: "local" | "cloudflare" | "globalping"; + health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"]; + health_check_aggregate: "any" | "all" | "majority"; sync_status: string | null; target_ip?: string | null | undefined; }, { @@ -848,7 +976,9 @@ declare const serviceGroupViewSchema: z.ZodObject<{ health_check_interval_sec: number; health_check_timeout_ms: number; health_check_verify_tls: boolean; - health_check_provider: "local" | "cloudflare"; + health_check_provider: "local" | "cloudflare" | "globalping"; + health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"]; + health_check_aggregate: "any" | "all" | "majority"; sync_status: string | null; target_ips?: string[] | undefined; target_ip?: string | null | undefined; @@ -877,6 +1007,8 @@ declare const serviceGroupViewSchema: z.ZodObject<{ provider: z.ZodOptional>; colo: z.ZodOptional>; }, z.core.$strip>>>; @@ -895,11 +1027,11 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ id: z.ZodNumber; name: z.ZodString; type: z.ZodCatch>; icon: z.ZodDefault>; domain: z.ZodDefault>; @@ -925,6 +1057,17 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ health_check_provider: z.ZodCatch>; + health_check_providers: z.ZodCatch>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>; + health_check_aggregate: z.ZodCatch>; created_at: z.ZodString; updated_at: z.ZodString; @@ -977,6 +1120,17 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ health_check_provider: z.ZodCatch>; + health_check_providers: z.ZodCatch>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>; + health_check_aggregate: z.ZodCatch>; sync_status: z.ZodDefault>; }, z.core.$strip>, z.ZodTransform<{ @@ -999,7 +1153,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ health_check_interval_sec: number; health_check_timeout_ms: number; health_check_verify_tls: boolean; - health_check_provider: "local" | "cloudflare"; + health_check_provider: "local" | "cloudflare" | "globalping"; + health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"]; + health_check_aggregate: "any" | "all" | "majority"; sync_status: string | null; target_ip?: string | null | undefined; }, { @@ -1018,7 +1174,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ health_check_interval_sec: number; health_check_timeout_ms: number; health_check_verify_tls: boolean; - health_check_provider: "local" | "cloudflare"; + health_check_provider: "local" | "cloudflare" | "globalping"; + health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"]; + health_check_aggregate: "any" | "all" | "majority"; sync_status: string | null; target_ips?: string[] | undefined; target_ip?: string | null | undefined; @@ -1047,6 +1205,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ provider: z.ZodOptional>; colo: z.ZodOptional>; }, z.core.$strip>>>; @@ -1109,6 +1269,17 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ health_check_provider: z.ZodCatch>; + health_check_providers: z.ZodCatch>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>; + health_check_aggregate: z.ZodCatch>; sync_status: z.ZodDefault>; }, z.core.$strip>, z.ZodTransform<{ @@ -1131,7 +1302,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ health_check_interval_sec: number; health_check_timeout_ms: number; health_check_verify_tls: boolean; - health_check_provider: "local" | "cloudflare"; + health_check_provider: "local" | "cloudflare" | "globalping"; + health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"]; + health_check_aggregate: "any" | "all" | "majority"; sync_status: string | null; target_ip?: string | null | undefined; }, { @@ -1150,7 +1323,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ health_check_interval_sec: number; health_check_timeout_ms: number; health_check_verify_tls: boolean; - health_check_provider: "local" | "cloudflare"; + health_check_provider: "local" | "cloudflare" | "globalping"; + health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"]; + health_check_aggregate: "any" | "all" | "majority"; sync_status: string | null; target_ips?: string[] | undefined; target_ip?: string | null | undefined; @@ -1179,6 +1354,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ provider: z.ZodOptional>; colo: z.ZodOptional>; }, z.core.$strip>>>; @@ -1387,6 +1564,17 @@ declare const healthCheckConfigSchema: z.ZodObject<{ health_check_provider: z.ZodOptional>; + health_check_providers: z.ZodOptional>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>; + health_check_aggregate: z.ZodOptional>; }, z.core.$strip>; type HealthCheckConfig = z.infer; @@ -1418,6 +1606,17 @@ declare const createServiceWithConfigSchema: z.ZodObject<{ health_check_provider: z.ZodOptional>; + health_check_providers: z.ZodOptional>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>; + health_check_aggregate: z.ZodOptional>; fqdn: z.ZodString; target_ips: z.ZodOptional>; @@ -1610,6 +1809,17 @@ declare const updateServiceConfigSchema: z.ZodObject<{ health_check_provider: z.ZodOptional>; + health_check_providers: z.ZodOptional>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>; + health_check_aggregate: z.ZodOptional>; fqdn: z.ZodString; target_ips: z.ZodOptional>; @@ -1641,14 +1851,25 @@ declare const createServiceGroupSchema: z.ZodObject<{ health_check_provider: z.ZodOptional>; + health_check_providers: z.ZodOptional>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>; + health_check_aggregate: z.ZodOptional>; name: z.ZodString; type: z.ZodDefault>; icon: z.ZodOptional>; domain: z.ZodOptional>; @@ -1675,14 +1896,25 @@ declare const updateServiceGroupSchema: z.ZodObject<{ health_check_provider: z.ZodOptional>; + health_check_providers: z.ZodOptional>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>; + health_check_aggregate: z.ZodOptional>; name: z.ZodOptional; type: z.ZodOptional>; icon: z.ZodOptional>; domain: z.ZodOptional>; @@ -1776,6 +2008,7 @@ declare const originHealthCheckSchema: z.ZodObject<{ provider: z.ZodEnum<{ local: "local"; cloudflare: "cloudflare"; + globalping: "globalping"; }>; cf_healthcheck_id: z.ZodNullable; cf_zone_id: z.ZodNullable; @@ -1797,6 +2030,7 @@ declare const createOriginHealthCheckSchema: z.ZodObject<{ provider: z.ZodEnum<{ local: "local"; cloudflare: "cloudflare"; + globalping: "globalping"; }>; name: z.ZodString; cf_zone_id: z.ZodOptional>; @@ -1924,6 +2158,9 @@ declare const appSettingsPatchSchema: z.ZodObject<{ healthSuccessRecoveries: z.ZodOptional; healthWorkerUrl: z.ZodOptional]>>; healthWorkerToken: z.ZodOptional; + globalpingToken: z.ZodOptional; + globalpingLocations: z.ZodOptional; + globalpingLimit: z.ZodOptional; }, z.core.$strip>; type AppSettingsPatch = z.infer; declare const vpsTrackerEventSchema: z.ZodObject<{ @@ -2085,4 +2322,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{ }, z.core.$strip>; type IngestAuditEvent = z.infer; -export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, HEALTH_KV_CURSOR_KEY, HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, HEALTH_PROBE_BATCH, HEALTH_PROBE_CONCURRENCY, HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthProbeLog, type HealthProbeResultItem, type HealthProbeResultsDoc, type HealthProbeTargetItem, type HealthProbeTargetsDoc, type HealthStatusQuery, type HealthWorkerStatus, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type ToggleServiceIpInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthProbeLogSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, notificationLogSchema, originHealthCheckSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, toggleServiceIpSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema }; +export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, HEALTH_CHECK_AGGREGATES, HEALTH_CHECK_PROVIDERS, HEALTH_KV_CURSOR_KEY, HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, HEALTH_PROBE_BATCH, HEALTH_PROBE_CONCURRENCY, HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, HEALTH_STATUS_PROVIDERS, type HealthCheckAggregate, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthProbeLog, type HealthProbeResultItem, type HealthProbeResultsDoc, type HealthProbeTargetItem, type HealthProbeTargetsDoc, type HealthStatusProvider, type HealthStatusQuery, type HealthWorkerStatus, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type ToggleServiceIpInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, aggregateHealthOk, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, clampGlobalpingLimit, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, derivePrimaryProvider, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckAggregateSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckProvidersSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthProbeLogSchema, healthStatusProviderSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, normalizeProbeProvider, normalizeStatusProvider, notificationLogSchema, originHealthCheckSchema, parseFqdn, parseGlobalpingLocations, parseHealthAggregate, parseHealthProviders, reorderServicesSchema, serializeHealthProviders, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, targetHasProvider, targetProviders, toggleEnabledSchema, toggleServiceIpSchema, uniqueHealthProviders, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema }; diff --git a/packages/shared/dist/index.js b/packages/shared/dist/index.js index e2ca624..0c7f5bf 100644 --- a/packages/shared/dist/index.js +++ b/packages/shared/dist/index.js @@ -165,6 +165,100 @@ function bindingToFqdn(binding) { // src/schemas.ts import { z } from "zod"; + +// src/health-providers.ts +var HEALTH_CHECK_PROVIDERS = [ + "local", + "cloudflare", + "globalping" +]; +var HEALTH_STATUS_PROVIDERS = [ + ...HEALTH_CHECK_PROVIDERS, + "aggregate" +]; +var HEALTH_CHECK_AGGREGATES = ["any", "all", "majority"]; +var PROVIDER_SET = new Set(HEALTH_CHECK_PROVIDERS); +var STATUS_SET = new Set(HEALTH_STATUS_PROVIDERS); +var AGGREGATE_SET = new Set(HEALTH_CHECK_AGGREGATES); +function normalizeProbeProvider(value) { + return value === "cloudflare" || value === "globalping" || value === "local" ? value : "local"; +} +function normalizeStatusProvider(value) { + if (typeof value === "string" && STATUS_SET.has(value)) { + return value; + } + return "local"; +} +function uniqueHealthProviders(values) { + const out = []; + for (const value of values) { + if (!PROVIDER_SET.has(String(value))) continue; + const next = value; + if (!out.includes(next)) out.push(next); + } + return out; +} +function parseHealthProviders(json, fallback) { + if (Array.isArray(json)) { + const parsed = uniqueHealthProviders(json); + if (parsed.length > 0) return parsed; + } + if (typeof json === "string" && json.trim()) { + const trimmed = json.trim(); + if (trimmed.startsWith("[")) { + try { + const parsed = uniqueHealthProviders(JSON.parse(trimmed)); + if (parsed.length > 0) return parsed; + } catch { + } + } + const one = uniqueHealthProviders(trimmed.split(",")); + if (one.length > 0) return one; + } + return [normalizeProbeProvider(fallback)]; +} +function serializeHealthProviders(providers) { + const unique = uniqueHealthProviders(providers); + return JSON.stringify(unique.length > 0 ? unique : ["local"]); +} +function parseHealthAggregate(value) { + if (typeof value === "string" && AGGREGATE_SET.has(value)) { + return value; + } + return "majority"; +} +function derivePrimaryProvider(providers) { + return uniqueHealthProviders(providers)[0] ?? "local"; +} +function targetProviders(target) { + if (target.providers && target.providers.length > 0) { + return uniqueHealthProviders(target.providers); + } + return [normalizeProbeProvider(target.provider)]; +} +function targetHasProvider(target, provider) { + return targetProviders(target).includes(provider); +} +function aggregateHealthOk(oks, policy) { + const n = oks.length; + if (n === 0) return false; + const down = oks.filter((ok) => !ok).length; + if (policy === "any") return down === 0; + if (policy === "all") return down < n; + return down < Math.floor(n / 2) + 1; +} +function clampGlobalpingLimit(value, fallback = 3) { + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.min(10, Math.max(1, Math.trunc(n))); +} +function parseGlobalpingLocations(value) { + const raw = typeof value === "string" ? value : ""; + const parts = raw.split(",").map((part) => part.trim()).filter(Boolean); + return parts.length > 0 ? parts : ["World"]; +} + +// src/schemas.ts var certMonitoringSchema = z.enum(["auto", "required", "skipped"]); var lbModeSchema = z.enum(["round_robin", "failover", "weighted"]); var healthCheckTypeSchema = z.enum(["tcp", "http", "ping", "dns"]); @@ -179,7 +273,13 @@ var nodeHealthStateSchema = z.enum([ "unhealthy", "disabled" ]); -var healthCheckProviderSchema = z.enum(["local", "cloudflare"]); +var healthCheckProviderSchema = z.enum(HEALTH_CHECK_PROVIDERS); +var healthStatusProviderSchema = z.enum(HEALTH_STATUS_PROVIDERS); +var healthCheckAggregateSchema = z.enum(HEALTH_CHECK_AGGREGATES); +var healthCheckProvidersSchema = z.array(healthCheckProviderSchema).min(1).transform((arr) => { + const unique = uniqueHealthProviders(arr); + return unique.length > 0 ? unique : ["local"]; +}); var healthCheckScopeSchema = z.enum(["binding", "group"]); var ipHealthStatusSchema = z.object({ scope: healthCheckScopeSchema, @@ -192,7 +292,7 @@ var ipHealthStatusSchema = z.object({ last_checked_at: z.string().nullable(), last_error: z.string().nullable(), colo: z.string().nullable().optional(), - provider: healthCheckProviderSchema.optional() + provider: healthStatusProviderSchema.optional() }); var serviceIpHealthSchema = z.object({ ip: z.string(), @@ -200,7 +300,7 @@ var serviceIpHealthSchema = z.object({ latency_ms: z.number().nullable(), last_checked_at: z.string().nullable().optional(), last_error: z.string().nullable().optional(), - provider: healthCheckProviderSchema.optional(), + provider: healthStatusProviderSchema.optional(), colo: z.string().nullable().optional() }); var healthProbeLogSchema = z.object({ @@ -250,6 +350,8 @@ var serviceGroupSchema = z.object({ health_check_timeout_ms: z.number().default(3e3), health_check_verify_tls: z.coerce.boolean().default(false), health_check_provider: healthCheckProviderSchema.catch("local"), + health_check_providers: healthCheckProvidersSchema.catch(["local"]), + health_check_aggregate: healthCheckAggregateSchema.catch("majority"), created_at: z.string(), updated_at: z.string() }); @@ -288,6 +390,8 @@ var serviceDomainBindingSchema = z.object({ health_check_timeout_ms: z.number().default(3e3), health_check_verify_tls: z.coerce.boolean().default(false), health_check_provider: healthCheckProviderSchema.catch("local"), + health_check_providers: healthCheckProvidersSchema.catch(["local"]), + health_check_aggregate: healthCheckAggregateSchema.catch("majority"), sync_status: z.string().nullable().default(null) }).transform((binding) => ({ ...binding, @@ -414,7 +518,9 @@ var healthCheckConfigFields = { health_check_interval_sec: z.number().int().min(5).max(3600).optional(), health_check_timeout_ms: z.number().int().min(100).max(3e4).optional(), health_check_verify_tls: z.boolean().optional(), - health_check_provider: healthCheckProviderSchema.optional() + health_check_provider: healthCheckProviderSchema.optional(), + health_check_providers: healthCheckProvidersSchema.optional(), + health_check_aggregate: healthCheckAggregateSchema.optional() }; var healthCheckConfigSchema = z.object(healthCheckConfigFields); var serviceDomainInputSchema = z.object({ @@ -727,7 +833,10 @@ var appSettingsPatchSchema = z3.object({ healthLatencyWarnMs: z3.number().int().min(50).max(6e4).optional(), healthSuccessRecoveries: z3.number().int().min(1).max(20).optional(), healthWorkerUrl: z3.string().url().or(z3.literal("")).optional(), - healthWorkerToken: z3.string().optional() + healthWorkerToken: z3.string().optional(), + globalpingToken: z3.string().optional(), + globalpingLocations: z3.string().trim().max(200).optional(), + globalpingLimit: z3.number().int().min(1).max(10).optional() }).superRefine((data, ctx) => { if (data.healthDegradedFailures != null && data.healthDownFailures != null && data.healthDownFailures < data.healthDegradedFailures) { ctx.addIssue({ @@ -829,6 +938,8 @@ export { CERT_OK, CERT_UNKNOWN, CERT_WARNING, + HEALTH_CHECK_AGGREGATES, + HEALTH_CHECK_PROVIDERS, HEALTH_KV_CURSOR_KEY, HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, @@ -836,12 +947,14 @@ export { HEALTH_PROBE_CONCURRENCY, HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, + HEALTH_STATUS_PROVIDERS, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, ValidationError, + aggregateHealthOk, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, @@ -860,6 +973,7 @@ export { cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, + clampGlobalpingLimit, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, @@ -871,6 +985,7 @@ export { createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, + derivePrimaryProvider, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, @@ -883,11 +998,14 @@ export { fqdnToDisplay, groupSchema, groupWithStatsSchema, + healthCheckAggregateSchema, healthCheckConfigSchema, healthCheckProviderSchema, + healthCheckProvidersSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthProbeLogSchema, + healthStatusProviderSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, @@ -898,10 +1016,16 @@ export { loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, + normalizeProbeProvider, + normalizeStatusProvider, notificationLogSchema, originHealthCheckSchema, parseFqdn, + parseGlobalpingLocations, + parseHealthAggregate, + parseHealthProviders, reorderServicesSchema, + serializeHealthProviders, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, @@ -915,8 +1039,11 @@ export { shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, + targetHasProvider, + targetProviders, toggleEnabledSchema, toggleServiceIpSchema, + uniqueHealthProviders, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, diff --git a/packages/shared/src/health-providers.ts b/packages/shared/src/health-providers.ts new file mode 100644 index 0000000..a9bfd96 --- /dev/null +++ b/packages/shared/src/health-providers.ts @@ -0,0 +1,143 @@ +export const HEALTH_CHECK_PROVIDERS = [ + "local", + "cloudflare", + "globalping", +] as const; + +export type HealthCheckProvider = (typeof HEALTH_CHECK_PROVIDERS)[number]; + +export const HEALTH_STATUS_PROVIDERS = [ + ...HEALTH_CHECK_PROVIDERS, + "aggregate", +] as const; + +export type HealthStatusProvider = (typeof HEALTH_STATUS_PROVIDERS)[number]; + +export const HEALTH_CHECK_AGGREGATES = ["any", "all", "majority"] as const; + +export type HealthCheckAggregate = (typeof HEALTH_CHECK_AGGREGATES)[number]; + +const PROVIDER_SET = new Set(HEALTH_CHECK_PROVIDERS); +const STATUS_SET = new Set(HEALTH_STATUS_PROVIDERS); +const AGGREGATE_SET = new Set(HEALTH_CHECK_AGGREGATES); + +export function normalizeProbeProvider(value: unknown): HealthCheckProvider { + return value === "cloudflare" || value === "globalping" || value === "local" + ? value + : "local"; +} + +export function normalizeStatusProvider(value: unknown): HealthStatusProvider { + if (typeof value === "string" && STATUS_SET.has(value)) { + return value as HealthStatusProvider; + } + return "local"; +} + +export function uniqueHealthProviders( + values: readonly unknown[], +): HealthCheckProvider[] { + const out: HealthCheckProvider[] = []; + for (const value of values) { + if (!PROVIDER_SET.has(String(value))) continue; + const next = value as HealthCheckProvider; + if (!out.includes(next)) out.push(next); + } + return out; +} + +export function parseHealthProviders( + json: unknown, + fallback?: unknown, +): HealthCheckProvider[] { + if (Array.isArray(json)) { + const parsed = uniqueHealthProviders(json); + if (parsed.length > 0) return parsed; + } + if (typeof json === "string" && json.trim()) { + const trimmed = json.trim(); + if (trimmed.startsWith("[")) { + try { + const parsed = uniqueHealthProviders(JSON.parse(trimmed) as unknown[]); + if (parsed.length > 0) return parsed; + } catch { + // fall through to single-provider + } + } + const one = uniqueHealthProviders(trimmed.split(",")); + if (one.length > 0) return one; + } + return [normalizeProbeProvider(fallback)]; +} + +export function serializeHealthProviders( + providers: readonly HealthCheckProvider[], +): string { + const unique = uniqueHealthProviders(providers); + return JSON.stringify(unique.length > 0 ? unique : ["local"]); +} + +export function parseHealthAggregate(value: unknown): HealthCheckAggregate { + if (typeof value === "string" && AGGREGATE_SET.has(value)) { + return value as HealthCheckAggregate; + } + return "majority"; +} + +export function derivePrimaryProvider( + providers: readonly HealthCheckProvider[], +): HealthCheckProvider { + return uniqueHealthProviders(providers)[0] ?? "local"; +} + +export function targetProviders(target: { + providers?: readonly HealthCheckProvider[] | null; + provider?: HealthCheckProvider | null; +}): HealthCheckProvider[] { + if (target.providers && target.providers.length > 0) { + return uniqueHealthProviders(target.providers); + } + return [normalizeProbeProvider(target.provider)]; +} + +export function targetHasProvider( + target: { + providers?: readonly HealthCheckProvider[] | null; + provider?: HealthCheckProvider | null; + }, + provider: HealthCheckProvider, +): boolean { + return targetProviders(target).includes(provider); +} + +/** + * any — Down if at least one source is Down (ok only if all ok). + * all — Down only if every source is Down (ok if any ok). + * majority — Down if a strict majority of sources are Down (2 → both, 3 → ≥2). + */ +export function aggregateHealthOk( + oks: readonly boolean[], + policy: HealthCheckAggregate, +): boolean { + const n = oks.length; + if (n === 0) return false; + const down = oks.filter((ok) => !ok).length; + if (policy === "any") return down === 0; + if (policy === "all") return down < n; + return down < Math.floor(n / 2) + 1; +} + +export function clampGlobalpingLimit(value: unknown, fallback = 3): number { + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.min(10, Math.max(1, Math.trunc(n))); +} + +export function parseGlobalpingLocations(value: unknown): string[] { + const raw = typeof value === "string" ? value : ""; + const parts = raw + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + return parts.length > 0 ? parts : ["World"]; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 61e6527..05db025 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,6 +6,7 @@ export * from "./schemas.js"; export * from "./app-switcher.js"; export * from "./integration-vps-tracker.js"; export * from "./health-probe-mailbox.js"; +export * from "./health-providers.js"; export * from "./audit.js"; export type { CfZone, @@ -23,7 +24,6 @@ export type { HealthCheckType, IpHealthState, NodeHealthState, - HealthCheckProvider, HealthCheckScope, IpHealthStatus, HealthCheckTarget, diff --git a/packages/shared/src/integration-vps-tracker.ts b/packages/shared/src/integration-vps-tracker.ts index 7943a91..a219f57 100644 --- a/packages/shared/src/integration-vps-tracker.ts +++ b/packages/shared/src/integration-vps-tracker.ts @@ -37,6 +37,9 @@ export const appSettingsPatchSchema = z.object({ healthSuccessRecoveries: z.number().int().min(1).max(20).optional(), healthWorkerUrl: z.string().url().or(z.literal("")).optional(), healthWorkerToken: z.string().optional(), + globalpingToken: z.string().optional(), + globalpingLocations: z.string().trim().max(200).optional(), + globalpingLimit: z.number().int().min(1).max(10).optional(), }).superRefine((data, ctx) => { if ( data.healthDegradedFailures != null && diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 3b475ea..c431fa8 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -1,4 +1,10 @@ import { z } from 'zod' +import { + HEALTH_CHECK_AGGREGATES, + HEALTH_CHECK_PROVIDERS, + HEALTH_STATUS_PROVIDERS, + uniqueHealthProviders, +} from './health-providers.js' export const certMonitoringSchema = z.enum(['auto', 'required', 'skipped']) @@ -29,8 +35,19 @@ export const nodeHealthStateSchema = z.enum([ ]) export type NodeHealthState = z.infer -export const healthCheckProviderSchema = z.enum(['local', 'cloudflare']) -export type HealthCheckProvider = z.infer +export const healthCheckProviderSchema = z.enum(HEALTH_CHECK_PROVIDERS) + +export const healthStatusProviderSchema = z.enum(HEALTH_STATUS_PROVIDERS) + +export const healthCheckAggregateSchema = z.enum(HEALTH_CHECK_AGGREGATES) + +export const healthCheckProvidersSchema = z + .array(healthCheckProviderSchema) + .min(1) + .transform((arr) => { + const unique = uniqueHealthProviders(arr) + return unique.length > 0 ? unique : (['local'] as const) + }) export const healthCheckScopeSchema = z.enum(['binding', 'group']) export type HealthCheckScope = z.infer @@ -46,7 +63,7 @@ export const ipHealthStatusSchema = z.object({ last_checked_at: z.string().nullable(), last_error: z.string().nullable(), colo: z.string().nullable().optional(), - provider: healthCheckProviderSchema.optional(), + provider: healthStatusProviderSchema.optional(), }) export type IpHealthStatus = z.infer @@ -57,7 +74,7 @@ export const serviceIpHealthSchema = z.object({ latency_ms: z.number().nullable(), last_checked_at: z.string().nullable().optional(), last_error: z.string().nullable().optional(), - provider: healthCheckProviderSchema.optional(), + provider: healthStatusProviderSchema.optional(), colo: z.string().nullable().optional(), }) @@ -116,6 +133,8 @@ export const serviceGroupSchema = z.object({ health_check_timeout_ms: z.number().default(3000), health_check_verify_tls: z.coerce.boolean().default(false), health_check_provider: healthCheckProviderSchema.catch('local'), + health_check_providers: healthCheckProvidersSchema.catch(['local']), + health_check_aggregate: healthCheckAggregateSchema.catch('majority'), created_at: z.string(), updated_at: z.string(), }) @@ -157,6 +176,8 @@ export const serviceDomainBindingSchema = z health_check_timeout_ms: z.number().default(3000), health_check_verify_tls: z.coerce.boolean().default(false), health_check_provider: healthCheckProviderSchema.catch('local'), + health_check_providers: healthCheckProvidersSchema.catch(['local']), + health_check_aggregate: healthCheckAggregateSchema.catch('majority'), sync_status: z.string().nullable().default(null), }) .transform((binding) => ({ @@ -329,6 +350,8 @@ const healthCheckConfigFields = { health_check_timeout_ms: z.number().int().min(100).max(30000).optional(), health_check_verify_tls: z.boolean().optional(), health_check_provider: healthCheckProviderSchema.optional(), + health_check_providers: healthCheckProvidersSchema.optional(), + health_check_aggregate: healthCheckAggregateSchema.optional(), } export const healthCheckConfigSchema = z.object(healthCheckConfigFields) diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 8b6e946..cf54e4e 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1,3 +1,15 @@ +import type { + HealthCheckProvider, + HealthCheckAggregate, + HealthStatusProvider, +} from "./health-providers.js"; + +export type { + HealthCheckProvider, + HealthCheckAggregate, + HealthStatusProvider, +} from "./health-providers.js"; + export interface Group { id: number; name: string; @@ -23,6 +35,8 @@ export interface ServiceGroup { health_check_timeout_ms: number; health_check_verify_tls: boolean; health_check_provider: HealthCheckProvider; + health_check_providers: HealthCheckProvider[]; + health_check_aggregate: HealthCheckAggregate; created_at: string; updated_at: string; } @@ -123,6 +137,8 @@ export interface ServiceBinding { health_check_timeout_ms: number; health_check_verify_tls: boolean; health_check_provider: HealthCheckProvider; + health_check_providers: HealthCheckProvider[]; + health_check_aggregate: HealthCheckAggregate; routing_strategy: LbMode; operation_version: number; created_at: string; @@ -155,6 +171,8 @@ export interface ServiceBindingView { health_check_timeout_ms: number; health_check_verify_tls: boolean; health_check_provider: HealthCheckProvider; + health_check_providers: HealthCheckProvider[]; + health_check_aggregate: HealthCheckAggregate; sync_status: string | null; created_at: string; updated_at: string; @@ -181,6 +199,8 @@ export interface ServiceDomainBindingView { health_check_timeout_ms: number; health_check_verify_tls: boolean; health_check_provider: HealthCheckProvider; + health_check_providers: HealthCheckProvider[]; + health_check_aggregate: HealthCheckAggregate; sync_status: string | null; } @@ -284,8 +304,6 @@ export type NodeHealthState = | "unhealthy" | "disabled"; -export type HealthCheckProvider = "local" | "cloudflare"; - export type HealthCheckScope = "binding" | "group"; export interface IpHealthStatus { @@ -299,7 +317,7 @@ export interface IpHealthStatus { last_checked_at: string | null; last_error: string | null; colo?: string | null; - provider?: HealthCheckProvider; + provider?: HealthStatusProvider; } export interface ServiceIpHealth { @@ -308,7 +326,7 @@ export interface ServiceIpHealth { latency_ms: number | null; last_checked_at?: string | null; last_error?: string | null; - provider?: HealthCheckProvider; + provider?: HealthStatusProvider; colo?: string | null; } @@ -394,4 +412,6 @@ export interface HealthCheckTarget { timeout_ms: number; verify_tls: boolean; provider: HealthCheckProvider; + providers?: HealthCheckProvider[]; + aggregate?: HealthCheckAggregate; } diff --git a/packages/ui/src/components/toggle-group.tsx b/packages/ui/src/components/toggle-group.tsx new file mode 100644 index 0000000..8a00e1c --- /dev/null +++ b/packages/ui/src/components/toggle-group.tsx @@ -0,0 +1,89 @@ +"use client" + +import * as React from "react" +import { Toggle as TogglePrimitive } from "@base-ui/react/toggle" +import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group" +import { type VariantProps } from "class-variance-authority" + +import { cn } from "@cfdm/ui/lib/utils" +import { toggleVariants } from "@cfdm/ui/components/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps & { + spacing?: number + orientation?: "horizontal" | "vertical" + } +>({ + size: "default", + variant: "default", + spacing: 2, + orientation: "horizontal", +}) + +function ToggleGroup({ + className, + variant, + size, + spacing = 2, + orientation = "horizontal", + children, + ...props +}: ToggleGroupPrimitive.Props & + VariantProps & { + spacing?: number + orientation?: "horizontal" | "vertical" + }) { + return ( + + + {children} + + + ) +} + +function ToggleGroupItem({ + className, + children, + variant = "default", + size = "default", + ...props +}: TogglePrimitive.Props & VariantProps) { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +} + +export { ToggleGroup, ToggleGroupItem } diff --git a/packages/ui/src/components/toggle.tsx b/packages/ui/src/components/toggle.tsx new file mode 100644 index 0000000..cd14f01 --- /dev/null +++ b/packages/ui/src/components/toggle.tsx @@ -0,0 +1,43 @@ +import { Toggle as TogglePrimitive } from "@base-ui/react/toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@cfdm/ui/lib/utils" + +const toggleVariants = cva( + "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-transparent", + outline: "border border-input bg-transparent hover:bg-muted", + }, + size: { + default: + "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Toggle({ + className, + variant = "default", + size = "default", + ...props +}: TogglePrimitive.Props & VariantProps) { + return ( + + ) +} + +export { Toggle, toggleVariants }