feat(health-checks): enhance health check configuration and logging
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 51s
quality / api (push) Successful in 44s
CD / quality (push) Successful in 1m44s
CD / publish (push) Successful in 1m35s
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 51s
quality / api (push) Successful in 44s
CD / quality (push) Successful in 1m44s
CD / publish (push) Successful in 1m35s
- Added new health worker URL and token fields to the AppConfig interface, allowing for Cloudflare Worker integration. - Updated health check routes to utilize the new worker configuration, enabling dynamic health checks via Cloudflare Workers. - Introduced a health log endpoint for services, providing detailed logs of health probe results. - Enhanced health check service logic to support both local and Cloudflare Worker providers, improving flexibility in health monitoring. - Updated UI components to reflect changes in health check provider settings and display relevant health information. This commit significantly improves the health check management capabilities, allowing for better integration with Cloudflare Workers and enhanced logging features.
This commit is contained in:
@@ -17,6 +17,8 @@ export interface AppConfig {
|
||||
healthLatencyWarnMs: number;
|
||||
/** Min pause between probes to different physical targets (same IP is probed once). */
|
||||
healthProbeGapMs: number;
|
||||
healthWorkerUrl: string;
|
||||
healthWorkerToken: string;
|
||||
logLevel: string;
|
||||
/** Portal SSO — when true, require portal JWT with apps includes cfdm */
|
||||
authRequired: boolean;
|
||||
@@ -61,6 +63,8 @@ export function loadConfig(): AppConfig {
|
||||
healthLatencyWarnMs:
|
||||
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
|
||||
healthProbeGapMs: Number(process.env.HEALTH_PROBE_GAP_MS ?? "2000") || 2000,
|
||||
healthWorkerUrl: (process.env.HEALTH_WORKER_URL ?? "").trim(),
|
||||
healthWorkerToken: (process.env.HEALTH_WORKER_TOKEN ?? "").trim(),
|
||||
logLevel: process.env.LOG_LEVEL ?? "info",
|
||||
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||
authIssuer:
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { healthStatusQuerySchema } from "@cfdm/shared";
|
||||
import { repos } from "@cfdm/db";
|
||||
import { getAppSettings, repos } from "@cfdm/db";
|
||||
import * as healthCheckService from "../services/health-check-service.js";
|
||||
import * as serviceConfigService from "../services/service-config-service.js";
|
||||
import {
|
||||
healthEngineFallbacksFromConfig,
|
||||
resolveWorkerProbeConfig,
|
||||
} from "../services/health-check-scheduler.js";
|
||||
|
||||
export async function healthCheckRoutes(app: FastifyInstance) {
|
||||
app.get("/health-status", async (request) => {
|
||||
@@ -16,15 +20,20 @@ export async function healthCheckRoutes(app: FastifyInstance) {
|
||||
|
||||
app.post("/health-check/run", async (request) => {
|
||||
const config = request.server.config;
|
||||
const settings = getAppSettings(
|
||||
request.server.db,
|
||||
healthEngineFallbacksFromConfig(config),
|
||||
);
|
||||
const thresholds = {
|
||||
degradedFailures: config.healthDegradedFailures,
|
||||
downFailures: config.healthDownFailures,
|
||||
latencyWarnMs: config.healthLatencyWarnMs,
|
||||
successRecoveries: config.healthSuccessRecoveries,
|
||||
degradedFailures: settings.healthDegradedFailures,
|
||||
downFailures: settings.healthDownFailures,
|
||||
latencyWarnMs: settings.healthLatencyWarnMs,
|
||||
successRecoveries: settings.healthSuccessRecoveries,
|
||||
};
|
||||
const checked = await healthCheckService.runAllChecks(request.server.db, {
|
||||
thresholds,
|
||||
probeGapMs: config.healthProbeGapMs,
|
||||
worker: resolveWorkerProbeConfig(request.server.db, config),
|
||||
onStatusChange: async (target, prev, next) => {
|
||||
try {
|
||||
const label =
|
||||
|
||||
@@ -65,6 +65,14 @@ export async function serviceRoutes(app: FastifyInstance) {
|
||||
return serviceConfig.getView(request.server.db, Number(id));
|
||||
});
|
||||
|
||||
app.get("/services/:id/health-log", async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
repos.getService(request.server.db, Number(id));
|
||||
return {
|
||||
items: repos.listHealthProbeLogForService(request.server.db, Number(id)),
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/services/:id/overview", async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
return nodeService.getOverview(request.server.db, Number(id));
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
|
||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||
import {
|
||||
getAppSettings,
|
||||
getAppSettingsSecrets,
|
||||
type HealthEngineFallbacks,
|
||||
} from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
@@ -27,9 +28,23 @@ export function healthEngineFallbacksFromConfig(
|
||||
healthDownFailures: config.healthDownFailures,
|
||||
healthLatencyWarnMs: config.healthLatencyWarnMs,
|
||||
healthSuccessRecoveries: config.healthSuccessRecoveries,
|
||||
healthWorkerUrl: config.healthWorkerUrl,
|
||||
healthWorkerTokenSet: Boolean(config.healthWorkerToken),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWorkerProbeConfig(
|
||||
db: import("@cfdm/db").Db,
|
||||
config: AppConfig,
|
||||
): { url: string; token: string } | null {
|
||||
const settings = getAppSettings(db, healthEngineFallbacksFromConfig(config));
|
||||
const secrets = getAppSettingsSecrets(db);
|
||||
const url = settings.healthWorkerUrl.trim();
|
||||
const token = (secrets.healthWorkerToken || config.healthWorkerToken).trim();
|
||||
if (!url || !token) return null;
|
||||
return { url, token };
|
||||
}
|
||||
|
||||
export function assertValidHealthCron(expr: string): void {
|
||||
const cronExpression = expr.trim();
|
||||
const parts = cronExpression.split(/\s+/).filter(Boolean);
|
||||
@@ -66,6 +81,7 @@ export function createHealthCheckTask(
|
||||
const n = await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
probeGapMs: config.healthProbeGapMs,
|
||||
worker: resolveWorkerProbeConfig(app.db, config),
|
||||
onStatusChange: async (target, prev, next) => {
|
||||
try {
|
||||
const label =
|
||||
|
||||
@@ -6,6 +6,11 @@ import { repos } from "@cfdm/db";
|
||||
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
|
||||
import { AppError } from "../errors.js";
|
||||
import { nextHealthState } from "./health/state-machine.js";
|
||||
import { LocalHealthCheckProvider } from "./health/local.js";
|
||||
import {
|
||||
CloudflareWorkerHealthCheckProvider,
|
||||
workerNotConfiguredResult,
|
||||
} from "./health/worker.js";
|
||||
|
||||
export interface HealthCheckThresholds {
|
||||
degradedFailures: number;
|
||||
@@ -18,6 +23,7 @@ export interface ProbeResult {
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
error: string | null;
|
||||
colo?: string | null;
|
||||
}
|
||||
|
||||
/** Bracket IPv6 for URL authority; leave IPv4/hostname as-is. */
|
||||
@@ -261,6 +267,8 @@ export interface RunAllChecksOptions {
|
||||
thresholds: HealthCheckThresholds;
|
||||
/** Pause between unique physical probes (default 2000). Same IP is only probed once. */
|
||||
probeGapMs?: number;
|
||||
/** Cloudflare Worker URL+token. Missing → cloudflare targets fail, never Local fallback. */
|
||||
worker?: { url: string; token: string } | null;
|
||||
onStatusChange?: (
|
||||
target: HealthCheckTarget,
|
||||
prevState: IpHealthState | null,
|
||||
@@ -277,21 +285,34 @@ function sleep(ms: number): Promise<void> {
|
||||
* so anti-bot / rate-limit on the origin is not tripped by back-to-back checks.
|
||||
*/
|
||||
export function physicalProbeKey(target: HealthCheckTarget): string {
|
||||
const kind = target.provider === "cloudflare" ? "cloudflare" : "local";
|
||||
const port = target.port ?? (target.type === "http" ? 80 : 80);
|
||||
const ip = String(target.ip || "").trim().toLowerCase();
|
||||
if (target.type === "http") {
|
||||
const path = (target.path?.trim() || "/") || "/";
|
||||
const expected = target.expected_status ?? "";
|
||||
return `http|${ip}|${port}|${path}|${expected}`;
|
||||
return `${kind}|http|${ip}|${port}|${path}|${expected}`;
|
||||
}
|
||||
if (target.type === "tcp") return `tcp|${ip}|${port}`;
|
||||
if (target.type === "tcp") return `${kind}|tcp|${ip}|${port}`;
|
||||
if (target.type === "ping") {
|
||||
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||
return `${kind}|ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||
}
|
||||
if (target.type === "dns") {
|
||||
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||
return `${kind}|dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||
}
|
||||
return `${target.type}|${ip}|${port}`;
|
||||
return `${kind}|${target.type}|${ip}|${port}`;
|
||||
}
|
||||
|
||||
async function executeProbe(
|
||||
target: HealthCheckTarget,
|
||||
local: LocalHealthCheckProvider,
|
||||
worker: CloudflareWorkerHealthCheckProvider | null,
|
||||
): Promise<ProbeResult> {
|
||||
if (target.provider === "cloudflare") {
|
||||
if (!worker) return workerNotConfiguredResult();
|
||||
return worker.probe(target);
|
||||
}
|
||||
return local.probe(target);
|
||||
}
|
||||
|
||||
export async function runAllChecks(
|
||||
@@ -300,6 +321,11 @@ export async function runAllChecks(
|
||||
): Promise<number> {
|
||||
const targets = repos.listHealthCheckTargets(db);
|
||||
const gapMs = Math.max(0, options.probeGapMs ?? 2000);
|
||||
const local = new LocalHealthCheckProvider();
|
||||
const worker =
|
||||
options.worker?.url && options.worker.token
|
||||
? new CloudflareWorkerHealthCheckProvider(options.worker)
|
||||
: null;
|
||||
|
||||
const byPhysical = new Map<string, HealthCheckTarget[]>();
|
||||
for (const target of targets) {
|
||||
@@ -319,7 +345,7 @@ export async function runAllChecks(
|
||||
// Prefer binding hostname for SNI when several scopes share one IP.
|
||||
const representative =
|
||||
group.find((t) => t.scope === "binding") ?? group[0]!;
|
||||
const result = await probeTarget(representative);
|
||||
const result = await executeProbe(representative, local, worker);
|
||||
|
||||
for (const target of group) {
|
||||
const prev = repos.getIpHealthStatusRow(
|
||||
@@ -343,6 +369,7 @@ export async function runAllChecks(
|
||||
const prevState: IpHealthState | null = prev
|
||||
? (prev.status as IpHealthState)
|
||||
: null;
|
||||
const provider = target.provider === "cloudflare" ? "cloudflare" : "local";
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
target.scope,
|
||||
@@ -353,7 +380,19 @@ export async function runAllChecks(
|
||||
failures,
|
||||
result.error,
|
||||
successes,
|
||||
{ colo: result.colo ?? null, provider },
|
||||
);
|
||||
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, {
|
||||
@@ -392,6 +431,7 @@ export async function runDomainMonitors(
|
||||
expected_status: monitor.expected_status,
|
||||
timeout_ms: monitor.timeout_ms,
|
||||
verify_tls: false,
|
||||
provider: "local",
|
||||
};
|
||||
let result: ProbeResult;
|
||||
if (monitor.type === "http") {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||
import type { ProbeResult } from "../health-check-service.js";
|
||||
import type { HealthCheckProvider } from "./provider.js";
|
||||
|
||||
export interface WorkerProbeConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
const WORKER_NOT_CONFIGURED = "Cloudflare Worker не настроен (URL и токен)";
|
||||
|
||||
export class CloudflareWorkerHealthCheckProvider implements HealthCheckProvider {
|
||||
readonly kind = "cloudflare" as const;
|
||||
|
||||
constructor(private readonly config: WorkerProbeConfig) {}
|
||||
|
||||
async probe(target: HealthCheckTarget): Promise<ProbeResult> {
|
||||
const base = this.config.url.replace(/\/$/, "");
|
||||
if (!base || !this.config.token) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: WORKER_NOT_CONFIGURED,
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
const timeoutMs = Math.max(100, target.timeout_ms ?? 3000);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs + 2500);
|
||||
try {
|
||||
const res = await fetch(`${base}/probe`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: target.type === "http" ? "http" : "tcp",
|
||||
ip: target.ip,
|
||||
hostname: target.hostname,
|
||||
port: target.port ?? (target.type === "http" ? 80 : 80),
|
||||
path: target.path ?? "/",
|
||||
expected_status: target.expected_status,
|
||||
timeout_ms: timeoutMs,
|
||||
verify_tls: Boolean(target.verify_tls),
|
||||
method: "GET",
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: `Worker HTTP ${res.status}${text ? `: ${text.slice(0, 180)}` : ""}`,
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
const body = (await res.json()) as {
|
||||
ok?: boolean;
|
||||
latencyMs?: number;
|
||||
error?: string | null;
|
||||
colo?: string | null;
|
||||
};
|
||||
const ok = Boolean(body.ok);
|
||||
return {
|
||||
ok,
|
||||
latencyMs: typeof body.latencyMs === "number" ? body.latencyMs : 0,
|
||||
error: ok ? null : (body.error ?? "probe failed"),
|
||||
colo: body.colo ?? null,
|
||||
};
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.name === "AbortError"
|
||||
? "Worker timeout"
|
||||
: err.message
|
||||
: "Worker probe failed";
|
||||
return { ok: false, latencyMs: 0, error: message, colo: null };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function workerNotConfiguredResult(): ProbeResult {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: WORKER_NOT_CONFIGURED,
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export interface ServiceDomainInput {
|
||||
health_check_interval_sec?: number;
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: "local" | "cloudflare";
|
||||
}
|
||||
|
||||
export interface ToggleRequest {
|
||||
@@ -70,6 +71,7 @@ export interface ServiceGroupBody {
|
||||
health_check_interval_sec?: number;
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: "local" | "cloudflare";
|
||||
}
|
||||
|
||||
export interface UpdateServiceGroupBody {
|
||||
@@ -86,6 +88,7 @@ export interface UpdateServiceGroupBody {
|
||||
health_check_interval_sec?: number;
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: "local" | "cloudflare";
|
||||
}
|
||||
|
||||
export interface UpdateServiceConfigRequest {
|
||||
@@ -291,6 +294,7 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
health_check_interval_sec: binding.health_check_interval_sec,
|
||||
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",
|
||||
sync_status: aggregateSyncStatus(statuses),
|
||||
};
|
||||
});
|
||||
@@ -334,6 +338,10 @@ function attachServiceHealth(
|
||||
ip,
|
||||
status: row?.status ?? ("unknown" as const),
|
||||
latency_ms: row?.latency_ms ?? null,
|
||||
last_checked_at: row?.last_checked_at ?? null,
|
||||
last_error: row?.last_error ?? null,
|
||||
provider: row?.provider ?? "local",
|
||||
colo: row?.colo ?? null,
|
||||
};
|
||||
});
|
||||
return {
|
||||
@@ -1154,7 +1162,8 @@ export async function updateConfig(
|
||||
input.health_check_expected_status !== undefined ||
|
||||
input.health_check_interval_sec !== undefined ||
|
||||
input.health_check_timeout_ms !== undefined ||
|
||||
input.health_check_verify_tls !== undefined
|
||||
input.health_check_verify_tls !== undefined ||
|
||||
input.health_check_provider !== undefined
|
||||
) {
|
||||
repos.updateBindingLbConfig(db, binding.id, {
|
||||
lb_mode: input.lb_mode,
|
||||
@@ -1166,6 +1175,7 @@ export async function updateConfig(
|
||||
health_check_interval_sec: input.health_check_interval_sec,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1267,6 +1277,7 @@ export async function createGroup(
|
||||
health_check_interval_sec: body.health_check_interval_sec,
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1303,6 +1314,7 @@ export async function updateGroup(
|
||||
health_check_interval_sec: body.health_check_interval_sec,
|
||||
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,
|
||||
},
|
||||
);
|
||||
if (!domain && group.enabled) {
|
||||
|
||||
@@ -56,6 +56,7 @@ describe("health-check probeTarget", () => {
|
||||
expected_status: null,
|
||||
timeout_ms: 1000,
|
||||
verify_tls: false,
|
||||
provider: "local",
|
||||
};
|
||||
const result = await healthCheckService.probeTarget(target);
|
||||
expect(result.ok).toBe(true);
|
||||
@@ -75,6 +76,7 @@ describe("health-check probeTarget", () => {
|
||||
expected_status: null,
|
||||
timeout_ms: 500,
|
||||
verify_tls: false,
|
||||
provider: "local",
|
||||
};
|
||||
const result = await healthCheckService.probeTarget(target);
|
||||
expect(result.ok).toBe(false);
|
||||
@@ -107,6 +109,7 @@ describe("health-check probeTarget", () => {
|
||||
expected_status: 200,
|
||||
timeout_ms: 1000,
|
||||
verify_tls: false,
|
||||
provider: "local",
|
||||
};
|
||||
const bindingTarget: HealthCheckTarget = {
|
||||
...groupTarget,
|
||||
@@ -142,6 +145,7 @@ describe("health-check probeTarget", () => {
|
||||
expected_status: null,
|
||||
timeout_ms: 3000,
|
||||
verify_tls: false,
|
||||
provider: "local",
|
||||
};
|
||||
const binding: HealthCheckTarget = {
|
||||
...group,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { createServer, type Server as HttpServer } from "node:http";
|
||||
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";
|
||||
|
||||
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/auth/login",
|
||||
payload: { username: "admin", password: "admin" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { token } = res.json() as { token: string };
|
||||
return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
function startWorkerMock(handler: (req: {
|
||||
url?: string;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
body: string;
|
||||
}) => { status: number; json: unknown } | "hang"): Promise<{
|
||||
server: HttpServer;
|
||||
url: string;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) => chunks.push(chunk as Buffer));
|
||||
req.on("end", () => {
|
||||
const result = handler({
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
body: Buffer.concat(chunks).toString("utf8"),
|
||||
});
|
||||
if (result === "hang") return;
|
||||
res.writeHead(result.status, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(result.json));
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
resolve({ server, url: `http://127.0.0.1:${port}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function seedBinding(
|
||||
db: Db,
|
||||
opts: { provider: "local" | "cloudflare"; ip: string },
|
||||
) {
|
||||
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: 1,
|
||||
health_check_timeout_ms: 400,
|
||||
health_check_provider: opts.provider,
|
||||
});
|
||||
return { service, binding, domain };
|
||||
}
|
||||
|
||||
const thresholds = {
|
||||
degradedFailures: 1,
|
||||
downFailures: 2,
|
||||
latencyWarnMs: 1000,
|
||||
successRecoveries: 2,
|
||||
};
|
||||
|
||||
describe("health-check XOR worker", () => {
|
||||
it("lists only local providers when no cloudflare bindings", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
await seedBinding(app.db, {
|
||||
provider: "local",
|
||||
ip: "10.0.0.1",
|
||||
});
|
||||
const targets = repos.listHealthCheckTargets(app.db);
|
||||
expect(targets.length).toBeGreaterThan(0);
|
||||
expect(targets.every((t) => t.provider === "local")).toBe(true);
|
||||
expect(targets.some((t) => t.provider === "cloudflare")).toBe(false);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("cloudflare without worker URL does not fall back to local", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const { binding } = await seedBinding(app.db, {
|
||||
provider: "cloudflare",
|
||||
ip: "127.0.0.1",
|
||||
});
|
||||
await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
probeGapMs: 0,
|
||||
worker: null,
|
||||
});
|
||||
const row = repos.getIpHealthStatusRow(
|
||||
app.db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"127.0.0.1",
|
||||
);
|
||||
expect(row?.last_error).toMatch(/Worker не настроен/i);
|
||||
expect(row?.provider).toBe("cloudflare");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("worker mock 200 writes colo and last_checked_at", async () => {
|
||||
const mock = await startWorkerMock((req) => {
|
||||
const auth = String(req.headers.authorization ?? "");
|
||||
if (auth !== "Bearer secret") {
|
||||
return { status: 401, json: { ok: false, error: "unauthorized" } };
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
json: { ok: true, latencyMs: 42, error: null, colo: "AMS" },
|
||||
};
|
||||
});
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
const { service, binding } = await seedBinding(app.db, {
|
||||
provider: "cloudflare",
|
||||
ip: "203.0.113.10",
|
||||
});
|
||||
await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
probeGapMs: 0,
|
||||
worker: { url: mock.url, token: "secret" },
|
||||
});
|
||||
const row = repos.getIpHealthStatusRow(
|
||||
app.db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"203.0.113.10",
|
||||
);
|
||||
expect(row?.status).toBe("up");
|
||||
expect(row?.colo).toBe("AMS");
|
||||
expect(row?.last_checked_at).toBeTruthy();
|
||||
expect(row?.provider).toBe("cloudflare");
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/services/${service.id}`,
|
||||
headers,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as {
|
||||
ip_health: Array<{
|
||||
ip: string;
|
||||
colo: string | null;
|
||||
last_checked_at: string | null;
|
||||
provider: string;
|
||||
}>;
|
||||
};
|
||||
const ipRow = body.ip_health.find((item) => item.ip === "203.0.113.10");
|
||||
expect(ipRow?.colo).toBe("AMS");
|
||||
expect(ipRow?.last_checked_at).toBeTruthy();
|
||||
expect(ipRow?.provider).toBe("cloudflare");
|
||||
|
||||
const logRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/services/${service.id}/health-log`,
|
||||
headers,
|
||||
});
|
||||
expect(logRes.statusCode).toBe(200);
|
||||
const logBody = logRes.json() as { items: Array<{ colo: string | null }> };
|
||||
expect(logBody.items[0]?.colo).toBe("AMS");
|
||||
|
||||
await new Promise<void>((resolve) => mock.server.close(() => resolve()));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("worker timeout is recorded, not local probe", async () => {
|
||||
const mock = await startWorkerMock(() => "hang");
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const { binding } = await seedBinding(app.db, {
|
||||
provider: "cloudflare",
|
||||
ip: "203.0.113.20",
|
||||
});
|
||||
await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
probeGapMs: 0,
|
||||
worker: { url: mock.url, token: "secret" },
|
||||
});
|
||||
const row = repos.getIpHealthStatusRow(
|
||||
app.db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"203.0.113.20",
|
||||
);
|
||||
expect(row?.last_error).toMatch(/timeout|Worker/i);
|
||||
expect(row?.provider).toBe("cloudflare");
|
||||
await new Promise<void>((resolve) => mock.server.close(() => resolve()));
|
||||
await app.close();
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -99,7 +99,15 @@ describe("service groups health enrichment", () => {
|
||||
expect(groupView!.services[0]?.health_status).toBe("degraded");
|
||||
expect(groupView!.services[0]?.health_latency_ms).toBe(120);
|
||||
expect(groupView!.services[0]?.ip_health).toEqual([
|
||||
{ ip: "1.2.3.4", status: "degraded", latency_ms: 120 },
|
||||
{
|
||||
ip: "1.2.3.4",
|
||||
status: "degraded",
|
||||
latency_ms: 120,
|
||||
last_checked_at: expect.any(String),
|
||||
last_error: null,
|
||||
provider: "local",
|
||||
colo: null,
|
||||
},
|
||||
]);
|
||||
// group worst = degraded (from service) over up (group scope)
|
||||
expect(groupView!.health_status).toBe("degraded");
|
||||
|
||||
@@ -133,4 +133,33 @@ describe("settings health engine", () => {
|
||||
expect(res.statusCode).toBe(400);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("PATCH worker URL; token is not returned in GET", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
const res = await app.inject({
|
||||
method: "PATCH",
|
||||
url: "/api/v1/settings",
|
||||
headers,
|
||||
payload: {
|
||||
healthWorkerUrl: "https://cfdm-health-probe.example.workers.dev",
|
||||
healthWorkerToken: "super-secret",
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as {
|
||||
healthWorkerUrl: string;
|
||||
healthWorkerTokenSet: boolean;
|
||||
healthWorkerToken?: string;
|
||||
};
|
||||
expect(body.healthWorkerUrl).toBe(
|
||||
"https://cfdm-health-probe.example.workers.dev",
|
||||
);
|
||||
expect(body.healthWorkerTokenSet).toBe(true);
|
||||
expect(body.healthWorkerToken).toBeUndefined();
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,8 @@ interface HealthCheckBadgeProps {
|
||||
latencyMs?: number | null
|
||||
lastCheckedAt?: string | null
|
||||
lastError?: string | null
|
||||
colo?: string | null
|
||||
provider?: 'local' | 'cloudflare' | string | null
|
||||
title?: string
|
||||
showLatency?: boolean
|
||||
size?: 'xs' | 'sm'
|
||||
@@ -65,6 +67,8 @@ export function HealthCheckBadge({
|
||||
latencyMs,
|
||||
lastCheckedAt,
|
||||
lastError,
|
||||
colo,
|
||||
provider,
|
||||
title,
|
||||
showLatency = false,
|
||||
size = 'sm',
|
||||
@@ -79,6 +83,9 @@ export function HealthCheckBadge({
|
||||
tooltipParts.push(`Статус: ${label}`)
|
||||
if (latencyMs != null) tooltipParts.push(`Задержка: ${latencyMs} мс`)
|
||||
if (lastCheckedAt) tooltipParts.push(`Проверка: ${formatDate(lastCheckedAt)}`)
|
||||
if (colo) tooltipParts.push(`Colo: ${colo}`)
|
||||
if (provider === 'cloudflare') tooltipParts.push('Провайдер: Cloudflare Worker')
|
||||
if (provider === 'local') tooltipParts.push('Провайдер: Local')
|
||||
if (lastError) tooltipParts.push(`Ошибка: ${lastError}`)
|
||||
|
||||
return (
|
||||
|
||||
@@ -190,7 +190,7 @@ export function HealthCheckConfigFields({
|
||||
|
||||
<SettingRow
|
||||
title="Провайдер health-check"
|
||||
description="Local TCP/HTTP или Cloudflare Health Checks API"
|
||||
description="Откуда идёт проба: API CFDM или Cloudflare Worker (edge)"
|
||||
labelFor={`${idPrefix}-provider`}
|
||||
compact
|
||||
className={rowClass}
|
||||
@@ -208,21 +208,26 @@ export function HealthCheckConfigFields({
|
||||
</SettingRow>
|
||||
{value.provider === 'cloudflare' ? (
|
||||
<Alert>
|
||||
<AlertTitle>Cloudflare Health Checks</AlertTitle>
|
||||
<AlertTitle>Cloudflare Worker</AlertTitle>
|
||||
<AlertDescription>
|
||||
Поля соответствуют официальному API зоны. Если план не позволяет Health
|
||||
Checks, API вернёт ошибку — останется Local. Workers не используются.
|
||||
Проба с edge Cloudflare, не продукт Health Checks API (на Free его нет).
|
||||
Регионы WNAM/WEU недоступны — в результате будет colo ближайшего POP
|
||||
(например AMS). URL и токен Worker — в{' '}
|
||||
<Link to="/settings/health" className="text-foreground underline">
|
||||
Настройках → Health-check
|
||||
</Link>
|
||||
. Если Worker не задан, цель не пробируется как Local.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertTitle>Local health-check</AlertTitle>
|
||||
<AlertDescription>
|
||||
Интервал и таймаут пробы — ниже. Cron и пороги Slow/Down задаются в{' '}
|
||||
Проба TCP/HTTP с сервера API. Cron и пороги Slow/Down — в{' '}
|
||||
<Link to="/settings/health" className="text-foreground underline">
|
||||
Настройках → Health-check
|
||||
</Link>
|
||||
, как параметры Cloudflare Health Checks в этой форме.
|
||||
. Интервал в карточке не используется.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -334,83 +339,18 @@ export function HealthCheckConfigFields({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormFieldSimple label="Интервал, сек" htmlFor={`${idPrefix}-interval`}>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-interval`}
|
||||
value={value.interval_sec}
|
||||
min={5}
|
||||
max={3600}
|
||||
placeholder="30"
|
||||
onValueChange={(next) =>
|
||||
patch({ interval_sec: next ?? 30 })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-timeout`}
|
||||
value={value.timeout_ms}
|
||||
min={100}
|
||||
max={30000}
|
||||
placeholder="3000"
|
||||
onValueChange={(next) =>
|
||||
patch({ timeout_ms: next ?? 3000 })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</div>
|
||||
{value.provider === 'cloudflare' ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormFieldSimple
|
||||
label="Retries"
|
||||
htmlFor={`${idPrefix}-retries`}
|
||||
hint="Cloudflare retries"
|
||||
>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-retries`}
|
||||
value={value.retries ?? 2}
|
||||
min={0}
|
||||
max={10}
|
||||
placeholder="2"
|
||||
onValueChange={(retries) => patch({ retries: retries ?? 2 })}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Successes"
|
||||
htmlFor={`${idPrefix}-successes`}
|
||||
hint="consecutive_successes"
|
||||
>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-successes`}
|
||||
value={value.consecutive_successes ?? 2}
|
||||
min={1}
|
||||
max={20}
|
||||
placeholder="2"
|
||||
onValueChange={(consecutive_successes) =>
|
||||
patch({ consecutive_successes: consecutive_successes ?? 2 })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</div>
|
||||
) : null}
|
||||
{value.provider === 'cloudflare' && isHttp ? (
|
||||
<FormFieldSimple label="HTTP method" htmlFor={`${idPrefix}-method`}>
|
||||
<Select
|
||||
modal={false}
|
||||
value={value.method ?? 'GET'}
|
||||
onValueChange={(v) => patch({ method: v ?? 'GET' })}
|
||||
>
|
||||
<SelectTrigger id={`${idPrefix}-method`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="HEAD">HEAD</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormFieldSimple>
|
||||
) : null}
|
||||
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-timeout`}
|
||||
value={value.timeout_ms}
|
||||
min={100}
|
||||
max={30000}
|
||||
placeholder="3000"
|
||||
onValueChange={(next) =>
|
||||
patch({ timeout_ms: next ?? 3000 })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</div>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface HealthTimelineEvent {
|
||||
latency_ms?: number | null
|
||||
error?: string | null
|
||||
checked_at: string
|
||||
colo?: string | null
|
||||
provider?: string | null
|
||||
}
|
||||
|
||||
interface HealthTimelineProps {
|
||||
@@ -63,6 +65,8 @@ export function HealthTimeline({ events }: HealthTimelineProps) {
|
||||
<HealthCheckBadge
|
||||
status={event.status}
|
||||
latencyMs={event.latency_ms}
|
||||
colo={event.colo}
|
||||
provider={event.provider}
|
||||
size="xs"
|
||||
showLatency
|
||||
/>
|
||||
|
||||
@@ -110,7 +110,7 @@ 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: 'local',
|
||||
provider: binding.health_check_provider === 'cloudflare' ? 'cloudflare' : 'local',
|
||||
},
|
||||
target_ip_weights: binding.target_ip_weights ?? {},
|
||||
target_ip_priorities: binding.target_ip_priorities ?? {},
|
||||
@@ -138,6 +138,7 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||
health_check_interval_sec: binding.health.interval_sec,
|
||||
health_check_timeout_ms: binding.health.timeout_ms,
|
||||
health_check_verify_tls: binding.health.verify_tls,
|
||||
health_check_provider: binding.health.provider,
|
||||
}
|
||||
: {
|
||||
fqdn: binding.fqdn.trim(),
|
||||
@@ -153,6 +154,7 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||
health_check_interval_sec: binding.health.interval_sec,
|
||||
health_check_timeout_ms: binding.health.timeout_ms,
|
||||
health_check_verify_tls: binding.health.verify_tls,
|
||||
health_check_provider: binding.health.provider,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -166,6 +166,10 @@ export function ServiceIpList({
|
||||
<HealthCheckBadge
|
||||
status={health?.status ?? 'unknown'}
|
||||
latencyMs={health?.latency_ms}
|
||||
lastCheckedAt={health?.last_checked_at}
|
||||
lastError={health?.last_error}
|
||||
colo={health?.colo}
|
||||
provider={health?.provider}
|
||||
size="xs"
|
||||
/>
|
||||
<TruncatedText
|
||||
|
||||
@@ -36,6 +36,7 @@ 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'),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
@@ -76,6 +77,7 @@ 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'),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
})
|
||||
.transform((binding) => ({
|
||||
@@ -98,6 +100,24 @@ export const serviceIpHealthSchema = z.object({
|
||||
ip: z.string(),
|
||||
status: z.enum(['up', 'down', 'degraded', 'unknown']),
|
||||
latency_ms: z.number().nullable(),
|
||||
last_checked_at: z.string().nullable().optional(),
|
||||
last_error: z.string().nullable().optional(),
|
||||
provider: z.enum(['local', 'cloudflare']).optional(),
|
||||
colo: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export const healthProbeLogSchema = z.object({
|
||||
id: z.number(),
|
||||
scope: z.string(),
|
||||
ref_id: z.number(),
|
||||
ip: z.string(),
|
||||
provider: z.enum(['local', 'cloudflare']),
|
||||
status: z.enum(['up', 'down', 'degraded', 'unknown']),
|
||||
ok: z.coerce.boolean(),
|
||||
latency_ms: z.number().nullable(),
|
||||
colo: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
checked_at: z.string(),
|
||||
})
|
||||
|
||||
export const serviceViewSchema = serviceSchema.extend({
|
||||
@@ -168,6 +188,7 @@ 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'),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
@@ -252,6 +273,7 @@ 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(),
|
||||
}
|
||||
|
||||
const serviceDomainInputSchema = z
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import {
|
||||
healthProbeLogSchema,
|
||||
serviceBindingSchema,
|
||||
serviceGroupsResponseSchema,
|
||||
serviceViewSchema,
|
||||
@@ -87,8 +88,28 @@ export async function deleteServiceBinding(id: number) {
|
||||
export const serviceDetailKeys = {
|
||||
overview: (id: number) => [...serviceKeys.all, id, 'overview'] as const,
|
||||
nodes: (id: number) => [...serviceKeys.all, id, 'nodes'] as const,
|
||||
healthLog: (id: number) => [...serviceKeys.all, id, 'health-log'] as const,
|
||||
view: (id: number) => [...serviceKeys.all, id, 'view'] as const,
|
||||
}
|
||||
|
||||
export const serviceViewQueryOptions = (id: number) =>
|
||||
queryOptions({
|
||||
queryKey: serviceDetailKeys.view(id),
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown>(`/api/v1/services/${id}`)
|
||||
return serviceViewSchema.parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
export const serviceHealthLogQueryOptions = (id: number) =>
|
||||
queryOptions({
|
||||
queryKey: serviceDetailKeys.healthLog(id),
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown>(`/api/v1/services/${id}/health-log`)
|
||||
return z.object({ items: z.array(healthProbeLogSchema) }).parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
export const serviceOverviewQueryOptions = (id: number) =>
|
||||
queryOptions({
|
||||
queryKey: serviceDetailKeys.overview(id),
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { DetailPanel } from '@/components/reui-kit'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
|
||||
import { DetailPanel, KpiStatGrid } from '@/components/reui-kit'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { HealthProviderToggle } from '@/components/health-check-config-fields'
|
||||
import { HealthTimeline } from '@/components/health/health-timeline'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import {
|
||||
createOriginHealthCheck,
|
||||
listOriginHealthChecks,
|
||||
serviceOverviewQueryOptions,
|
||||
serviceHealthLogQueryOptions,
|
||||
serviceViewQueryOptions,
|
||||
} from '@/queries'
|
||||
import { formatDate } from '@/lib/format'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
|
||||
component: ServiceHealthPage,
|
||||
@@ -25,119 +19,86 @@ export const Route = createFileRoute('/_auth/services/$serviceId/health')({
|
||||
export function ServiceHealthPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const id = Number(serviceId)
|
||||
const queryClient = useQueryClient()
|
||||
const overview = useQuery(serviceOverviewQueryOptions(id))
|
||||
const checksQuery = useQuery({
|
||||
queryKey: ['health-checks'],
|
||||
queryFn: listOriginHealthChecks,
|
||||
})
|
||||
const [open, setOpen] = useState(false)
|
||||
const form = useForm<{
|
||||
name: string
|
||||
provider: 'local' | 'cloudflare'
|
||||
protocol: string
|
||||
}>({
|
||||
defaultValues: { name: '', provider: 'local', protocol: 'tcp' },
|
||||
})
|
||||
const provider = form.watch('provider')
|
||||
const checks = (checksQuery.data ?? []) as Array<{
|
||||
id: number
|
||||
name: string
|
||||
provider: string
|
||||
protocol: string
|
||||
}>
|
||||
const serviceQuery = useQuery(serviceViewQueryOptions(id))
|
||||
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
|
||||
const service = serviceQuery.data
|
||||
const items = logQuery.data?.items ?? []
|
||||
const ipHealth = service?.ip_health ?? []
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (values: { name: string; provider: 'local' | 'cloudflare'; protocol: string }) =>
|
||||
createOriginHealthCheck({
|
||||
name: values.name,
|
||||
provider: values.provider,
|
||||
protocol: values.protocol,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
toast.success('Health check сохранён')
|
||||
await queryClient.invalidateQueries({ queryKey: ['health-checks'] })
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (e: unknown) =>
|
||||
toast.error(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: 'Cloudflare Health Checks недоступны для этой зоны',
|
||||
const kpiCards = ipHealth.map((row) => {
|
||||
const variant =
|
||||
row.status === 'down'
|
||||
? ('destructive' as const)
|
||||
: row.status === 'degraded'
|
||||
? ('warning' as const)
|
||||
: ('default' as const)
|
||||
return {
|
||||
id: row.ip,
|
||||
label: row.ip,
|
||||
value: row.latency_ms != null ? `${row.latency_ms} мс` : '—',
|
||||
hint: row.colo ? `colo ${row.colo}` : row.provider === 'cloudflare' ? 'Worker' : 'Local',
|
||||
icon: row.provider === 'cloudflare' ? <GlobeIcon /> : <ServerIcon />,
|
||||
variant,
|
||||
footer: (
|
||||
<HealthCheckBadge
|
||||
status={row.status}
|
||||
latencyMs={row.latency_ms}
|
||||
lastCheckedAt={row.last_checked_at}
|
||||
lastError={row.last_error}
|
||||
colo={row.colo}
|
||||
provider={row.provider}
|
||||
size="xs"
|
||||
/>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
void overview
|
||||
|
||||
return (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title="Health checks"
|
||||
description="Local TCP/HTTP или официальный Cloudflare Health Checks API."
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
Добавить проверку
|
||||
</Button>
|
||||
title="Health"
|
||||
description="Снимок проб этого сервиса. Cloudflare = Worker с edge, не Health Checks API."
|
||||
/>
|
||||
<Alert>
|
||||
<AlertTitle>XOR провайдеров</AlertTitle>
|
||||
<AlertDescription>
|
||||
Local ходит с API CFDM; Cloudflare — через Worker. Cron и пороги Slow/Down общие, в{' '}
|
||||
<Link to="/settings/health" className="text-foreground underline">
|
||||
Настройках → Health-check
|
||||
</Link>
|
||||
. Если Worker не задан, цель не пробируется как Local.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{kpiCards.length > 0 ? (
|
||||
<KpiStatGrid cards={kpiCards} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title="Нет проб"
|
||||
description="Включите health-check на привязке — статус IP появится после cron."
|
||||
/>
|
||||
)}
|
||||
<DetailPanel.Header
|
||||
title="Журнал проб"
|
||||
description={
|
||||
items[0]?.checked_at
|
||||
? `Последняя: ${formatDate(items[0].checked_at)}`
|
||||
: 'Последние пробы по IP этого сервиса'
|
||||
}
|
||||
/>
|
||||
{checks.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Нет проверок"
|
||||
description="Локальные пробы уже работают на привязках. Cloudflare Health Checks — опционально."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{checks.map((check) => (
|
||||
<div
|
||||
key={check.id}
|
||||
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">{check.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{check.provider} · {check.protocol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Health check"
|
||||
description="Поля Cloudflare соответствуют официальному API (address, type, interval, timeout, retries)."
|
||||
form={form}
|
||||
onSubmit={(values) => createMut.mutate(values)}
|
||||
footer={
|
||||
<LoadingButton type="submit" isLoading={createMut.isPending}>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FormFieldSimple label="Имя" htmlFor="hc-name">
|
||||
<Input id="hc-name" {...form.register('name')} />
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Провайдер" htmlFor="hc-provider">
|
||||
<HealthProviderToggle
|
||||
id="hc-provider"
|
||||
value={provider}
|
||||
onChange={(next) => form.setValue('provider', next)}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
{provider === 'cloudflare' ? (
|
||||
<Alert>
|
||||
<AlertTitle>Cloudflare Health Checks</AlertTitle>
|
||||
<AlertDescription>
|
||||
Если зона не поддерживает Health Checks, вернётся ошибка плана — останется
|
||||
Local. Workers не используются.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<FormFieldSimple label="Протокол" htmlFor="hc-protocol">
|
||||
<Input id="hc-protocol" {...form.register('protocol')} placeholder="tcp" />
|
||||
</FormFieldSimple>
|
||||
</FormSheet>
|
||||
<HealthTimeline
|
||||
events={items.map((row) => ({
|
||||
id: row.id,
|
||||
hostname: row.ip,
|
||||
type: row.provider,
|
||||
status: row.status,
|
||||
latency_ms: row.latency_ms,
|
||||
error: row.error,
|
||||
checked_at: row.checked_at,
|
||||
colo: row.colo,
|
||||
provider: row.provider,
|
||||
}))}
|
||||
/>
|
||||
</DetailPanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '@/components/reui/number-field'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
|
||||
const formSchema = z.object({
|
||||
healthCheckCron: z.string().trim().min(1, 'Укажите cron').max(64),
|
||||
@@ -34,6 +35,8 @@ const formSchema = z.object({
|
||||
healthDownFailures: z.number().int().min(1).max(50),
|
||||
healthLatencyWarnMs: z.number().int().min(50).max(60_000),
|
||||
healthSuccessRecoveries: z.number().int().min(1).max(20),
|
||||
healthWorkerUrl: z.string().trim().url('Некорректный URL').or(z.literal('')),
|
||||
healthWorkerToken: z.string().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.healthDownFailures < data.healthDegradedFailures) {
|
||||
ctx.addIssue({
|
||||
@@ -48,6 +51,7 @@ type FormValues = z.infer<typeof formSchema>
|
||||
|
||||
type SettingsResponse = FormValues & {
|
||||
id: string
|
||||
healthWorkerTokenSet?: boolean
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings/health')({
|
||||
@@ -105,6 +109,8 @@ function HealthSettingsPage() {
|
||||
healthDownFailures: 2,
|
||||
healthLatencyWarnMs: 1000,
|
||||
healthSuccessRecoveries: 2,
|
||||
healthWorkerUrl: '',
|
||||
healthWorkerToken: '',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -116,12 +122,26 @@ function HealthSettingsPage() {
|
||||
healthDownFailures: data.healthDownFailures,
|
||||
healthLatencyWarnMs: data.healthLatencyWarnMs,
|
||||
healthSuccessRecoveries: data.healthSuccessRecoveries,
|
||||
healthWorkerUrl: data.healthWorkerUrl ?? '',
|
||||
healthWorkerToken: '',
|
||||
})
|
||||
}, [data, form])
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (values: FormValues) =>
|
||||
api.patch<SettingsResponse>('/api/v1/settings', values),
|
||||
mutationFn: (values: FormValues) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
healthCheckCron: values.healthCheckCron,
|
||||
healthDegradedFailures: values.healthDegradedFailures,
|
||||
healthDownFailures: values.healthDownFailures,
|
||||
healthLatencyWarnMs: values.healthLatencyWarnMs,
|
||||
healthSuccessRecoveries: values.healthSuccessRecoveries,
|
||||
healthWorkerUrl: values.healthWorkerUrl,
|
||||
}
|
||||
if (values.healthWorkerToken?.trim()) {
|
||||
payload.healthWorkerToken = values.healthWorkerToken.trim()
|
||||
}
|
||||
return api.patch<SettingsResponse>('/api/v1/settings', payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||
toast.success('Настройки health-check сохранены')
|
||||
@@ -144,8 +164,8 @@ function HealthSettingsPage() {
|
||||
Local health-check
|
||||
</FrameTitle>
|
||||
<FrameDescription>
|
||||
Расписание и пороги движка. Параметры Cloudflare Health Checks
|
||||
задаются в карточке сервиса.
|
||||
Расписание и пороги движка — общие для Local и Cloudflare Worker.
|
||||
Тип/порт/path задаются в карточке сервиса.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
@@ -247,7 +267,6 @@ function HealthSettingsPage() {
|
||||
description="Подряд успешных проб, чтобы выйти из Checking в Healthy. Env: HEALTH_SUCCESS_RECOVERIES."
|
||||
labelFor="health-recoveries"
|
||||
compact
|
||||
last
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
@@ -264,7 +283,55 @@ function HealthSettingsPage() {
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="URL Worker"
|
||||
description="https://cfdm-health-probe.<account>.workers.dev. Env: HEALTH_WORKER_URL."
|
||||
labelFor="health-worker-url"
|
||||
compact
|
||||
>
|
||||
<Input
|
||||
id="health-worker-url"
|
||||
type="url"
|
||||
placeholder="https://cfdm-health-probe.workers.dev"
|
||||
disabled={isLoading || saveMut.isPending}
|
||||
{...form.register('healthWorkerUrl')}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.formState.errors.healthWorkerUrl ? (
|
||||
<p className="text-destructive px-5 pb-2 text-sm">
|
||||
{form.formState.errors.healthWorkerUrl.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SettingRow
|
||||
title="Токен Worker"
|
||||
description={
|
||||
data?.healthWorkerTokenSet
|
||||
? 'Токен задан. Оставьте пустым, чтобы не менять.'
|
||||
: 'Authorization Bearer. Env: HEALTH_WORKER_TOKEN.'
|
||||
}
|
||||
labelFor="health-worker-token"
|
||||
compact
|
||||
last
|
||||
>
|
||||
<Input
|
||||
id="health-worker-token"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={data?.healthWorkerTokenSet ? '••••••••' : 'секрет'}
|
||||
disabled={isLoading || saveMut.isPending}
|
||||
{...form.register('healthWorkerToken')}
|
||||
/>
|
||||
</SettingRow>
|
||||
</FieldGroup>
|
||||
<Alert>
|
||||
<AlertTitle>Cloudflare Worker, не Health Checks API</AlertTitle>
|
||||
<AlertDescription>
|
||||
На Free-плане продукта Health Checks нет. CFDM вызывает Worker с edge;
|
||||
cron остаётся здесь. Лимит Free Workers ≈ 100k запросов/сутки (cron × число IP).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<FrameFooter className="flex flex-row justify-end">
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
|
||||
Reference in New Issue
Block a user