feat(health): добавить Globalping и мультивыбор источников проб
CD / update-wiki (push) Successful in 8s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
quality / api (push) Successful in 46s
CD / quality (push) Successful in 1m49s
CD / publish (push) Successful in 1m40s

Несколько источников проб сразу и правило агрегации на сервисе вместо XOR Local/Cloudflare.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-19 18:32:18 +07:00
co-authored by Cursor
parent 4c4908558b
commit b9bea44dce
31 changed files with 2671 additions and 271 deletions
+277
View File
@@ -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<string, unknown>;
}
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<void> {
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<MeasurementResponse> {
try {
return (await response.json()) as MeasurementResponse;
} catch {
return {};
}
}
export async function runGlobalpingMeasurement(
target: HealthCheckTarget,
options: GlobalpingClientOptions = {},
): Promise<GlobalpingProbeResult> {
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<string, string> = {
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,
};
}
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
import { AsyncTask, CronJob } from "toad-scheduler"; import { AsyncTask, CronJob } from "toad-scheduler";
import { import {
getAppSettings, getAppSettings,
getAppSettingsSecrets,
updateAppSettings, updateAppSettings,
type HealthEngineFallbacks, type HealthEngineFallbacks,
} from "@cfdm/db"; } from "@cfdm/db";
@@ -71,11 +72,17 @@ export function createHealthCheckTask(
successRecoveries: settings.healthSuccessRecoveries, successRecoveries: settings.healthSuccessRecoveries,
}; };
const mailbox = mailboxFromSettings(app.db, app.cf, fallbacks); const mailbox = mailboxFromSettings(app.db, app.cf, fallbacks);
const secrets = getAppSettingsSecrets(app.db);
const n = await healthCheckService.runAllChecks(app.db, { const n = await healthCheckService.runAllChecks(app.db, {
thresholds, thresholds,
probeGapMs: config.healthProbeGapMs, probeGapMs: config.healthProbeGapMs,
mailbox, mailbox,
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron), staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
globalping: {
token: secrets.globalpingToken,
locations: secrets.globalpingLocations,
limit: secrets.globalpingLimit,
},
onStatusChange: async (target, prev, next) => { onStatusChange: async (target, prev, next) => {
try { try {
const label = const label =
+137 -69
View File
@@ -3,11 +3,20 @@ import { resolve4, resolve6 } from "node:dns/promises";
import { Agent, buildConnector, fetch as undiciFetch } from "undici"; import { Agent, buildConnector, fetch as undiciFetch } from "undici";
import type { Db } from "@cfdm/db"; import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db"; import { repos } from "@cfdm/db";
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared"; import type { HealthCheckTarget, IpHealthState, HealthCheckProvider } from "@cfdm/shared";
import {
aggregateHealthOk,
parseHealthAggregate,
targetProviders,
} from "@cfdm/shared";
import { AppError } from "../errors.js"; import { AppError } from "../errors.js";
import { nextHealthState } from "./health/state-machine.js"; import { nextHealthState } from "./health/state-machine.js";
import { LocalHealthCheckProvider } from "./health/local.js"; import { LocalHealthCheckProvider } from "./health/local.js";
import { workerNotConfiguredResult } from "./health/worker.js"; import { workerNotConfiguredResult } from "./health/worker.js";
import {
globalpingNotConfiguredResult,
probeWithGlobalping,
} from "./health/globalping.js";
import { import {
buildTargetsDoc, buildTargetsDoc,
indexResults, indexResults,
@@ -15,6 +24,7 @@ import {
originProbeKey, originProbeKey,
type HealthMailbox, type HealthMailbox,
} from "./health/mailbox.js"; } from "./health/mailbox.js";
import type { GlobalpingClientOptions } from "../lib/globalping-client.js";
export interface HealthCheckThresholds { export interface HealthCheckThresholds {
degradedFailures: number; degradedFailures: number;
@@ -275,6 +285,7 @@ export interface RunAllChecksOptions {
mailbox?: HealthMailbox | null; mailbox?: HealthMailbox | null;
/** Results older than this are stale (default 10 min). */ /** Results older than this are stale (default 10 min). */
staleAfterMs?: number; staleAfterMs?: number;
globalping?: GlobalpingClientOptions | null;
onStatusChange?: ( onStatusChange?: (
target: HealthCheckTarget, target: HealthCheckTarget,
prevState: IpHealthState | null, prevState: IpHealthState | null,
@@ -287,20 +298,60 @@ function sleep(ms: number): Promise<void> {
} }
/** /**
* One network hit per key. Group+binding on the same IP share a single TCP/HTTP probe * One network hit per origin+provider. Group+binding on the same IP share a probe.
* so anti-bot / rate-limit on the origin is not tripped by back-to-back checks.
*/ */
export function physicalProbeKey(target: HealthCheckTarget): string { export function physicalProbeKey(
const kind = target.provider === "cloudflare" ? "cloudflare" : "local"; target: HealthCheckTarget,
return `${kind}|${originProbeKey(target)}`; provider: HealthCheckProvider = target.provider,
): string {
return `${provider}|${originProbeKey(target)}`;
} }
function applyProbeResult( function logSourceResult(
db: Db, db: Db,
target: HealthCheckTarget, target: HealthCheckTarget,
provider: HealthCheckProvider,
result: ProbeResult, 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, options: RunAllChecksOptions,
): void { ): 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( const prev = repos.getIpHealthStatusRow(
db, db,
target.scope, target.scope,
@@ -308,8 +359,8 @@ function applyProbeResult(
target.ip, target.ip,
); );
const { state, failures, successes, node } = deriveState( const { state, failures, successes, node } = deriveState(
result.ok, aggregatedOk,
result.latencyMs, latencyMs,
prev prev
? { ? {
consecutive_failures: prev.consecutive_failures, consecutive_failures: prev.consecutive_failures,
@@ -322,30 +373,18 @@ function applyProbeResult(
const prevState: IpHealthState | null = prev const prevState: IpHealthState | null = prev
? (prev.status as IpHealthState) ? (prev.status as IpHealthState)
: null; : null;
const provider = target.provider === "cloudflare" ? "cloudflare" : "local";
repos.upsertIpHealthStatus( repos.upsertIpHealthStatus(
db, db,
target.scope, target.scope,
target.ref_id, target.ref_id,
target.ip, target.ip,
state, state,
result.latencyMs, latencyMs,
failures, failures,
result.error, error,
successes, 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); const matchedNode = repos.findNodeByIp(db, target.ip);
if (matchedNode && matchedNode.enabled) { if (matchedNode && matchedNode.enabled) {
repos.updateNode(db, matchedNode.id, { repos.updateNode(db, matchedNode.id, {
@@ -353,7 +392,7 @@ function applyProbeResult(
consecutive_failures: failures, consecutive_failures: failures,
consecutive_successes: successes, consecutive_successes: successes,
last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19), last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19),
last_failure_reason: result.error, last_failure_reason: error,
}); });
} }
if (prevState !== state) { if (prevState !== state) {
@@ -379,42 +418,26 @@ export async function runAllChecks(
const local = new LocalHealthCheckProvider(); const local = new LocalHealthCheckProvider();
const staleAfterMs = options.staleAfterMs ?? 10 * 60_000; const staleAfterMs = options.staleAfterMs ?? 10 * 60_000;
const byPhysical = new Map<string, HealthCheckTarget[]>(); const byOrigin = new Map<string, HealthCheckTarget[]>();
for (const target of targets) { for (const target of targets) {
const key = physicalProbeKey(target); const key = originProbeKey(target);
const list = byPhysical.get(key); const list = byOrigin.get(key);
if (list) list.push(target); if (list) list.push(target);
else byPhysical.set(key, [target]); else byOrigin.set(key, [target]);
} }
const localGroups: HealthCheckTarget[][] = []; const needsCloudflare = targets.some((t) =>
const cloudflareGroups: HealthCheckTarget[][] = []; targetProviders(t).includes("cloudflare"),
for (const group of byPhysical.values()) { );
if (group[0]?.provider === "cloudflare") cloudflareGroups.push(group); let mailboxResults = new Map<string, { ok: boolean; latencyMs: number; error: string | null }>();
else localGroups.push(group); let mailboxColo: string | null = null;
} let mailboxStale = true;
const mailbox = options.mailbox ?? null;
let probeIndex = 0; if (needsCloudflare) {
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 resultsDoc = mailbox ? await mailbox.getResults() : null; const resultsDoc = mailbox ? await mailbox.getResults() : null;
const byKey = indexResults(resultsDoc); mailboxResults = indexResults(resultsDoc);
const stale = !mailbox || isResultsStale(resultsDoc, staleAfterMs); mailboxStale = !mailbox || isResultsStale(resultsDoc, staleAfterMs);
const colo = resultsDoc?.colo ?? null; mailboxColo = resultsDoc?.colo ?? null;
if (mailbox) { if (mailbox) {
try { try {
const next = buildTargetsDoc(targets); const next = buildTargetsDoc(targets);
@@ -426,28 +449,71 @@ export async function runAllChecks(
// ingest still proceeds // ingest still proceeds
} }
} }
}
for (const group of cloudflareGroups) { const probeCache = new Map<string, ProbeResult>();
const representative = let probeIndex = 0;
group.find((t) => t.scope === "binding") ?? group[0]!;
const item = byKey.get(originProbeKey(representative)); async function resolveProvider(
let result: ProbeResult; provider: HealthCheckProvider,
if (!mailbox) { representative: HealthCheckTarget,
result = workerNotConfiguredResult(); originKey: string,
} else if (stale || !item) { ): Promise<ProbeResult> {
result = staleWorkerResult(colo); const cacheKey = `${provider}|${originKey}`;
} else { 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 = { result = {
ok: item.ok, ok: item.ok,
latencyMs: item.latencyMs, latencyMs: item.latencyMs,
error: item.error, error: item.error,
colo, colo: mailboxColo,
}; };
} }
for (const target of group) { } else {
applyProbeResult(db, target, result, options); 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<HealthCheckProvider>();
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); repos.pruneStaleIpHealthStatus(db, targets);
@@ -473,6 +539,8 @@ export async function runDomainMonitors(
timeout_ms: monitor.timeout_ms, timeout_ms: monitor.timeout_ms,
verify_tls: false, verify_tls: false,
provider: "local", provider: "local",
providers: ["local"],
aggregate: "majority",
}; };
let result: ProbeResult; let result: ProbeResult;
if (monitor.type === "http") { if (monitor.type === "http") {
@@ -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<ProbeResult> {
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,
};
}
}
@@ -1,6 +1,6 @@
import { getAppSettings, repos, updateAppSettings, type HealthEngineFallbacks } from "@cfdm/db"; import { getAppSettings, repos, updateAppSettings, type HealthEngineFallbacks } from "@cfdm/db";
import type { Db } 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 type { CloudflareClient } from "../../lib/cf-client.js";
import { AppError } from "../../errors.js"; import { AppError } from "../../errors.js";
import { loadHealthProbeWorkerSource } from "./health-probe-script.js"; import { loadHealthProbeWorkerSource } from "./health-probe-script.js";
@@ -126,7 +126,7 @@ export async function maybeEnsureHealthWorker(
): Promise<void> { ): Promise<void> {
const hasCloudflare = repos const hasCloudflare = repos
.listHealthCheckTargets(db) .listHealthCheckTargets(db)
.some((target) => target.provider === "cloudflare"); .some((target) => targetHasProvider(target, "cloudflare"));
if (!hasCloudflare) return; if (!hasCloudflare) return;
const settings = getAppSettings(db, fallbacks); const settings = getAppSettings(db, fallbacks);
if (settings.healthWorkerKvNamespaceId.trim() && !settings.healthWorkerError) { if (settings.healthWorkerKvNamespaceId.trim() && !settings.healthWorkerError) {
@@ -172,7 +172,7 @@ export function fireEnsureHealthWorker(
if (!cf.isConfigured) return; if (!cf.isConfigured) return;
const hasCloudflare = repos const hasCloudflare = repos
.listHealthCheckTargets(db) .listHealthCheckTargets(db)
.some((target) => target.provider === "cloudflare"); .some((target) => targetHasProvider(target, "cloudflare"));
if (!hasCloudflare) { if (!hasCloudflare) {
void syncCloudflareTargetsToKv(db, cf, fallbacks).catch((err) => { void syncCloudflareTargetsToKv(db, cf, fallbacks).catch((err) => {
log?.warn({ err }, "health worker KV sync failed"); log?.warn({ err }, "health worker KV sync failed");
+2 -2
View File
@@ -5,7 +5,7 @@ import type {
HealthProbeTargetItem, HealthProbeTargetItem,
HealthProbeTargetsDoc, HealthProbeTargetsDoc,
} from "@cfdm/shared"; } 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"; import type { CloudflareClient } from "../../lib/cf-client.js";
export interface HealthMailbox { export interface HealthMailbox {
@@ -70,7 +70,7 @@ export function cloudflareMailboxTargets(
): HealthProbeTargetItem[] { ): HealthProbeTargetItem[] {
const unique = new Map<string, HealthProbeTargetItem>(); const unique = new Map<string, HealthProbeTargetItem>();
for (const target of targets) { for (const target of targets) {
if (target.provider !== "cloudflare") continue; if (!targetHasProvider(target, "cloudflare")) continue;
if (target.type !== "tcp" && target.type !== "http") continue; if (target.type !== "tcp" && target.type !== "http") continue;
const key = originProbeKey(target); const key = originProbeKey(target);
if (unique.has(key)) continue; if (unique.has(key)) continue;
@@ -2,6 +2,8 @@ import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db"; import { repos } from "@cfdm/db";
import type { import type {
DnsRecord, DnsRecord,
HealthCheckAggregate,
HealthCheckProvider,
HealthCheckScope, HealthCheckScope,
HealthCheckType, HealthCheckType,
IpHealthState, IpHealthState,
@@ -51,7 +53,9 @@ export interface ServiceDomainInput {
health_check_interval_sec?: number; health_check_interval_sec?: number;
health_check_timeout_ms?: number; health_check_timeout_ms?: number;
health_check_verify_tls?: boolean; 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 { export interface ToggleRequest {
@@ -72,7 +76,9 @@ export interface ServiceGroupBody {
health_check_interval_sec?: number; health_check_interval_sec?: number;
health_check_timeout_ms?: number; health_check_timeout_ms?: number;
health_check_verify_tls?: boolean; 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 { export interface UpdateServiceGroupBody {
@@ -89,7 +95,9 @@ export interface UpdateServiceGroupBody {
health_check_interval_sec?: number; health_check_interval_sec?: number;
health_check_timeout_ms?: number; health_check_timeout_ms?: number;
health_check_verify_tls?: boolean; 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 { export interface UpdateServiceConfigRequest {
@@ -296,6 +304,10 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
health_check_timeout_ms: binding.health_check_timeout_ms, health_check_timeout_ms: binding.health_check_timeout_ms,
health_check_verify_tls: binding.health_check_verify_tls, health_check_verify_tls: binding.health_check_verify_tls,
health_check_provider: binding.health_check_provider ?? "local", 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), sync_status: aggregateSyncStatus(statuses),
}; };
}); });
@@ -1164,7 +1176,9 @@ export async function updateConfig(
input.health_check_interval_sec !== undefined || input.health_check_interval_sec !== undefined ||
input.health_check_timeout_ms !== undefined || input.health_check_timeout_ms !== undefined ||
input.health_check_verify_tls !== 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, { repos.updateBindingLbConfig(db, binding.id, {
lb_mode: input.lb_mode, lb_mode: input.lb_mode,
@@ -1177,6 +1191,8 @@ export async function updateConfig(
health_check_timeout_ms: input.health_check_timeout_ms, health_check_timeout_ms: input.health_check_timeout_ms,
health_check_verify_tls: input.health_check_verify_tls, health_check_verify_tls: input.health_check_verify_tls,
health_check_provider: input.health_check_provider, 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_timeout_ms: body.health_check_timeout_ms,
health_check_verify_tls: body.health_check_verify_tls, health_check_verify_tls: body.health_check_verify_tls,
health_check_provider: body.health_check_provider, 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); 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_timeout_ms: body.health_check_timeout_ms,
health_check_verify_tls: body.health_check_verify_tls, health_check_verify_tls: body.health_check_verify_tls,
health_check_provider: body.health_check_provider, 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) { if (!domain && group.enabled) {
+273
View File
@@ -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>): 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();
});
});
+47
View File
@@ -164,4 +164,51 @@ describe("settings health engine", () => {
expect(body.healthWorkerToken).toBeUndefined(); expect(body.healthWorkerToken).toBeUndefined();
await app.close(); 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();
});
}); });
@@ -17,16 +17,22 @@ import {
SelectValue, SelectValue,
} from '@cfdm/ui/components/select' } from '@cfdm/ui/components/select'
import { Switch } from '@cfdm/ui/components/switch' 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 { FieldGroup } from '@cfdm/ui/components/field'
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { cn } from '@cfdm/ui/lib/utils' 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 LbMode = 'round_robin' | 'failover' | 'weighted'
export type HealthCheckType = 'tcp' | 'http' export type HealthCheckType = 'tcp' | 'http'
export type HealthProvider = 'local' | 'cloudflare' export type { HealthProvider, HealthAggregate }
export interface HealthCheckConfig { export interface HealthCheckConfig {
enabled: boolean enabled: boolean
@@ -38,6 +44,8 @@ export interface HealthCheckConfig {
timeout_ms: number timeout_ms: number
verify_tls: boolean verify_tls: boolean
provider: HealthProvider provider: HealthProvider
providers: HealthProvider[]
aggregate: HealthAggregate
method?: string | null method?: string | null
retries?: number retries?: number
consecutive_fails?: number consecutive_fails?: number
@@ -54,62 +62,6 @@ const defaultLbModeOptions = [
{ value: 'weighted', label: 'Weighted (веса)' }, { 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 (
<ButtonGroup className="w-full" id={id}>
<Button
type="button"
size="sm"
className="flex-1"
variant={provider === 'local' ? 'secondary' : 'outline'}
aria-pressed={provider === 'local'}
onClick={() => onChange('local')}
>
Local
</Button>
<Button
type="button"
size="sm"
className="flex-1"
variant={provider === 'cloudflare' ? 'secondary' : 'outline'}
aria-pressed={provider === 'cloudflare'}
onClick={() => onChange('cloudflare')}
>
Cloudflare
</Button>
</ButtonGroup>
)
}
interface HealthCheckConfigFieldsProps {
value: LbAndHealthConfig
onChange: (next: LbAndHealthConfig) => void
lbModeLabel?: string
lbModeOptions?: { value: string; label: string }[]
idPrefix?: string
showLbMode?: boolean
className?: string
}
function CompactNumberField({ function CompactNumberField({
id, id,
value, value,
@@ -151,13 +103,29 @@ export function HealthCheckConfigFields({
idPrefix = 'health', idPrefix = 'health',
showLbMode = true, showLbMode = true,
className, 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<LbAndHealthConfig>) { function patch(next: Partial<LbAndHealthConfig>) {
onChange({ ...value, ...next }) 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 isHttp = value.type === 'http'
const rowClass = 'gap-3 px-0 py-3' const rowClass = 'gap-3 px-0 py-3'
const hasCloudflare = providers.includes('cloudflare')
const hasGlobalping = providers.includes('globalping')
const hasLocal = providers.includes('local')
return ( return (
<FieldGroup className={cn('gap-0', className)}> <FieldGroup className={cn('gap-0', className)}>
@@ -190,23 +158,25 @@ export function HealthCheckConfigFields({
<SettingRow <SettingRow
title="Провайдер health-check" title="Провайдер health-check"
description="Откуда идёт проба: API CFDM или Cloudflare Worker (edge)" description="Кто пробирует цель. Можно выбрать несколько источников."
labelFor={`${idPrefix}-provider`} labelFor={`${idPrefix}-provider`}
compact compact
stacked
className={rowClass} className={rowClass}
> >
<HealthProviderToggle <HealthSourceTiles
id={`${idPrefix}-provider`} value={providers}
value={value.provider ?? 'local'} onChange={(next) =>
onChange={(provider) =>
patch({ patch({
provider, providers: next,
enabled: provider === 'cloudflare' ? true : value.enabled, provider: next[0] ?? 'local',
enabled: next.includes('cloudflare') ? true : value.enabled,
}) })
} }
/> />
</SettingRow> </SettingRow>
{value.provider === 'cloudflare' ? (
{hasCloudflare ? (
<Alert> <Alert>
<AlertTitle>Cloudflare Worker</AlertTitle> <AlertTitle>Cloudflare Worker</AlertTitle>
<AlertDescription> <AlertDescription>
@@ -216,10 +186,24 @@ export function HealthCheckConfigFields({
<Link to="/settings/health" className="text-foreground underline"> <Link to="/settings/health" className="text-foreground underline">
Настройках Health-check Настройках Health-check
</Link> </Link>
. Если Worker не создан, цель не пробируется как Local. . Если Worker не создан, этот источник не пробируется как Local.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : ( ) : null}
{hasGlobalping ? (
<Alert>
<AlertTitle>Globalping</AlertTitle>
<AlertDescription>
Пробы из сети globalping.io (TCP ping / HTTP). Токен, локации и лимит
в{' '}
<Link to="/settings/health" className="text-foreground underline">
Настройках Health-check
</Link>
. Без токена или при 429 этот источник = fail, без fallback на Local.
</AlertDescription>
</Alert>
) : null}
{hasLocal ? (
<Alert> <Alert>
<AlertTitle>Local health-check</AlertTitle> <AlertTitle>Local health-check</AlertTitle>
<AlertDescription> <AlertDescription>
@@ -230,7 +214,22 @@ export function HealthCheckConfigFields({
. Интервал в карточке не используется. . Интервал в карточке не используется.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} ) : null}
{providers.length > 1 ? (
<SettingRow
title="Агрегация"
description="Как свести результаты источников в один статус IP для failover"
compact
stacked
className={rowClass}
>
<HealthAggregateTiles
value={aggregate}
onChange={(next) => patch({ aggregate: next })}
/>
</SettingRow>
) : null}
<SettingRow <SettingRow
title="Health-check" title="Health-check"
@@ -259,25 +258,25 @@ export function HealthCheckConfigFields({
<div className="flex flex-col gap-3 pt-1 pb-1"> <div className="flex flex-col gap-3 pt-1 pb-1">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}> <FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
<Select <ToggleGroup
modal={false} id={`${idPrefix}-type`}
value={value.type} variant="outline"
onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })} className="w-full"
value={[value.type]}
onValueChange={(next) => {
const picked = next[0]
if (picked === 'tcp' || picked === 'http') {
patch({ type: picked })
}
}}
> >
<SelectTrigger id={`${idPrefix}-type`} className="w-full"> <ToggleGroupItem value="tcp" className="flex-1">
<SelectValue placeholder="Тип" /> TCP
</SelectTrigger> </ToggleGroupItem>
<SelectContent> <ToggleGroupItem value="http" className="flex-1">
{(value.provider === 'cloudflare' HTTP
? cloudflareTypes </ToggleGroupItem>
: healthCheckTypes </ToggleGroup>
).map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormFieldSimple> </FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}> <FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
@@ -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: <ServerIcon />,
iconClassName: 'text-info [&_svg]:text-current',
},
{
id: 'cloudflare',
title: 'Cloudflare',
description: 'Worker на edge, KV mailbox',
icon: <CloudIcon />,
iconClassName: 'text-warning [&_svg]:text-current',
},
{
id: 'globalping',
title: 'Globalping',
description: 'Пробы из сети globalping.io',
icon: <GlobeIcon />,
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: <ShieldAlertIcon />,
},
{
id: 'all',
title: 'All',
description: 'Down, только если все выбранные Down',
icon: <LayersIcon />,
},
{
id: 'majority',
title: 'Majority',
description: 'Down по большинству (2 → оба, 3 → ≥2)',
icon: <ScaleIcon />,
},
]
function handleTileKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
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 (
<FramePanel
role={role}
aria-checked={selected}
aria-pressed={selected}
tabIndex={0}
className={cn(
'relative isolate flex h-full cursor-pointer flex-col p-3 transition-colors',
'hover:bg-muted/40 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
selected && 'ring-ring ring-1',
)}
onClick={onActivate}
onKeyDown={(event) => handleTileKeyDown(onActivate, event)}
>
<div className="relative z-10 flex h-full items-start gap-3">
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', iconClassName ?? DEFAULT_ICON_CLASS)}
>
{icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<span className="text-foreground text-sm font-medium">{title}</span>
{selected ? (
<CheckIcon className="text-foreground size-4 shrink-0" aria-hidden />
) : null}
</div>
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
</div>
</div>
</FramePanel>
)
}
/**
* Мультивыбор источников проб (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 (
<Frame dense spacing="sm" className="@container w-full">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{PROVIDER_ITEMS.map((item) => (
<TilePanel
key={item.id}
selected={selected.includes(item.id)}
title={item.title}
description={item.description}
icon={item.icon}
iconClassName={item.iconClassName}
role="checkbox"
onActivate={() => toggle(item.id)}
/>
))}
</div>
</Frame>
)
}
/**
* Правило агрегации (ровно одно): 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 (
<Frame dense spacing="sm" className="@container w-full">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{AGGREGATE_ITEMS.map((item) => (
<TilePanel
key={item.id}
selected={selected === item.id}
title={item.title}
description={item.description}
icon={item.icon}
role="radio"
onActivate={() => onChange(item.id)}
/>
))}
</div>
</Frame>
)
}
@@ -18,3 +18,9 @@ export { OpsDashboard } from './ops-dashboard'
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board' export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
export { DetailPanel, type DetailMetricCard } from './detail-panel' export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell' export { SettingsShell, type SettingsTabConfig } from './settings-shell'
export {
HealthSourceTiles,
HealthAggregateTiles,
type HealthProvider,
type HealthAggregate,
} from './health-source-tiles'
+19 -2
View File
@@ -8,6 +8,8 @@ import {
type LbAndHealthConfig, type LbAndHealthConfig,
type LbMode, type LbMode,
type HealthCheckType, type HealthCheckType,
type HealthProvider,
type HealthAggregate,
} from '@/components/health-check-config-fields' } from '@/components/health-check-config-fields'
import type { import type {
CreateServiceWithConfigInput, CreateServiceWithConfigInput,
@@ -53,7 +55,9 @@ interface BindingHealthConfig {
interval_sec: number interval_sec: number
timeout_ms: number timeout_ms: number
verify_tls: boolean verify_tls: boolean
provider: 'local' | 'cloudflare' provider: HealthProvider
providers: HealthProvider[]
aggregate: HealthAggregate
} }
export interface ServiceBindingDraft { export interface ServiceBindingDraft {
@@ -77,6 +81,8 @@ const defaultHealth: BindingHealthConfig = {
timeout_ms: 3000, timeout_ms: 3000,
verify_tls: false, verify_tls: false,
provider: 'local', provider: 'local',
providers: ['local'],
aggregate: 'majority',
} }
interface ServiceEditSheetProps { interface ServiceEditSheetProps {
@@ -110,7 +116,12 @@ function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
interval_sec: binding.health_check_interval_sec, interval_sec: binding.health_check_interval_sec,
timeout_ms: binding.health_check_timeout_ms, timeout_ms: binding.health_check_timeout_ms,
verify_tls: binding.health_check_verify_tls ?? false, 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_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {}, 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_timeout_ms: binding.health.timeout_ms,
health_check_verify_tls: binding.health.verify_tls, health_check_verify_tls: binding.health.verify_tls,
health_check_provider: binding.health.provider, health_check_provider: binding.health.provider,
health_check_providers: binding.health.providers,
health_check_aggregate: binding.health.aggregate,
} }
: { : {
fqdn: binding.fqdn.trim(), fqdn: binding.fqdn.trim(),
@@ -155,6 +168,8 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
health_check_timeout_ms: binding.health.timeout_ms, health_check_timeout_ms: binding.health.timeout_ms,
health_check_verify_tls: binding.health.verify_tls, health_check_verify_tls: binding.health.verify_tls,
health_check_provider: binding.health.provider, 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, timeout_ms: next.timeout_ms,
verify_tls: next.verify_tls, verify_tls: next.verify_tls,
provider: next.provider, provider: next.provider,
providers: next.providers,
aggregate: next.aggregate,
} }
} }
+14 -6
View File
@@ -36,7 +36,9 @@ export const serviceGroupSchema = z.object({
health_check_interval_sec: z.number().default(30), health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000), health_check_timeout_ms: z.number().default(3000),
health_check_verify_tls: z.coerce.boolean().default(false), 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(), created_at: z.string(),
updated_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_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000), health_check_timeout_ms: z.number().default(3000),
health_check_verify_tls: z.coerce.boolean().default(false), 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), sync_status: z.string().nullable().default(null),
}) })
.transform((binding) => ({ .transform((binding) => ({
@@ -102,7 +106,7 @@ export const serviceIpHealthSchema = z.object({
latency_ms: z.number().nullable(), latency_ms: z.number().nullable(),
last_checked_at: z.string().nullable().optional(), last_checked_at: z.string().nullable().optional(),
last_error: 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(), colo: z.string().nullable().optional(),
}) })
@@ -111,7 +115,7 @@ export const healthProbeLogSchema = z.object({
scope: z.string(), scope: z.string(),
ref_id: z.number(), ref_id: z.number(),
ip: z.string(), ip: z.string(),
provider: z.enum(['local', 'cloudflare']), provider: z.enum(['local', 'cloudflare', 'globalping']),
status: z.enum(['up', 'down', 'degraded', 'unknown']), status: z.enum(['up', 'down', 'degraded', 'unknown']),
ok: z.coerce.boolean(), ok: z.coerce.boolean(),
latency_ms: z.number().nullable(), latency_ms: z.number().nullable(),
@@ -188,7 +192,9 @@ export const serviceBindingSchema = z
health_check_interval_sec: z.number().default(30), health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000), health_check_timeout_ms: z.number().default(3000),
health_check_verify_tls: z.coerce.boolean().default(false), 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), sync_status: z.string().nullable().default(null),
created_at: z.string(), created_at: z.string(),
updated_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_interval_sec: z.number().int().min(5).max(3600).optional(),
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(), health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
health_check_verify_tls: z.boolean().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 const serviceDomainInputSchema = z
+172 -4
View File
@@ -5,7 +5,7 @@ import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { toast } from 'sonner' import { toast } from 'sonner'
import { HeartPulseIcon } from 'lucide-react' import { HeartPulseIcon, GlobeIcon } from 'lucide-react'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { SettingRow } from '@/components/setting-row' 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<typeof formSchema> type FormValues = z.infer<typeof formSchema>
type GlobalpingValues = z.infer<typeof globalpingSchema>
type HealthWorkerStatus = 'missing' | 'ready' | 'error' type HealthWorkerStatus = 'missing' | 'ready' | 'error'
type SettingsResponse = FormValues & { type SettingsResponse = FormValues & {
@@ -58,6 +65,9 @@ type SettingsResponse = FormValues & {
healthWorkerDeployedAt?: string | null healthWorkerDeployedAt?: string | null
healthWorkerLastIngestAt?: string | null healthWorkerLastIngestAt?: string | null
healthWorkerKvNamespaceId?: string healthWorkerKvNamespaceId?: string
globalpingTokenSet?: boolean
globalpingLocations?: string
globalpingLimit?: number
} }
export const Route = createFileRoute('/_auth/settings/health')({ export const Route = createFileRoute('/_auth/settings/health')({
@@ -140,6 +150,15 @@ function HealthSettingsPage() {
}, },
}) })
const gpForm = useForm<GlobalpingValues>({
resolver: zodResolver(globalpingSchema),
defaultValues: {
globalpingToken: '',
globalpingLocations: 'World',
globalpingLimit: 3,
},
})
useEffect(() => { useEffect(() => {
if (!data) return if (!data) return
form.reset({ form.reset({
@@ -149,7 +168,12 @@ function HealthSettingsPage() {
healthLatencyWarnMs: data.healthLatencyWarnMs, healthLatencyWarnMs: data.healthLatencyWarnMs,
healthSuccessRecoveries: data.healthSuccessRecoveries, healthSuccessRecoveries: data.healthSuccessRecoveries,
}) })
}, [data, form]) gpForm.reset({
globalpingToken: '',
globalpingLocations: data.globalpingLocations || 'World',
globalpingLimit: data.globalpingLimit ?? 3,
})
}, [data, form, gpForm])
const saveMut = useMutation({ const saveMut = useMutation({
mutationFn: (values: FormValues) => mutationFn: (values: FormValues) =>
@@ -168,6 +192,27 @@ function HealthSettingsPage() {
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'), toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
}) })
const saveGpMut = useMutation({
mutationFn: (values: GlobalpingValues) =>
api.patch<SettingsResponse>('/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({ const ensureMut = useMutation({
mutationFn: () => mutationFn: () =>
api.post<SettingsResponse>('/api/v1/settings/health/worker/ensure'), api.post<SettingsResponse>('/api/v1/settings/health/worker/ensure'),
@@ -180,6 +225,7 @@ function HealthSettingsPage() {
}) })
return ( return (
<div className="flex w-full flex-col gap-4">
<form <form
className="flex w-full flex-col gap-4" className="flex w-full flex-col gap-4"
onSubmit={(event) => onSubmit={(event) =>
@@ -193,8 +239,8 @@ function HealthSettingsPage() {
Local health-check Local health-check
</FrameTitle> </FrameTitle>
<FrameDescription> <FrameDescription>
Расписание и пороги движка общие для Local и Cloudflare Worker. Расписание и пороги движка общие для Local, Cloudflare Worker и Globalping.
Тип/порт/path задаются в карточке сервиса. Тип/порт/path и правило агрегации задаются в карточке сервиса.
</FrameDescription> </FrameDescription>
</FrameHeader> </FrameHeader>
<FramePanel className="p-0"> <FramePanel className="p-0">
@@ -416,5 +462,127 @@ function HealthSettingsPage() {
</FramePanel> </FramePanel>
</Frame> </Frame>
</form> </form>
<form
className="flex w-full flex-col gap-4"
onSubmit={(event) =>
void gpForm.handleSubmit((values) => saveGpMut.mutate(values))(event)
}
>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle className="flex items-center gap-2">
<GlobeIcon className="size-4" aria-hidden />
Globalping
<Badge
variant={data?.globalpingTokenSet ? 'success-light' : 'outline'}
size="sm"
>
{data?.globalpingTokenSet ? 'Токен задан' : 'Нет токена'}
</Badge>
</FrameTitle>
<FrameDescription>
Пробы из сети globalping.io. Poll 500 мс.{' '}
<a
href="https://reui.io/preview/base/settings-16"
className="underline"
target="_blank"
rel="noreferrer"
>
settings-16
</a>
.
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<div className="flex flex-col gap-3 p-4">
<Alert>
<AlertTitle>Лимиты и credits</AlertTitle>
<AlertDescription>
Без токена 250 tests/hour, с токеном 500 +{' '}
<a
href="https://globalping.io/credits"
className="underline"
target="_blank"
rel="noreferrer"
>
credits
</a>
. Токен: dash.globalping.io/tokens. Один measurement на
уникальный IP/порт за тик cron. При десятках IP следите за
hourly credits.
</AlertDescription>
</Alert>
</div>
<FieldGroup className="gap-0">
<SettingRow
title="Токен"
description={
data?.globalpingTokenSet
? 'Оставьте пустым, чтобы не менять сохранённый токен.'
: 'Authorization: Bearer. Без токена источник Globalping = fail.'
}
labelFor="gp-token"
stacked
>
<Input
id="gp-token"
type="password"
autoComplete="off"
placeholder={data?.globalpingTokenSet ? '••••••••' : 'gp_…'}
disabled={isLoading || saveGpMut.isPending}
{...gpForm.register('globalpingToken')}
/>
</SettingRow>
<SettingRow
title="Локации"
description="Magic CSV, например World или EU,US. Default World."
labelFor="gp-locations"
stacked
>
<Input
id="gp-locations"
spellCheck={false}
autoComplete="off"
disabled={isLoading || saveGpMut.isPending}
{...gpForm.register('globalpingLocations')}
/>
</SettingRow>
<SettingRow
title="Проб в measurement"
description="limit 110. Default 3."
labelFor="gp-limit"
compact
last
>
<Controller
control={gpForm.control}
name="globalpingLimit"
render={({ field }) => (
<CompactNumberInput
id="gp-limit"
value={field.value}
min={1}
max={10}
disabled={isLoading || saveGpMut.isPending}
onValueChange={field.onChange}
/>
)}
/>
</SettingRow>
</FieldGroup>
<FrameFooter className="flex flex-row justify-end">
<LoadingButton
type="submit"
isLoading={saveGpMut.isPending}
disabled={isLoading || !gpForm.formState.isDirty}
>
Сохранить
</LoadingButton>
</FrameFooter>
</FramePanel>
</Frame>
</form>
</div>
) )
} }
+25 -11
View File
@@ -49,18 +49,26 @@ health-check работают на двух уровнях:
после `HEALTH_SUCCESS_RECOVERIES` (default 2). Пороги и cron движка задаются в после `HEALTH_SUCCESS_RECOVERIES` (default 2). Пороги и cron движка задаются в
**Настройки → Health-check** (env — fallback, пока значения не сохранены в UI). **Настройки → Health-check** (env — fallback, пока значения не сохранены в UI).
### Local XOR Cloudflare Worker ### Источники проб: Local, Cloudflare Worker, Globalping
Провайдер задаётся на привязке (`service_bindings.health_check_provider`): **local** На привязке/группе задаётся **мультивыбор** источников (`health_check_providers` JSON)
или **cloudflare**. Одновременно оба не работают. и **правило агрегации** (`health_check_aggregate`: `any` | `all` | `majority`).
Failover читает одну строку `ip_health_status` (агрегат). Журнал `health_probe_log`
строка на каждый источник.
| | Local | Cloudflare Worker | | | Local | Cloudflare Worker | Globalping |
|---|---|---| |---|---|---|---|
| Кто пробирует | процесс API CFDM | Worker на edge (Cron Trigger) | | Кто пробирует | процесс API CFDM | Worker на edge (Cron Trigger) | [globalping.io](https://globalping.io) |
| Планировщик | глобальный cron CFDM | cron Worker + ingest KV в CFDM | | Планировщик | глобальный cron CFDM | cron Worker + ingest KV | тот же cron CFDM (POST/GET measurements) |
| Пороги Slow/Down | Настройки → Health-check | те же | | Пороги Slow/Down | Настройки → Health-check | те же | те же (по агрегату) |
| Результат | SQLite `ip_health_status` | та же SQLite + `colo` из KV | | Результат | SQLite `ip_health_status` | та же SQLite + `colo` из KV | та же SQLite, colo = city/country пробы |
| Регионы Health Checks | нет | нет (на Free продукта нет) | | 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). **Cloudflare в CFDM — это Worker**, не [Health Checks API](https://developers.cloudflare.com/api/resources/healthchecks).
Продукт Health Checks на Free-плане недоступен и **не используется**. Продукт 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 мин); Free: 5 Cron Triggers на аккаунт; KV 1000 writes/сутки (интервал ≥ 2 мин);
≤ 48 целей за тик. Исходник: [`workers/health-probe/`](../workers/health-probe/). ≤ 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` (110, default 3) — **Настройки → Health-check**.
Один measurement на уникальный origin (IP/порт/path) за тик.
Reconcile DNS запускается cron-задачей `health-check` после ingest KV и агрегации.
## Docker ## Docker
+278 -3
View File
@@ -1,7 +1,7 @@
import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core'; import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/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<{ declare const groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
name: "groups"; name: "groups";
@@ -599,6 +599,44 @@ declare const serviceGroups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
}, {}, { }, {}, {
length: number | undefined; 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<{ created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "created_at"; name: "created_at";
tableName: "service_groups"; tableName: "service_groups";
@@ -1537,6 +1575,44 @@ declare const serviceBindings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
}, {}, { }, {}, {
length: number | undefined; 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<{ routing_strategy: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "routing_strategy"; name: "routing_strategy";
tableName: "service_bindings"; tableName: "service_bindings";
@@ -3460,6 +3536,61 @@ declare const appSettings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
}, {}, { }, {}, {
length: number | undefined; 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<{ created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "created_at"; name: "created_at";
tableName: "app_settings"; tableName: "app_settings";
@@ -5151,6 +5282,44 @@ declare const schema: {
}, {}, { }, {}, {
length: number | undefined; 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<{ created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "created_at"; name: "created_at";
tableName: "service_groups"; tableName: "service_groups";
@@ -6089,6 +6258,44 @@ declare const schema: {
}, {}, { }, {}, {
length: number | undefined; 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<{ routing_strategy: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "routing_strategy"; name: "routing_strategy";
tableName: "service_bindings"; tableName: "service_bindings";
@@ -8012,6 +8219,61 @@ declare const schema: {
}, {}, { }, {}, {
length: number | undefined; 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<{ created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "created_at"; name: "created_at";
tableName: "app_settings"; tableName: "app_settings";
@@ -9176,6 +9438,9 @@ type AppSettingsDto = {
healthWorkerError: string | null; healthWorkerError: string | null;
healthWorkerDeployedAt: string | null; healthWorkerDeployedAt: string | null;
healthWorkerLastIngestAt: string | null; healthWorkerLastIngestAt: string | null;
globalpingTokenSet: boolean;
globalpingLocations: string;
globalpingLimit: number;
} & HealthEngineSettings; } & HealthEngineSettings;
type AppSettingsPatch = { type AppSettingsPatch = {
vpsTrackerUrl?: string; vpsTrackerUrl?: string;
@@ -9194,6 +9459,9 @@ type AppSettingsPatch = {
healthWorkerError?: string | null; healthWorkerError?: string | null;
healthWorkerDeployedAt?: string | null; healthWorkerDeployedAt?: string | null;
healthWorkerLastIngestAt?: string | null; healthWorkerLastIngestAt?: string | null;
globalpingToken?: string;
globalpingLocations?: string;
globalpingLimit?: number;
}; };
type HealthEngineFallbacks = HealthEngineSettings & { type HealthEngineFallbacks = HealthEngineSettings & {
healthWorkerUrl: string; healthWorkerUrl: string;
@@ -9206,6 +9474,9 @@ declare function getAppSettingsSecrets(db: Db): {
vpsTrackerSyncEnabled: boolean; vpsTrackerSyncEnabled: boolean;
healthWorkerUrl: string; healthWorkerUrl: string;
healthWorkerToken: string; healthWorkerToken: string;
globalpingToken: string;
globalpingLocations: string;
globalpingLimit: number;
}; };
declare function updateAppSettings(db: Db, patch: AppSettingsPatch, fallbacks?: HealthEngineFallbacks): AppSettingsDto; declare function updateAppSettings(db: Db, patch: AppSettingsPatch, fallbacks?: HealthEngineFallbacks): AppSettingsDto;
declare function touchVpsTrackerSync(db: Db): void; declare function touchVpsTrackerSync(db: Db): void;
@@ -9294,6 +9565,8 @@ interface ServiceGroupLbPatch {
health_check_timeout_ms?: number; health_check_timeout_ms?: number;
health_check_verify_tls?: boolean; health_check_verify_tls?: boolean;
health_check_provider?: HealthCheckProvider; 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 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; 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_timeout_ms?: number;
health_check_verify_tls?: boolean; health_check_verify_tls?: boolean;
health_check_provider?: HealthCheckProvider; health_check_provider?: HealthCheckProvider;
health_check_providers?: HealthCheckProvider[];
health_check_aggregate?: HealthCheckAggregate;
} }
declare function updateBindingLbConfig(db: Db, bindingId: number, patch: BindingLbPatch): void; declare function updateBindingLbConfig(db: Db, bindingId: number, patch: BindingLbPatch): void;
declare function setBindingCnameTarget(db: Db, bindingId: number, target: string | null): void; declare function setBindingCnameTarget(db: Db, bindingId: number, target: string | null): void;
@@ -9453,7 +9728,7 @@ type ServiceIpHealthRow = {
latency_ms: number | null; latency_ms: number | null;
last_checked_at: string | null; last_checked_at: string | null;
last_error: string | null; last_error: string | null;
provider: HealthCheckProvider; provider: HealthStatusProvider;
colo: string | null; colo: string | null;
}; };
/** Per-IP binding-scope health, worst status if the same IP is on several bindings. */ /** Per-IP binding-scope health, worst status if the same IP is on several bindings. */
@@ -9462,7 +9737,7 @@ declare function mergeHealthAggregates(parts: Array<HealthAggregate | undefined
declare function getIpHealthStatusRow(db: Db, scope: HealthCheckScope, refId: number, ip: string): IpHealthStatus | null; declare function getIpHealthStatusRow(db: Db, scope: HealthCheckScope, refId: number, ip: string): IpHealthStatus | null;
declare function upsertIpHealthStatus(db: Db, scope: HealthCheckScope, refId: number, ip: string, status: string, latencyMs: number | null, consecutiveFailures: number, lastError: string | null, consecutiveSuccesses?: number, extras?: { declare function upsertIpHealthStatus(db: Db, scope: HealthCheckScope, refId: number, ip: string, status: string, latencyMs: number | null, consecutiveFailures: number, lastError: string | null, consecutiveSuccesses?: number, extras?: {
colo?: string | null; colo?: string | null;
provider?: HealthCheckProvider; provider?: HealthStatusProvider;
}): void; }): void;
declare function deleteIpHealthStatusForRef(db: Db, scope: HealthCheckScope, refId: number): void; declare function deleteIpHealthStatusForRef(db: Db, scope: HealthCheckScope, refId: number): void;
declare function deleteIpHealthStatusForIp(db: Db, scope: HealthCheckScope, refId: number, ip: string): void; declare function deleteIpHealthStatusForIp(db: Db, scope: HealthCheckScope, refId: number, ip: string): void;
+126 -22
View File
@@ -53,6 +53,8 @@ var serviceGroups = sqliteTable("service_groups", {
health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3), health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3),
health_check_verify_tls: integer("health_check_verify_tls", { mode: "boolean" }).notNull().default(false), health_check_verify_tls: integer("health_check_verify_tls", { mode: "boolean" }).notNull().default(false),
health_check_provider: text("health_check_provider").notNull().default("local"), 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')`), created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`) updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
}); });
@@ -119,6 +121,8 @@ var serviceBindings = sqliteTable(
mode: "boolean" mode: "boolean"
}).notNull().default(false), }).notNull().default(false),
health_check_provider: text("health_check_provider").notNull().default("local"), 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"), routing_strategy: text("routing_strategy").notNull().default("round_robin"),
operation_version: integer("operation_version").notNull().default(0), operation_version: integer("operation_version").notNull().default(0),
created_at: text("created_at").notNull().default(sql`datetime('now')`), created_at: text("created_at").notNull().default(sql`datetime('now')`),
@@ -286,6 +290,9 @@ var appSettings = sqliteTable("app_settings", {
health_worker_error: text("health_worker_error"), health_worker_error: text("health_worker_error"),
health_worker_deployed_at: text("health_worker_deployed_at"), health_worker_deployed_at: text("health_worker_deployed_at"),
health_worker_last_ingest_at: text("health_worker_last_ingest_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')`), created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`) updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
}); });
@@ -577,7 +584,10 @@ function toDto(row, fallbacks) {
healthWorkerError: row.health_worker_error?.trim() || null, healthWorkerError: row.health_worker_error?.trim() || null,
healthWorkerDeployedAt: row.health_worker_deployed_at ?? null, healthWorkerDeployedAt: row.health_worker_deployed_at ?? null,
healthWorkerLastIngestAt: row.health_worker_last_ingest_at ?? null, healthWorkerLastIngestAt: row.health_worker_last_ingest_at ?? null,
healthWorkerStatus: workerStatus(row, env.healthWorkerUrl) 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)
}; };
} }
function getAppSettings(db, fallbacks) { function getAppSettings(db, fallbacks) {
@@ -593,12 +603,16 @@ function getAppSettings(db, fallbacks) {
} }
function getAppSettingsSecrets(db) { function getAppSettingsSecrets(db) {
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get(); const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
const limit = row?.globalping_limit;
return { return {
vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "", vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "",
vpsTrackerIntegrationToken: row?.vps_tracker_integration_token?.trim() ?? "", vpsTrackerIntegrationToken: row?.vps_tracker_integration_token?.trim() ?? "",
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled), vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled),
healthWorkerUrl: row?.health_worker_url?.trim() ?? "", healthWorkerUrl: row?.health_worker_url?.trim() ?? "",
healthWorkerToken: row?.health_worker_token?.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)
}; };
} }
function updateAppSettings(db, patch, fallbacks) { function updateAppSettings(db, patch, fallbacks) {
@@ -624,6 +638,9 @@ function updateAppSettings(db, patch, fallbacks) {
health_worker_error: patch.healthWorkerError !== void 0 ? patch.healthWorkerError?.trim() || null : current.health_worker_error, health_worker_error: patch.healthWorkerError !== void 0 ? patch.healthWorkerError?.trim() || null : current.health_worker_error,
health_worker_deployed_at: patch.healthWorkerDeployedAt !== void 0 ? patch.healthWorkerDeployedAt : current.health_worker_deployed_at, health_worker_deployed_at: patch.healthWorkerDeployedAt !== void 0 ? patch.healthWorkerDeployedAt : current.health_worker_deployed_at,
health_worker_last_ingest_at: patch.healthWorkerLastIngestAt !== void 0 ? patch.healthWorkerLastIngestAt : current.health_worker_last_ingest_at, health_worker_last_ingest_at: patch.healthWorkerLastIngestAt !== void 0 ? patch.healthWorkerLastIngestAt : current.health_worker_last_ingest_at,
globalping_token: patch.globalpingToken !== void 0 && patch.globalpingToken.trim() !== "" ? patch.globalpingToken : current.globalping_token,
globalping_locations: patch.globalpingLocations !== void 0 ? patch.globalpingLocations.trim() || "World" : current.globalping_locations,
globalping_limit: patch.globalpingLimit !== void 0 ? Math.min(10, Math.max(1, patch.globalpingLimit)) : current.globalping_limit,
updated_at: (/* @__PURE__ */ new Date()).toISOString() updated_at: (/* @__PURE__ */ new Date()).toISOString()
}).where(eq2(appSettings.id, SETTINGS_ID)).run(); }).where(eq2(appSettings.id, SETTINGS_ID)).run();
return getAppSettings(db, fallbacks); return getAppSettings(db, fallbacks);
@@ -771,7 +788,15 @@ __export(repos_exports, {
upsertIpHealthStatus: () => upsertIpHealthStatus, upsertIpHealthStatus: () => upsertIpHealthStatus,
upsertSubdomain: () => upsertSubdomain 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"; import { and as and2, asc, count, eq as eq3, isNull, like, notInArray, or as or2, sql as sql2 } from "drizzle-orm";
function listGroups(db) { function listGroups(db) {
return db.select().from(groups).orderBy(asc(groups.name)).all(); 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(); const result = db.delete(services).where(eq3(services.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service ${id}`); if (result.changes === 0) throw new NotFoundError(`service ${id}`);
} }
function normalizeHealthProvider(value) { function healthProviderColumns(patch) {
return value === "cloudflare" ? "cloudflare" : "local"; 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) { function mapServiceGroup(row) {
return { return {
@@ -1178,7 +1253,7 @@ function mapServiceGroup(row) {
health_check_interval_sec: row.health_check_interval_sec, health_check_interval_sec: row.health_check_interval_sec,
health_check_timeout_ms: row.health_check_timeout_ms, health_check_timeout_ms: row.health_check_timeout_ms,
health_check_verify_tls: row.health_check_verify_tls, health_check_verify_tls: row.health_check_verify_tls,
health_check_provider: normalizeHealthProvider(row.health_check_provider), ...mapHealthFields(row),
created_at: row.created_at, created_at: row.created_at,
updated_at: row.updated_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_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3e3, health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3e3,
health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false, 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; }).returning({ id: serviceGroups.id }).get().id;
return getServiceGroup(db, 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; update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
if (lbPatch.health_check_verify_tls !== void 0) if (lbPatch.health_check_verify_tls !== void 0)
update.health_check_verify_tls = lbPatch.health_check_verify_tls; update.health_check_verify_tls = lbPatch.health_check_verify_tls;
if (lbPatch.health_check_provider !== void 0) Object.assign(update, healthProviderColumns(lbPatch));
update.health_check_provider = lbPatch.health_check_provider;
} }
const result = db.update(serviceGroups).set(update).where(eq3(serviceGroups.id, id)).run(); const result = db.update(serviceGroups).set(update).where(eq3(serviceGroups.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service group ${id}`); 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; update.health_check_timeout_ms = patch.health_check_timeout_ms;
if (patch.health_check_verify_tls !== void 0) if (patch.health_check_verify_tls !== void 0)
update.health_check_verify_tls = patch.health_check_verify_tls; update.health_check_verify_tls = patch.health_check_verify_tls;
if (patch.health_check_provider !== void 0) Object.assign(update, healthProviderColumns(patch));
update.health_check_provider = patch.health_check_provider;
db.update(serviceBindings).set(update).where(eq3(serviceBindings.id, bindingId)).run(); db.update(serviceBindings).set(update).where(eq3(serviceBindings.id, bindingId)).run();
} }
function setBindingCnameTarget(db, bindingId, target) { 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, 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.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_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, d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug, s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status, dr.content AS target_ip, dr.sync_status,
@@ -1655,6 +1733,7 @@ function enrichServiceBindingView(db, row) {
return { return {
...row, ...row,
cname_target: row.cname_target ?? null, cname_target: row.cname_target ?? null,
...mapHealthFields(row),
target_ips, target_ips,
target_ip: target_ips[0] ?? null, target_ip: target_ips[0] ?? null,
target_ip_weights, target_ip_weights,
@@ -1695,7 +1774,7 @@ function listBindingsByService(db, serviceId) {
function getBinding(db, id) { function getBinding(db, id) {
const row = db.select().from(serviceBindings).where(eq3(serviceBindings.id, id)).get(); const row = db.select().from(serviceBindings).where(eq3(serviceBindings.id, id)).get();
if (!row) throw new NotFoundError(`service binding ${id}`); if (!row) throw new NotFoundError(`service binding ${id}`);
return row; return mapServiceBinding(row);
} }
function getBindingView(db, id) { function getBindingView(db, id) {
const rows = db.all(sql2` const rows = db.all(sql2`
@@ -1718,7 +1797,8 @@ function findBinding(db, serviceId, domainId, hostname) {
eq3(serviceBindings.hostname, hostname) eq3(serviceBindings.hostname, hostname)
) )
).get(); ).get();
return row ?? null; if (!row) return null;
return mapServiceBinding(row);
} }
function insertBinding(db, domainId, serviceId, hostname, dnsRecordId) { function insertBinding(db, domainId, serviceId, hostname, dnsRecordId) {
const id = db.insert(serviceBindings).values({ const id = db.insert(serviceBindings).values({
@@ -1744,7 +1824,7 @@ function setBindingDnsRecordId(db, bindingId, dnsRecordId) {
}).where(eq3(serviceBindings.id, bindingId)).run(); }).where(eq3(serviceBindings.id, bindingId)).run();
} }
function bindingsToRemove(db, serviceId, keepIds) { 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)); return all.filter((b) => !keepIds.includes(b.id));
} }
function deleteBindingsExcept(db, serviceId, keepIds) { function deleteBindingsExcept(db, serviceId, keepIds) {
@@ -1975,7 +2055,7 @@ function listIpHealthByServiceIds(db, serviceIds) {
latency_ms: parsed.health_latency_ms, latency_ms: parsed.health_latency_ms,
last_checked_at: row.last_checked_at, last_checked_at: row.last_checked_at,
last_error: row.last_error, last_error: row.last_error,
provider: normalizeHealthProvider(row.provider), provider: normalizeStatusProvider(row.provider),
colo: row.colo colo: row.colo
}); });
result.set(row.service_id, list); result.set(row.service_id, list);
@@ -2101,6 +2181,8 @@ function listHealthCheckTargets(db) {
sb.health_check_expected_status AS expected_status, sb.health_check_expected_status AS expected_status,
sb.health_check_timeout_ms AS timeout_ms, sb.health_check_timeout_ms AS timeout_ms,
sb.health_check_verify_tls AS verify_tls, 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 COALESCE(sb.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id 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_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms, sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls, 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 COALESCE(sg.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id 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_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms, sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls, 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 COALESCE(sg.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id 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_expected_status AS expected_status,
sb.health_check_timeout_ms AS timeout_ms, sb.health_check_timeout_ms AS timeout_ms,
sb.health_check_verify_tls AS verify_tls, 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 COALESCE(sb.health_check_provider, 'local') AS provider
FROM service_bindings sb FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id 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_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms, sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls, 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 COALESCE(sg.health_check_provider, 'local') AS provider
FROM service_bindings sb FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id JOIN domains d ON d.id = sb.domain_id
@@ -2195,11 +2285,25 @@ function listHealthCheckTargets(db) {
...groupInheritedBindingTargets, ...groupInheritedBindingTargets,
...cnameBindingTargets, ...cnameBindingTargets,
...groupInheritedCnameBindingTargets ...groupInheritedCnameBindingTargets
].map((t) => ({ ].map((t) => {
...t, const row = t;
verify_tls: Boolean(t.verify_tls), const providers = parseHealthProviders(row.providers_json, row.provider);
provider: normalizeHealthProvider(t.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) { function listDomainTags(db, domainId) {
return db.select({ tag: domainTags.tag }).from(domainTags).where(eq3(domainTags.domain_id, domainId)).all().map((r) => r.tag); 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) => ({ return rows.map((row) => ({
...row, ...row,
provider: normalizeHealthProvider(row.provider), provider: parseHealthProviders(null, row.provider)[0] ?? "local",
ok: Boolean(row.ok) ok: Boolean(row.ok)
})); }));
} }
@@ -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;
+143 -22
View File
@@ -5,10 +5,12 @@ import type {
DomainListItem, DomainListItem,
Group, Group,
GroupWithStats, GroupWithStats,
HealthCheckAggregate,
HealthCheckProvider, HealthCheckProvider,
HealthCheckScope, HealthCheckScope,
HealthCheckTarget, HealthCheckTarget,
HealthCheckType, HealthCheckType,
HealthStatusProvider,
IpHealthState, IpHealthState,
IpHealthStatus, IpHealthStatus,
LbMode, LbMode,
@@ -21,7 +23,15 @@ import type {
Subdomain, Subdomain,
SyncJob, SyncJob,
} from "@cfdm/shared"; } 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 { and, asc, count, eq, isNull, like, notInArray, or, sql } from "drizzle-orm";
import type { Db } from "./client.js"; import type { Db } from "./client.js";
import { ConflictError, NotFoundError } from "./errors.js"; import { ConflictError, NotFoundError } from "./errors.js";
@@ -770,8 +780,82 @@ export function deleteService(db: Db, id: number): void {
// --- Service Groups --- // --- Service Groups ---
function normalizeHealthProvider(value: unknown): HealthCheckProvider { function healthProviderColumns(patch: {
return value === "cloudflare" ? "cloudflare" : "local"; 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 { 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_interval_sec: row.health_check_interval_sec,
health_check_timeout_ms: row.health_check_timeout_ms, health_check_timeout_ms: row.health_check_timeout_ms,
health_check_verify_tls: row.health_check_verify_tls, health_check_verify_tls: row.health_check_verify_tls,
health_check_provider: normalizeHealthProvider(row.health_check_provider), ...mapHealthFields(row),
created_at: row.created_at, created_at: row.created_at,
updated_at: row.updated_at, updated_at: row.updated_at,
}; };
@@ -827,6 +911,8 @@ export interface ServiceGroupLbPatch {
health_check_timeout_ms?: number; health_check_timeout_ms?: number;
health_check_verify_tls?: boolean; health_check_verify_tls?: boolean;
health_check_provider?: HealthCheckProvider; health_check_provider?: HealthCheckProvider;
health_check_providers?: HealthCheckProvider[];
health_check_aggregate?: HealthCheckAggregate;
} }
export function createServiceGroup( export function createServiceGroup(
@@ -853,7 +939,11 @@ export function createServiceGroup(
health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30, health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3000, health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3000,
health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false, 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 }) .returning({ id: serviceGroups.id })
.get()!.id; .get()!.id;
@@ -894,8 +984,7 @@ export function updateServiceGroup(
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms; update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
if (lbPatch.health_check_verify_tls !== undefined) if (lbPatch.health_check_verify_tls !== undefined)
update.health_check_verify_tls = lbPatch.health_check_verify_tls; update.health_check_verify_tls = lbPatch.health_check_verify_tls;
if (lbPatch.health_check_provider !== undefined) Object.assign(update, healthProviderColumns(lbPatch));
update.health_check_provider = lbPatch.health_check_provider;
} }
const result = db const result = db
.update(serviceGroups) .update(serviceGroups)
@@ -1439,6 +1528,8 @@ export interface BindingLbPatch {
health_check_timeout_ms?: number; health_check_timeout_ms?: number;
health_check_verify_tls?: boolean; health_check_verify_tls?: boolean;
health_check_provider?: HealthCheckProvider; health_check_provider?: HealthCheckProvider;
health_check_providers?: HealthCheckProvider[];
health_check_aggregate?: HealthCheckAggregate;
} }
export function updateBindingLbConfig( export function updateBindingLbConfig(
@@ -1469,8 +1560,7 @@ export function updateBindingLbConfig(
update.health_check_timeout_ms = patch.health_check_timeout_ms; update.health_check_timeout_ms = patch.health_check_timeout_ms;
if (patch.health_check_verify_tls !== undefined) if (patch.health_check_verify_tls !== undefined)
update.health_check_verify_tls = patch.health_check_verify_tls; update.health_check_verify_tls = patch.health_check_verify_tls;
if (patch.health_check_provider !== undefined) Object.assign(update, healthProviderColumns(patch));
update.health_check_provider = patch.health_check_provider;
db.update(serviceBindings) db.update(serviceBindings)
.set(update) .set(update)
.where(eq(serviceBindings.id, bindingId)) .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, 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.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_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, d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug, s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status, dr.content AS target_ip, dr.sync_status,
@@ -1642,6 +1733,7 @@ function enrichServiceBindingView(
return { return {
...row, ...row,
cname_target: row.cname_target ?? null, cname_target: row.cname_target ?? null,
...mapHealthFields(row),
target_ips, target_ips,
target_ip: target_ips[0] ?? null, target_ip: target_ips[0] ?? null,
target_ip_weights, target_ip_weights,
@@ -1694,7 +1786,7 @@ export function getBinding(db: Db, id: number): ServiceBinding {
.where(eq(serviceBindings.id, id)) .where(eq(serviceBindings.id, id))
.get(); .get();
if (!row) throw new NotFoundError(`service binding ${id}`); if (!row) throw new NotFoundError(`service binding ${id}`);
return row as ServiceBinding; return mapServiceBinding(row);
} }
export function getBindingView(db: Db, id: number): ServiceBindingView { export function getBindingView(db: Db, id: number): ServiceBindingView {
@@ -1728,7 +1820,8 @@ export function findBinding(
), ),
) )
.get(); .get();
return (row as ServiceBinding) ?? null; if (!row) return null;
return mapServiceBinding(row);
} }
export function insertBinding( export function insertBinding(
@@ -1792,7 +1885,8 @@ export function bindingsToRemove(
.select() .select()
.from(serviceBindings) .from(serviceBindings)
.where(eq(serviceBindings.service_id, serviceId)) .where(eq(serviceBindings.service_id, serviceId))
.all() as ServiceBinding[]; .all()
.map(mapServiceBinding);
return all.filter((b) => !keepIds.includes(b.id)); return all.filter((b) => !keepIds.includes(b.id));
} }
@@ -2124,7 +2218,7 @@ export type ServiceIpHealthRow = {
latency_ms: number | null; latency_ms: number | null;
last_checked_at: string | null; last_checked_at: string | null;
last_error: string | null; last_error: string | null;
provider: HealthCheckProvider; provider: HealthStatusProvider;
colo: string | null; colo: string | null;
}; };
@@ -2175,7 +2269,7 @@ export function listIpHealthByServiceIds(
latency_ms: parsed.health_latency_ms, latency_ms: parsed.health_latency_ms,
last_checked_at: row.last_checked_at, last_checked_at: row.last_checked_at,
last_error: row.last_error, last_error: row.last_error,
provider: normalizeHealthProvider(row.provider), provider: normalizeStatusProvider(row.provider),
colo: row.colo, colo: row.colo,
}); });
result.set(row.service_id, list); result.set(row.service_id, list);
@@ -2243,7 +2337,7 @@ export function upsertIpHealthStatus(
consecutiveFailures: number, consecutiveFailures: number,
lastError: string | null, lastError: string | null,
consecutiveSuccesses = 0, consecutiveSuccesses = 0,
extras?: { colo?: string | null; provider?: HealthCheckProvider }, extras?: { colo?: string | null; provider?: HealthStatusProvider },
): void { ): void {
const colo = extras?.colo ?? null; const colo = extras?.colo ?? null;
const provider = extras?.provider ?? "local"; 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_expected_status AS expected_status,
sb.health_check_timeout_ms AS timeout_ms, sb.health_check_timeout_ms AS timeout_ms,
sb.health_check_verify_tls AS verify_tls, 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 COALESCE(sb.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id 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_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms, sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls, 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 COALESCE(sg.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id 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_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms, sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls, 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 COALESCE(sg.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id 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_expected_status AS expected_status,
sb.health_check_timeout_ms AS timeout_ms, sb.health_check_timeout_ms AS timeout_ms,
sb.health_check_verify_tls AS verify_tls, 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 COALESCE(sb.health_check_provider, 'local') AS provider
FROM service_bindings sb FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id 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_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms, sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls, 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 COALESCE(sg.health_check_provider, 'local') AS provider
FROM service_bindings sb FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id JOIN domains d ON d.id = sb.domain_id
@@ -2468,11 +2572,28 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
...groupInheritedBindingTargets, ...groupInheritedBindingTargets,
...cnameBindingTargets, ...cnameBindingTargets,
...groupInheritedCnameBindingTargets, ...groupInheritedCnameBindingTargets,
].map((t) => ({ ].map((t) => {
...t, const row = t as HealthCheckTarget & {
verify_tls: Boolean(t.verify_tls), providers_json?: string | null;
provider: normalizeHealthProvider(t.provider), 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 --- // --- Domain tags ---
@@ -2759,7 +2880,7 @@ export function listHealthProbeLogForService(
`); `);
return rows.map((row) => ({ return rows.map((row) => ({
...row, ...row,
provider: normalizeHealthProvider(row.provider), provider: parseHealthProviders(null, row.provider)[0] ?? "local",
ok: Boolean(row.ok), ok: Boolean(row.ok),
})); }));
} }
+11
View File
@@ -65,6 +65,8 @@ export const serviceGroups = sqliteTable("service_groups", {
.notNull() .notNull()
.default(false), .default(false),
health_check_provider: text("health_check_provider").notNull().default("local"), 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") created_at: text("created_at")
.notNull() .notNull()
.default(sql`datetime('now')`), .default(sql`datetime('now')`),
@@ -169,6 +171,12 @@ export const serviceBindings = sqliteTable(
health_check_provider: text("health_check_provider") health_check_provider: text("health_check_provider")
.notNull() .notNull()
.default("local"), .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"), routing_strategy: text("routing_strategy").notNull().default("round_robin"),
operation_version: integer("operation_version").notNull().default(0), operation_version: integer("operation_version").notNull().default(0),
created_at: text("created_at") created_at: text("created_at")
@@ -398,6 +406,9 @@ export const appSettings = sqliteTable("app_settings", {
health_worker_error: text("health_worker_error"), health_worker_error: text("health_worker_error"),
health_worker_deployed_at: text("health_worker_deployed_at"), health_worker_deployed_at: text("health_worker_deployed_at"),
health_worker_last_ingest_at: text("health_worker_last_ingest_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") created_at: text("created_at")
.notNull() .notNull()
.default(sql`datetime('now')`), .default(sql`datetime('now')`),
+37
View File
@@ -28,6 +28,9 @@ export type AppSettingsDto = {
healthWorkerError: string | null; healthWorkerError: string | null;
healthWorkerDeployedAt: string | null; healthWorkerDeployedAt: string | null;
healthWorkerLastIngestAt: string | null; healthWorkerLastIngestAt: string | null;
globalpingTokenSet: boolean;
globalpingLocations: string;
globalpingLimit: number;
} & HealthEngineSettings; } & HealthEngineSettings;
export type AppSettingsPatch = { export type AppSettingsPatch = {
@@ -47,6 +50,9 @@ export type AppSettingsPatch = {
healthWorkerError?: string | null; healthWorkerError?: string | null;
healthWorkerDeployedAt?: string | null; healthWorkerDeployedAt?: string | null;
healthWorkerLastIngestAt?: string | null; healthWorkerLastIngestAt?: string | null;
globalpingToken?: string;
globalpingLocations?: string;
globalpingLimit?: number;
}; };
export type HealthEngineFallbacks = HealthEngineSettings & { export type HealthEngineFallbacks = HealthEngineSettings & {
@@ -118,6 +124,14 @@ function toDto(
healthWorkerDeployedAt: row.health_worker_deployed_at ?? null, healthWorkerDeployedAt: row.health_worker_deployed_at ?? null,
healthWorkerLastIngestAt: row.health_worker_last_ingest_at ?? null, healthWorkerLastIngestAt: row.health_worker_last_ingest_at ?? null,
healthWorkerStatus: workerStatus(row, env.healthWorkerUrl), 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; vpsTrackerSyncEnabled: boolean;
healthWorkerUrl: string; healthWorkerUrl: string;
healthWorkerToken: string; healthWorkerToken: string;
globalpingToken: string;
globalpingLocations: string;
globalpingLimit: number;
} { } {
const row = db const row = db
.select() .select()
.from(appSettings) .from(appSettings)
.where(eq(appSettings.id, SETTINGS_ID)) .where(eq(appSettings.id, SETTINGS_ID))
.get(); .get();
const limit = row?.globalping_limit;
return { return {
vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "", vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "",
vpsTrackerIntegrationToken: vpsTrackerIntegrationToken:
@@ -159,6 +177,12 @@ export function getAppSettingsSecrets(db: Db): {
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled), vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled),
healthWorkerUrl: row?.health_worker_url?.trim() ?? "", healthWorkerUrl: row?.health_worker_url?.trim() ?? "",
healthWorkerToken: row?.health_worker_token?.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 !== undefined
? patch.healthWorkerLastIngestAt ? patch.healthWorkerLastIngestAt
: current.health_worker_last_ingest_at, : 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(), updated_at: new Date().toISOString(),
}) })
.where(eq(appSettings.id, SETTINGS_ID)) .where(eq(appSettings.id, SETTINGS_ID))
+257 -20
View File
@@ -15,6 +15,36 @@ declare const CERT_MONITOR_REQUIRED = "required";
declare const CERT_MONITOR_SKIPPED = "skipped"; declare const CERT_MONITOR_SKIPPED = "skipped";
declare const CERT_MONITORING_VALUES: readonly ["auto", "required", "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 { interface ServiceGroup$1 {
id: number; id: number;
name: string; name: string;
@@ -32,6 +62,8 @@ interface ServiceGroup$1 {
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider; health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -62,6 +94,8 @@ interface ServiceBinding {
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider; health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
routing_strategy: LbMode; routing_strategy: LbMode;
operation_version: number; operation_version: number;
created_at: string; created_at: string;
@@ -93,6 +127,8 @@ interface ServiceBindingView {
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider; health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
sync_status: string | null; sync_status: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -118,6 +154,8 @@ interface ServiceDomainBindingView {
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider; health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
sync_status: string | null; sync_status: string | null;
} }
interface ServiceView$1 { interface ServiceView$1 {
@@ -189,7 +227,6 @@ type LbMode = "round_robin" | "failover" | "weighted";
type HealthCheckType = "tcp" | "http" | "ping" | "dns"; type HealthCheckType = "tcp" | "http" | "ping" | "dns";
type IpHealthState = "up" | "down" | "degraded" | "unknown"; type IpHealthState = "up" | "down" | "degraded" | "unknown";
type NodeHealthState = "unknown" | "checking" | "healthy" | "degraded" | "unhealthy" | "disabled"; type NodeHealthState = "unknown" | "checking" | "healthy" | "degraded" | "unhealthy" | "disabled";
type HealthCheckProvider = "local" | "cloudflare";
type HealthCheckScope = "binding" | "group"; type HealthCheckScope = "binding" | "group";
interface IpHealthStatus { interface IpHealthStatus {
scope: HealthCheckScope; scope: HealthCheckScope;
@@ -202,7 +239,7 @@ interface IpHealthStatus {
last_checked_at: string | null; last_checked_at: string | null;
last_error: string | null; last_error: string | null;
colo?: string | null; colo?: string | null;
provider?: HealthCheckProvider; provider?: HealthStatusProvider;
} }
interface ServiceIpHealth$1 { interface ServiceIpHealth$1 {
ip: string; ip: string;
@@ -210,7 +247,7 @@ interface ServiceIpHealth$1 {
latency_ms: number | null; latency_ms: number | null;
last_checked_at?: string | null; last_checked_at?: string | null;
last_error?: string | null; last_error?: string | null;
provider?: HealthCheckProvider; provider?: HealthStatusProvider;
colo?: string | null; colo?: string | null;
} }
interface ServiceNode { interface ServiceNode {
@@ -290,6 +327,8 @@ interface HealthCheckTarget {
timeout_ms: number; timeout_ms: number;
verify_tls: boolean; verify_tls: boolean;
provider: HealthCheckProvider; provider: HealthCheckProvider;
providers?: HealthCheckProvider[];
aggregate?: HealthCheckAggregate;
} }
declare class ValidationError extends Error { declare class ValidationError extends Error {
@@ -370,7 +409,24 @@ declare const nodeHealthStateSchema: z.ZodEnum<{
declare const healthCheckProviderSchema: z.ZodEnum<{ declare const healthCheckProviderSchema: z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; 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.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>;
declare const healthCheckScopeSchema: z.ZodEnum<{ declare const healthCheckScopeSchema: z.ZodEnum<{
binding: "binding"; binding: "binding";
group: "group"; group: "group";
@@ -397,6 +453,8 @@ declare const ipHealthStatusSchema: z.ZodObject<{
provider: z.ZodOptional<z.ZodEnum<{ provider: z.ZodOptional<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
aggregate: "aggregate";
}>>; }>>;
}, z.core.$strip>; }, z.core.$strip>;
declare const serviceIpHealthSchema: z.ZodObject<{ declare const serviceIpHealthSchema: z.ZodObject<{
@@ -413,6 +471,8 @@ declare const serviceIpHealthSchema: z.ZodObject<{
provider: z.ZodOptional<z.ZodEnum<{ provider: z.ZodOptional<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
aggregate: "aggregate";
}>>; }>>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>; colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>; }, z.core.$strip>;
@@ -425,6 +485,7 @@ declare const healthProbeLogSchema: z.ZodObject<{
provider: z.ZodEnum<{ provider: z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
}>; }>;
status: z.ZodEnum<{ status: z.ZodEnum<{
unknown: "unknown"; unknown: "unknown";
@@ -455,21 +516,21 @@ declare const groupWithStatsSchema: z.ZodObject<{
domain_count: z.ZodNumber; domain_count: z.ZodNumber;
}, z.core.$strip>; }, z.core.$strip>;
declare const serviceGroupTypeSchema: z.ZodEnum<{ declare const serviceGroupTypeSchema: z.ZodEnum<{
custom: "custom";
vpn: "vpn"; vpn: "vpn";
network: "network"; network: "network";
internet: "internet"; internet: "internet";
bgp: "bgp"; bgp: "bgp";
custom: "custom";
}>; }>;
declare const serviceGroupSchema: z.ZodObject<{ declare const serviceGroupSchema: z.ZodObject<{
id: z.ZodNumber; id: z.ZodNumber;
name: z.ZodString; name: z.ZodString;
type: z.ZodCatch<z.ZodEnum<{ type: z.ZodCatch<z.ZodEnum<{
custom: "custom";
vpn: "vpn"; vpn: "vpn";
network: "network"; network: "network";
internet: "internet"; internet: "internet";
bgp: "bgp"; bgp: "bgp";
custom: "custom";
}>>; }>>;
icon: z.ZodDefault<z.ZodNullable<z.ZodString>>; icon: z.ZodDefault<z.ZodNullable<z.ZodString>>;
domain: z.ZodDefault<z.ZodNullable<z.ZodString>>; domain: z.ZodDefault<z.ZodNullable<z.ZodString>>;
@@ -495,6 +556,17 @@ declare const serviceGroupSchema: z.ZodObject<{
health_check_provider: z.ZodCatch<z.ZodEnum<{ health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>; }>>;
created_at: z.ZodString; created_at: z.ZodString;
updated_at: z.ZodString; updated_at: z.ZodString;
@@ -548,6 +620,17 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_provider: z.ZodCatch<z.ZodEnum<{ health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>; }>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>; sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{ }, z.core.$strip>, z.ZodTransform<{
@@ -570,7 +653,9 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_interval_sec: number; health_check_interval_sec: number;
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; 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; sync_status: string | null;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
}, { }, {
@@ -589,7 +674,9 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_interval_sec: number; health_check_interval_sec: number;
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; 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; sync_status: string | null;
target_ips?: string[] | undefined; target_ips?: string[] | undefined;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
@@ -646,6 +733,17 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_provider: z.ZodCatch<z.ZodEnum<{ health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>; }>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>; sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{ }, z.core.$strip>, z.ZodTransform<{
@@ -668,7 +766,9 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_interval_sec: number; health_check_interval_sec: number;
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; 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; sync_status: string | null;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
}, { }, {
@@ -687,7 +787,9 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_interval_sec: number; health_check_interval_sec: number;
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; 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; sync_status: string | null;
target_ips?: string[] | undefined; target_ips?: string[] | undefined;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
@@ -716,6 +818,8 @@ declare const serviceViewSchema: z.ZodObject<{
provider: z.ZodOptional<z.ZodEnum<{ provider: z.ZodOptional<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
aggregate: "aggregate";
}>>; }>>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>; colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
@@ -725,11 +829,11 @@ declare const serviceGroupViewSchema: z.ZodObject<{
id: z.ZodNumber; id: z.ZodNumber;
name: z.ZodString; name: z.ZodString;
type: z.ZodCatch<z.ZodEnum<{ type: z.ZodCatch<z.ZodEnum<{
custom: "custom";
vpn: "vpn"; vpn: "vpn";
network: "network"; network: "network";
internet: "internet"; internet: "internet";
bgp: "bgp"; bgp: "bgp";
custom: "custom";
}>>; }>>;
icon: z.ZodDefault<z.ZodNullable<z.ZodString>>; icon: z.ZodDefault<z.ZodNullable<z.ZodString>>;
domain: z.ZodDefault<z.ZodNullable<z.ZodString>>; domain: z.ZodDefault<z.ZodNullable<z.ZodString>>;
@@ -755,6 +859,17 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_provider: z.ZodCatch<z.ZodEnum<{ health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>; }>>;
created_at: z.ZodString; created_at: z.ZodString;
updated_at: z.ZodString; updated_at: z.ZodString;
@@ -807,6 +922,17 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_provider: z.ZodCatch<z.ZodEnum<{ health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>; }>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>; sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{ }, z.core.$strip>, z.ZodTransform<{
@@ -829,7 +955,9 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_interval_sec: number; health_check_interval_sec: number;
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; 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; sync_status: string | null;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
}, { }, {
@@ -848,7 +976,9 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_interval_sec: number; health_check_interval_sec: number;
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; 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; sync_status: string | null;
target_ips?: string[] | undefined; target_ips?: string[] | undefined;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
@@ -877,6 +1007,8 @@ declare const serviceGroupViewSchema: z.ZodObject<{
provider: z.ZodOptional<z.ZodEnum<{ provider: z.ZodOptional<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
aggregate: "aggregate";
}>>; }>>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>; colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
@@ -895,11 +1027,11 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
id: z.ZodNumber; id: z.ZodNumber;
name: z.ZodString; name: z.ZodString;
type: z.ZodCatch<z.ZodEnum<{ type: z.ZodCatch<z.ZodEnum<{
custom: "custom";
vpn: "vpn"; vpn: "vpn";
network: "network"; network: "network";
internet: "internet"; internet: "internet";
bgp: "bgp"; bgp: "bgp";
custom: "custom";
}>>; }>>;
icon: z.ZodDefault<z.ZodNullable<z.ZodString>>; icon: z.ZodDefault<z.ZodNullable<z.ZodString>>;
domain: z.ZodDefault<z.ZodNullable<z.ZodString>>; domain: z.ZodDefault<z.ZodNullable<z.ZodString>>;
@@ -925,6 +1057,17 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_provider: z.ZodCatch<z.ZodEnum<{ health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>; }>>;
created_at: z.ZodString; created_at: z.ZodString;
updated_at: z.ZodString; updated_at: z.ZodString;
@@ -977,6 +1120,17 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_provider: z.ZodCatch<z.ZodEnum<{ health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>; }>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>; sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{ }, z.core.$strip>, z.ZodTransform<{
@@ -999,7 +1153,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: number; health_check_interval_sec: number;
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; 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; sync_status: string | null;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
}, { }, {
@@ -1018,7 +1174,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: number; health_check_interval_sec: number;
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; 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; sync_status: string | null;
target_ips?: string[] | undefined; target_ips?: string[] | undefined;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
@@ -1047,6 +1205,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
provider: z.ZodOptional<z.ZodEnum<{ provider: z.ZodOptional<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
aggregate: "aggregate";
}>>; }>>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>; colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
@@ -1109,6 +1269,17 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_provider: z.ZodCatch<z.ZodEnum<{ health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>; }>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>; sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{ }, z.core.$strip>, z.ZodTransform<{
@@ -1131,7 +1302,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: number; health_check_interval_sec: number;
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; 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; sync_status: string | null;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
}, { }, {
@@ -1150,7 +1323,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: number; health_check_interval_sec: number;
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; 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; sync_status: string | null;
target_ips?: string[] | undefined; target_ips?: string[] | undefined;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
@@ -1179,6 +1354,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
provider: z.ZodOptional<z.ZodEnum<{ provider: z.ZodOptional<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
aggregate: "aggregate";
}>>; }>>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>; colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
@@ -1387,6 +1564,17 @@ declare const healthCheckConfigSchema: z.ZodObject<{
health_check_provider: z.ZodOptional<z.ZodEnum<{ health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>; }>>;
}, z.core.$strip>; }, z.core.$strip>;
type HealthCheckConfig = z.infer<typeof healthCheckConfigSchema>; type HealthCheckConfig = z.infer<typeof healthCheckConfigSchema>;
@@ -1418,6 +1606,17 @@ declare const createServiceWithConfigSchema: z.ZodObject<{
health_check_provider: z.ZodOptional<z.ZodEnum<{ health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>; }>>;
fqdn: z.ZodString; fqdn: z.ZodString;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>; target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -1610,6 +1809,17 @@ declare const updateServiceConfigSchema: z.ZodObject<{
health_check_provider: z.ZodOptional<z.ZodEnum<{ health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>; }>>;
fqdn: z.ZodString; fqdn: z.ZodString;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>; target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -1641,14 +1851,25 @@ declare const createServiceGroupSchema: z.ZodObject<{
health_check_provider: z.ZodOptional<z.ZodEnum<{ health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>; }>>;
name: z.ZodString; name: z.ZodString;
type: z.ZodDefault<z.ZodEnum<{ type: z.ZodDefault<z.ZodEnum<{
custom: "custom";
vpn: "vpn"; vpn: "vpn";
network: "network"; network: "network";
internet: "internet"; internet: "internet";
bgp: "bgp"; bgp: "bgp";
custom: "custom";
}>>; }>>;
icon: z.ZodOptional<z.ZodNullable<z.ZodString>>; icon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
domain: z.ZodOptional<z.ZodNullable<z.ZodString>>; domain: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -1675,14 +1896,25 @@ declare const updateServiceGroupSchema: z.ZodObject<{
health_check_provider: z.ZodOptional<z.ZodEnum<{ health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>; }>>;
name: z.ZodOptional<z.ZodString>; name: z.ZodOptional<z.ZodString>;
type: z.ZodOptional<z.ZodEnum<{ type: z.ZodOptional<z.ZodEnum<{
custom: "custom";
vpn: "vpn"; vpn: "vpn";
network: "network"; network: "network";
internet: "internet"; internet: "internet";
bgp: "bgp"; bgp: "bgp";
custom: "custom";
}>>; }>>;
icon: z.ZodOptional<z.ZodNullable<z.ZodString>>; icon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
domain: z.ZodOptional<z.ZodNullable<z.ZodString>>; domain: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -1776,6 +2008,7 @@ declare const originHealthCheckSchema: z.ZodObject<{
provider: z.ZodEnum<{ provider: z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
}>; }>;
cf_healthcheck_id: z.ZodNullable<z.ZodString>; cf_healthcheck_id: z.ZodNullable<z.ZodString>;
cf_zone_id: z.ZodNullable<z.ZodString>; cf_zone_id: z.ZodNullable<z.ZodString>;
@@ -1797,6 +2030,7 @@ declare const createOriginHealthCheckSchema: z.ZodObject<{
provider: z.ZodEnum<{ provider: z.ZodEnum<{
local: "local"; local: "local";
cloudflare: "cloudflare"; cloudflare: "cloudflare";
globalping: "globalping";
}>; }>;
name: z.ZodString; name: z.ZodString;
cf_zone_id: z.ZodOptional<z.ZodNullable<z.ZodString>>; cf_zone_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -1924,6 +2158,9 @@ declare const appSettingsPatchSchema: z.ZodObject<{
healthSuccessRecoveries: z.ZodOptional<z.ZodNumber>; healthSuccessRecoveries: z.ZodOptional<z.ZodNumber>;
healthWorkerUrl: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodLiteral<"">]>>; healthWorkerUrl: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodLiteral<"">]>>;
healthWorkerToken: z.ZodOptional<z.ZodString>; healthWorkerToken: z.ZodOptional<z.ZodString>;
globalpingToken: z.ZodOptional<z.ZodString>;
globalpingLocations: z.ZodOptional<z.ZodString>;
globalpingLimit: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>; }, z.core.$strip>;
type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>; type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
declare const vpsTrackerEventSchema: z.ZodObject<{ declare const vpsTrackerEventSchema: z.ZodObject<{
@@ -2085,4 +2322,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{
}, z.core.$strip>; }, z.core.$strip>;
type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>; type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>;
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 };
+132 -5
View File
@@ -165,6 +165,100 @@ function bindingToFqdn(binding) {
// src/schemas.ts // src/schemas.ts
import { z } from "zod"; 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 certMonitoringSchema = z.enum(["auto", "required", "skipped"]);
var lbModeSchema = z.enum(["round_robin", "failover", "weighted"]); var lbModeSchema = z.enum(["round_robin", "failover", "weighted"]);
var healthCheckTypeSchema = z.enum(["tcp", "http", "ping", "dns"]); var healthCheckTypeSchema = z.enum(["tcp", "http", "ping", "dns"]);
@@ -179,7 +273,13 @@ var nodeHealthStateSchema = z.enum([
"unhealthy", "unhealthy",
"disabled" "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 healthCheckScopeSchema = z.enum(["binding", "group"]);
var ipHealthStatusSchema = z.object({ var ipHealthStatusSchema = z.object({
scope: healthCheckScopeSchema, scope: healthCheckScopeSchema,
@@ -192,7 +292,7 @@ var ipHealthStatusSchema = z.object({
last_checked_at: z.string().nullable(), last_checked_at: z.string().nullable(),
last_error: z.string().nullable(), last_error: z.string().nullable(),
colo: z.string().nullable().optional(), colo: z.string().nullable().optional(),
provider: healthCheckProviderSchema.optional() provider: healthStatusProviderSchema.optional()
}); });
var serviceIpHealthSchema = z.object({ var serviceIpHealthSchema = z.object({
ip: z.string(), ip: z.string(),
@@ -200,7 +300,7 @@ var serviceIpHealthSchema = z.object({
latency_ms: z.number().nullable(), latency_ms: z.number().nullable(),
last_checked_at: z.string().nullable().optional(), last_checked_at: z.string().nullable().optional(),
last_error: z.string().nullable().optional(), last_error: z.string().nullable().optional(),
provider: healthCheckProviderSchema.optional(), provider: healthStatusProviderSchema.optional(),
colo: z.string().nullable().optional() colo: z.string().nullable().optional()
}); });
var healthProbeLogSchema = z.object({ var healthProbeLogSchema = z.object({
@@ -250,6 +350,8 @@ var serviceGroupSchema = z.object({
health_check_timeout_ms: z.number().default(3e3), health_check_timeout_ms: z.number().default(3e3),
health_check_verify_tls: z.coerce.boolean().default(false), health_check_verify_tls: z.coerce.boolean().default(false),
health_check_provider: healthCheckProviderSchema.catch("local"), health_check_provider: healthCheckProviderSchema.catch("local"),
health_check_providers: healthCheckProvidersSchema.catch(["local"]),
health_check_aggregate: healthCheckAggregateSchema.catch("majority"),
created_at: z.string(), created_at: z.string(),
updated_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_timeout_ms: z.number().default(3e3),
health_check_verify_tls: z.coerce.boolean().default(false), health_check_verify_tls: z.coerce.boolean().default(false),
health_check_provider: healthCheckProviderSchema.catch("local"), 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) sync_status: z.string().nullable().default(null)
}).transform((binding) => ({ }).transform((binding) => ({
...binding, ...binding,
@@ -414,7 +518,9 @@ var healthCheckConfigFields = {
health_check_interval_sec: z.number().int().min(5).max(3600).optional(), 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_timeout_ms: z.number().int().min(100).max(3e4).optional(),
health_check_verify_tls: z.boolean().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 healthCheckConfigSchema = z.object(healthCheckConfigFields);
var serviceDomainInputSchema = z.object({ var serviceDomainInputSchema = z.object({
@@ -727,7 +833,10 @@ var appSettingsPatchSchema = z3.object({
healthLatencyWarnMs: z3.number().int().min(50).max(6e4).optional(), healthLatencyWarnMs: z3.number().int().min(50).max(6e4).optional(),
healthSuccessRecoveries: z3.number().int().min(1).max(20).optional(), healthSuccessRecoveries: z3.number().int().min(1).max(20).optional(),
healthWorkerUrl: z3.string().url().or(z3.literal("")).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) => { }).superRefine((data, ctx) => {
if (data.healthDegradedFailures != null && data.healthDownFailures != null && data.healthDownFailures < data.healthDegradedFailures) { if (data.healthDegradedFailures != null && data.healthDownFailures != null && data.healthDownFailures < data.healthDegradedFailures) {
ctx.addIssue({ ctx.addIssue({
@@ -829,6 +938,8 @@ export {
CERT_OK, CERT_OK,
CERT_UNKNOWN, CERT_UNKNOWN,
CERT_WARNING, CERT_WARNING,
HEALTH_CHECK_AGGREGATES,
HEALTH_CHECK_PROVIDERS,
HEALTH_KV_CURSOR_KEY, HEALTH_KV_CURSOR_KEY,
HEALTH_KV_RESULTS_KEY, HEALTH_KV_RESULTS_KEY,
HEALTH_KV_TARGETS_KEY, HEALTH_KV_TARGETS_KEY,
@@ -836,12 +947,14 @@ export {
HEALTH_PROBE_CONCURRENCY, HEALTH_PROBE_CONCURRENCY,
HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_KV_TITLE,
HEALTH_PROBE_SCRIPT_NAME, HEALTH_PROBE_SCRIPT_NAME,
HEALTH_STATUS_PROVIDERS,
SYNC_CONFLICT, SYNC_CONFLICT,
SYNC_ERROR, SYNC_ERROR,
SYNC_PENDING_DELETE, SYNC_PENDING_DELETE,
SYNC_PENDING_PUSH, SYNC_PENDING_PUSH,
SYNC_SYNCED, SYNC_SYNCED,
ValidationError, ValidationError,
aggregateHealthOk,
appSettingsPatchSchema, appSettingsPatchSchema,
appSwitcherConfigSchema, appSwitcherConfigSchema,
appSwitcherEntrySchema, appSwitcherEntrySchema,
@@ -860,6 +973,7 @@ export {
cfdmSyncBindingsBodySchema, cfdmSyncBindingsBodySchema,
changeDomainSchema, changeDomainSchema,
changeIpSchema, changeIpSchema,
clampGlobalpingLimit,
createDnsRecordSchema, createDnsRecordSchema,
createDomainMonitorSchema, createDomainMonitorSchema,
createDomainSchema, createDomainSchema,
@@ -871,6 +985,7 @@ export {
createServiceSchema, createServiceSchema,
createServiceWithConfigSchema, createServiceWithConfigSchema,
createSubdomainSchema, createSubdomainSchema,
derivePrimaryProvider,
dnsNameToSubdomainLabel, dnsNameToSubdomainLabel,
dnsRecordNamesMatch, dnsRecordNamesMatch,
dnsRecordSchema, dnsRecordSchema,
@@ -883,11 +998,14 @@ export {
fqdnToDisplay, fqdnToDisplay,
groupSchema, groupSchema,
groupWithStatsSchema, groupWithStatsSchema,
healthCheckAggregateSchema,
healthCheckConfigSchema, healthCheckConfigSchema,
healthCheckProviderSchema, healthCheckProviderSchema,
healthCheckProvidersSchema,
healthCheckScopeSchema, healthCheckScopeSchema,
healthCheckTypeSchema, healthCheckTypeSchema,
healthProbeLogSchema, healthProbeLogSchema,
healthStatusProviderSchema,
healthStatusQuerySchema, healthStatusQuerySchema,
ingestAuditEventSchema, ingestAuditEventSchema,
ipHealthStateSchema, ipHealthStateSchema,
@@ -898,10 +1016,16 @@ export {
loginSchema, loginSchema,
nodeHealthStateSchema, nodeHealthStateSchema,
normalizeDnsRecordName, normalizeDnsRecordName,
normalizeProbeProvider,
normalizeStatusProvider,
notificationLogSchema, notificationLogSchema,
originHealthCheckSchema, originHealthCheckSchema,
parseFqdn, parseFqdn,
parseGlobalpingLocations,
parseHealthAggregate,
parseHealthProviders,
reorderServicesSchema, reorderServicesSchema,
serializeHealthProviders,
serviceBindingSchema, serviceBindingSchema,
serviceDomainBindingSchema, serviceDomainBindingSchema,
serviceGroupSchema, serviceGroupSchema,
@@ -915,8 +1039,11 @@ export {
shouldMonitorService, shouldMonitorService,
subdomainLabelToFqdn, subdomainLabelToFqdn,
subdomainSchema, subdomainSchema,
targetHasProvider,
targetProviders,
toggleEnabledSchema, toggleEnabledSchema,
toggleServiceIpSchema, toggleServiceIpSchema,
uniqueHealthProviders,
updateDomainGroupSchema, updateDomainGroupSchema,
updateDomainSchema, updateDomainSchema,
updateServiceConfigSchema, updateServiceConfigSchema,
+143
View File
@@ -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<string>(HEALTH_CHECK_PROVIDERS);
const STATUS_SET = new Set<string>(HEALTH_STATUS_PROVIDERS);
const AGGREGATE_SET = new Set<string>(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"];
}
+1 -1
View File
@@ -6,6 +6,7 @@ export * from "./schemas.js";
export * from "./app-switcher.js"; export * from "./app-switcher.js";
export * from "./integration-vps-tracker.js"; export * from "./integration-vps-tracker.js";
export * from "./health-probe-mailbox.js"; export * from "./health-probe-mailbox.js";
export * from "./health-providers.js";
export * from "./audit.js"; export * from "./audit.js";
export type { export type {
CfZone, CfZone,
@@ -23,7 +24,6 @@ export type {
HealthCheckType, HealthCheckType,
IpHealthState, IpHealthState,
NodeHealthState, NodeHealthState,
HealthCheckProvider,
HealthCheckScope, HealthCheckScope,
IpHealthStatus, IpHealthStatus,
HealthCheckTarget, HealthCheckTarget,
@@ -37,6 +37,9 @@ export const appSettingsPatchSchema = z.object({
healthSuccessRecoveries: z.number().int().min(1).max(20).optional(), healthSuccessRecoveries: z.number().int().min(1).max(20).optional(),
healthWorkerUrl: z.string().url().or(z.literal("")).optional(), healthWorkerUrl: z.string().url().or(z.literal("")).optional(),
healthWorkerToken: z.string().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) => { }).superRefine((data, ctx) => {
if ( if (
data.healthDegradedFailures != null && data.healthDegradedFailures != null &&
+27 -4
View File
@@ -1,4 +1,10 @@
import { z } from 'zod' 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']) export const certMonitoringSchema = z.enum(['auto', 'required', 'skipped'])
@@ -29,8 +35,19 @@ export const nodeHealthStateSchema = z.enum([
]) ])
export type NodeHealthState = z.infer<typeof nodeHealthStateSchema> export type NodeHealthState = z.infer<typeof nodeHealthStateSchema>
export const healthCheckProviderSchema = z.enum(['local', 'cloudflare']) export const healthCheckProviderSchema = z.enum(HEALTH_CHECK_PROVIDERS)
export type HealthCheckProvider = z.infer<typeof healthCheckProviderSchema>
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 const healthCheckScopeSchema = z.enum(['binding', 'group'])
export type HealthCheckScope = z.infer<typeof healthCheckScopeSchema> export type HealthCheckScope = z.infer<typeof healthCheckScopeSchema>
@@ -46,7 +63,7 @@ export const ipHealthStatusSchema = z.object({
last_checked_at: z.string().nullable(), last_checked_at: z.string().nullable(),
last_error: z.string().nullable(), last_error: z.string().nullable(),
colo: z.string().nullable().optional(), colo: z.string().nullable().optional(),
provider: healthCheckProviderSchema.optional(), provider: healthStatusProviderSchema.optional(),
}) })
export type IpHealthStatus = z.infer<typeof ipHealthStatusSchema> export type IpHealthStatus = z.infer<typeof ipHealthStatusSchema>
@@ -57,7 +74,7 @@ export const serviceIpHealthSchema = z.object({
latency_ms: z.number().nullable(), latency_ms: z.number().nullable(),
last_checked_at: z.string().nullable().optional(), last_checked_at: z.string().nullable().optional(),
last_error: z.string().nullable().optional(), last_error: z.string().nullable().optional(),
provider: healthCheckProviderSchema.optional(), provider: healthStatusProviderSchema.optional(),
colo: z.string().nullable().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_timeout_ms: z.number().default(3000),
health_check_verify_tls: z.coerce.boolean().default(false), health_check_verify_tls: z.coerce.boolean().default(false),
health_check_provider: healthCheckProviderSchema.catch('local'), health_check_provider: healthCheckProviderSchema.catch('local'),
health_check_providers: healthCheckProvidersSchema.catch(['local']),
health_check_aggregate: healthCheckAggregateSchema.catch('majority'),
created_at: z.string(), created_at: z.string(),
updated_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_timeout_ms: z.number().default(3000),
health_check_verify_tls: z.coerce.boolean().default(false), health_check_verify_tls: z.coerce.boolean().default(false),
health_check_provider: healthCheckProviderSchema.catch('local'), 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), sync_status: z.string().nullable().default(null),
}) })
.transform((binding) => ({ .transform((binding) => ({
@@ -329,6 +350,8 @@ const healthCheckConfigFields = {
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(), health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
health_check_verify_tls: z.boolean().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(),
} }
export const healthCheckConfigSchema = z.object(healthCheckConfigFields) export const healthCheckConfigSchema = z.object(healthCheckConfigFields)
+24 -4
View File
@@ -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 { export interface Group {
id: number; id: number;
name: string; name: string;
@@ -23,6 +35,8 @@ export interface ServiceGroup {
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider; health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -123,6 +137,8 @@ export interface ServiceBinding {
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider; health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
routing_strategy: LbMode; routing_strategy: LbMode;
operation_version: number; operation_version: number;
created_at: string; created_at: string;
@@ -155,6 +171,8 @@ export interface ServiceBindingView {
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider; health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
sync_status: string | null; sync_status: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -181,6 +199,8 @@ export interface ServiceDomainBindingView {
health_check_timeout_ms: number; health_check_timeout_ms: number;
health_check_verify_tls: boolean; health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider; health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
sync_status: string | null; sync_status: string | null;
} }
@@ -284,8 +304,6 @@ export type NodeHealthState =
| "unhealthy" | "unhealthy"
| "disabled"; | "disabled";
export type HealthCheckProvider = "local" | "cloudflare";
export type HealthCheckScope = "binding" | "group"; export type HealthCheckScope = "binding" | "group";
export interface IpHealthStatus { export interface IpHealthStatus {
@@ -299,7 +317,7 @@ export interface IpHealthStatus {
last_checked_at: string | null; last_checked_at: string | null;
last_error: string | null; last_error: string | null;
colo?: string | null; colo?: string | null;
provider?: HealthCheckProvider; provider?: HealthStatusProvider;
} }
export interface ServiceIpHealth { export interface ServiceIpHealth {
@@ -308,7 +326,7 @@ export interface ServiceIpHealth {
latency_ms: number | null; latency_ms: number | null;
last_checked_at?: string | null; last_checked_at?: string | null;
last_error?: string | null; last_error?: string | null;
provider?: HealthCheckProvider; provider?: HealthStatusProvider;
colo?: string | null; colo?: string | null;
} }
@@ -394,4 +412,6 @@ export interface HealthCheckTarget {
timeout_ms: number; timeout_ms: number;
verify_tls: boolean; verify_tls: boolean;
provider: HealthCheckProvider; provider: HealthCheckProvider;
providers?: HealthCheckProvider[];
aggregate?: HealthCheckAggregate;
} }
@@ -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<typeof toggleVariants> & {
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<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }
+43
View File
@@ -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<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }