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

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

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-19 18:32:18 +07:00
co-authored by Cursor
parent 4c4908558b
commit b9bea44dce
31 changed files with 2671 additions and 271 deletions
+277
View File
@@ -0,0 +1,277 @@
import type { HealthCheckTarget } from "@cfdm/shared";
import {
clampGlobalpingLimit,
parseGlobalpingLocations,
} from "@cfdm/shared";
export const GLOBALPING_API_ROOT = "https://api.globalping.io";
export const GLOBALPING_MIN_POLL_MS = 500;
export const GLOBALPING_UA = "CFDM-health/1.0";
export interface GlobalpingClientOptions {
token?: string | null;
locations?: string;
limit?: number;
pollIntervalMs?: number;
maxWaitMs?: number;
fetchImpl?: typeof fetch;
}
export interface GlobalpingProbeResult {
ok: boolean;
latencyMs: number;
error: string | null;
colo: string | null;
}
interface MeasurementCreateBody {
type: "ping" | "http";
target: string;
inProgressUpdates: false;
limit: number;
locations: Array<{ magic: string }>;
measurementOptions: Record<string, unknown>;
}
interface MeasurementProbe {
continent?: string;
country?: string;
city?: string;
network?: string;
}
interface MeasurementResultRow {
probe?: MeasurementProbe;
result?: {
status?: string;
statusCode?: number;
timings?: { total?: number };
stats?: { avg?: number; loss?: number };
};
}
interface MeasurementResponse {
id?: string;
status?: string;
results?: MeasurementResultRow[];
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function locationLabel(probe: MeasurementProbe | undefined): string | null {
if (!probe) return null;
const city = probe.city?.trim();
const country = probe.country?.trim();
if (city && country) return `${city}, ${country}`;
return city || country || null;
}
export function buildMeasurementBody(
target: HealthCheckTarget,
options: GlobalpingClientOptions,
): MeasurementCreateBody {
const limit = clampGlobalpingLimit(options.limit, 3);
const locations = parseGlobalpingLocations(options.locations).map((magic) => ({
magic,
}));
const port = target.port ?? (target.type === "http" ? 80 : 80);
const ip = String(target.ip || "").trim();
const hostname = (target.hostname || ip).trim();
if (target.type === "http") {
const path = target.path?.trim() || "/";
const protocol = port === 443 ? "HTTPS" : "HTTP";
return {
type: "http",
target: ip,
inProgressUpdates: false,
limit,
locations,
measurementOptions: {
protocol,
port,
request: {
method: "GET",
host: hostname,
path: path.startsWith("/") ? path : `/${path}`,
},
},
};
}
return {
type: "ping",
target: ip,
inProgressUpdates: false,
limit,
locations,
measurementOptions: {
protocol: "TCP",
port,
},
};
}
function rowOk(target: HealthCheckTarget, row: MeasurementResultRow): boolean {
const result = row.result;
if (!result) return false;
const status = String(result.status ?? "").toLowerCase();
if (status && status !== "finished") return false;
if (target.type === "http") {
const code = result.statusCode;
if (code == null) return false;
if (target.expected_status != null) return code === target.expected_status;
return code >= 200 && code < 400;
}
const loss = result.stats?.loss;
if (loss != null && loss >= 100) return false;
return status === "finished" || status === "";
}
function rowLatency(row: MeasurementResultRow): number {
const total = row.result?.timings?.total;
if (typeof total === "number" && Number.isFinite(total)) return Math.round(total);
const avg = row.result?.stats?.avg;
if (typeof avg === "number" && Number.isFinite(avg)) return Math.round(avg);
return 0;
}
export function summarizeMeasurement(
target: HealthCheckTarget,
doc: MeasurementResponse,
): GlobalpingProbeResult {
const rows = doc.results ?? [];
if (rows.length === 0) {
return {
ok: false,
latencyMs: 0,
error: "Globalping: пустой результат",
colo: null,
};
}
const oks = rows.map((row) => rowOk(target, row));
const okCount = oks.filter(Boolean).length;
const ok = okCount > rows.length / 2;
const latencies = rows.map(rowLatency);
const latencyMs = Math.round(
latencies.reduce((sum, n) => sum + n, 0) / latencies.length,
);
const colo =
locationLabel(rows.find((_, i) => oks[i])?.probe) ??
locationLabel(rows[0]?.probe);
if (ok) {
return { ok: true, latencyMs, error: null, colo };
}
const expected =
target.type === "http" && target.expected_status != null
? `ожидали HTTP ${target.expected_status}`
: target.type === "http"
? "ожидали HTTP 2xx/3xx"
: "TCP ping с packet loss < 100%";
return {
ok: false,
latencyMs,
error: `Globalping: ${okCount}/${rows.length} проб успешны (${expected})`,
colo,
};
}
async function parseJson(response: Response): Promise<MeasurementResponse> {
try {
return (await response.json()) as MeasurementResponse;
} catch {
return {};
}
}
export async function runGlobalpingMeasurement(
target: HealthCheckTarget,
options: GlobalpingClientOptions = {},
): Promise<GlobalpingProbeResult> {
const fetchImpl = options.fetchImpl ?? fetch;
const pollMs =
options.pollIntervalMs === undefined
? GLOBALPING_MIN_POLL_MS
: Math.max(0, options.pollIntervalMs);
const maxWaitMs = options.maxWaitMs ?? Math.max(target.timeout_ms ?? 3000, 3000) + 15_000;
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": GLOBALPING_UA,
};
const token = options.token?.trim();
if (token) headers.Authorization = `Bearer ${token}`;
const created = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements`, {
method: "POST",
headers,
body: JSON.stringify(buildMeasurementBody(target, options)),
});
if (created.status === 429) {
return {
ok: false,
latencyMs: 0,
error: "Globalping: 429 rate limit",
colo: null,
};
}
if (created.status !== 202 && created.status !== 200) {
const body = await parseJson(created);
return {
ok: false,
latencyMs: 0,
error: `Globalping: HTTP ${created.status}${body.status ? ` (${body.status})` : ""}`,
colo: null,
};
}
const createdBody = await parseJson(created);
const id = createdBody.id?.trim();
if (!id) {
return {
ok: false,
latencyMs: 0,
error: "Globalping: нет id измерения",
colo: null,
};
}
const started = Date.now();
while (Date.now() - started < maxWaitMs) {
await sleep(pollMs);
const polled = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements/${id}`, {
method: "GET",
headers: {
Accept: "application/json",
"User-Agent": GLOBALPING_UA,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (polled.status === 429) {
return {
ok: false,
latencyMs: 0,
error: "Globalping: 429 rate limit",
colo: null,
};
}
if (!polled.ok) {
return {
ok: false,
latencyMs: 0,
error: `Globalping: HTTP ${polled.status} при опросе`,
colo: null,
};
}
const doc = await parseJson(polled);
if (String(doc.status ?? "").toLowerCase() === "in-progress") continue;
return summarizeMeasurement(target, doc);
}
return {
ok: false,
latencyMs: Date.now() - started,
error: "Globalping: timeout ожидания measurement",
colo: null,
};
}