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
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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 {
|
||||
getAppSettings,
|
||||
getAppSettingsSecrets,
|
||||
updateAppSettings,
|
||||
type HealthEngineFallbacks,
|
||||
} from "@cfdm/db";
|
||||
@@ -71,11 +72,17 @@ export function createHealthCheckTask(
|
||||
successRecoveries: settings.healthSuccessRecoveries,
|
||||
};
|
||||
const mailbox = mailboxFromSettings(app.db, app.cf, fallbacks);
|
||||
const secrets = getAppSettingsSecrets(app.db);
|
||||
const n = await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
probeGapMs: config.healthProbeGapMs,
|
||||
mailbox,
|
||||
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
|
||||
globalping: {
|
||||
token: secrets.globalpingToken,
|
||||
locations: secrets.globalpingLocations,
|
||||
limit: secrets.globalpingLimit,
|
||||
},
|
||||
onStatusChange: async (target, prev, next) => {
|
||||
try {
|
||||
const label =
|
||||
|
||||
@@ -3,11 +3,20 @@ import { resolve4, resolve6 } from "node:dns/promises";
|
||||
import { Agent, buildConnector, fetch as undiciFetch } from "undici";
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
|
||||
import type { HealthCheckTarget, IpHealthState, HealthCheckProvider } from "@cfdm/shared";
|
||||
import {
|
||||
aggregateHealthOk,
|
||||
parseHealthAggregate,
|
||||
targetProviders,
|
||||
} from "@cfdm/shared";
|
||||
import { AppError } from "../errors.js";
|
||||
import { nextHealthState } from "./health/state-machine.js";
|
||||
import { LocalHealthCheckProvider } from "./health/local.js";
|
||||
import { workerNotConfiguredResult } from "./health/worker.js";
|
||||
import {
|
||||
globalpingNotConfiguredResult,
|
||||
probeWithGlobalping,
|
||||
} from "./health/globalping.js";
|
||||
import {
|
||||
buildTargetsDoc,
|
||||
indexResults,
|
||||
@@ -15,6 +24,7 @@ import {
|
||||
originProbeKey,
|
||||
type HealthMailbox,
|
||||
} from "./health/mailbox.js";
|
||||
import type { GlobalpingClientOptions } from "../lib/globalping-client.js";
|
||||
|
||||
export interface HealthCheckThresholds {
|
||||
degradedFailures: number;
|
||||
@@ -275,6 +285,7 @@ export interface RunAllChecksOptions {
|
||||
mailbox?: HealthMailbox | null;
|
||||
/** Results older than this are stale (default 10 min). */
|
||||
staleAfterMs?: number;
|
||||
globalping?: GlobalpingClientOptions | null;
|
||||
onStatusChange?: (
|
||||
target: HealthCheckTarget,
|
||||
prevState: IpHealthState | null,
|
||||
@@ -287,20 +298,60 @@ function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* One network hit per key. Group+binding on the same IP share a single TCP/HTTP probe
|
||||
* so anti-bot / rate-limit on the origin is not tripped by back-to-back checks.
|
||||
* One network hit per origin+provider. Group+binding on the same IP share a probe.
|
||||
*/
|
||||
export function physicalProbeKey(target: HealthCheckTarget): string {
|
||||
const kind = target.provider === "cloudflare" ? "cloudflare" : "local";
|
||||
return `${kind}|${originProbeKey(target)}`;
|
||||
export function physicalProbeKey(
|
||||
target: HealthCheckTarget,
|
||||
provider: HealthCheckProvider = target.provider,
|
||||
): string {
|
||||
return `${provider}|${originProbeKey(target)}`;
|
||||
}
|
||||
|
||||
function applyProbeResult(
|
||||
function logSourceResult(
|
||||
db: Db,
|
||||
target: HealthCheckTarget,
|
||||
provider: HealthCheckProvider,
|
||||
result: ProbeResult,
|
||||
): void {
|
||||
repos.insertHealthProbeLog(db, {
|
||||
scope: target.scope,
|
||||
refId: target.ref_id,
|
||||
ip: target.ip,
|
||||
provider,
|
||||
status: result.ok ? "up" : "down",
|
||||
ok: result.ok,
|
||||
latencyMs: result.latencyMs,
|
||||
colo: result.colo ?? null,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
function applyAggregatedStatus(
|
||||
db: Db,
|
||||
target: HealthCheckTarget,
|
||||
sources: Array<{ provider: HealthCheckProvider; result: ProbeResult }>,
|
||||
options: RunAllChecksOptions,
|
||||
): void {
|
||||
const policy = parseHealthAggregate(target.aggregate);
|
||||
const oks = sources.map((s) => s.result.ok);
|
||||
const aggregatedOk = aggregateHealthOk(oks, policy);
|
||||
const latencies = sources.map((s) => s.result.latencyMs);
|
||||
const latencyMs = latencies.length
|
||||
? Math.round(latencies.reduce((sum, n) => sum + n, 0) / latencies.length)
|
||||
: 0;
|
||||
const colo =
|
||||
sources.find((s) => s.result.colo)?.result.colo ??
|
||||
sources[0]?.result.colo ??
|
||||
null;
|
||||
const error = aggregatedOk
|
||||
? null
|
||||
: sources
|
||||
.map((s) => s.result.error)
|
||||
.filter((msg): msg is string => Boolean(msg))
|
||||
.join("; ") || "health aggregate down";
|
||||
const statusProvider =
|
||||
sources.length > 1 ? "aggregate" : (sources[0]?.provider ?? target.provider);
|
||||
|
||||
const prev = repos.getIpHealthStatusRow(
|
||||
db,
|
||||
target.scope,
|
||||
@@ -308,8 +359,8 @@ function applyProbeResult(
|
||||
target.ip,
|
||||
);
|
||||
const { state, failures, successes, node } = deriveState(
|
||||
result.ok,
|
||||
result.latencyMs,
|
||||
aggregatedOk,
|
||||
latencyMs,
|
||||
prev
|
||||
? {
|
||||
consecutive_failures: prev.consecutive_failures,
|
||||
@@ -322,30 +373,18 @@ function applyProbeResult(
|
||||
const prevState: IpHealthState | null = prev
|
||||
? (prev.status as IpHealthState)
|
||||
: null;
|
||||
const provider = target.provider === "cloudflare" ? "cloudflare" : "local";
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
target.scope,
|
||||
target.ref_id,
|
||||
target.ip,
|
||||
state,
|
||||
result.latencyMs,
|
||||
latencyMs,
|
||||
failures,
|
||||
result.error,
|
||||
error,
|
||||
successes,
|
||||
{ colo: result.colo ?? null, provider },
|
||||
{ colo, provider: statusProvider },
|
||||
);
|
||||
repos.insertHealthProbeLog(db, {
|
||||
scope: target.scope,
|
||||
refId: target.ref_id,
|
||||
ip: target.ip,
|
||||
provider,
|
||||
status: state,
|
||||
ok: result.ok,
|
||||
latencyMs: result.latencyMs,
|
||||
colo: result.colo ?? null,
|
||||
error: result.error,
|
||||
});
|
||||
const matchedNode = repos.findNodeByIp(db, target.ip);
|
||||
if (matchedNode && matchedNode.enabled) {
|
||||
repos.updateNode(db, matchedNode.id, {
|
||||
@@ -353,7 +392,7 @@ function applyProbeResult(
|
||||
consecutive_failures: failures,
|
||||
consecutive_successes: successes,
|
||||
last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||
last_failure_reason: result.error,
|
||||
last_failure_reason: error,
|
||||
});
|
||||
}
|
||||
if (prevState !== state) {
|
||||
@@ -379,42 +418,26 @@ export async function runAllChecks(
|
||||
const local = new LocalHealthCheckProvider();
|
||||
const staleAfterMs = options.staleAfterMs ?? 10 * 60_000;
|
||||
|
||||
const byPhysical = new Map<string, HealthCheckTarget[]>();
|
||||
const byOrigin = new Map<string, HealthCheckTarget[]>();
|
||||
for (const target of targets) {
|
||||
const key = physicalProbeKey(target);
|
||||
const list = byPhysical.get(key);
|
||||
const key = originProbeKey(target);
|
||||
const list = byOrigin.get(key);
|
||||
if (list) list.push(target);
|
||||
else byPhysical.set(key, [target]);
|
||||
else byOrigin.set(key, [target]);
|
||||
}
|
||||
|
||||
const localGroups: HealthCheckTarget[][] = [];
|
||||
const cloudflareGroups: HealthCheckTarget[][] = [];
|
||||
for (const group of byPhysical.values()) {
|
||||
if (group[0]?.provider === "cloudflare") cloudflareGroups.push(group);
|
||||
else localGroups.push(group);
|
||||
}
|
||||
|
||||
let probeIndex = 0;
|
||||
for (const group of localGroups) {
|
||||
if (probeIndex > 0 && gapMs > 0) {
|
||||
await sleep(gapMs);
|
||||
}
|
||||
probeIndex += 1;
|
||||
const representative =
|
||||
group.find((t) => t.scope === "binding") ?? group[0]!;
|
||||
const result = await local.probe(representative);
|
||||
for (const target of group) {
|
||||
applyProbeResult(db, target, result, options);
|
||||
}
|
||||
}
|
||||
|
||||
if (cloudflareGroups.length > 0) {
|
||||
const mailbox = options.mailbox ?? null;
|
||||
const needsCloudflare = targets.some((t) =>
|
||||
targetProviders(t).includes("cloudflare"),
|
||||
);
|
||||
let mailboxResults = new Map<string, { ok: boolean; latencyMs: number; error: string | null }>();
|
||||
let mailboxColo: string | null = null;
|
||||
let mailboxStale = true;
|
||||
const mailbox = options.mailbox ?? null;
|
||||
if (needsCloudflare) {
|
||||
const resultsDoc = mailbox ? await mailbox.getResults() : null;
|
||||
const byKey = indexResults(resultsDoc);
|
||||
const stale = !mailbox || isResultsStale(resultsDoc, staleAfterMs);
|
||||
const colo = resultsDoc?.colo ?? null;
|
||||
|
||||
mailboxResults = indexResults(resultsDoc);
|
||||
mailboxStale = !mailbox || isResultsStale(resultsDoc, staleAfterMs);
|
||||
mailboxColo = resultsDoc?.colo ?? null;
|
||||
if (mailbox) {
|
||||
try {
|
||||
const next = buildTargetsDoc(targets);
|
||||
@@ -426,28 +449,71 @@ export async function runAllChecks(
|
||||
// ingest still proceeds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of cloudflareGroups) {
|
||||
const representative =
|
||||
group.find((t) => t.scope === "binding") ?? group[0]!;
|
||||
const item = byKey.get(originProbeKey(representative));
|
||||
let result: ProbeResult;
|
||||
if (!mailbox) {
|
||||
result = workerNotConfiguredResult();
|
||||
} else if (stale || !item) {
|
||||
result = staleWorkerResult(colo);
|
||||
} else {
|
||||
const probeCache = new Map<string, ProbeResult>();
|
||||
let probeIndex = 0;
|
||||
|
||||
async function resolveProvider(
|
||||
provider: HealthCheckProvider,
|
||||
representative: HealthCheckTarget,
|
||||
originKey: string,
|
||||
): Promise<ProbeResult> {
|
||||
const cacheKey = `${provider}|${originKey}`;
|
||||
const cached = probeCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
let result: ProbeResult;
|
||||
if (provider === "local") {
|
||||
if (probeIndex > 0 && gapMs > 0) await sleep(gapMs);
|
||||
probeIndex += 1;
|
||||
result = await local.probe(representative);
|
||||
} else if (provider === "cloudflare") {
|
||||
const item = mailboxResults.get(originKey);
|
||||
if (!mailbox) result = workerNotConfiguredResult();
|
||||
else if (mailboxStale || !item) result = staleWorkerResult(mailboxColo);
|
||||
else {
|
||||
result = {
|
||||
ok: item.ok,
|
||||
latencyMs: item.latencyMs,
|
||||
error: item.error,
|
||||
colo,
|
||||
colo: mailboxColo,
|
||||
};
|
||||
}
|
||||
for (const target of group) {
|
||||
applyProbeResult(db, target, result, options);
|
||||
} else {
|
||||
if (!options.globalping?.token?.trim()) {
|
||||
result = globalpingNotConfiguredResult();
|
||||
} else {
|
||||
if (probeIndex > 0 && gapMs > 0) await sleep(gapMs);
|
||||
probeIndex += 1;
|
||||
result = await probeWithGlobalping(representative, options.globalping);
|
||||
}
|
||||
}
|
||||
probeCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const [originKey, group] of byOrigin) {
|
||||
const representative =
|
||||
group.find((t) => t.scope === "binding") ?? group[0]!;
|
||||
const needed = new Set<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);
|
||||
@@ -473,6 +539,8 @@ export async function runDomainMonitors(
|
||||
timeout_ms: monitor.timeout_ms,
|
||||
verify_tls: false,
|
||||
provider: "local",
|
||||
providers: ["local"],
|
||||
aggregate: "majority",
|
||||
};
|
||||
let result: ProbeResult;
|
||||
if (monitor.type === "http") {
|
||||
|
||||
@@ -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 type { Db } from "@cfdm/db";
|
||||
import { HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME } from "@cfdm/shared";
|
||||
import { HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, targetHasProvider } from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../../lib/cf-client.js";
|
||||
import { AppError } from "../../errors.js";
|
||||
import { loadHealthProbeWorkerSource } from "./health-probe-script.js";
|
||||
@@ -126,7 +126,7 @@ export async function maybeEnsureHealthWorker(
|
||||
): Promise<void> {
|
||||
const hasCloudflare = repos
|
||||
.listHealthCheckTargets(db)
|
||||
.some((target) => target.provider === "cloudflare");
|
||||
.some((target) => targetHasProvider(target, "cloudflare"));
|
||||
if (!hasCloudflare) return;
|
||||
const settings = getAppSettings(db, fallbacks);
|
||||
if (settings.healthWorkerKvNamespaceId.trim() && !settings.healthWorkerError) {
|
||||
@@ -172,7 +172,7 @@ export function fireEnsureHealthWorker(
|
||||
if (!cf.isConfigured) return;
|
||||
const hasCloudflare = repos
|
||||
.listHealthCheckTargets(db)
|
||||
.some((target) => target.provider === "cloudflare");
|
||||
.some((target) => targetHasProvider(target, "cloudflare"));
|
||||
if (!hasCloudflare) {
|
||||
void syncCloudflareTargetsToKv(db, cf, fallbacks).catch((err) => {
|
||||
log?.warn({ err }, "health worker KV sync failed");
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
HealthProbeTargetItem,
|
||||
HealthProbeTargetsDoc,
|
||||
} from "@cfdm/shared";
|
||||
import { HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY } from "@cfdm/shared";
|
||||
import { HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, targetHasProvider } from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../../lib/cf-client.js";
|
||||
|
||||
export interface HealthMailbox {
|
||||
@@ -70,7 +70,7 @@ export function cloudflareMailboxTargets(
|
||||
): HealthProbeTargetItem[] {
|
||||
const unique = new Map<string, HealthProbeTargetItem>();
|
||||
for (const target of targets) {
|
||||
if (target.provider !== "cloudflare") continue;
|
||||
if (!targetHasProvider(target, "cloudflare")) continue;
|
||||
if (target.type !== "tcp" && target.type !== "http") continue;
|
||||
const key = originProbeKey(target);
|
||||
if (unique.has(key)) continue;
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type {
|
||||
DnsRecord,
|
||||
HealthCheckAggregate,
|
||||
HealthCheckProvider,
|
||||
HealthCheckScope,
|
||||
HealthCheckType,
|
||||
IpHealthState,
|
||||
@@ -51,7 +53,9 @@ export interface ServiceDomainInput {
|
||||
health_check_interval_sec?: number;
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: "local" | "cloudflare";
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
export interface ToggleRequest {
|
||||
@@ -72,7 +76,9 @@ export interface ServiceGroupBody {
|
||||
health_check_interval_sec?: number;
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: "local" | "cloudflare";
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
export interface UpdateServiceGroupBody {
|
||||
@@ -89,7 +95,9 @@ export interface UpdateServiceGroupBody {
|
||||
health_check_interval_sec?: number;
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: "local" | "cloudflare";
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
export interface UpdateServiceConfigRequest {
|
||||
@@ -296,6 +304,10 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
health_check_timeout_ms: binding.health_check_timeout_ms,
|
||||
health_check_verify_tls: binding.health_check_verify_tls,
|
||||
health_check_provider: binding.health_check_provider ?? "local",
|
||||
health_check_providers: binding.health_check_providers ?? [
|
||||
binding.health_check_provider ?? "local",
|
||||
],
|
||||
health_check_aggregate: binding.health_check_aggregate ?? "majority",
|
||||
sync_status: aggregateSyncStatus(statuses),
|
||||
};
|
||||
});
|
||||
@@ -1164,7 +1176,9 @@ export async function updateConfig(
|
||||
input.health_check_interval_sec !== undefined ||
|
||||
input.health_check_timeout_ms !== undefined ||
|
||||
input.health_check_verify_tls !== undefined ||
|
||||
input.health_check_provider !== undefined
|
||||
input.health_check_provider !== undefined ||
|
||||
input.health_check_providers !== undefined ||
|
||||
input.health_check_aggregate !== undefined
|
||||
) {
|
||||
repos.updateBindingLbConfig(db, binding.id, {
|
||||
lb_mode: input.lb_mode,
|
||||
@@ -1177,6 +1191,8 @@ export async function updateConfig(
|
||||
health_check_timeout_ms: input.health_check_timeout_ms,
|
||||
health_check_verify_tls: input.health_check_verify_tls,
|
||||
health_check_provider: input.health_check_provider,
|
||||
health_check_providers: input.health_check_providers,
|
||||
health_check_aggregate: input.health_check_aggregate,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1281,6 +1297,8 @@ export async function createGroup(
|
||||
health_check_timeout_ms: body.health_check_timeout_ms,
|
||||
health_check_verify_tls: body.health_check_verify_tls,
|
||||
health_check_provider: body.health_check_provider,
|
||||
health_check_providers: body.health_check_providers,
|
||||
health_check_aggregate: body.health_check_aggregate,
|
||||
},
|
||||
);
|
||||
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||
@@ -1320,6 +1338,8 @@ export async function updateGroup(
|
||||
health_check_timeout_ms: body.health_check_timeout_ms,
|
||||
health_check_verify_tls: body.health_check_verify_tls,
|
||||
health_check_provider: body.health_check_provider,
|
||||
health_check_providers: body.health_check_providers,
|
||||
health_check_aggregate: body.health_check_aggregate,
|
||||
},
|
||||
);
|
||||
if (!domain && group.enabled) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -164,4 +164,51 @@ describe("settings health engine", () => {
|
||||
expect(body.healthWorkerToken).toBeUndefined();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("GET exposes Globalping flags without the token; PATCH persists locations/limit", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
const initial = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings",
|
||||
headers,
|
||||
});
|
||||
expect(initial.statusCode).toBe(200);
|
||||
const before = initial.json() as {
|
||||
globalpingTokenSet?: boolean;
|
||||
globalpingLocations?: string;
|
||||
globalpingLimit?: number;
|
||||
globalpingToken?: string;
|
||||
};
|
||||
expect(before.globalpingTokenSet).toBe(false);
|
||||
expect(before.globalpingLocations).toBe("World");
|
||||
expect(before.globalpingLimit).toBe(3);
|
||||
expect(before.globalpingToken).toBeUndefined();
|
||||
|
||||
const patched = await app.inject({
|
||||
method: "PATCH",
|
||||
url: "/api/v1/settings",
|
||||
headers,
|
||||
payload: {
|
||||
globalpingToken: "gp_secret",
|
||||
globalpingLocations: "EU,US",
|
||||
globalpingLimit: 5,
|
||||
},
|
||||
});
|
||||
expect(patched.statusCode).toBe(200);
|
||||
const body = patched.json() as {
|
||||
globalpingTokenSet: boolean;
|
||||
globalpingLocations: string;
|
||||
globalpingLimit: number;
|
||||
globalpingToken?: string;
|
||||
};
|
||||
expect(body.globalpingTokenSet).toBe(true);
|
||||
expect(body.globalpingLocations).toBe("EU,US");
|
||||
expect(body.globalpingLimit).toBe(5);
|
||||
expect(body.globalpingToken).toBeUndefined();
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,16 +17,22 @@ import {
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { ButtonGroup } from '@cfdm/ui/components/button-group'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import {
|
||||
HealthAggregateTiles,
|
||||
HealthSourceTiles,
|
||||
type HealthAggregate,
|
||||
type HealthProvider,
|
||||
} from '@/components/reui-kit/health-source-tiles'
|
||||
import { uniqueHealthProviders } from '@cfdm/shared'
|
||||
|
||||
export type LbMode = 'round_robin' | 'failover' | 'weighted'
|
||||
export type HealthCheckType = 'tcp' | 'http'
|
||||
export type HealthProvider = 'local' | 'cloudflare'
|
||||
export type { HealthProvider, HealthAggregate }
|
||||
|
||||
export interface HealthCheckConfig {
|
||||
enabled: boolean
|
||||
@@ -38,6 +44,8 @@ export interface HealthCheckConfig {
|
||||
timeout_ms: number
|
||||
verify_tls: boolean
|
||||
provider: HealthProvider
|
||||
providers: HealthProvider[]
|
||||
aggregate: HealthAggregate
|
||||
method?: string | null
|
||||
retries?: number
|
||||
consecutive_fails?: number
|
||||
@@ -54,62 +62,6 @@ const defaultLbModeOptions = [
|
||||
{ value: 'weighted', label: 'Weighted (веса)' },
|
||||
]
|
||||
|
||||
const healthCheckTypes = [
|
||||
{ value: 'tcp', label: 'TCP connect' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
] as const
|
||||
|
||||
const cloudflareTypes = [
|
||||
{ value: 'tcp', label: 'TCP' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
] as const
|
||||
|
||||
export function HealthProviderToggle({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
}: {
|
||||
value: HealthProvider
|
||||
onChange: (next: HealthProvider) => void
|
||||
id?: string
|
||||
}) {
|
||||
const provider = value || 'local'
|
||||
return (
|
||||
<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({
|
||||
id,
|
||||
value,
|
||||
@@ -151,13 +103,29 @@ export function HealthCheckConfigFields({
|
||||
idPrefix = 'health',
|
||||
showLbMode = true,
|
||||
className,
|
||||
}: HealthCheckConfigFieldsProps) {
|
||||
}: {
|
||||
value: LbAndHealthConfig
|
||||
onChange: (next: LbAndHealthConfig) => void
|
||||
lbModeLabel?: string
|
||||
lbModeOptions?: { value: string; label: string }[]
|
||||
idPrefix?: string
|
||||
showLbMode?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
function patch(next: Partial<LbAndHealthConfig>) {
|
||||
onChange({ ...value, ...next })
|
||||
}
|
||||
|
||||
const providers =
|
||||
value.providers?.length > 0
|
||||
? uniqueHealthProviders(value.providers)
|
||||
: uniqueHealthProviders([value.provider ?? 'local'])
|
||||
const aggregate = value.aggregate ?? 'majority'
|
||||
const isHttp = value.type === 'http'
|
||||
const rowClass = 'gap-3 px-0 py-3'
|
||||
const hasCloudflare = providers.includes('cloudflare')
|
||||
const hasGlobalping = providers.includes('globalping')
|
||||
const hasLocal = providers.includes('local')
|
||||
|
||||
return (
|
||||
<FieldGroup className={cn('gap-0', className)}>
|
||||
@@ -190,23 +158,25 @@ export function HealthCheckConfigFields({
|
||||
|
||||
<SettingRow
|
||||
title="Провайдер health-check"
|
||||
description="Откуда идёт проба: API CFDM или Cloudflare Worker (edge)"
|
||||
description="Кто пробирует цель. Можно выбрать несколько источников."
|
||||
labelFor={`${idPrefix}-provider`}
|
||||
compact
|
||||
stacked
|
||||
className={rowClass}
|
||||
>
|
||||
<HealthProviderToggle
|
||||
id={`${idPrefix}-provider`}
|
||||
value={value.provider ?? 'local'}
|
||||
onChange={(provider) =>
|
||||
<HealthSourceTiles
|
||||
value={providers}
|
||||
onChange={(next) =>
|
||||
patch({
|
||||
provider,
|
||||
enabled: provider === 'cloudflare' ? true : value.enabled,
|
||||
providers: next,
|
||||
provider: next[0] ?? 'local',
|
||||
enabled: next.includes('cloudflare') ? true : value.enabled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
{value.provider === 'cloudflare' ? (
|
||||
|
||||
{hasCloudflare ? (
|
||||
<Alert>
|
||||
<AlertTitle>Cloudflare Worker</AlertTitle>
|
||||
<AlertDescription>
|
||||
@@ -216,10 +186,24 @@ export function HealthCheckConfigFields({
|
||||
<Link to="/settings/health" className="text-foreground underline">
|
||||
Настройках → Health-check
|
||||
</Link>
|
||||
. Если Worker не создан, цель не пробируется как Local.
|
||||
. Если Worker не создан, этот источник не пробируется как Local.
|
||||
</AlertDescription>
|
||||
</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>
|
||||
<AlertTitle>Local health-check</AlertTitle>
|
||||
<AlertDescription>
|
||||
@@ -230,7 +214,22 @@ export function HealthCheckConfigFields({
|
||||
. Интервал в карточке не используется.
|
||||
</AlertDescription>
|
||||
</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
|
||||
title="Health-check"
|
||||
@@ -259,25 +258,25 @@ export function HealthCheckConfigFields({
|
||||
<div className="flex flex-col gap-3 pt-1 pb-1">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
|
||||
<Select
|
||||
modal={false}
|
||||
value={value.type}
|
||||
onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })}
|
||||
<ToggleGroup
|
||||
id={`${idPrefix}-type`}
|
||||
variant="outline"
|
||||
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">
|
||||
<SelectValue placeholder="Тип" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(value.provider === 'cloudflare'
|
||||
? cloudflareTypes
|
||||
: healthCheckTypes
|
||||
).map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ToggleGroupItem value="tcp" className="flex-1">
|
||||
TCP
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="http" className="flex-1">
|
||||
HTTP
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</FormFieldSimple>
|
||||
|
||||
<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 { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||
export {
|
||||
HealthSourceTiles,
|
||||
HealthAggregateTiles,
|
||||
type HealthProvider,
|
||||
type HealthAggregate,
|
||||
} from './health-source-tiles'
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
type LbAndHealthConfig,
|
||||
type LbMode,
|
||||
type HealthCheckType,
|
||||
type HealthProvider,
|
||||
type HealthAggregate,
|
||||
} from '@/components/health-check-config-fields'
|
||||
import type {
|
||||
CreateServiceWithConfigInput,
|
||||
@@ -53,7 +55,9 @@ interface BindingHealthConfig {
|
||||
interval_sec: number
|
||||
timeout_ms: number
|
||||
verify_tls: boolean
|
||||
provider: 'local' | 'cloudflare'
|
||||
provider: HealthProvider
|
||||
providers: HealthProvider[]
|
||||
aggregate: HealthAggregate
|
||||
}
|
||||
|
||||
export interface ServiceBindingDraft {
|
||||
@@ -77,6 +81,8 @@ const defaultHealth: BindingHealthConfig = {
|
||||
timeout_ms: 3000,
|
||||
verify_tls: false,
|
||||
provider: 'local',
|
||||
providers: ['local'],
|
||||
aggregate: 'majority',
|
||||
}
|
||||
|
||||
interface ServiceEditSheetProps {
|
||||
@@ -110,7 +116,12 @@ function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||
interval_sec: binding.health_check_interval_sec,
|
||||
timeout_ms: binding.health_check_timeout_ms,
|
||||
verify_tls: binding.health_check_verify_tls ?? false,
|
||||
provider: binding.health_check_provider === 'cloudflare' ? 'cloudflare' : 'local',
|
||||
provider: binding.health_check_provider ?? 'local',
|
||||
providers:
|
||||
binding.health_check_providers?.length > 0
|
||||
? binding.health_check_providers
|
||||
: [binding.health_check_provider ?? 'local'],
|
||||
aggregate: binding.health_check_aggregate ?? 'majority',
|
||||
},
|
||||
target_ip_weights: binding.target_ip_weights ?? {},
|
||||
target_ip_priorities: binding.target_ip_priorities ?? {},
|
||||
@@ -139,6 +150,8 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||
health_check_timeout_ms: binding.health.timeout_ms,
|
||||
health_check_verify_tls: binding.health.verify_tls,
|
||||
health_check_provider: binding.health.provider,
|
||||
health_check_providers: binding.health.providers,
|
||||
health_check_aggregate: binding.health.aggregate,
|
||||
}
|
||||
: {
|
||||
fqdn: binding.fqdn.trim(),
|
||||
@@ -155,6 +168,8 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||
health_check_timeout_ms: binding.health.timeout_ms,
|
||||
health_check_verify_tls: binding.health.verify_tls,
|
||||
health_check_provider: binding.health.provider,
|
||||
health_check_providers: binding.health.providers,
|
||||
health_check_aggregate: binding.health.aggregate,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -334,6 +349,8 @@ export function ServiceEditSheet({
|
||||
timeout_ms: next.timeout_ms,
|
||||
verify_tls: next.verify_tls,
|
||||
provider: next.provider,
|
||||
providers: next.providers,
|
||||
aggregate: next.aggregate,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ export const serviceGroupSchema = z.object({
|
||||
health_check_interval_sec: z.number().default(30),
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: z.enum(['local', 'cloudflare']).catch('local'),
|
||||
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
@@ -77,7 +79,9 @@ export const serviceDomainBindingSchema = z
|
||||
health_check_interval_sec: z.number().default(30),
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: z.enum(['local', 'cloudflare']).catch('local'),
|
||||
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
})
|
||||
.transform((binding) => ({
|
||||
@@ -102,7 +106,7 @@ export const serviceIpHealthSchema = z.object({
|
||||
latency_ms: z.number().nullable(),
|
||||
last_checked_at: z.string().nullable().optional(),
|
||||
last_error: z.string().nullable().optional(),
|
||||
provider: z.enum(['local', 'cloudflare']).optional(),
|
||||
provider: z.enum(['local', 'cloudflare', 'globalping', 'aggregate']).optional(),
|
||||
colo: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
@@ -111,7 +115,7 @@ export const healthProbeLogSchema = z.object({
|
||||
scope: z.string(),
|
||||
ref_id: z.number(),
|
||||
ip: z.string(),
|
||||
provider: z.enum(['local', 'cloudflare']),
|
||||
provider: z.enum(['local', 'cloudflare', 'globalping']),
|
||||
status: z.enum(['up', 'down', 'degraded', 'unknown']),
|
||||
ok: z.coerce.boolean(),
|
||||
latency_ms: z.number().nullable(),
|
||||
@@ -188,7 +192,9 @@ export const serviceBindingSchema = z
|
||||
health_check_interval_sec: z.number().default(30),
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: z.enum(['local', 'cloudflare']).catch('local'),
|
||||
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
@@ -273,7 +279,9 @@ const healthCheckConfigFields = {
|
||||
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
|
||||
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
|
||||
health_check_verify_tls: z.boolean().optional(),
|
||||
health_check_provider: z.enum(['local', 'cloudflare']).optional(),
|
||||
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).optional(),
|
||||
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).optional(),
|
||||
health_check_aggregate: z.enum(['any', 'all', 'majority']).optional(),
|
||||
}
|
||||
|
||||
const serviceDomainInputSchema = z
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
import { HeartPulseIcon } from 'lucide-react'
|
||||
import { HeartPulseIcon, GlobeIcon } from 'lucide-react'
|
||||
|
||||
import { api } from '@/lib/api-client'
|
||||
import { SettingRow } from '@/components/setting-row'
|
||||
@@ -47,7 +47,14 @@ const formSchema = z.object({
|
||||
}
|
||||
})
|
||||
|
||||
const globalpingSchema = z.object({
|
||||
globalpingToken: z.string().optional(),
|
||||
globalpingLocations: z.string().trim().min(1).max(200),
|
||||
globalpingLimit: z.number().int().min(1).max(10),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>
|
||||
type GlobalpingValues = z.infer<typeof globalpingSchema>
|
||||
type HealthWorkerStatus = 'missing' | 'ready' | 'error'
|
||||
|
||||
type SettingsResponse = FormValues & {
|
||||
@@ -58,6 +65,9 @@ type SettingsResponse = FormValues & {
|
||||
healthWorkerDeployedAt?: string | null
|
||||
healthWorkerLastIngestAt?: string | null
|
||||
healthWorkerKvNamespaceId?: string
|
||||
globalpingTokenSet?: boolean
|
||||
globalpingLocations?: string
|
||||
globalpingLimit?: number
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings/health')({
|
||||
@@ -140,6 +150,15 @@ function HealthSettingsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const gpForm = useForm<GlobalpingValues>({
|
||||
resolver: zodResolver(globalpingSchema),
|
||||
defaultValues: {
|
||||
globalpingToken: '',
|
||||
globalpingLocations: 'World',
|
||||
globalpingLimit: 3,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
form.reset({
|
||||
@@ -149,7 +168,12 @@ function HealthSettingsPage() {
|
||||
healthLatencyWarnMs: data.healthLatencyWarnMs,
|
||||
healthSuccessRecoveries: data.healthSuccessRecoveries,
|
||||
})
|
||||
}, [data, form])
|
||||
gpForm.reset({
|
||||
globalpingToken: '',
|
||||
globalpingLocations: data.globalpingLocations || 'World',
|
||||
globalpingLimit: data.globalpingLimit ?? 3,
|
||||
})
|
||||
}, [data, form, gpForm])
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (values: FormValues) =>
|
||||
@@ -168,6 +192,27 @@ function HealthSettingsPage() {
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||
})
|
||||
|
||||
const saveGpMut = useMutation({
|
||||
mutationFn: (values: GlobalpingValues) =>
|
||||
api.patch<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({
|
||||
mutationFn: () =>
|
||||
api.post<SettingsResponse>('/api/v1/settings/health/worker/ensure'),
|
||||
@@ -180,6 +225,7 @@ function HealthSettingsPage() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<form
|
||||
className="flex w-full flex-col gap-4"
|
||||
onSubmit={(event) =>
|
||||
@@ -193,8 +239,8 @@ function HealthSettingsPage() {
|
||||
Local health-check
|
||||
</FrameTitle>
|
||||
<FrameDescription>
|
||||
Расписание и пороги движка — общие для Local и Cloudflare Worker.
|
||||
Тип/порт/path задаются в карточке сервиса.
|
||||
Расписание и пороги движка — общие для Local, Cloudflare Worker и Globalping.
|
||||
Тип/порт/path и правило агрегации задаются в карточке сервиса.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
@@ -416,5 +462,127 @@ function HealthSettingsPage() {
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</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 1–10. 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
@@ -49,18 +49,26 @@ health-check работают на двух уровнях:
|
||||
после `HEALTH_SUCCESS_RECOVERIES` (default 2). Пороги и cron движка задаются в
|
||||
**Настройки → Health-check** (env — fallback, пока значения не сохранены в UI).
|
||||
|
||||
### Local XOR Cloudflare Worker
|
||||
### Источники проб: Local, Cloudflare Worker, Globalping
|
||||
|
||||
Провайдер задаётся на привязке (`service_bindings.health_check_provider`): **local**
|
||||
или **cloudflare**. Одновременно оба не работают.
|
||||
На привязке/группе задаётся **мультивыбор** источников (`health_check_providers` JSON)
|
||||
и **правило агрегации** (`health_check_aggregate`: `any` | `all` | `majority`).
|
||||
Failover читает одну строку `ip_health_status` (агрегат). Журнал `health_probe_log` —
|
||||
строка на каждый источник.
|
||||
|
||||
| | Local | Cloudflare Worker |
|
||||
|---|---|---|
|
||||
| Кто пробирует | процесс API CFDM | Worker на edge (Cron Trigger) |
|
||||
| Планировщик | глобальный cron CFDM | cron Worker + ingest KV в CFDM |
|
||||
| Пороги Slow/Down | Настройки → Health-check | те же |
|
||||
| Результат | SQLite `ip_health_status` | та же SQLite + `colo` из KV |
|
||||
| Регионы Health Checks | нет | нет (на Free продукта нет) |
|
||||
| | Local | Cloudflare Worker | Globalping |
|
||||
|---|---|---|---|
|
||||
| Кто пробирует | процесс API CFDM | Worker на edge (Cron Trigger) | [globalping.io](https://globalping.io) |
|
||||
| Планировщик | глобальный cron CFDM | cron Worker + ingest KV | тот же cron CFDM (POST/GET measurements) |
|
||||
| Пороги Slow/Down | Настройки → Health-check | те же | те же (по агрегату) |
|
||||
| Результат | SQLite `ip_health_status` | та же SQLite + `colo` из KV | та же SQLite, colo = city/country пробы |
|
||||
| Fallback | — | нет (не Local) | нет (нет токена / 429 / timeout = fail) |
|
||||
|
||||
**Агрегация (на сервисе/группе):**
|
||||
|
||||
- `any` — Down, если хотя бы один выбранный источник Down
|
||||
- `all` — Down, только если все выбранные Down
|
||||
- `majority` — Down по большинству (2 источника → оба; 3 → ≥2)
|
||||
|
||||
**Cloudflare в CFDM — это Worker**, не [Health Checks API](https://developers.cloudflare.com/api/resources/healthchecks).
|
||||
Продукт Health Checks на Free-плане недоступен и **не используется**.
|
||||
@@ -75,7 +83,13 @@ Account `Workers Scripts Write` + `Workers KV Storage Write`. Zone DNS недо
|
||||
Free: 5 Cron Triggers на аккаунт; KV 1000 writes/сутки (интервал ≥ 2 мин);
|
||||
≤ 48 целей за тик. Исходник: [`workers/health-probe/`](../workers/health-probe/).
|
||||
|
||||
Reconcile DNS запускается cron-задачей `health-check` после ingest KV.
|
||||
**Globalping:** `POST /v1/measurements` → poll `GET` каждые ≥ 500 мс.
|
||||
CFDM TCP → `type: ping` + `protocol: TCP`; HTTP → `type: http`, `target` = IP, `request.host` = hostname.
|
||||
Токен: [dash.globalping.io/tokens](https://dash.globalping.io/tokens). Без токена 250 tests/hour, с токеном 500 + [credits](https://globalping.io/credits).
|
||||
Локации (magic CSV, default `World`) и `limit` (1–10, default 3) — **Настройки → Health-check**.
|
||||
Один measurement на уникальный origin (IP/порт/path) за тик.
|
||||
|
||||
Reconcile DNS запускается cron-задачей `health-check` после ingest KV и агрегации.
|
||||
|
||||
## Docker
|
||||
|
||||
|
||||
Vendored
+278
-3
@@ -1,7 +1,7 @@
|
||||
import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { AuditSourceApp, AuditSeverity, AuditTargetType, AuditLogEntry, HealthWorkerStatus, LbMode, HealthCheckType, HealthCheckProvider, IpHealthState, HealthCheckScope, ServiceBinding, Domain, Group, OriginHealthCheck, ServiceNode, Service, ServiceGroup, Subdomain, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared';
|
||||
import { AuditSourceApp, AuditSeverity, AuditTargetType, AuditLogEntry, HealthWorkerStatus, LbMode, HealthCheckType, HealthCheckProvider, HealthCheckAggregate, IpHealthState, HealthStatusProvider, HealthCheckScope, ServiceBinding, Domain, Group, OriginHealthCheck, ServiceNode, Service, ServiceGroup, Subdomain, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared';
|
||||
|
||||
declare const groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
||||
name: "groups";
|
||||
@@ -599,6 +599,44 @@ declare const serviceGroups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_check_providers: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_check_providers";
|
||||
tableName: "service_groups";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_check_aggregate: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_check_aggregate";
|
||||
tableName: "service_groups";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "created_at";
|
||||
tableName: "service_groups";
|
||||
@@ -1537,6 +1575,44 @@ declare const serviceBindings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_check_providers: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_check_providers";
|
||||
tableName: "service_bindings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_check_aggregate: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_check_aggregate";
|
||||
tableName: "service_bindings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
routing_strategy: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "routing_strategy";
|
||||
tableName: "service_bindings";
|
||||
@@ -3460,6 +3536,61 @@ declare const appSettings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
globalping_token: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "globalping_token";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
globalping_locations: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "globalping_locations";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
globalping_limit: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "globalping_limit";
|
||||
tableName: "app_settings";
|
||||
dataType: "number";
|
||||
columnType: "SQLiteInteger";
|
||||
data: number;
|
||||
driverParam: number;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: undefined;
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {}>;
|
||||
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "created_at";
|
||||
tableName: "app_settings";
|
||||
@@ -5151,6 +5282,44 @@ declare const schema: {
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_check_providers: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_check_providers";
|
||||
tableName: "service_groups";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_check_aggregate: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_check_aggregate";
|
||||
tableName: "service_groups";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "created_at";
|
||||
tableName: "service_groups";
|
||||
@@ -6089,6 +6258,44 @@ declare const schema: {
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_check_providers: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_check_providers";
|
||||
tableName: "service_bindings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_check_aggregate: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_check_aggregate";
|
||||
tableName: "service_bindings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
routing_strategy: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "routing_strategy";
|
||||
tableName: "service_bindings";
|
||||
@@ -8012,6 +8219,61 @@ declare const schema: {
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
globalping_token: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "globalping_token";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
globalping_locations: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "globalping_locations";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
globalping_limit: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "globalping_limit";
|
||||
tableName: "app_settings";
|
||||
dataType: "number";
|
||||
columnType: "SQLiteInteger";
|
||||
data: number;
|
||||
driverParam: number;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: undefined;
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {}>;
|
||||
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "created_at";
|
||||
tableName: "app_settings";
|
||||
@@ -9176,6 +9438,9 @@ type AppSettingsDto = {
|
||||
healthWorkerError: string | null;
|
||||
healthWorkerDeployedAt: string | null;
|
||||
healthWorkerLastIngestAt: string | null;
|
||||
globalpingTokenSet: boolean;
|
||||
globalpingLocations: string;
|
||||
globalpingLimit: number;
|
||||
} & HealthEngineSettings;
|
||||
type AppSettingsPatch = {
|
||||
vpsTrackerUrl?: string;
|
||||
@@ -9194,6 +9459,9 @@ type AppSettingsPatch = {
|
||||
healthWorkerError?: string | null;
|
||||
healthWorkerDeployedAt?: string | null;
|
||||
healthWorkerLastIngestAt?: string | null;
|
||||
globalpingToken?: string;
|
||||
globalpingLocations?: string;
|
||||
globalpingLimit?: number;
|
||||
};
|
||||
type HealthEngineFallbacks = HealthEngineSettings & {
|
||||
healthWorkerUrl: string;
|
||||
@@ -9206,6 +9474,9 @@ declare function getAppSettingsSecrets(db: Db): {
|
||||
vpsTrackerSyncEnabled: boolean;
|
||||
healthWorkerUrl: string;
|
||||
healthWorkerToken: string;
|
||||
globalpingToken: string;
|
||||
globalpingLocations: string;
|
||||
globalpingLimit: number;
|
||||
};
|
||||
declare function updateAppSettings(db: Db, patch: AppSettingsPatch, fallbacks?: HealthEngineFallbacks): AppSettingsDto;
|
||||
declare function touchVpsTrackerSync(db: Db): void;
|
||||
@@ -9294,6 +9565,8 @@ interface ServiceGroupLbPatch {
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
declare function createServiceGroup(db: Db, name: string, groupType: string, icon: string | null, domain: string | null, lbPatch?: ServiceGroupLbPatch): ServiceGroup;
|
||||
declare function updateServiceGroup(db: Db, id: number, name: string, groupType: string, icon: string | null, domain: string | null, lbPatch?: ServiceGroupLbPatch): ServiceGroup;
|
||||
@@ -9402,6 +9675,8 @@ interface BindingLbPatch {
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
declare function updateBindingLbConfig(db: Db, bindingId: number, patch: BindingLbPatch): void;
|
||||
declare function setBindingCnameTarget(db: Db, bindingId: number, target: string | null): void;
|
||||
@@ -9453,7 +9728,7 @@ type ServiceIpHealthRow = {
|
||||
latency_ms: number | null;
|
||||
last_checked_at: string | null;
|
||||
last_error: string | null;
|
||||
provider: HealthCheckProvider;
|
||||
provider: HealthStatusProvider;
|
||||
colo: string | null;
|
||||
};
|
||||
/** Per-IP binding-scope health, worst status if the same IP is on several bindings. */
|
||||
@@ -9462,7 +9737,7 @@ declare function mergeHealthAggregates(parts: Array<HealthAggregate | undefined
|
||||
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?: {
|
||||
colo?: string | null;
|
||||
provider?: HealthCheckProvider;
|
||||
provider?: HealthStatusProvider;
|
||||
}): void;
|
||||
declare function deleteIpHealthStatusForRef(db: Db, scope: HealthCheckScope, refId: number): void;
|
||||
declare function deleteIpHealthStatusForIp(db: Db, scope: HealthCheckScope, refId: number, ip: string): void;
|
||||
|
||||
Vendored
+126
-22
@@ -53,6 +53,8 @@ var serviceGroups = sqliteTable("service_groups", {
|
||||
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_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')`),
|
||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||
});
|
||||
@@ -119,6 +121,8 @@ var serviceBindings = sqliteTable(
|
||||
mode: "boolean"
|
||||
}).notNull().default(false),
|
||||
health_check_provider: text("health_check_provider").notNull().default("local"),
|
||||
health_check_providers: text("health_check_providers").notNull().default('["local"]'),
|
||||
health_check_aggregate: text("health_check_aggregate").notNull().default("majority"),
|
||||
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
|
||||
operation_version: integer("operation_version").notNull().default(0),
|
||||
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_deployed_at: text("health_worker_deployed_at"),
|
||||
health_worker_last_ingest_at: text("health_worker_last_ingest_at"),
|
||||
globalping_token: text("globalping_token"),
|
||||
globalping_locations: text("globalping_locations"),
|
||||
globalping_limit: integer("globalping_limit"),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||
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,
|
||||
healthWorkerDeployedAt: row.health_worker_deployed_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) {
|
||||
@@ -593,12 +603,16 @@ function getAppSettings(db, fallbacks) {
|
||||
}
|
||||
function getAppSettingsSecrets(db) {
|
||||
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
||||
const limit = row?.globalping_limit;
|
||||
return {
|
||||
vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "",
|
||||
vpsTrackerIntegrationToken: row?.vps_tracker_integration_token?.trim() ?? "",
|
||||
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled),
|
||||
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) {
|
||||
@@ -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_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,
|
||||
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()
|
||||
}).where(eq2(appSettings.id, SETTINGS_ID)).run();
|
||||
return getAppSettings(db, fallbacks);
|
||||
@@ -771,7 +788,15 @@ __export(repos_exports, {
|
||||
upsertIpHealthStatus: () => upsertIpHealthStatus,
|
||||
upsertSubdomain: () => upsertSubdomain
|
||||
});
|
||||
import { dnsRecordNamesMatch, isIpLiteral } from "@cfdm/shared";
|
||||
import {
|
||||
derivePrimaryProvider,
|
||||
dnsRecordNamesMatch,
|
||||
isIpLiteral,
|
||||
parseHealthAggregate,
|
||||
parseHealthProviders,
|
||||
serializeHealthProviders,
|
||||
normalizeStatusProvider
|
||||
} from "@cfdm/shared";
|
||||
import { and as and2, asc, count, eq as eq3, isNull, like, notInArray, or as or2, sql as sql2 } from "drizzle-orm";
|
||||
function listGroups(db) {
|
||||
return db.select().from(groups).orderBy(asc(groups.name)).all();
|
||||
@@ -1158,8 +1183,58 @@ function deleteService(db, id) {
|
||||
const result = db.delete(services).where(eq3(services.id, id)).run();
|
||||
if (result.changes === 0) throw new NotFoundError(`service ${id}`);
|
||||
}
|
||||
function normalizeHealthProvider(value) {
|
||||
return value === "cloudflare" ? "cloudflare" : "local";
|
||||
function healthProviderColumns(patch) {
|
||||
const out = {};
|
||||
if (patch.health_check_providers !== void 0) {
|
||||
const list = parseHealthProviders(patch.health_check_providers);
|
||||
out.health_check_providers = serializeHealthProviders(list);
|
||||
out.health_check_provider = derivePrimaryProvider(list);
|
||||
} else if (patch.health_check_provider !== void 0) {
|
||||
const list = parseHealthProviders(null, patch.health_check_provider);
|
||||
out.health_check_providers = serializeHealthProviders(list);
|
||||
out.health_check_provider = derivePrimaryProvider(list);
|
||||
}
|
||||
if (patch.health_check_aggregate !== void 0) {
|
||||
out.health_check_aggregate = parseHealthAggregate(
|
||||
patch.health_check_aggregate
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function mapHealthFields(row) {
|
||||
const providers = parseHealthProviders(
|
||||
row.health_check_providers,
|
||||
row.health_check_provider
|
||||
);
|
||||
return {
|
||||
health_check_providers: providers,
|
||||
health_check_provider: derivePrimaryProvider(providers),
|
||||
health_check_aggregate: parseHealthAggregate(row.health_check_aggregate)
|
||||
};
|
||||
}
|
||||
function mapServiceBinding(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
domain_id: row.domain_id,
|
||||
service_id: row.service_id,
|
||||
hostname: row.hostname,
|
||||
cname_target: row.cname_target,
|
||||
dns_record_id: row.dns_record_id,
|
||||
lb_mode: row.lb_mode,
|
||||
health_check_enabled: row.health_check_enabled,
|
||||
health_check_type: row.health_check_type,
|
||||
health_check_port: row.health_check_port,
|
||||
health_check_path: row.health_check_path,
|
||||
health_check_expected_status: row.health_check_expected_status,
|
||||
health_check_interval_sec: row.health_check_interval_sec,
|
||||
health_check_timeout_ms: row.health_check_timeout_ms,
|
||||
health_check_verify_tls: row.health_check_verify_tls,
|
||||
...mapHealthFields(row),
|
||||
routing_strategy: row.routing_strategy,
|
||||
operation_version: row.operation_version,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
function mapServiceGroup(row) {
|
||||
return {
|
||||
@@ -1178,7 +1253,7 @@ function mapServiceGroup(row) {
|
||||
health_check_interval_sec: row.health_check_interval_sec,
|
||||
health_check_timeout_ms: row.health_check_timeout_ms,
|
||||
health_check_verify_tls: row.health_check_verify_tls,
|
||||
health_check_provider: normalizeHealthProvider(row.health_check_provider),
|
||||
...mapHealthFields(row),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
@@ -1206,7 +1281,11 @@ function createServiceGroup(db, name, groupType, icon, domain, lbPatch) {
|
||||
health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
|
||||
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3e3,
|
||||
health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false,
|
||||
health_check_provider: lbPatch?.health_check_provider ?? "local"
|
||||
...healthProviderColumns({
|
||||
health_check_provider: lbPatch?.health_check_provider ?? "local",
|
||||
health_check_providers: lbPatch?.health_check_providers,
|
||||
health_check_aggregate: lbPatch?.health_check_aggregate ?? "majority"
|
||||
})
|
||||
}).returning({ id: serviceGroups.id }).get().id;
|
||||
return getServiceGroup(db, id);
|
||||
}
|
||||
@@ -1236,8 +1315,7 @@ function updateServiceGroup(db, id, name, groupType, icon, domain, lbPatch) {
|
||||
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
|
||||
if (lbPatch.health_check_verify_tls !== void 0)
|
||||
update.health_check_verify_tls = lbPatch.health_check_verify_tls;
|
||||
if (lbPatch.health_check_provider !== void 0)
|
||||
update.health_check_provider = lbPatch.health_check_provider;
|
||||
Object.assign(update, healthProviderColumns(lbPatch));
|
||||
}
|
||||
const result = db.update(serviceGroups).set(update).where(eq3(serviceGroups.id, id)).run();
|
||||
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
|
||||
@@ -1547,8 +1625,7 @@ function updateBindingLbConfig(db, bindingId, patch) {
|
||||
update.health_check_timeout_ms = patch.health_check_timeout_ms;
|
||||
if (patch.health_check_verify_tls !== void 0)
|
||||
update.health_check_verify_tls = patch.health_check_verify_tls;
|
||||
if (patch.health_check_provider !== void 0)
|
||||
update.health_check_provider = patch.health_check_provider;
|
||||
Object.assign(update, healthProviderColumns(patch));
|
||||
db.update(serviceBindings).set(update).where(eq3(serviceBindings.id, bindingId)).run();
|
||||
}
|
||||
function setBindingCnameTarget(db, bindingId, target) {
|
||||
@@ -1607,7 +1684,8 @@ function dnsRecordMatchesHostname(recordName, hostname, zoneName) {
|
||||
var SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
|
||||
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
|
||||
sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec,
|
||||
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider, sb.cname_target,
|
||||
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider,
|
||||
sb.health_check_providers, sb.health_check_aggregate, sb.cname_target,
|
||||
d.zone_name, d.group_id, g.name AS group_name,
|
||||
s.name AS service_name, s.slug AS service_slug,
|
||||
dr.content AS target_ip, dr.sync_status,
|
||||
@@ -1655,6 +1733,7 @@ function enrichServiceBindingView(db, row) {
|
||||
return {
|
||||
...row,
|
||||
cname_target: row.cname_target ?? null,
|
||||
...mapHealthFields(row),
|
||||
target_ips,
|
||||
target_ip: target_ips[0] ?? null,
|
||||
target_ip_weights,
|
||||
@@ -1695,7 +1774,7 @@ function listBindingsByService(db, serviceId) {
|
||||
function getBinding(db, id) {
|
||||
const row = db.select().from(serviceBindings).where(eq3(serviceBindings.id, id)).get();
|
||||
if (!row) throw new NotFoundError(`service binding ${id}`);
|
||||
return row;
|
||||
return mapServiceBinding(row);
|
||||
}
|
||||
function getBindingView(db, id) {
|
||||
const rows = db.all(sql2`
|
||||
@@ -1718,7 +1797,8 @@ function findBinding(db, serviceId, domainId, hostname) {
|
||||
eq3(serviceBindings.hostname, hostname)
|
||||
)
|
||||
).get();
|
||||
return row ?? null;
|
||||
if (!row) return null;
|
||||
return mapServiceBinding(row);
|
||||
}
|
||||
function insertBinding(db, domainId, serviceId, hostname, dnsRecordId) {
|
||||
const id = db.insert(serviceBindings).values({
|
||||
@@ -1744,7 +1824,7 @@ function setBindingDnsRecordId(db, bindingId, dnsRecordId) {
|
||||
}).where(eq3(serviceBindings.id, bindingId)).run();
|
||||
}
|
||||
function bindingsToRemove(db, serviceId, keepIds) {
|
||||
const all = db.select().from(serviceBindings).where(eq3(serviceBindings.service_id, serviceId)).all();
|
||||
const all = db.select().from(serviceBindings).where(eq3(serviceBindings.service_id, serviceId)).all().map(mapServiceBinding);
|
||||
return all.filter((b) => !keepIds.includes(b.id));
|
||||
}
|
||||
function deleteBindingsExcept(db, serviceId, keepIds) {
|
||||
@@ -1975,7 +2055,7 @@ function listIpHealthByServiceIds(db, serviceIds) {
|
||||
latency_ms: parsed.health_latency_ms,
|
||||
last_checked_at: row.last_checked_at,
|
||||
last_error: row.last_error,
|
||||
provider: normalizeHealthProvider(row.provider),
|
||||
provider: normalizeStatusProvider(row.provider),
|
||||
colo: row.colo
|
||||
});
|
||||
result.set(row.service_id, list);
|
||||
@@ -2101,6 +2181,8 @@ function listHealthCheckTargets(db) {
|
||||
sb.health_check_expected_status AS expected_status,
|
||||
sb.health_check_timeout_ms AS timeout_ms,
|
||||
sb.health_check_verify_tls AS verify_tls,
|
||||
sb.health_check_providers AS providers_json,
|
||||
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sb.health_check_provider, 'local') AS provider
|
||||
FROM service_binding_ips sbi
|
||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||
@@ -2116,6 +2198,8 @@ function listHealthCheckTargets(db) {
|
||||
sg.health_check_expected_status AS expected_status,
|
||||
sg.health_check_timeout_ms AS timeout_ms,
|
||||
sg.health_check_verify_tls AS verify_tls,
|
||||
sg.health_check_providers AS providers_json,
|
||||
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sg.health_check_provider, 'local') AS provider
|
||||
FROM service_binding_ips sbi
|
||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||
@@ -2137,6 +2221,8 @@ function listHealthCheckTargets(db) {
|
||||
sg.health_check_expected_status AS expected_status,
|
||||
sg.health_check_timeout_ms AS timeout_ms,
|
||||
sg.health_check_verify_tls AS verify_tls,
|
||||
sg.health_check_providers AS providers_json,
|
||||
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sg.health_check_provider, 'local') AS provider
|
||||
FROM service_binding_ips sbi
|
||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||
@@ -2158,6 +2244,8 @@ function listHealthCheckTargets(db) {
|
||||
sb.health_check_expected_status AS expected_status,
|
||||
sb.health_check_timeout_ms AS timeout_ms,
|
||||
sb.health_check_verify_tls AS verify_tls,
|
||||
sb.health_check_providers AS providers_json,
|
||||
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sb.health_check_provider, 'local') AS provider
|
||||
FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
@@ -2176,6 +2264,8 @@ function listHealthCheckTargets(db) {
|
||||
sg.health_check_expected_status AS expected_status,
|
||||
sg.health_check_timeout_ms AS timeout_ms,
|
||||
sg.health_check_verify_tls AS verify_tls,
|
||||
sg.health_check_providers AS providers_json,
|
||||
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sg.health_check_provider, 'local') AS provider
|
||||
FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
@@ -2195,11 +2285,25 @@ function listHealthCheckTargets(db) {
|
||||
...groupInheritedBindingTargets,
|
||||
...cnameBindingTargets,
|
||||
...groupInheritedCnameBindingTargets
|
||||
].map((t) => ({
|
||||
...t,
|
||||
verify_tls: Boolean(t.verify_tls),
|
||||
provider: normalizeHealthProvider(t.provider)
|
||||
}));
|
||||
].map((t) => {
|
||||
const row = t;
|
||||
const providers = parseHealthProviders(row.providers_json, row.provider);
|
||||
return {
|
||||
scope: row.scope,
|
||||
ref_id: row.ref_id,
|
||||
ip: row.ip,
|
||||
hostname: row.hostname,
|
||||
type: row.type,
|
||||
port: row.port,
|
||||
path: row.path,
|
||||
expected_status: row.expected_status,
|
||||
timeout_ms: row.timeout_ms,
|
||||
verify_tls: Boolean(row.verify_tls),
|
||||
providers,
|
||||
aggregate: parseHealthAggregate(row.aggregate),
|
||||
provider: derivePrimaryProvider(providers)
|
||||
};
|
||||
});
|
||||
}
|
||||
function listDomainTags(db, domainId) {
|
||||
return db.select({ tag: domainTags.tag }).from(domainTags).where(eq3(domainTags.domain_id, domainId)).all().map((r) => r.tag);
|
||||
@@ -2329,7 +2433,7 @@ function listHealthProbeLogForService(db, serviceId, limit = 50) {
|
||||
`);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
provider: normalizeHealthProvider(row.provider),
|
||||
provider: parseHealthProviders(null, row.provider)[0] ?? "local",
|
||||
ok: Boolean(row.ok)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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
@@ -5,10 +5,12 @@ import type {
|
||||
DomainListItem,
|
||||
Group,
|
||||
GroupWithStats,
|
||||
HealthCheckAggregate,
|
||||
HealthCheckProvider,
|
||||
HealthCheckScope,
|
||||
HealthCheckTarget,
|
||||
HealthCheckType,
|
||||
HealthStatusProvider,
|
||||
IpHealthState,
|
||||
IpHealthStatus,
|
||||
LbMode,
|
||||
@@ -21,7 +23,15 @@ import type {
|
||||
Subdomain,
|
||||
SyncJob,
|
||||
} from "@cfdm/shared";
|
||||
import { dnsRecordNamesMatch, isIpLiteral } from "@cfdm/shared";
|
||||
import {
|
||||
derivePrimaryProvider,
|
||||
dnsRecordNamesMatch,
|
||||
isIpLiteral,
|
||||
parseHealthAggregate,
|
||||
parseHealthProviders,
|
||||
serializeHealthProviders,
|
||||
normalizeStatusProvider,
|
||||
} from "@cfdm/shared";
|
||||
import { and, asc, count, eq, isNull, like, notInArray, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "./client.js";
|
||||
import { ConflictError, NotFoundError } from "./errors.js";
|
||||
@@ -770,8 +780,82 @@ export function deleteService(db: Db, id: number): void {
|
||||
|
||||
// --- Service Groups ---
|
||||
|
||||
function normalizeHealthProvider(value: unknown): HealthCheckProvider {
|
||||
return value === "cloudflare" ? "cloudflare" : "local";
|
||||
function healthProviderColumns(patch: {
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}): {
|
||||
health_check_provider?: string;
|
||||
health_check_providers?: string;
|
||||
health_check_aggregate?: string;
|
||||
} {
|
||||
const out: {
|
||||
health_check_provider?: string;
|
||||
health_check_providers?: string;
|
||||
health_check_aggregate?: string;
|
||||
} = {};
|
||||
if (patch.health_check_providers !== undefined) {
|
||||
const list = parseHealthProviders(patch.health_check_providers);
|
||||
out.health_check_providers = serializeHealthProviders(list);
|
||||
out.health_check_provider = derivePrimaryProvider(list);
|
||||
} else if (patch.health_check_provider !== undefined) {
|
||||
const list = parseHealthProviders(null, patch.health_check_provider);
|
||||
out.health_check_providers = serializeHealthProviders(list);
|
||||
out.health_check_provider = derivePrimaryProvider(list);
|
||||
}
|
||||
if (patch.health_check_aggregate !== undefined) {
|
||||
out.health_check_aggregate = parseHealthAggregate(
|
||||
patch.health_check_aggregate,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mapHealthFields(row: {
|
||||
health_check_provider?: unknown;
|
||||
health_check_providers?: unknown;
|
||||
health_check_aggregate?: unknown;
|
||||
}): {
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
} {
|
||||
const providers = parseHealthProviders(
|
||||
row.health_check_providers,
|
||||
row.health_check_provider,
|
||||
);
|
||||
return {
|
||||
health_check_providers: providers,
|
||||
health_check_provider: derivePrimaryProvider(providers),
|
||||
health_check_aggregate: parseHealthAggregate(row.health_check_aggregate),
|
||||
};
|
||||
}
|
||||
|
||||
function mapServiceBinding(
|
||||
row: typeof serviceBindings.$inferSelect,
|
||||
): ServiceBinding {
|
||||
return {
|
||||
id: row.id,
|
||||
domain_id: row.domain_id,
|
||||
service_id: row.service_id,
|
||||
hostname: row.hostname,
|
||||
cname_target: row.cname_target,
|
||||
dns_record_id: row.dns_record_id,
|
||||
lb_mode: row.lb_mode as LbMode,
|
||||
health_check_enabled: row.health_check_enabled,
|
||||
health_check_type: row.health_check_type as HealthCheckType,
|
||||
health_check_port: row.health_check_port,
|
||||
health_check_path: row.health_check_path,
|
||||
health_check_expected_status: row.health_check_expected_status,
|
||||
health_check_interval_sec: row.health_check_interval_sec,
|
||||
health_check_timeout_ms: row.health_check_timeout_ms,
|
||||
health_check_verify_tls: row.health_check_verify_tls,
|
||||
...mapHealthFields(row),
|
||||
routing_strategy: row.routing_strategy as LbMode,
|
||||
operation_version: row.operation_version,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapServiceGroup(row: typeof serviceGroups.$inferSelect): ServiceGroup {
|
||||
@@ -791,7 +875,7 @@ function mapServiceGroup(row: typeof serviceGroups.$inferSelect): ServiceGroup {
|
||||
health_check_interval_sec: row.health_check_interval_sec,
|
||||
health_check_timeout_ms: row.health_check_timeout_ms,
|
||||
health_check_verify_tls: row.health_check_verify_tls,
|
||||
health_check_provider: normalizeHealthProvider(row.health_check_provider),
|
||||
...mapHealthFields(row),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
@@ -827,6 +911,8 @@ export interface ServiceGroupLbPatch {
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
export function createServiceGroup(
|
||||
@@ -853,7 +939,11 @@ export function createServiceGroup(
|
||||
health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
|
||||
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3000,
|
||||
health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false,
|
||||
health_check_provider: lbPatch?.health_check_provider ?? "local",
|
||||
...healthProviderColumns({
|
||||
health_check_provider: lbPatch?.health_check_provider ?? "local",
|
||||
health_check_providers: lbPatch?.health_check_providers,
|
||||
health_check_aggregate: lbPatch?.health_check_aggregate ?? "majority",
|
||||
}),
|
||||
})
|
||||
.returning({ id: serviceGroups.id })
|
||||
.get()!.id;
|
||||
@@ -894,8 +984,7 @@ export function updateServiceGroup(
|
||||
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
|
||||
if (lbPatch.health_check_verify_tls !== undefined)
|
||||
update.health_check_verify_tls = lbPatch.health_check_verify_tls;
|
||||
if (lbPatch.health_check_provider !== undefined)
|
||||
update.health_check_provider = lbPatch.health_check_provider;
|
||||
Object.assign(update, healthProviderColumns(lbPatch));
|
||||
}
|
||||
const result = db
|
||||
.update(serviceGroups)
|
||||
@@ -1439,6 +1528,8 @@ export interface BindingLbPatch {
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
export function updateBindingLbConfig(
|
||||
@@ -1469,8 +1560,7 @@ export function updateBindingLbConfig(
|
||||
update.health_check_timeout_ms = patch.health_check_timeout_ms;
|
||||
if (patch.health_check_verify_tls !== undefined)
|
||||
update.health_check_verify_tls = patch.health_check_verify_tls;
|
||||
if (patch.health_check_provider !== undefined)
|
||||
update.health_check_provider = patch.health_check_provider;
|
||||
Object.assign(update, healthProviderColumns(patch));
|
||||
db.update(serviceBindings)
|
||||
.set(update)
|
||||
.where(eq(serviceBindings.id, bindingId))
|
||||
@@ -1578,7 +1668,8 @@ function dnsRecordMatchesHostname(
|
||||
const SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
|
||||
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
|
||||
sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec,
|
||||
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider, sb.cname_target,
|
||||
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider,
|
||||
sb.health_check_providers, sb.health_check_aggregate, sb.cname_target,
|
||||
d.zone_name, d.group_id, g.name AS group_name,
|
||||
s.name AS service_name, s.slug AS service_slug,
|
||||
dr.content AS target_ip, dr.sync_status,
|
||||
@@ -1642,6 +1733,7 @@ function enrichServiceBindingView(
|
||||
return {
|
||||
...row,
|
||||
cname_target: row.cname_target ?? null,
|
||||
...mapHealthFields(row),
|
||||
target_ips,
|
||||
target_ip: target_ips[0] ?? null,
|
||||
target_ip_weights,
|
||||
@@ -1694,7 +1786,7 @@ export function getBinding(db: Db, id: number): ServiceBinding {
|
||||
.where(eq(serviceBindings.id, id))
|
||||
.get();
|
||||
if (!row) throw new NotFoundError(`service binding ${id}`);
|
||||
return row as ServiceBinding;
|
||||
return mapServiceBinding(row);
|
||||
}
|
||||
|
||||
export function getBindingView(db: Db, id: number): ServiceBindingView {
|
||||
@@ -1728,7 +1820,8 @@ export function findBinding(
|
||||
),
|
||||
)
|
||||
.get();
|
||||
return (row as ServiceBinding) ?? null;
|
||||
if (!row) return null;
|
||||
return mapServiceBinding(row);
|
||||
}
|
||||
|
||||
export function insertBinding(
|
||||
@@ -1792,7 +1885,8 @@ export function bindingsToRemove(
|
||||
.select()
|
||||
.from(serviceBindings)
|
||||
.where(eq(serviceBindings.service_id, serviceId))
|
||||
.all() as ServiceBinding[];
|
||||
.all()
|
||||
.map(mapServiceBinding);
|
||||
return all.filter((b) => !keepIds.includes(b.id));
|
||||
}
|
||||
|
||||
@@ -2124,7 +2218,7 @@ export type ServiceIpHealthRow = {
|
||||
latency_ms: number | null;
|
||||
last_checked_at: string | null;
|
||||
last_error: string | null;
|
||||
provider: HealthCheckProvider;
|
||||
provider: HealthStatusProvider;
|
||||
colo: string | null;
|
||||
};
|
||||
|
||||
@@ -2175,7 +2269,7 @@ export function listIpHealthByServiceIds(
|
||||
latency_ms: parsed.health_latency_ms,
|
||||
last_checked_at: row.last_checked_at,
|
||||
last_error: row.last_error,
|
||||
provider: normalizeHealthProvider(row.provider),
|
||||
provider: normalizeStatusProvider(row.provider),
|
||||
colo: row.colo,
|
||||
});
|
||||
result.set(row.service_id, list);
|
||||
@@ -2243,7 +2337,7 @@ export function upsertIpHealthStatus(
|
||||
consecutiveFailures: number,
|
||||
lastError: string | null,
|
||||
consecutiveSuccesses = 0,
|
||||
extras?: { colo?: string | null; provider?: HealthCheckProvider },
|
||||
extras?: { colo?: string | null; provider?: HealthStatusProvider },
|
||||
): void {
|
||||
const colo = extras?.colo ?? null;
|
||||
const provider = extras?.provider ?? "local";
|
||||
@@ -2359,6 +2453,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
sb.health_check_expected_status AS expected_status,
|
||||
sb.health_check_timeout_ms AS timeout_ms,
|
||||
sb.health_check_verify_tls AS verify_tls,
|
||||
sb.health_check_providers AS providers_json,
|
||||
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sb.health_check_provider, 'local') AS provider
|
||||
FROM service_binding_ips sbi
|
||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||
@@ -2378,6 +2474,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
sg.health_check_expected_status AS expected_status,
|
||||
sg.health_check_timeout_ms AS timeout_ms,
|
||||
sg.health_check_verify_tls AS verify_tls,
|
||||
sg.health_check_providers AS providers_json,
|
||||
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sg.health_check_provider, 'local') AS provider
|
||||
FROM service_binding_ips sbi
|
||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||
@@ -2404,6 +2502,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
sg.health_check_expected_status AS expected_status,
|
||||
sg.health_check_timeout_ms AS timeout_ms,
|
||||
sg.health_check_verify_tls AS verify_tls,
|
||||
sg.health_check_providers AS providers_json,
|
||||
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sg.health_check_provider, 'local') AS provider
|
||||
FROM service_binding_ips sbi
|
||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||
@@ -2427,6 +2527,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
sb.health_check_expected_status AS expected_status,
|
||||
sb.health_check_timeout_ms AS timeout_ms,
|
||||
sb.health_check_verify_tls AS verify_tls,
|
||||
sb.health_check_providers AS providers_json,
|
||||
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sb.health_check_provider, 'local') AS provider
|
||||
FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
@@ -2448,6 +2550,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
sg.health_check_expected_status AS expected_status,
|
||||
sg.health_check_timeout_ms AS timeout_ms,
|
||||
sg.health_check_verify_tls AS verify_tls,
|
||||
sg.health_check_providers AS providers_json,
|
||||
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sg.health_check_provider, 'local') AS provider
|
||||
FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
@@ -2468,11 +2572,28 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
...groupInheritedBindingTargets,
|
||||
...cnameBindingTargets,
|
||||
...groupInheritedCnameBindingTargets,
|
||||
].map((t) => ({
|
||||
...t,
|
||||
verify_tls: Boolean(t.verify_tls),
|
||||
provider: normalizeHealthProvider(t.provider),
|
||||
}));
|
||||
].map((t) => {
|
||||
const row = t as HealthCheckTarget & {
|
||||
providers_json?: string | null;
|
||||
aggregate?: string | null;
|
||||
};
|
||||
const providers = parseHealthProviders(row.providers_json, row.provider);
|
||||
return {
|
||||
scope: row.scope,
|
||||
ref_id: row.ref_id,
|
||||
ip: row.ip,
|
||||
hostname: row.hostname,
|
||||
type: row.type,
|
||||
port: row.port,
|
||||
path: row.path,
|
||||
expected_status: row.expected_status,
|
||||
timeout_ms: row.timeout_ms,
|
||||
verify_tls: Boolean(row.verify_tls),
|
||||
providers,
|
||||
aggregate: parseHealthAggregate(row.aggregate),
|
||||
provider: derivePrimaryProvider(providers),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- Domain tags ---
|
||||
@@ -2759,7 +2880,7 @@ export function listHealthProbeLogForService(
|
||||
`);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
provider: normalizeHealthProvider(row.provider),
|
||||
provider: parseHealthProviders(null, row.provider)[0] ?? "local",
|
||||
ok: Boolean(row.ok),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ export const serviceGroups = sqliteTable("service_groups", {
|
||||
.notNull()
|
||||
.default(false),
|
||||
health_check_provider: text("health_check_provider").notNull().default("local"),
|
||||
health_check_providers: text("health_check_providers").notNull().default('["local"]'),
|
||||
health_check_aggregate: text("health_check_aggregate").notNull().default("majority"),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
@@ -169,6 +171,12 @@ export const serviceBindings = sqliteTable(
|
||||
health_check_provider: text("health_check_provider")
|
||||
.notNull()
|
||||
.default("local"),
|
||||
health_check_providers: text("health_check_providers")
|
||||
.notNull()
|
||||
.default('["local"]'),
|
||||
health_check_aggregate: text("health_check_aggregate")
|
||||
.notNull()
|
||||
.default("majority"),
|
||||
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
|
||||
operation_version: integer("operation_version").notNull().default(0),
|
||||
created_at: text("created_at")
|
||||
@@ -398,6 +406,9 @@ export const appSettings = sqliteTable("app_settings", {
|
||||
health_worker_error: text("health_worker_error"),
|
||||
health_worker_deployed_at: text("health_worker_deployed_at"),
|
||||
health_worker_last_ingest_at: text("health_worker_last_ingest_at"),
|
||||
globalping_token: text("globalping_token"),
|
||||
globalping_locations: text("globalping_locations"),
|
||||
globalping_limit: integer("globalping_limit"),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
|
||||
@@ -28,6 +28,9 @@ export type AppSettingsDto = {
|
||||
healthWorkerError: string | null;
|
||||
healthWorkerDeployedAt: string | null;
|
||||
healthWorkerLastIngestAt: string | null;
|
||||
globalpingTokenSet: boolean;
|
||||
globalpingLocations: string;
|
||||
globalpingLimit: number;
|
||||
} & HealthEngineSettings;
|
||||
|
||||
export type AppSettingsPatch = {
|
||||
@@ -47,6 +50,9 @@ export type AppSettingsPatch = {
|
||||
healthWorkerError?: string | null;
|
||||
healthWorkerDeployedAt?: string | null;
|
||||
healthWorkerLastIngestAt?: string | null;
|
||||
globalpingToken?: string;
|
||||
globalpingLocations?: string;
|
||||
globalpingLimit?: number;
|
||||
};
|
||||
|
||||
export type HealthEngineFallbacks = HealthEngineSettings & {
|
||||
@@ -118,6 +124,14 @@ function toDto(
|
||||
healthWorkerDeployedAt: row.health_worker_deployed_at ?? null,
|
||||
healthWorkerLastIngestAt: row.health_worker_last_ingest_at ?? null,
|
||||
healthWorkerStatus: workerStatus(row, env.healthWorkerUrl),
|
||||
globalpingTokenSet: Boolean(row.globalping_token?.trim()),
|
||||
globalpingLocations: row.globalping_locations?.trim() || "World",
|
||||
globalpingLimit:
|
||||
row.globalping_limit == null ||
|
||||
Number.isNaN(row.globalping_limit) ||
|
||||
row.globalping_limit < 1
|
||||
? 3
|
||||
: Math.min(10, row.globalping_limit),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,12 +160,16 @@ export function getAppSettingsSecrets(db: Db): {
|
||||
vpsTrackerSyncEnabled: boolean;
|
||||
healthWorkerUrl: string;
|
||||
healthWorkerToken: string;
|
||||
globalpingToken: string;
|
||||
globalpingLocations: string;
|
||||
globalpingLimit: number;
|
||||
} {
|
||||
const row = db
|
||||
.select()
|
||||
.from(appSettings)
|
||||
.where(eq(appSettings.id, SETTINGS_ID))
|
||||
.get();
|
||||
const limit = row?.globalping_limit;
|
||||
return {
|
||||
vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "",
|
||||
vpsTrackerIntegrationToken:
|
||||
@@ -159,6 +177,12 @@ export function getAppSettingsSecrets(db: Db): {
|
||||
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled),
|
||||
healthWorkerUrl: row?.health_worker_url?.trim() ?? "",
|
||||
healthWorkerToken: row?.health_worker_token?.trim() ?? "",
|
||||
globalpingToken: row?.globalping_token?.trim() ?? "",
|
||||
globalpingLocations: row?.globalping_locations?.trim() || "World",
|
||||
globalpingLimit:
|
||||
limit == null || Number.isNaN(limit) || limit < 1
|
||||
? 3
|
||||
: Math.min(10, limit),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -249,6 +273,19 @@ export function updateAppSettings(
|
||||
patch.healthWorkerLastIngestAt !== undefined
|
||||
? patch.healthWorkerLastIngestAt
|
||||
: current.health_worker_last_ingest_at,
|
||||
globalping_token:
|
||||
patch.globalpingToken !== undefined &&
|
||||
patch.globalpingToken.trim() !== ""
|
||||
? patch.globalpingToken
|
||||
: current.globalping_token,
|
||||
globalping_locations:
|
||||
patch.globalpingLocations !== undefined
|
||||
? patch.globalpingLocations.trim() || "World"
|
||||
: current.globalping_locations,
|
||||
globalping_limit:
|
||||
patch.globalpingLimit !== undefined
|
||||
? Math.min(10, Math.max(1, patch.globalpingLimit))
|
||||
: current.globalping_limit,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(appSettings.id, SETTINGS_ID))
|
||||
|
||||
Vendored
+257
-20
@@ -15,6 +15,36 @@ declare const CERT_MONITOR_REQUIRED = "required";
|
||||
declare const CERT_MONITOR_SKIPPED = "skipped";
|
||||
declare const CERT_MONITORING_VALUES: readonly ["auto", "required", "skipped"];
|
||||
|
||||
declare const HEALTH_CHECK_PROVIDERS: readonly ["local", "cloudflare", "globalping"];
|
||||
type HealthCheckProvider = (typeof HEALTH_CHECK_PROVIDERS)[number];
|
||||
declare const HEALTH_STATUS_PROVIDERS: readonly ["local", "cloudflare", "globalping", "aggregate"];
|
||||
type HealthStatusProvider = (typeof HEALTH_STATUS_PROVIDERS)[number];
|
||||
declare const HEALTH_CHECK_AGGREGATES: readonly ["any", "all", "majority"];
|
||||
type HealthCheckAggregate = (typeof HEALTH_CHECK_AGGREGATES)[number];
|
||||
declare function normalizeProbeProvider(value: unknown): HealthCheckProvider;
|
||||
declare function normalizeStatusProvider(value: unknown): HealthStatusProvider;
|
||||
declare function uniqueHealthProviders(values: readonly unknown[]): HealthCheckProvider[];
|
||||
declare function parseHealthProviders(json: unknown, fallback?: unknown): HealthCheckProvider[];
|
||||
declare function serializeHealthProviders(providers: readonly HealthCheckProvider[]): string;
|
||||
declare function parseHealthAggregate(value: unknown): HealthCheckAggregate;
|
||||
declare function derivePrimaryProvider(providers: readonly HealthCheckProvider[]): HealthCheckProvider;
|
||||
declare function targetProviders(target: {
|
||||
providers?: readonly HealthCheckProvider[] | null;
|
||||
provider?: HealthCheckProvider | null;
|
||||
}): HealthCheckProvider[];
|
||||
declare function targetHasProvider(target: {
|
||||
providers?: readonly HealthCheckProvider[] | null;
|
||||
provider?: HealthCheckProvider | null;
|
||||
}, provider: HealthCheckProvider): boolean;
|
||||
/**
|
||||
* any — Down if at least one source is Down (ok only if all ok).
|
||||
* all — Down only if every source is Down (ok if any ok).
|
||||
* majority — Down if a strict majority of sources are Down (2 → both, 3 → ≥2).
|
||||
*/
|
||||
declare function aggregateHealthOk(oks: readonly boolean[], policy: HealthCheckAggregate): boolean;
|
||||
declare function clampGlobalpingLimit(value: unknown, fallback?: number): number;
|
||||
declare function parseGlobalpingLocations(value: unknown): string[];
|
||||
|
||||
interface ServiceGroup$1 {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -32,6 +62,8 @@ interface ServiceGroup$1 {
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -62,6 +94,8 @@ interface ServiceBinding {
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
routing_strategy: LbMode;
|
||||
operation_version: number;
|
||||
created_at: string;
|
||||
@@ -93,6 +127,8 @@ interface ServiceBindingView {
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
sync_status: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -118,6 +154,8 @@ interface ServiceDomainBindingView {
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
sync_status: string | null;
|
||||
}
|
||||
interface ServiceView$1 {
|
||||
@@ -189,7 +227,6 @@ type LbMode = "round_robin" | "failover" | "weighted";
|
||||
type HealthCheckType = "tcp" | "http" | "ping" | "dns";
|
||||
type IpHealthState = "up" | "down" | "degraded" | "unknown";
|
||||
type NodeHealthState = "unknown" | "checking" | "healthy" | "degraded" | "unhealthy" | "disabled";
|
||||
type HealthCheckProvider = "local" | "cloudflare";
|
||||
type HealthCheckScope = "binding" | "group";
|
||||
interface IpHealthStatus {
|
||||
scope: HealthCheckScope;
|
||||
@@ -202,7 +239,7 @@ interface IpHealthStatus {
|
||||
last_checked_at: string | null;
|
||||
last_error: string | null;
|
||||
colo?: string | null;
|
||||
provider?: HealthCheckProvider;
|
||||
provider?: HealthStatusProvider;
|
||||
}
|
||||
interface ServiceIpHealth$1 {
|
||||
ip: string;
|
||||
@@ -210,7 +247,7 @@ interface ServiceIpHealth$1 {
|
||||
latency_ms: number | null;
|
||||
last_checked_at?: string | null;
|
||||
last_error?: string | null;
|
||||
provider?: HealthCheckProvider;
|
||||
provider?: HealthStatusProvider;
|
||||
colo?: string | null;
|
||||
}
|
||||
interface ServiceNode {
|
||||
@@ -290,6 +327,8 @@ interface HealthCheckTarget {
|
||||
timeout_ms: number;
|
||||
verify_tls: boolean;
|
||||
provider: HealthCheckProvider;
|
||||
providers?: HealthCheckProvider[];
|
||||
aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
declare class ValidationError extends Error {
|
||||
@@ -370,7 +409,24 @@ declare const nodeHealthStateSchema: z.ZodEnum<{
|
||||
declare const healthCheckProviderSchema: z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>;
|
||||
declare const healthStatusProviderSchema: z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
aggregate: "aggregate";
|
||||
}>;
|
||||
declare const healthCheckAggregateSchema: z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>;
|
||||
declare const healthCheckProvidersSchema: z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>;
|
||||
declare const healthCheckScopeSchema: z.ZodEnum<{
|
||||
binding: "binding";
|
||||
group: "group";
|
||||
@@ -397,6 +453,8 @@ declare const ipHealthStatusSchema: z.ZodObject<{
|
||||
provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
aggregate: "aggregate";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
declare const serviceIpHealthSchema: z.ZodObject<{
|
||||
@@ -413,6 +471,8 @@ declare const serviceIpHealthSchema: z.ZodObject<{
|
||||
provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
aggregate: "aggregate";
|
||||
}>>;
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>;
|
||||
@@ -425,6 +485,7 @@ declare const healthProbeLogSchema: z.ZodObject<{
|
||||
provider: z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>;
|
||||
status: z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
@@ -455,21 +516,21 @@ declare const groupWithStatsSchema: z.ZodObject<{
|
||||
domain_count: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
declare const serviceGroupTypeSchema: z.ZodEnum<{
|
||||
custom: "custom";
|
||||
vpn: "vpn";
|
||||
network: "network";
|
||||
internet: "internet";
|
||||
bgp: "bgp";
|
||||
custom: "custom";
|
||||
}>;
|
||||
declare const serviceGroupSchema: z.ZodObject<{
|
||||
id: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
type: z.ZodCatch<z.ZodEnum<{
|
||||
custom: "custom";
|
||||
vpn: "vpn";
|
||||
network: "network";
|
||||
internet: "internet";
|
||||
bgp: "bgp";
|
||||
custom: "custom";
|
||||
}>>;
|
||||
icon: 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<{
|
||||
local: "local";
|
||||
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;
|
||||
updated_at: z.ZodString;
|
||||
@@ -548,6 +620,17 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
|
||||
health_check_provider: z.ZodCatch<z.ZodEnum<{
|
||||
local: "local";
|
||||
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>>;
|
||||
}, z.core.$strip>, z.ZodTransform<{
|
||||
@@ -570,7 +653,9 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
sync_status: string | null;
|
||||
target_ip?: string | null | undefined;
|
||||
}, {
|
||||
@@ -589,7 +674,9 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
sync_status: string | null;
|
||||
target_ips?: string[] | undefined;
|
||||
target_ip?: string | null | undefined;
|
||||
@@ -646,6 +733,17 @@ declare const serviceViewSchema: z.ZodObject<{
|
||||
health_check_provider: z.ZodCatch<z.ZodEnum<{
|
||||
local: "local";
|
||||
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>>;
|
||||
}, z.core.$strip>, z.ZodTransform<{
|
||||
@@ -668,7 +766,9 @@ declare const serviceViewSchema: z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
sync_status: string | null;
|
||||
target_ip?: string | null | undefined;
|
||||
}, {
|
||||
@@ -687,7 +787,9 @@ declare const serviceViewSchema: z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
sync_status: string | null;
|
||||
target_ips?: string[] | undefined;
|
||||
target_ip?: string | null | undefined;
|
||||
@@ -716,6 +818,8 @@ declare const serviceViewSchema: z.ZodObject<{
|
||||
provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
aggregate: "aggregate";
|
||||
}>>;
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
@@ -725,11 +829,11 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
id: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
type: z.ZodCatch<z.ZodEnum<{
|
||||
custom: "custom";
|
||||
vpn: "vpn";
|
||||
network: "network";
|
||||
internet: "internet";
|
||||
bgp: "bgp";
|
||||
custom: "custom";
|
||||
}>>;
|
||||
icon: 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<{
|
||||
local: "local";
|
||||
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;
|
||||
updated_at: z.ZodString;
|
||||
@@ -807,6 +922,17 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
health_check_provider: z.ZodCatch<z.ZodEnum<{
|
||||
local: "local";
|
||||
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>>;
|
||||
}, z.core.$strip>, z.ZodTransform<{
|
||||
@@ -829,7 +955,9 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
sync_status: string | null;
|
||||
target_ip?: string | null | undefined;
|
||||
}, {
|
||||
@@ -848,7 +976,9 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
sync_status: string | null;
|
||||
target_ips?: string[] | undefined;
|
||||
target_ip?: string | null | undefined;
|
||||
@@ -877,6 +1007,8 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
aggregate: "aggregate";
|
||||
}>>;
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
@@ -895,11 +1027,11 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
id: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
type: z.ZodCatch<z.ZodEnum<{
|
||||
custom: "custom";
|
||||
vpn: "vpn";
|
||||
network: "network";
|
||||
internet: "internet";
|
||||
bgp: "bgp";
|
||||
custom: "custom";
|
||||
}>>;
|
||||
icon: 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<{
|
||||
local: "local";
|
||||
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;
|
||||
updated_at: z.ZodString;
|
||||
@@ -977,6 +1120,17 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_provider: z.ZodCatch<z.ZodEnum<{
|
||||
local: "local";
|
||||
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>>;
|
||||
}, z.core.$strip>, z.ZodTransform<{
|
||||
@@ -999,7 +1153,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
sync_status: string | null;
|
||||
target_ip?: string | null | undefined;
|
||||
}, {
|
||||
@@ -1018,7 +1174,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
sync_status: string | null;
|
||||
target_ips?: string[] | undefined;
|
||||
target_ip?: string | null | undefined;
|
||||
@@ -1047,6 +1205,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
aggregate: "aggregate";
|
||||
}>>;
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
@@ -1109,6 +1269,17 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_provider: z.ZodCatch<z.ZodEnum<{
|
||||
local: "local";
|
||||
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>>;
|
||||
}, z.core.$strip>, z.ZodTransform<{
|
||||
@@ -1131,7 +1302,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
sync_status: string | null;
|
||||
target_ip?: string | null | undefined;
|
||||
}, {
|
||||
@@ -1150,7 +1323,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
sync_status: string | null;
|
||||
target_ips?: string[] | undefined;
|
||||
target_ip?: string | null | undefined;
|
||||
@@ -1179,6 +1354,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
aggregate: "aggregate";
|
||||
}>>;
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
@@ -1387,6 +1564,17 @@ declare const healthCheckConfigSchema: z.ZodObject<{
|
||||
health_check_provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
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>;
|
||||
type HealthCheckConfig = z.infer<typeof healthCheckConfigSchema>;
|
||||
@@ -1418,6 +1606,17 @@ declare const createServiceWithConfigSchema: z.ZodObject<{
|
||||
health_check_provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
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;
|
||||
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
@@ -1610,6 +1809,17 @@ declare const updateServiceConfigSchema: z.ZodObject<{
|
||||
health_check_provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
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;
|
||||
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
@@ -1641,14 +1851,25 @@ declare const createServiceGroupSchema: z.ZodObject<{
|
||||
health_check_provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
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;
|
||||
type: z.ZodDefault<z.ZodEnum<{
|
||||
custom: "custom";
|
||||
vpn: "vpn";
|
||||
network: "network";
|
||||
internet: "internet";
|
||||
bgp: "bgp";
|
||||
custom: "custom";
|
||||
}>>;
|
||||
icon: 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<{
|
||||
local: "local";
|
||||
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>;
|
||||
type: z.ZodOptional<z.ZodEnum<{
|
||||
custom: "custom";
|
||||
vpn: "vpn";
|
||||
network: "network";
|
||||
internet: "internet";
|
||||
bgp: "bgp";
|
||||
custom: "custom";
|
||||
}>>;
|
||||
icon: 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<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>;
|
||||
cf_healthcheck_id: z.ZodNullable<z.ZodString>;
|
||||
cf_zone_id: z.ZodNullable<z.ZodString>;
|
||||
@@ -1797,6 +2030,7 @@ declare const createOriginHealthCheckSchema: z.ZodObject<{
|
||||
provider: z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>;
|
||||
name: z.ZodString;
|
||||
cf_zone_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
@@ -1924,6 +2158,9 @@ declare const appSettingsPatchSchema: z.ZodObject<{
|
||||
healthSuccessRecoveries: z.ZodOptional<z.ZodNumber>;
|
||||
healthWorkerUrl: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodLiteral<"">]>>;
|
||||
healthWorkerToken: z.ZodOptional<z.ZodString>;
|
||||
globalpingToken: z.ZodOptional<z.ZodString>;
|
||||
globalpingLocations: z.ZodOptional<z.ZodString>;
|
||||
globalpingLimit: z.ZodOptional<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
|
||||
declare const vpsTrackerEventSchema: z.ZodObject<{
|
||||
@@ -2085,4 +2322,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{
|
||||
}, z.core.$strip>;
|
||||
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 };
|
||||
|
||||
Vendored
+132
-5
@@ -165,6 +165,100 @@ function bindingToFqdn(binding) {
|
||||
|
||||
// src/schemas.ts
|
||||
import { z } from "zod";
|
||||
|
||||
// src/health-providers.ts
|
||||
var HEALTH_CHECK_PROVIDERS = [
|
||||
"local",
|
||||
"cloudflare",
|
||||
"globalping"
|
||||
];
|
||||
var HEALTH_STATUS_PROVIDERS = [
|
||||
...HEALTH_CHECK_PROVIDERS,
|
||||
"aggregate"
|
||||
];
|
||||
var HEALTH_CHECK_AGGREGATES = ["any", "all", "majority"];
|
||||
var PROVIDER_SET = new Set(HEALTH_CHECK_PROVIDERS);
|
||||
var STATUS_SET = new Set(HEALTH_STATUS_PROVIDERS);
|
||||
var AGGREGATE_SET = new Set(HEALTH_CHECK_AGGREGATES);
|
||||
function normalizeProbeProvider(value) {
|
||||
return value === "cloudflare" || value === "globalping" || value === "local" ? value : "local";
|
||||
}
|
||||
function normalizeStatusProvider(value) {
|
||||
if (typeof value === "string" && STATUS_SET.has(value)) {
|
||||
return value;
|
||||
}
|
||||
return "local";
|
||||
}
|
||||
function uniqueHealthProviders(values) {
|
||||
const out = [];
|
||||
for (const value of values) {
|
||||
if (!PROVIDER_SET.has(String(value))) continue;
|
||||
const next = value;
|
||||
if (!out.includes(next)) out.push(next);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function parseHealthProviders(json, fallback) {
|
||||
if (Array.isArray(json)) {
|
||||
const parsed = uniqueHealthProviders(json);
|
||||
if (parsed.length > 0) return parsed;
|
||||
}
|
||||
if (typeof json === "string" && json.trim()) {
|
||||
const trimmed = json.trim();
|
||||
if (trimmed.startsWith("[")) {
|
||||
try {
|
||||
const parsed = uniqueHealthProviders(JSON.parse(trimmed));
|
||||
if (parsed.length > 0) return parsed;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
const one = uniqueHealthProviders(trimmed.split(","));
|
||||
if (one.length > 0) return one;
|
||||
}
|
||||
return [normalizeProbeProvider(fallback)];
|
||||
}
|
||||
function serializeHealthProviders(providers) {
|
||||
const unique = uniqueHealthProviders(providers);
|
||||
return JSON.stringify(unique.length > 0 ? unique : ["local"]);
|
||||
}
|
||||
function parseHealthAggregate(value) {
|
||||
if (typeof value === "string" && AGGREGATE_SET.has(value)) {
|
||||
return value;
|
||||
}
|
||||
return "majority";
|
||||
}
|
||||
function derivePrimaryProvider(providers) {
|
||||
return uniqueHealthProviders(providers)[0] ?? "local";
|
||||
}
|
||||
function targetProviders(target) {
|
||||
if (target.providers && target.providers.length > 0) {
|
||||
return uniqueHealthProviders(target.providers);
|
||||
}
|
||||
return [normalizeProbeProvider(target.provider)];
|
||||
}
|
||||
function targetHasProvider(target, provider) {
|
||||
return targetProviders(target).includes(provider);
|
||||
}
|
||||
function aggregateHealthOk(oks, policy) {
|
||||
const n = oks.length;
|
||||
if (n === 0) return false;
|
||||
const down = oks.filter((ok) => !ok).length;
|
||||
if (policy === "any") return down === 0;
|
||||
if (policy === "all") return down < n;
|
||||
return down < Math.floor(n / 2) + 1;
|
||||
}
|
||||
function clampGlobalpingLimit(value, fallback = 3) {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.min(10, Math.max(1, Math.trunc(n)));
|
||||
}
|
||||
function parseGlobalpingLocations(value) {
|
||||
const raw = typeof value === "string" ? value : "";
|
||||
const parts = raw.split(",").map((part) => part.trim()).filter(Boolean);
|
||||
return parts.length > 0 ? parts : ["World"];
|
||||
}
|
||||
|
||||
// src/schemas.ts
|
||||
var certMonitoringSchema = z.enum(["auto", "required", "skipped"]);
|
||||
var lbModeSchema = z.enum(["round_robin", "failover", "weighted"]);
|
||||
var healthCheckTypeSchema = z.enum(["tcp", "http", "ping", "dns"]);
|
||||
@@ -179,7 +273,13 @@ var nodeHealthStateSchema = z.enum([
|
||||
"unhealthy",
|
||||
"disabled"
|
||||
]);
|
||||
var healthCheckProviderSchema = z.enum(["local", "cloudflare"]);
|
||||
var healthCheckProviderSchema = z.enum(HEALTH_CHECK_PROVIDERS);
|
||||
var healthStatusProviderSchema = z.enum(HEALTH_STATUS_PROVIDERS);
|
||||
var healthCheckAggregateSchema = z.enum(HEALTH_CHECK_AGGREGATES);
|
||||
var healthCheckProvidersSchema = z.array(healthCheckProviderSchema).min(1).transform((arr) => {
|
||||
const unique = uniqueHealthProviders(arr);
|
||||
return unique.length > 0 ? unique : ["local"];
|
||||
});
|
||||
var healthCheckScopeSchema = z.enum(["binding", "group"]);
|
||||
var ipHealthStatusSchema = z.object({
|
||||
scope: healthCheckScopeSchema,
|
||||
@@ -192,7 +292,7 @@ var ipHealthStatusSchema = z.object({
|
||||
last_checked_at: z.string().nullable(),
|
||||
last_error: z.string().nullable(),
|
||||
colo: z.string().nullable().optional(),
|
||||
provider: healthCheckProviderSchema.optional()
|
||||
provider: healthStatusProviderSchema.optional()
|
||||
});
|
||||
var serviceIpHealthSchema = z.object({
|
||||
ip: z.string(),
|
||||
@@ -200,7 +300,7 @@ var serviceIpHealthSchema = z.object({
|
||||
latency_ms: z.number().nullable(),
|
||||
last_checked_at: z.string().nullable().optional(),
|
||||
last_error: z.string().nullable().optional(),
|
||||
provider: healthCheckProviderSchema.optional(),
|
||||
provider: healthStatusProviderSchema.optional(),
|
||||
colo: z.string().nullable().optional()
|
||||
});
|
||||
var healthProbeLogSchema = z.object({
|
||||
@@ -250,6 +350,8 @@ var serviceGroupSchema = z.object({
|
||||
health_check_timeout_ms: z.number().default(3e3),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: healthCheckProviderSchema.catch("local"),
|
||||
health_check_providers: healthCheckProvidersSchema.catch(["local"]),
|
||||
health_check_aggregate: healthCheckAggregateSchema.catch("majority"),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string()
|
||||
});
|
||||
@@ -288,6 +390,8 @@ var serviceDomainBindingSchema = z.object({
|
||||
health_check_timeout_ms: z.number().default(3e3),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: healthCheckProviderSchema.catch("local"),
|
||||
health_check_providers: healthCheckProvidersSchema.catch(["local"]),
|
||||
health_check_aggregate: healthCheckAggregateSchema.catch("majority"),
|
||||
sync_status: z.string().nullable().default(null)
|
||||
}).transform((binding) => ({
|
||||
...binding,
|
||||
@@ -414,7 +518,9 @@ var healthCheckConfigFields = {
|
||||
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
|
||||
health_check_timeout_ms: z.number().int().min(100).max(3e4).optional(),
|
||||
health_check_verify_tls: z.boolean().optional(),
|
||||
health_check_provider: healthCheckProviderSchema.optional()
|
||||
health_check_provider: healthCheckProviderSchema.optional(),
|
||||
health_check_providers: healthCheckProvidersSchema.optional(),
|
||||
health_check_aggregate: healthCheckAggregateSchema.optional()
|
||||
};
|
||||
var healthCheckConfigSchema = z.object(healthCheckConfigFields);
|
||||
var serviceDomainInputSchema = z.object({
|
||||
@@ -727,7 +833,10 @@ var appSettingsPatchSchema = z3.object({
|
||||
healthLatencyWarnMs: z3.number().int().min(50).max(6e4).optional(),
|
||||
healthSuccessRecoveries: z3.number().int().min(1).max(20).optional(),
|
||||
healthWorkerUrl: z3.string().url().or(z3.literal("")).optional(),
|
||||
healthWorkerToken: z3.string().optional()
|
||||
healthWorkerToken: z3.string().optional(),
|
||||
globalpingToken: z3.string().optional(),
|
||||
globalpingLocations: z3.string().trim().max(200).optional(),
|
||||
globalpingLimit: z3.number().int().min(1).max(10).optional()
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.healthDegradedFailures != null && data.healthDownFailures != null && data.healthDownFailures < data.healthDegradedFailures) {
|
||||
ctx.addIssue({
|
||||
@@ -829,6 +938,8 @@ export {
|
||||
CERT_OK,
|
||||
CERT_UNKNOWN,
|
||||
CERT_WARNING,
|
||||
HEALTH_CHECK_AGGREGATES,
|
||||
HEALTH_CHECK_PROVIDERS,
|
||||
HEALTH_KV_CURSOR_KEY,
|
||||
HEALTH_KV_RESULTS_KEY,
|
||||
HEALTH_KV_TARGETS_KEY,
|
||||
@@ -836,12 +947,14 @@ export {
|
||||
HEALTH_PROBE_CONCURRENCY,
|
||||
HEALTH_PROBE_KV_TITLE,
|
||||
HEALTH_PROBE_SCRIPT_NAME,
|
||||
HEALTH_STATUS_PROVIDERS,
|
||||
SYNC_CONFLICT,
|
||||
SYNC_ERROR,
|
||||
SYNC_PENDING_DELETE,
|
||||
SYNC_PENDING_PUSH,
|
||||
SYNC_SYNCED,
|
||||
ValidationError,
|
||||
aggregateHealthOk,
|
||||
appSettingsPatchSchema,
|
||||
appSwitcherConfigSchema,
|
||||
appSwitcherEntrySchema,
|
||||
@@ -860,6 +973,7 @@ export {
|
||||
cfdmSyncBindingsBodySchema,
|
||||
changeDomainSchema,
|
||||
changeIpSchema,
|
||||
clampGlobalpingLimit,
|
||||
createDnsRecordSchema,
|
||||
createDomainMonitorSchema,
|
||||
createDomainSchema,
|
||||
@@ -871,6 +985,7 @@ export {
|
||||
createServiceSchema,
|
||||
createServiceWithConfigSchema,
|
||||
createSubdomainSchema,
|
||||
derivePrimaryProvider,
|
||||
dnsNameToSubdomainLabel,
|
||||
dnsRecordNamesMatch,
|
||||
dnsRecordSchema,
|
||||
@@ -883,11 +998,14 @@ export {
|
||||
fqdnToDisplay,
|
||||
groupSchema,
|
||||
groupWithStatsSchema,
|
||||
healthCheckAggregateSchema,
|
||||
healthCheckConfigSchema,
|
||||
healthCheckProviderSchema,
|
||||
healthCheckProvidersSchema,
|
||||
healthCheckScopeSchema,
|
||||
healthCheckTypeSchema,
|
||||
healthProbeLogSchema,
|
||||
healthStatusProviderSchema,
|
||||
healthStatusQuerySchema,
|
||||
ingestAuditEventSchema,
|
||||
ipHealthStateSchema,
|
||||
@@ -898,10 +1016,16 @@ export {
|
||||
loginSchema,
|
||||
nodeHealthStateSchema,
|
||||
normalizeDnsRecordName,
|
||||
normalizeProbeProvider,
|
||||
normalizeStatusProvider,
|
||||
notificationLogSchema,
|
||||
originHealthCheckSchema,
|
||||
parseFqdn,
|
||||
parseGlobalpingLocations,
|
||||
parseHealthAggregate,
|
||||
parseHealthProviders,
|
||||
reorderServicesSchema,
|
||||
serializeHealthProviders,
|
||||
serviceBindingSchema,
|
||||
serviceDomainBindingSchema,
|
||||
serviceGroupSchema,
|
||||
@@ -915,8 +1039,11 @@ export {
|
||||
shouldMonitorService,
|
||||
subdomainLabelToFqdn,
|
||||
subdomainSchema,
|
||||
targetHasProvider,
|
||||
targetProviders,
|
||||
toggleEnabledSchema,
|
||||
toggleServiceIpSchema,
|
||||
uniqueHealthProviders,
|
||||
updateDomainGroupSchema,
|
||||
updateDomainSchema,
|
||||
updateServiceConfigSchema,
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export * from "./schemas.js";
|
||||
export * from "./app-switcher.js";
|
||||
export * from "./integration-vps-tracker.js";
|
||||
export * from "./health-probe-mailbox.js";
|
||||
export * from "./health-providers.js";
|
||||
export * from "./audit.js";
|
||||
export type {
|
||||
CfZone,
|
||||
@@ -23,7 +24,6 @@ export type {
|
||||
HealthCheckType,
|
||||
IpHealthState,
|
||||
NodeHealthState,
|
||||
HealthCheckProvider,
|
||||
HealthCheckScope,
|
||||
IpHealthStatus,
|
||||
HealthCheckTarget,
|
||||
|
||||
@@ -37,6 +37,9 @@ export const appSettingsPatchSchema = z.object({
|
||||
healthSuccessRecoveries: z.number().int().min(1).max(20).optional(),
|
||||
healthWorkerUrl: z.string().url().or(z.literal("")).optional(),
|
||||
healthWorkerToken: z.string().optional(),
|
||||
globalpingToken: z.string().optional(),
|
||||
globalpingLocations: z.string().trim().max(200).optional(),
|
||||
globalpingLimit: z.number().int().min(1).max(10).optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (
|
||||
data.healthDegradedFailures != null &&
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
HEALTH_CHECK_AGGREGATES,
|
||||
HEALTH_CHECK_PROVIDERS,
|
||||
HEALTH_STATUS_PROVIDERS,
|
||||
uniqueHealthProviders,
|
||||
} from './health-providers.js'
|
||||
|
||||
export const certMonitoringSchema = z.enum(['auto', 'required', 'skipped'])
|
||||
|
||||
@@ -29,8 +35,19 @@ export const nodeHealthStateSchema = z.enum([
|
||||
])
|
||||
export type NodeHealthState = z.infer<typeof nodeHealthStateSchema>
|
||||
|
||||
export const healthCheckProviderSchema = z.enum(['local', 'cloudflare'])
|
||||
export type HealthCheckProvider = z.infer<typeof healthCheckProviderSchema>
|
||||
export const healthCheckProviderSchema = z.enum(HEALTH_CHECK_PROVIDERS)
|
||||
|
||||
export const healthStatusProviderSchema = z.enum(HEALTH_STATUS_PROVIDERS)
|
||||
|
||||
export const healthCheckAggregateSchema = z.enum(HEALTH_CHECK_AGGREGATES)
|
||||
|
||||
export const healthCheckProvidersSchema = z
|
||||
.array(healthCheckProviderSchema)
|
||||
.min(1)
|
||||
.transform((arr) => {
|
||||
const unique = uniqueHealthProviders(arr)
|
||||
return unique.length > 0 ? unique : (['local'] as const)
|
||||
})
|
||||
|
||||
export const healthCheckScopeSchema = z.enum(['binding', 'group'])
|
||||
export type HealthCheckScope = z.infer<typeof healthCheckScopeSchema>
|
||||
@@ -46,7 +63,7 @@ export const ipHealthStatusSchema = z.object({
|
||||
last_checked_at: z.string().nullable(),
|
||||
last_error: z.string().nullable(),
|
||||
colo: z.string().nullable().optional(),
|
||||
provider: healthCheckProviderSchema.optional(),
|
||||
provider: healthStatusProviderSchema.optional(),
|
||||
})
|
||||
|
||||
export type IpHealthStatus = z.infer<typeof ipHealthStatusSchema>
|
||||
@@ -57,7 +74,7 @@ export const serviceIpHealthSchema = z.object({
|
||||
latency_ms: z.number().nullable(),
|
||||
last_checked_at: z.string().nullable().optional(),
|
||||
last_error: z.string().nullable().optional(),
|
||||
provider: healthCheckProviderSchema.optional(),
|
||||
provider: healthStatusProviderSchema.optional(),
|
||||
colo: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
@@ -116,6 +133,8 @@ export const serviceGroupSchema = z.object({
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: healthCheckProviderSchema.catch('local'),
|
||||
health_check_providers: healthCheckProvidersSchema.catch(['local']),
|
||||
health_check_aggregate: healthCheckAggregateSchema.catch('majority'),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
@@ -157,6 +176,8 @@ export const serviceDomainBindingSchema = z
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: healthCheckProviderSchema.catch('local'),
|
||||
health_check_providers: healthCheckProvidersSchema.catch(['local']),
|
||||
health_check_aggregate: healthCheckAggregateSchema.catch('majority'),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
})
|
||||
.transform((binding) => ({
|
||||
@@ -329,6 +350,8 @@ const healthCheckConfigFields = {
|
||||
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
|
||||
health_check_verify_tls: z.boolean().optional(),
|
||||
health_check_provider: healthCheckProviderSchema.optional(),
|
||||
health_check_providers: healthCheckProvidersSchema.optional(),
|
||||
health_check_aggregate: healthCheckAggregateSchema.optional(),
|
||||
}
|
||||
|
||||
export const healthCheckConfigSchema = z.object(healthCheckConfigFields)
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
import type {
|
||||
HealthCheckProvider,
|
||||
HealthCheckAggregate,
|
||||
HealthStatusProvider,
|
||||
} from "./health-providers.js";
|
||||
|
||||
export type {
|
||||
HealthCheckProvider,
|
||||
HealthCheckAggregate,
|
||||
HealthStatusProvider,
|
||||
} from "./health-providers.js";
|
||||
|
||||
export interface Group {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -23,6 +35,8 @@ export interface ServiceGroup {
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -123,6 +137,8 @@ export interface ServiceBinding {
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
routing_strategy: LbMode;
|
||||
operation_version: number;
|
||||
created_at: string;
|
||||
@@ -155,6 +171,8 @@ export interface ServiceBindingView {
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
sync_status: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -181,6 +199,8 @@ export interface ServiceDomainBindingView {
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
sync_status: string | null;
|
||||
}
|
||||
|
||||
@@ -284,8 +304,6 @@ export type NodeHealthState =
|
||||
| "unhealthy"
|
||||
| "disabled";
|
||||
|
||||
export type HealthCheckProvider = "local" | "cloudflare";
|
||||
|
||||
export type HealthCheckScope = "binding" | "group";
|
||||
|
||||
export interface IpHealthStatus {
|
||||
@@ -299,7 +317,7 @@ export interface IpHealthStatus {
|
||||
last_checked_at: string | null;
|
||||
last_error: string | null;
|
||||
colo?: string | null;
|
||||
provider?: HealthCheckProvider;
|
||||
provider?: HealthStatusProvider;
|
||||
}
|
||||
|
||||
export interface ServiceIpHealth {
|
||||
@@ -308,7 +326,7 @@ export interface ServiceIpHealth {
|
||||
latency_ms: number | null;
|
||||
last_checked_at?: string | null;
|
||||
last_error?: string | null;
|
||||
provider?: HealthCheckProvider;
|
||||
provider?: HealthStatusProvider;
|
||||
colo?: string | null;
|
||||
}
|
||||
|
||||
@@ -394,4 +412,6 @@ export interface HealthCheckTarget {
|
||||
timeout_ms: number;
|
||||
verify_tls: boolean;
|
||||
provider: HealthCheckProvider;
|
||||
providers?: HealthCheckProvider[];
|
||||
aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user