Compare commits
11
Commits
v1.0.1
...
6008cd763a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6008cd763a | ||
|
|
b9bea44dce | ||
|
|
4c4908558b | ||
|
|
2c92e78b24 | ||
|
|
d63c86065c | ||
|
|
4224db8eb3 | ||
|
|
4ca948292d | ||
|
|
50c5c21c18 | ||
|
|
ab4ccbd7a1 | ||
|
|
b2dbb4ad98 | ||
|
|
3f6f402872 |
No files matched your search
@@ -36,3 +36,10 @@ RUST_LOG=info
|
|||||||
|
|
||||||
# Certificate scheduler (cron)
|
# Certificate scheduler (cron)
|
||||||
CERT_CHECK_CRON=0 0 */6 * * *
|
CERT_CHECK_CRON=0 0 */6 * * *
|
||||||
|
|
||||||
|
# Local health-check engine (override in UI: Настройки → Health-check)
|
||||||
|
# HEALTH_CHECK_CRON=0 */2 * * * *
|
||||||
|
# HEALTH_DEGRADED_FAILURES=1
|
||||||
|
# HEALTH_DOWN_FAILURES=2
|
||||||
|
# HEALTH_SUCCESS_RECOVERIES=2
|
||||||
|
# HEALTH_LATENCY_WARN_MS=1000
|
||||||
+2
-2
@@ -35,13 +35,13 @@ Runner: `ubuntu-latest`, Docker для **docker-check** (PR) и **publish** (CD)
|
|||||||
|
|
||||||
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
||||||
|
|
||||||
Job **update-wiki** идёт **параллельно** publish (не блокирует образы): при diff `docs/Home.md` копирует файл в wiki-репозиторий. Clone/push идут с `Authorization: Basic oauth2:<token>` — после clone git вырезает токен из `origin`, без header Gitea отвечает `Repository not found` (часто на внутреннем `GITEA_INSTANCE_URL` раннера). Секрет: **`GITEA_TOKEN`**, fallback **`ACTIONS_PAT`**.
|
Job **update-wiki** идёт **параллельно** publish (не блокирует образы): при diff `docs/Home.md` копирует файл в wiki-репозиторий. Clone/push идут на публичный **`https://git.shx.one`** (не внутренний `gitea.server_url` / `192.168.x.x:3000`): Gitea `ROOT_URL` совпадает с Host, иначе `git-receive-pack` wiki отвечает `Repository not found`. Токен в URL `https://oauth2:<PAT>@…/*.wiki.git` — Gitea на неаутентифицированный wiki push даёт **404, не 401**, поэтому `http.extraHeader` / ASKPASS не срабатывают. Секрет: **`ACTIONS_PAT`**, fallback **`GITEA_TOKEN`**.
|
||||||
|
|
||||||
### Секреты
|
### Секреты
|
||||||
|
|
||||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `gitea.token`. Push OCI — **только PAT** (у job token Gitea нет права packages).
|
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `gitea.token`. Push OCI — **только PAT** (у job token Gitea нет права packages).
|
||||||
|
|
||||||
**`GITEA_TOKEN`**: clone/push wiki.
|
**`GITEA_TOKEN`**: опциональный wiki-only PAT (fallback, если нет `ACTIONS_PAT`).
|
||||||
|
|
||||||
### Теги образов
|
### Теги образов
|
||||||
|
|
||||||
|
|||||||
+15
-10
@@ -40,21 +40,26 @@ jobs:
|
|||||||
- name: Update and push Wiki content
|
- name: Update and push Wiki content
|
||||||
if: steps.check_changes.outputs.changed == 'true'
|
if: steps.check_changes.outputs.changed == 'true'
|
||||||
env:
|
env:
|
||||||
WIKI_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.ACTIONS_PAT }}
|
# ACTIONS_PAT уже пишет git (tags/releases). GITEA_TOKEN — опциональный
|
||||||
SERVER_URL: ${{ gitea.server_url }}
|
# wiki-only PAT; если он задан без write, Gitea отвечает 404, не 403.
|
||||||
|
WIKI_TOKEN: ${{ secrets.ACTIONS_PAT || secrets.GITEA_TOKEN }}
|
||||||
|
# Не gitea.server_url: на runner это внутренний http://192.168.x.x:3000,
|
||||||
|
# а ROOT_URL = git.shx.one — git-receive-pack wiki тогда даёт 404.
|
||||||
|
GITEA_PUBLIC_URL: https://git.shx.one
|
||||||
REPO: ${{ gitea.repository }}
|
REPO: ${{ gitea.repository }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${WIKI_TOKEN:-}" ]; then
|
if [ -z "${WIKI_TOKEN:-}" ]; then
|
||||||
echo "GITEA_TOKEN / ACTIONS_PAT is empty — cannot push wiki"
|
echo "ACTIONS_PAT / GITEA_TOKEN is empty — cannot push wiki"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# runner GITEA_INSTANCE_URL часто внутренний (http://192.168.x.x:3000).
|
PUBLIC_URL="${GITEA_PUBLIC_URL%/}"
|
||||||
# git после clone вырезает userinfo из origin → push без токена даёт 404
|
TOKEN_ENC="$(python3 -c 'import urllib.parse,os; print(urllib.parse.quote(os.environ["WIKI_TOKEN"], safe=""))')"
|
||||||
# «Repository not found». Authorization header переживает insteadOf/sanitize.
|
WIKI_URL="${PUBLIC_URL}/${REPO}.wiki.git"
|
||||||
WIKI_URL="${SERVER_URL}/${REPO}.wiki.git"
|
# Gitea на неаутентифицированный wiki push отвечает 404, не 401 —
|
||||||
AUTH_HEADER="Authorization: Basic $(printf '%s' "oauth2:${WIKI_TOKEN}" | base64 | tr -d '\n')"
|
# extraHeader/ASKPASS не помогают: токен должен быть в URL с первого запроса.
|
||||||
git -c http.extraHeader="${AUTH_HEADER}" clone "${WIKI_URL}" cfdm.wiki
|
AUTH_INSTEAD="url.https://oauth2:${TOKEN_ENC}@${PUBLIC_URL#https://}/.insteadOf=${PUBLIC_URL}/"
|
||||||
|
GIT_TERMINAL_PROMPT=0 git -c "${AUTH_INSTEAD}" clone "${WIKI_URL}" cfdm.wiki
|
||||||
cp docs/Home.md cfdm.wiki/Home.md
|
cp docs/Home.md cfdm.wiki/Home.md
|
||||||
cd cfdm.wiki
|
cd cfdm.wiki
|
||||||
git config user.name "Gitea Actions"
|
git config user.name "Gitea Actions"
|
||||||
@@ -65,7 +70,7 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
git commit -m "docs: Update Wiki from main repository"
|
git commit -m "docs: Update Wiki from main repository"
|
||||||
git -c http.extraHeader="${AUTH_HEADER}" push origin HEAD
|
GIT_TERMINAL_PROMPT=0 git -c "${AUTH_INSTEAD}" push origin HEAD
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: [quality]
|
needs: [quality]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/server.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
"build": "tsup src/server.ts --format esm --dts",
|
"build": "tsup --config tsup.config.ts",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
|
|||||||
+21
-67
@@ -7,7 +7,6 @@ import {
|
|||||||
} from "@fastify/type-provider-zod";
|
} from "@fastify/type-provider-zod";
|
||||||
import type { AppConfig } from "./config.js";
|
import type { AppConfig } from "./config.js";
|
||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
import { repos } from "@cfdm/db";
|
|
||||||
import authPlugin from "./plugins/auth.js";
|
import authPlugin from "./plugins/auth.js";
|
||||||
import cfClientPlugin from "./plugins/cf-client.js";
|
import cfClientPlugin from "./plugins/cf-client.js";
|
||||||
import { requireAuth } from "./plugins/auth.js";
|
import { requireAuth } from "./plugins/auth.js";
|
||||||
@@ -24,6 +23,7 @@ import { dnsRoutes } from "./routes/dns.js";
|
|||||||
import { subdomainRoutes } from "./routes/subdomains.js";
|
import { subdomainRoutes } from "./routes/subdomains.js";
|
||||||
import { certificateRoutes } from "./routes/certificates.js";
|
import { certificateRoutes } from "./routes/certificates.js";
|
||||||
import { syncRoutes } from "./routes/sync.js";
|
import { syncRoutes } from "./routes/sync.js";
|
||||||
|
import { originHealthCheckRoutes } from "./routes/origin-health-checks.js";
|
||||||
import { healthCheckRoutes } from "./routes/health-check.js";
|
import { healthCheckRoutes } from "./routes/health-check.js";
|
||||||
import {
|
import {
|
||||||
domainMonitorRoutes,
|
domainMonitorRoutes,
|
||||||
@@ -33,8 +33,12 @@ import { settingsRoutes } from "./routes/settings.js";
|
|||||||
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
|
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
|
||||||
import { auditRoutes } from "./routes/audit.js";
|
import { auditRoutes } from "./routes/audit.js";
|
||||||
import * as certificateService from "./services/certificate-service.js";
|
import * as certificateService from "./services/certificate-service.js";
|
||||||
import * as healthCheckService from "./services/health-check-service.js";
|
import {
|
||||||
import * as serviceConfigService from "./services/service-config-service.js";
|
createHealthCheckTask,
|
||||||
|
healthEngineFallbacksFromConfig,
|
||||||
|
scheduleHealthCheckJob,
|
||||||
|
} from "./services/health-check-scheduler.js";
|
||||||
|
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
|
||||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||||
|
|
||||||
export interface BuildAppOptions {
|
export interface BuildAppOptions {
|
||||||
@@ -81,6 +85,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
await protectedApi.register(certificateRoutes);
|
await protectedApi.register(certificateRoutes);
|
||||||
await protectedApi.register(syncRoutes);
|
await protectedApi.register(syncRoutes);
|
||||||
await protectedApi.register(healthCheckRoutes);
|
await protectedApi.register(healthCheckRoutes);
|
||||||
|
await protectedApi.register(originHealthCheckRoutes);
|
||||||
await protectedApi.register(domainMonitorRoutes);
|
await protectedApi.register(domainMonitorRoutes);
|
||||||
await protectedApi.register(notificationRoutes);
|
await protectedApi.register(notificationRoutes);
|
||||||
await protectedApi.register(settingsRoutes);
|
await protectedApi.register(settingsRoutes);
|
||||||
@@ -123,70 +128,19 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const healthTask = new AsyncTask(
|
const healthTask = createHealthCheckTask(app, config);
|
||||||
"health-check",
|
scheduleHealthCheckJob(app, config, healthTask);
|
||||||
async () => {
|
app.decorate("reloadHealthCheckJob", () => {
|
||||||
const thresholds = {
|
scheduleHealthCheckJob(app, config, healthTask);
|
||||||
degradedFailures: config.healthDegradedFailures,
|
});
|
||||||
downFailures: config.healthDownFailures,
|
if (config.cloudflareApiToken) {
|
||||||
latencyWarnMs: config.healthLatencyWarnMs,
|
fireEnsureHealthWorker(
|
||||||
};
|
app.db,
|
||||||
const n = await healthCheckService.runAllChecks(app.db, {
|
app.cf,
|
||||||
thresholds,
|
healthEngineFallbacksFromConfig(config),
|
||||||
probeGapMs: config.healthProbeGapMs,
|
app.log,
|
||||||
onStatusChange: async (target, prev, next) => {
|
);
|
||||||
try {
|
}
|
||||||
const label =
|
|
||||||
next === "up"
|
|
||||||
? "OK"
|
|
||||||
: next === "degraded"
|
|
||||||
? "Slow"
|
|
||||||
: next === "down"
|
|
||||||
? "Down"
|
|
||||||
: "—";
|
|
||||||
repos.insertNotificationLog(
|
|
||||||
app.db,
|
|
||||||
"ip_health",
|
|
||||||
target.scope,
|
|
||||||
target.ref_id,
|
|
||||||
`${target.hostname || target.ip}: ${label}`,
|
|
||||||
`IP ${target.ip}: ${prev ?? "—"} → ${label}`,
|
|
||||||
);
|
|
||||||
await serviceConfigService.reconcileDnsForTarget(
|
|
||||||
app.db,
|
|
||||||
app.cf,
|
|
||||||
target.scope,
|
|
||||||
target.ref_id,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
app.log.warn(
|
|
||||||
{ err, scope: target.scope, refId: target.ref_id },
|
|
||||||
"health-check reconcile failed",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const monitors = await healthCheckService.runDomainMonitors(
|
|
||||||
app.db,
|
|
||||||
thresholds,
|
|
||||||
);
|
|
||||||
app.log.info(
|
|
||||||
{ checked: n, monitors },
|
|
||||||
"health check completed",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
app.log.warn({ err }, "health check failed");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.scheduler.addCronJob(
|
|
||||||
new CronJob(
|
|
||||||
{ cronExpression: config.healthCheckCron },
|
|
||||||
healthTask,
|
|
||||||
{ preventOverrun: true },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ export interface AppConfig {
|
|||||||
healthCheckCron: string;
|
healthCheckCron: string;
|
||||||
healthDegradedFailures: number;
|
healthDegradedFailures: number;
|
||||||
healthDownFailures: number;
|
healthDownFailures: number;
|
||||||
|
healthSuccessRecoveries: number;
|
||||||
healthLatencyWarnMs: number;
|
healthLatencyWarnMs: number;
|
||||||
/** Min pause between probes to different physical targets (same IP is probed once). */
|
/** Min pause between probes to different physical targets (same IP is probed once). */
|
||||||
healthProbeGapMs: number;
|
healthProbeGapMs: number;
|
||||||
|
healthWorkerUrl: string;
|
||||||
|
healthWorkerToken: string;
|
||||||
logLevel: string;
|
logLevel: string;
|
||||||
/** Portal SSO — when true, require portal JWT with apps includes cfdm */
|
/** Portal SSO — when true, require portal JWT with apps includes cfdm */
|
||||||
authRequired: boolean;
|
authRequired: boolean;
|
||||||
@@ -55,9 +58,13 @@ export function loadConfig(): AppConfig {
|
|||||||
healthDegradedFailures:
|
healthDegradedFailures:
|
||||||
Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1,
|
Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1,
|
||||||
healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2,
|
healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2,
|
||||||
|
healthSuccessRecoveries:
|
||||||
|
Number(process.env.HEALTH_SUCCESS_RECOVERIES ?? "2") || 2,
|
||||||
healthLatencyWarnMs:
|
healthLatencyWarnMs:
|
||||||
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
|
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
|
||||||
healthProbeGapMs: Number(process.env.HEALTH_PROBE_GAP_MS ?? "2000") || 2000,
|
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",
|
logLevel: process.env.LOG_LEVEL ?? "info",
|
||||||
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||||
authIssuer:
|
authIssuer:
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ export type ErrorCode =
|
|||||||
| "FORBIDDEN"
|
| "FORBIDDEN"
|
||||||
| "CONFLICT"
|
| "CONFLICT"
|
||||||
| "CLOUDFLARE_ERROR"
|
| "CLOUDFLARE_ERROR"
|
||||||
|
| "DNS_UPDATE_FAILED"
|
||||||
|
| "HEALTHCHECK_CREATE_FAILED"
|
||||||
|
| "ZONE_NOT_FOUND"
|
||||||
|
| "INVALID_IP"
|
||||||
|
| "INVALID_HOSTNAME"
|
||||||
|
| "RATE_LIMITED"
|
||||||
|
| "CLOUDFLARE_AUTH_FAILED"
|
||||||
|
| "SYNC_FAILED"
|
||||||
| "INTERNAL_ERROR";
|
| "INTERNAL_ERROR";
|
||||||
|
|
||||||
export class AppError extends Error {
|
export class AppError extends Error {
|
||||||
@@ -44,6 +52,42 @@ export class AppError extends Error {
|
|||||||
return new AppError("CLOUDFLARE_ERROR", message, 502);
|
return new AppError("CLOUDFLARE_ERROR", message, 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static dnsUpdateFailed(message: string) {
|
||||||
|
return new AppError(
|
||||||
|
"DNS_UPDATE_FAILED",
|
||||||
|
message,
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static healthcheckCreateFailed(message: string) {
|
||||||
|
return new AppError("HEALTHCHECK_CREATE_FAILED", message, 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
static zoneNotFound(message = "зона Cloudflare не найдена") {
|
||||||
|
return new AppError("ZONE_NOT_FOUND", message, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
static invalidIp(message = "Некорректный IP-адрес") {
|
||||||
|
return new AppError("INVALID_IP", message, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
static invalidHostname(message = "Некорректное имя хоста") {
|
||||||
|
return new AppError("INVALID_HOSTNAME", message, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
static rateLimited(message = "Cloudflare временно ограничил запросы. Повторите попытку.") {
|
||||||
|
return new AppError("RATE_LIMITED", message, 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
static cloudflareAuthFailed(message = "Cloudflare отклонил токен доступа") {
|
||||||
|
return new AppError("CLOUDFLARE_AUTH_FAILED", message, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
static syncFailed(message: string) {
|
||||||
|
return new AppError("SYNC_FAILED", message, 502);
|
||||||
|
}
|
||||||
|
|
||||||
static internal(message: string) {
|
static internal(message: string) {
|
||||||
return new AppError("INTERNAL_ERROR", message, 500);
|
return new AppError("INTERNAL_ERROR", message, 500);
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-125
@@ -1,152 +1,137 @@
|
|||||||
import type {
|
import type {
|
||||||
CfDnsRecord,
|
CfDnsRecord,
|
||||||
|
CfHealthCheck,
|
||||||
CfZone,
|
CfZone,
|
||||||
CreateDnsRecordPayload,
|
CreateDnsRecordPayload,
|
||||||
|
PatchDnsRecordPayload,
|
||||||
} from "@cfdm/shared";
|
} from "@cfdm/shared";
|
||||||
import { AppError } from "../errors.js";
|
import { createDnsAdapter } from "./cloudflare/dns-service.js";
|
||||||
import { withRetry, parseRetryAfter } from "./cf-retry.js";
|
import { createHealthCheckAdapter, type CfHealthCheckPayload } from "./cloudflare/healthcheck-service.js";
|
||||||
|
import { createKvAdapter } from "./cloudflare/kv-service.js";
|
||||||
|
import { createWorkersAdapter } from "./cloudflare/workers-service.js";
|
||||||
|
import { createZoneAdapter } from "./cloudflare/zone-service.js";
|
||||||
|
|
||||||
const BASE_URL = "https://api.cloudflare.com/client/v4";
|
export type { CfHealthCheckPayload };
|
||||||
|
|
||||||
interface CfResponse<T> {
|
|
||||||
success: boolean;
|
|
||||||
result?: T;
|
|
||||||
errors?: Array<{ code: number; message: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CloudflareClient {
|
export class CloudflareClient {
|
||||||
constructor(private readonly token: string) {}
|
private readonly zones;
|
||||||
|
private readonly dns;
|
||||||
|
private readonly healthchecks;
|
||||||
|
private readonly kv;
|
||||||
|
private readonly workers;
|
||||||
|
private readonly token;
|
||||||
|
|
||||||
private async handleResponse<T>(
|
constructor(token: string) {
|
||||||
response: Response,
|
this.token = token.trim();
|
||||||
operation: string,
|
this.zones = createZoneAdapter(token);
|
||||||
): Promise<T> {
|
this.dns = createDnsAdapter(token);
|
||||||
if (response.status === 429) {
|
this.healthchecks = createHealthCheckAdapter(token);
|
||||||
const wait = parseRetryAfter(response.headers) ?? 5000;
|
this.kv = createKvAdapter(token);
|
||||||
throw AppError.cloudflare(`rate limited, retry after ${wait}ms`);
|
this.workers = createWorkersAdapter(token);
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await response.json()) as CfResponse<T>;
|
|
||||||
if (!body.success) {
|
|
||||||
const msg =
|
|
||||||
body.errors?.map((e) => e.message).join("; ") ??
|
|
||||||
"unknown cloudflare error";
|
|
||||||
throw AppError.cloudflare(`${operation}: ${msg}`);
|
|
||||||
}
|
|
||||||
if (body.result === undefined) {
|
|
||||||
throw AppError.cloudflare(`${operation}: empty result`);
|
|
||||||
}
|
|
||||||
return body.result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async listZones(): Promise<CfZone[]> {
|
get isConfigured(): boolean {
|
||||||
return withRetry(async () => {
|
return this.token.length > 0;
|
||||||
const all: CfZone[] = [];
|
|
||||||
let page = 1;
|
|
||||||
while (true) {
|
|
||||||
const url = new URL(`${BASE_URL}/zones`);
|
|
||||||
url.searchParams.set("per_page", "50");
|
|
||||||
url.searchParams.set("page", String(page));
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: { Authorization: `Bearer ${this.token}` },
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
});
|
|
||||||
if (response.status >= 500 || response.status === 429) {
|
|
||||||
throw AppError.cloudflare(String(response.status));
|
|
||||||
}
|
|
||||||
const batch = await this.handleResponse<CfZone[]>(
|
|
||||||
response,
|
|
||||||
"list_zones",
|
|
||||||
);
|
|
||||||
if (batch.length === 0) break;
|
|
||||||
all.push(...batch);
|
|
||||||
if (batch.length < 50) break;
|
|
||||||
page += 1;
|
|
||||||
}
|
|
||||||
return all;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getZone(zoneId: string): Promise<CfZone> {
|
listZones(): Promise<CfZone[]> {
|
||||||
const response = await fetch(`${BASE_URL}/zones/${zoneId}`, {
|
return this.zones.listZones();
|
||||||
headers: { Authorization: `Bearer ${this.token}` },
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
});
|
|
||||||
return this.handleResponse(response, "get_zone");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
|
getZone(zoneId: string): Promise<CfZone> {
|
||||||
return withRetry(async () => {
|
return this.zones.getZone(zoneId);
|
||||||
const all: CfDnsRecord[] = [];
|
|
||||||
let page = 1;
|
|
||||||
while (page <= 50) {
|
|
||||||
const url = new URL(`${BASE_URL}/zones/${zoneId}/dns_records`);
|
|
||||||
url.searchParams.set("per_page", "100");
|
|
||||||
url.searchParams.set("page", String(page));
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: { Authorization: `Bearer ${this.token}` },
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
});
|
|
||||||
if (response.status >= 500 || response.status === 429) {
|
|
||||||
throw AppError.cloudflare(String(response.status));
|
|
||||||
}
|
|
||||||
const batch = await this.handleResponse<CfDnsRecord[]>(
|
|
||||||
response,
|
|
||||||
"list_dns_records",
|
|
||||||
);
|
|
||||||
if (batch.length === 0) break;
|
|
||||||
all.push(...batch);
|
|
||||||
page += 1;
|
|
||||||
}
|
|
||||||
return all;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDnsRecord(
|
listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
|
||||||
zoneId: string,
|
return this.dns.listDnsRecords(zoneId);
|
||||||
payload: CreateDnsRecordPayload,
|
|
||||||
): Promise<CfDnsRecord> {
|
|
||||||
const response = await fetch(`${BASE_URL}/zones/${zoneId}/dns_records`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.token}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
});
|
|
||||||
return this.handleResponse(response, "create_dns_record");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateDnsRecord(
|
createDnsRecord(zoneId: string, payload: CreateDnsRecordPayload): Promise<CfDnsRecord> {
|
||||||
|
return this.dns.createDnsRecord(zoneId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDnsRecord(
|
||||||
zoneId: string,
|
zoneId: string,
|
||||||
recordId: string,
|
recordId: string,
|
||||||
payload: CreateDnsRecordPayload,
|
payload: CreateDnsRecordPayload,
|
||||||
): Promise<CfDnsRecord> {
|
): Promise<CfDnsRecord> {
|
||||||
const response = await fetch(
|
return this.dns.updateDnsRecord(zoneId, recordId, payload);
|
||||||
`${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.token}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return this.handleResponse(response, "update_dns_record");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
|
patchDnsRecord(
|
||||||
const response = await fetch(
|
zoneId: string,
|
||||||
`${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`,
|
recordId: string,
|
||||||
{
|
payload: PatchDnsRecordPayload,
|
||||||
method: "DELETE",
|
): Promise<CfDnsRecord> {
|
||||||
headers: { Authorization: `Bearer ${this.token}` },
|
return this.dns.patchDnsRecord(zoneId, recordId, payload);
|
||||||
signal: AbortSignal.timeout(30_000),
|
}
|
||||||
},
|
|
||||||
);
|
deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
|
||||||
await this.handleResponse(response, "delete_dns_record");
|
return this.dns.deleteDnsRecord(zoneId, recordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
listHealthChecks(zoneId: string): Promise<CfHealthCheck[]> {
|
||||||
|
return this.healthchecks.listHealthChecks(zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getHealthCheck(zoneId: string, id: string): Promise<CfHealthCheck> {
|
||||||
|
return this.healthchecks.getHealthCheck(zoneId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
createHealthCheck(zoneId: string, payload: CfHealthCheckPayload): Promise<CfHealthCheck> {
|
||||||
|
return this.healthchecks.createHealthCheck(zoneId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHealthCheck(
|
||||||
|
zoneId: string,
|
||||||
|
id: string,
|
||||||
|
payload: CfHealthCheckPayload,
|
||||||
|
): Promise<CfHealthCheck> {
|
||||||
|
return this.healthchecks.updateHealthCheck(zoneId, id, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteHealthCheck(zoneId: string, id: string): Promise<void> {
|
||||||
|
return this.healthchecks.deleteHealthCheck(zoneId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
listAccounts() {
|
||||||
|
return this.workers.listAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
listKvNamespaces(accountId: string) {
|
||||||
|
return this.kv.listNamespaces(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
createKvNamespace(accountId: string, title: string) {
|
||||||
|
return this.kv.createNamespace(accountId, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
kvGet(accountId: string, namespaceId: string, key: string) {
|
||||||
|
return this.kv.getValue(accountId, namespaceId, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
kvPut(accountId: string, namespaceId: string, key: string, value: string) {
|
||||||
|
return this.kv.putValue(accountId, namespaceId, key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
putWorkerScript(opts: {
|
||||||
|
accountId: string;
|
||||||
|
scriptName: string;
|
||||||
|
source: string;
|
||||||
|
kvNamespaceId: string;
|
||||||
|
}) {
|
||||||
|
return this.workers.putScript(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
putWorkerSchedules(accountId: string, scriptName: string, crons: string[]) {
|
||||||
|
return this.workers.putSchedules(accountId, scriptName, crons);
|
||||||
|
}
|
||||||
|
|
||||||
|
enableWorkersDev(accountId: string, scriptName: string) {
|
||||||
|
return this.workers.enableWorkersDev(accountId, scriptName);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkersSubdomain(accountId: string) {
|
||||||
|
return this.workers.getWorkersSubdomain(accountId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { CfDnsRecord, CreateDnsRecordPayload, PatchDnsRecordPayload } from "@cfdm/shared";
|
||||||
|
import { withRetry } from "../cf-retry.js";
|
||||||
|
import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js";
|
||||||
|
|
||||||
|
export function createDnsAdapter(token: string) {
|
||||||
|
return {
|
||||||
|
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
|
||||||
|
return withRetry(async () => {
|
||||||
|
const all: CfDnsRecord[] = [];
|
||||||
|
let page = 1;
|
||||||
|
while (page <= 50) {
|
||||||
|
const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`);
|
||||||
|
url.searchParams.set("per_page", "100");
|
||||||
|
url.searchParams.set("page", String(page));
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure(
|
||||||
|
"list_dns_records",
|
||||||
|
response.status,
|
||||||
|
String(response.status),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const batch = await handleCfResponse<CfDnsRecord[]>(
|
||||||
|
response,
|
||||||
|
"list_dns_records",
|
||||||
|
);
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
all.push(...batch);
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async createDnsRecord(
|
||||||
|
zoneId: string,
|
||||||
|
payload: CreateDnsRecordPayload,
|
||||||
|
): Promise<CfDnsRecord> {
|
||||||
|
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}/dns_records`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
return handleCfResponse(response, "create_dns_record");
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateDnsRecord(
|
||||||
|
zoneId: string,
|
||||||
|
recordId: string,
|
||||||
|
payload: CreateDnsRecordPayload,
|
||||||
|
): Promise<CfDnsRecord> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleCfResponse(response, "update_dns_record");
|
||||||
|
},
|
||||||
|
|
||||||
|
async patchDnsRecord(
|
||||||
|
zoneId: string,
|
||||||
|
recordId: string,
|
||||||
|
payload: PatchDnsRecordPayload,
|
||||||
|
): Promise<CfDnsRecord> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleCfResponse(response, "patch_dns_record");
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await handleCfResponse(response, "delete_dns_record");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { CfHealthCheck } from "@cfdm/shared";
|
||||||
|
import { CF_API_BASE, handleCfResponse } from "./http.js";
|
||||||
|
|
||||||
|
export interface CfHealthCheckHttpConfig {
|
||||||
|
method?: string;
|
||||||
|
path?: string;
|
||||||
|
expected_codes?: string[];
|
||||||
|
header?: Record<string, string[]>;
|
||||||
|
port?: number;
|
||||||
|
follow_redirects?: boolean;
|
||||||
|
allow_insecure?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CfHealthCheckTcpConfig {
|
||||||
|
method?: "connection_established";
|
||||||
|
port?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CfHealthCheckPayload {
|
||||||
|
address: string;
|
||||||
|
name: string;
|
||||||
|
type?: "HTTP" | "HTTPS" | "TCP";
|
||||||
|
description?: string;
|
||||||
|
interval?: number;
|
||||||
|
timeout?: number;
|
||||||
|
retries?: number;
|
||||||
|
consecutive_fails?: number;
|
||||||
|
consecutive_successes?: number;
|
||||||
|
suspended?: boolean;
|
||||||
|
check_regions?: string[];
|
||||||
|
http_config?: CfHealthCheckHttpConfig;
|
||||||
|
tcp_config?: CfHealthCheckTcpConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHealthCheckAdapter(token: string) {
|
||||||
|
return {
|
||||||
|
async listHealthChecks(zoneId: string): Promise<CfHealthCheck[]> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/zones/${zoneId}/healthchecks`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleCfResponse(response, "list_healthchecks");
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHealthCheck(zoneId: string, id: string): Promise<CfHealthCheck> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleCfResponse(response, "get_healthcheck");
|
||||||
|
},
|
||||||
|
|
||||||
|
async createHealthCheck(
|
||||||
|
zoneId: string,
|
||||||
|
payload: CfHealthCheckPayload,
|
||||||
|
): Promise<CfHealthCheck> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/zones/${zoneId}/healthchecks`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleCfResponse(response, "create_healthcheck");
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateHealthCheck(
|
||||||
|
zoneId: string,
|
||||||
|
id: string,
|
||||||
|
payload: CfHealthCheckPayload,
|
||||||
|
): Promise<CfHealthCheck> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleCfResponse(response, "update_healthcheck");
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteHealthCheck(zoneId: string, id: string): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await handleCfResponse(response, "delete_healthcheck");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { CfDnsRecord } from "@cfdm/shared";
|
||||||
|
import { AppError } from "../../errors.js";
|
||||||
|
import { parseRetryAfter } from "../cf-retry.js";
|
||||||
|
|
||||||
|
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
||||||
|
|
||||||
|
export interface CfResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
result?: T;
|
||||||
|
errors?: Array<{ code: number; message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapCloudflareFailure(
|
||||||
|
operation: string,
|
||||||
|
status: number,
|
||||||
|
message: string,
|
||||||
|
): AppError {
|
||||||
|
const lower = message.toLowerCase();
|
||||||
|
if (status === 401 || status === 403 || lower.includes("authentication")) {
|
||||||
|
if (
|
||||||
|
operation.includes("workers") ||
|
||||||
|
operation.includes("kv_") ||
|
||||||
|
operation.includes("accounts")
|
||||||
|
) {
|
||||||
|
return AppError.cloudflareAuthFailed(
|
||||||
|
"Токену нужны права Account: Workers Scripts Write и Workers KV Storage Write. Zone DNS недостаточно.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return AppError.cloudflareAuthFailed(
|
||||||
|
"Cloudflare отклонил токен. Проверьте CLOUDFLARE_API_TOKEN.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 429 || lower.includes("rate limit")) {
|
||||||
|
return AppError.rateLimited();
|
||||||
|
}
|
||||||
|
if (lower.includes("zone") && (lower.includes("not found") || status === 404)) {
|
||||||
|
return AppError.zoneNotFound();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
operation.includes("healthcheck") &&
|
||||||
|
(lower.includes("plan") ||
|
||||||
|
lower.includes("not entitled") ||
|
||||||
|
lower.includes("not allowed") ||
|
||||||
|
lower.includes("permission"))
|
||||||
|
) {
|
||||||
|
return AppError.healthcheckCreateFailed(
|
||||||
|
"Cloudflare Health Checks недоступны для этой зоны. Используйте локальные проверки.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (operation.includes("dns") || operation.includes("dns_record")) {
|
||||||
|
return AppError.dnsUpdateFailed(`Не удалось обновить DNS в Cloudflare: ${message}`);
|
||||||
|
}
|
||||||
|
return AppError.cloudflare(`${operation}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCfResponse<T>(
|
||||||
|
response: Response,
|
||||||
|
operation: string,
|
||||||
|
): Promise<T> {
|
||||||
|
if (response.status === 429) {
|
||||||
|
const wait = parseRetryAfter(response.headers) ?? 5000;
|
||||||
|
throw AppError.rateLimited(
|
||||||
|
`Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json()) as CfResponse<T>;
|
||||||
|
if (!body.success) {
|
||||||
|
const msg =
|
||||||
|
body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
|
||||||
|
throw mapCloudflareFailure(operation, response.status, msg);
|
||||||
|
}
|
||||||
|
if (body.result === undefined) {
|
||||||
|
throw mapCloudflareFailure(operation, response.status, "empty result");
|
||||||
|
}
|
||||||
|
return body.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KV PUT / schedules often return `{ success: true }` without `result`. */
|
||||||
|
export async function handleCfSuccess(
|
||||||
|
response: Response,
|
||||||
|
operation: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (response.status === 429) {
|
||||||
|
const wait = parseRetryAfter(response.headers) ?? 5000;
|
||||||
|
throw AppError.rateLimited(
|
||||||
|
`Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw mapCloudflareFailure(operation, response.status, String(response.status));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let body: CfResponse<unknown>;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(text) as CfResponse<unknown>;
|
||||||
|
} catch {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw mapCloudflareFailure(operation, response.status, text.slice(0, 180));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!body.success) {
|
||||||
|
const msg =
|
||||||
|
body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
|
||||||
|
throw mapCloudflareFailure(operation, response.status, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cfRequest<T>(
|
||||||
|
token: string,
|
||||||
|
path: string,
|
||||||
|
operation: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(`${CF_API_BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
signal: init.signal ?? AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure(operation, response.status, String(response.status));
|
||||||
|
}
|
||||||
|
return handleCfResponse<T>(response, operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DnsRecordResult = CfDnsRecord;
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { CF_API_BASE, handleCfResponse, handleCfSuccess, mapCloudflareFailure } from "./http.js";
|
||||||
|
|
||||||
|
export interface CfKvNamespace {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createKvAdapter(token: string) {
|
||||||
|
return {
|
||||||
|
async listNamespaces(accountId: string): Promise<CfKvNamespace[]> {
|
||||||
|
const all: CfKvNamespace[] = [];
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const url = new URL(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,
|
||||||
|
);
|
||||||
|
url.searchParams.set("per_page", "100");
|
||||||
|
url.searchParams.set("page", String(page));
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("kv_list", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
const batch = await handleCfResponse<CfKvNamespace[]>(response, "kv_list");
|
||||||
|
all.push(...batch);
|
||||||
|
if (batch.length < 100) break;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createNamespace(accountId: string, title: string): Promise<CfKvNamespace> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("kv_create", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
return handleCfResponse<CfKvNamespace>(response, "kv_create");
|
||||||
|
},
|
||||||
|
|
||||||
|
async getValue(
|
||||||
|
accountId: string,
|
||||||
|
namespaceId: string,
|
||||||
|
key: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("kv_get", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => "");
|
||||||
|
throw mapCloudflareFailure("kv_get", response.status, text.slice(0, 180));
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
},
|
||||||
|
|
||||||
|
async putValue(
|
||||||
|
accountId: string,
|
||||||
|
namespaceId: string,
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
body: value,
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("kv_put", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
await handleCfSuccess(response, "kv_put");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import {
|
||||||
|
CF_API_BASE,
|
||||||
|
handleCfResponse,
|
||||||
|
handleCfSuccess,
|
||||||
|
mapCloudflareFailure,
|
||||||
|
} from "./http.js";
|
||||||
|
|
||||||
|
export interface CfAccount {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CfWorkersSubdomain {
|
||||||
|
subdomain?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkersAdapter(token: string) {
|
||||||
|
return {
|
||||||
|
async listAccounts(): Promise<CfAccount[]> {
|
||||||
|
const response = await fetch(`${CF_API_BASE}/accounts?per_page=50`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("list_accounts", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
return handleCfResponse<CfAccount[]>(response, "list_accounts");
|
||||||
|
},
|
||||||
|
|
||||||
|
async putScript(opts: {
|
||||||
|
accountId: string;
|
||||||
|
scriptName: string;
|
||||||
|
source: string;
|
||||||
|
kvNamespaceId: string;
|
||||||
|
filename?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const filename = opts.filename ?? "index.mjs";
|
||||||
|
const metadata = {
|
||||||
|
main_module: filename,
|
||||||
|
compatibility_date: "2025-04-01",
|
||||||
|
bindings: [
|
||||||
|
{
|
||||||
|
type: "kv_namespace",
|
||||||
|
name: "HEALTH_KV",
|
||||||
|
namespace_id: opts.kvNamespaceId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const form = new FormData();
|
||||||
|
form.append(
|
||||||
|
"metadata",
|
||||||
|
new Blob([JSON.stringify(metadata)], { type: "application/json" }),
|
||||||
|
);
|
||||||
|
form.append(
|
||||||
|
filename,
|
||||||
|
new Blob([opts.source], { type: "application/javascript+module" }),
|
||||||
|
filename,
|
||||||
|
);
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: form,
|
||||||
|
signal: AbortSignal.timeout(60_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("workers_put_script", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
await handleCfSuccess(response, "workers_put_script");
|
||||||
|
},
|
||||||
|
|
||||||
|
async putSchedules(
|
||||||
|
accountId: string,
|
||||||
|
scriptName: string,
|
||||||
|
crons: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/schedules`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(crons.map((cron) => ({ cron }))),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("workers_put_schedules", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
await handleCfSuccess(response, "workers_put_schedules");
|
||||||
|
},
|
||||||
|
|
||||||
|
async enableWorkersDev(
|
||||||
|
accountId: string,
|
||||||
|
scriptName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled: true }),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status === 409) return;
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("workers_subdomain", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
if (!response.ok && response.status !== 200 && response.status !== 201) {
|
||||||
|
await handleCfSuccess(response, "workers_subdomain");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getWorkersSubdomain(accountId: string): Promise<string | null> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/workers/subdomain`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("workers_get_subdomain", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
const result = await handleCfResponse<CfWorkersSubdomain>(
|
||||||
|
response,
|
||||||
|
"workers_get_subdomain",
|
||||||
|
);
|
||||||
|
return result.subdomain?.trim() || null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { CfZone } from "@cfdm/shared";
|
||||||
|
import { withRetry } from "../cf-retry.js";
|
||||||
|
import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js";
|
||||||
|
|
||||||
|
export function createZoneAdapter(token: string) {
|
||||||
|
return {
|
||||||
|
async listZones(): Promise<CfZone[]> {
|
||||||
|
return withRetry(async () => {
|
||||||
|
const all: CfZone[] = [];
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const url = new URL(`${CF_API_BASE}/zones`);
|
||||||
|
url.searchParams.set("per_page", "50");
|
||||||
|
url.searchParams.set("page", String(page));
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("list_zones", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
const batch = await handleCfResponse<CfZone[]>(response, "list_zones");
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
all.push(...batch);
|
||||||
|
if (batch.length < 50) break;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getZone(zoneId: string): Promise<CfZone> {
|
||||||
|
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
return handleCfResponse(response, "get_zone");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -94,14 +94,17 @@ const RULES: Rule[] = [
|
|||||||
methods: ["GET"],
|
methods: ["GET"],
|
||||||
match: (p) =>
|
match: (p) =>
|
||||||
p.startsWith("/api/v1/services") ||
|
p.startsWith("/api/v1/services") ||
|
||||||
p.startsWith("/api/v1/service-bindings"),
|
p.startsWith("/api/v1/service-bindings") ||
|
||||||
|
p.startsWith("/api/v1/health-checks") ||
|
||||||
|
p === "/api/v1/ops-summary",
|
||||||
permission: "cfdm:services:read",
|
permission: "cfdm:services:read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
match: (p) =>
|
match: (p) =>
|
||||||
p.startsWith("/api/v1/services") ||
|
p.startsWith("/api/v1/services") ||
|
||||||
p.startsWith("/api/v1/service-bindings"),
|
p.startsWith("/api/v1/service-bindings") ||
|
||||||
|
p.startsWith("/api/v1/health-checks"),
|
||||||
permission: "cfdm:services:write",
|
permission: "cfdm:services:write",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -114,8 +117,7 @@ const RULES: Rule[] = [
|
|||||||
match: (p) =>
|
match: (p) =>
|
||||||
p.startsWith("/api/v1/settings") ||
|
p.startsWith("/api/v1/settings") ||
|
||||||
p.startsWith("/api/v1/notifications") ||
|
p.startsWith("/api/v1/notifications") ||
|
||||||
p.startsWith("/api/v1/health-check") ||
|
p.startsWith("/api/v1/health-check"),
|
||||||
p.startsWith("/api/v1/health-checks"),
|
|
||||||
permission: "cfdm:settings:admin",
|
permission: "cfdm:settings:admin",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { healthStatusQuerySchema } from "@cfdm/shared";
|
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 healthCheckService from "../services/health-check-service.js";
|
||||||
import * as serviceConfigService from "../services/service-config-service.js";
|
import * as serviceConfigService from "../services/service-config-service.js";
|
||||||
|
import {
|
||||||
|
healthEngineFallbacksFromConfig,
|
||||||
|
} from "../services/health-check-scheduler.js";
|
||||||
|
import { mailboxFromSettings } from "../services/health/health-worker-deploy.js";
|
||||||
|
import { cronStaleAfterMs } from "../services/health/mailbox.js";
|
||||||
|
|
||||||
export async function healthCheckRoutes(app: FastifyInstance) {
|
export async function healthCheckRoutes(app: FastifyInstance) {
|
||||||
app.get("/health-status", async (request) => {
|
app.get("/health-status", async (request) => {
|
||||||
@@ -16,14 +21,22 @@ export async function healthCheckRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.post("/health-check/run", async (request) => {
|
app.post("/health-check/run", async (request) => {
|
||||||
const config = request.server.config;
|
const config = request.server.config;
|
||||||
|
const fallbacks = healthEngineFallbacksFromConfig(config);
|
||||||
|
const settings = getAppSettings(
|
||||||
|
request.server.db,
|
||||||
|
fallbacks,
|
||||||
|
);
|
||||||
const thresholds = {
|
const thresholds = {
|
||||||
degradedFailures: config.healthDegradedFailures,
|
degradedFailures: settings.healthDegradedFailures,
|
||||||
downFailures: config.healthDownFailures,
|
downFailures: settings.healthDownFailures,
|
||||||
latencyWarnMs: config.healthLatencyWarnMs,
|
latencyWarnMs: settings.healthLatencyWarnMs,
|
||||||
|
successRecoveries: settings.healthSuccessRecoveries,
|
||||||
};
|
};
|
||||||
const checked = await healthCheckService.runAllChecks(request.server.db, {
|
const checked = await healthCheckService.runAllChecks(request.server.db, {
|
||||||
thresholds,
|
thresholds,
|
||||||
probeGapMs: config.healthProbeGapMs,
|
probeGapMs: config.healthProbeGapMs,
|
||||||
|
mailbox: mailboxFromSettings(request.server.db, request.server.cf, fallbacks),
|
||||||
|
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
|
||||||
onStatusChange: async (target, prev, next) => {
|
onStatusChange: async (target, prev, next) => {
|
||||||
try {
|
try {
|
||||||
const label =
|
const label =
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import {
|
||||||
|
createOriginHealthCheckSchema,
|
||||||
|
} from "@cfdm/shared";
|
||||||
|
import * as originHealth from "../services/origin-health-check-service.js";
|
||||||
|
import { recordAudit } from "../lib/audit.js";
|
||||||
|
|
||||||
|
export async function originHealthCheckRoutes(app: FastifyInstance) {
|
||||||
|
app.get("/health-checks", async (request) => {
|
||||||
|
return originHealth.listOriginHealthChecks(request.server.db);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/health-checks", async (request) => {
|
||||||
|
const body = createOriginHealthCheckSchema.parse(request.body);
|
||||||
|
const check = await originHealth.createOriginHealthCheck(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "healthcheck.create",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(check.id),
|
||||||
|
summary: `Создан health check «${check.name}» (${check.provider})`,
|
||||||
|
});
|
||||||
|
return check;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/health-checks/:id", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return originHealth.getOriginHealthCheck(request.server.db, Number(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/health-checks/:id", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = createOriginHealthCheckSchema.partial().parse(request.body);
|
||||||
|
const check = await originHealth.updateOriginHealthCheck(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
Number(id),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "healthcheck.update",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: id,
|
||||||
|
summary: `Обновлён health check «${check.name}»`,
|
||||||
|
});
|
||||||
|
return check;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/health-checks/:id", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
await originHealth.deleteOriginHealthCheck(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
Number(id),
|
||||||
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "healthcheck.delete",
|
||||||
|
severity: "warning",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: id,
|
||||||
|
summary: `Удалён health check ${id}`,
|
||||||
|
});
|
||||||
|
return { deleted: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/health-checks/:id/sync", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return originHealth.syncOriginHealthCheck(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
Number(id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { changeIpSchema } from "@cfdm/shared";
|
||||||
import * as bindingService from "../services/binding-service.js";
|
import * as bindingService from "../services/binding-service.js";
|
||||||
|
import * as changeIp from "../services/change-ip-service.js";
|
||||||
|
import { recordAudit } from "../lib/audit.js";
|
||||||
|
|
||||||
export async function serviceBindingRoutes(app: FastifyInstance) {
|
export async function serviceBindingRoutes(app: FastifyInstance) {
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
@@ -52,6 +55,27 @@ export async function serviceBindingRoutes(app: FastifyInstance) {
|
|||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/service-bindings/:id/change-ip", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = changeIpSchema.parse(request.body);
|
||||||
|
const result = await changeIp.changeBindingIp(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
Number(id),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
if (result.applied) {
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "binding.change_ip",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: id,
|
||||||
|
summary: `Сменён IP: ${result.message}`,
|
||||||
|
details: result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/domains/:id/service-bindings", async (request) => {
|
app.get("/domains/:id/service-bindings", async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
return bindingService.listByDomain(request.server.db, Number(id));
|
return bindingService.listByDomain(request.server.db, Number(id));
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { reorderServicesSchema, updateServiceConfigSchema } from "@cfdm/shared";
|
import {
|
||||||
|
changeDomainSchema,
|
||||||
|
createServiceNodeSchema,
|
||||||
|
reorderServicesSchema,
|
||||||
|
toggleServiceIpSchema,
|
||||||
|
updateServiceConfigSchema,
|
||||||
|
updateServiceNodeSchema,
|
||||||
|
} from "@cfdm/shared";
|
||||||
import { repos } from "@cfdm/db";
|
import { repos } from "@cfdm/db";
|
||||||
import * as serviceConfig from "../services/service-config-service.js";
|
import * as serviceConfig from "../services/service-config-service.js";
|
||||||
|
import * as nodeService from "../services/node-service.js";
|
||||||
|
import * as changeDomain from "../services/change-domain-service.js";
|
||||||
import { recordAudit } from "../lib/audit.js";
|
import { recordAudit } from "../lib/audit.js";
|
||||||
|
|
||||||
export async function serviceRoutes(app: FastifyInstance) {
|
export async function serviceRoutes(app: FastifyInstance) {
|
||||||
@@ -56,6 +65,96 @@ export async function serviceRoutes(app: FastifyInstance) {
|
|||||||
return serviceConfig.getView(request.server.db, Number(id));
|
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));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/services/:id/nodes", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return nodeService.listNodes(request.server.db, Number(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/services/:id/nodes", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = createServiceNodeSchema.parse(request.body);
|
||||||
|
const node = nodeService.createNode(request.server.db, Number(id), body);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "node.create",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(node.id),
|
||||||
|
summary: `Добавлена нода ${node.address}`,
|
||||||
|
details: { service_id: Number(id), address: node.address },
|
||||||
|
});
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/services/:id/nodes/:nodeId", async (request) => {
|
||||||
|
const { id, nodeId } = request.params as { id: string; nodeId: string };
|
||||||
|
const body = updateServiceNodeSchema.parse(request.body);
|
||||||
|
const node = nodeService.updateNode(
|
||||||
|
request.server.db,
|
||||||
|
Number(id),
|
||||||
|
Number(nodeId),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "node.update",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(node.id),
|
||||||
|
summary: `Обновлена нода ${node.address}`,
|
||||||
|
details: body,
|
||||||
|
});
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/services/:id/nodes/:nodeId", async (request) => {
|
||||||
|
const { id, nodeId } = request.params as { id: string; nodeId: string };
|
||||||
|
const node = repos.getNode(request.server.db, Number(nodeId));
|
||||||
|
nodeService.deleteNode(request.server.db, Number(id), Number(nodeId));
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "node.delete",
|
||||||
|
severity: "warning",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: nodeId,
|
||||||
|
summary: `Удалена нода ${node.address}`,
|
||||||
|
});
|
||||||
|
return { deleted: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/services/:id/change-domain", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = changeDomainSchema.parse(request.body);
|
||||||
|
const result = await changeDomain.changeServiceDomain(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
Number(id),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
if (result.applied) {
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "service.change_domain",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: id,
|
||||||
|
summary: result.message,
|
||||||
|
details: result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/ops-summary", async (request) => {
|
||||||
|
return nodeService.opsSummary(request.server.db);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
app.patch("/services/:id", async (request) => {
|
app.patch("/services/:id", async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
@@ -101,4 +200,26 @@ export async function serviceRoutes(app: FastifyInstance) {
|
|||||||
body.enabled,
|
body.enabled,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.patch("/services/:id/ips/toggle", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = toggleServiceIpSchema.parse(request.body);
|
||||||
|
const view = await serviceConfig.toggleServiceIp(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
Number(id),
|
||||||
|
body.ip,
|
||||||
|
body.enabled,
|
||||||
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "service.ip.toggle",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(id),
|
||||||
|
summary: body.enabled
|
||||||
|
? `Включён IP ${body.ip} сервиса «${view.name}»`
|
||||||
|
: `Выключен IP ${body.ip} сервиса «${view.name}»`,
|
||||||
|
details: { ip: body.ip, enabled: body.enabled },
|
||||||
|
});
|
||||||
|
return view;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -5,15 +5,65 @@ import {
|
|||||||
updateAppSettings,
|
updateAppSettings,
|
||||||
} from "@cfdm/db";
|
} from "@cfdm/db";
|
||||||
import { pingVpsTracker } from "../services/vps-tracker-sync.js";
|
import { pingVpsTracker } from "../services/vps-tracker-sync.js";
|
||||||
|
import { AppError } from "../errors.js";
|
||||||
|
import {
|
||||||
|
assertValidHealthCron,
|
||||||
|
healthEngineFallbacksFromConfig,
|
||||||
|
} from "../services/health-check-scheduler.js";
|
||||||
|
import { ensureHealthWorker } from "../services/health/health-worker-deploy.js";
|
||||||
|
|
||||||
export async function settingsRoutes(app: FastifyInstance) {
|
export async function settingsRoutes(app: FastifyInstance) {
|
||||||
app.get("/settings", async (request) => {
|
app.get("/settings", async (request) => {
|
||||||
return getAppSettings(request.server.db);
|
return getAppSettings(
|
||||||
|
request.server.db,
|
||||||
|
healthEngineFallbacksFromConfig(request.server.config),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/settings", async (request) => {
|
app.patch("/settings", async (request) => {
|
||||||
const body = appSettingsPatchSchema.parse(request.body);
|
const parsed = appSettingsPatchSchema.safeParse(request.body);
|
||||||
return updateAppSettings(request.server.db, body);
|
if (!parsed.success) {
|
||||||
|
throw AppError.validation(
|
||||||
|
parsed.error.issues[0]?.message ?? "некорректные настройки",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const body = parsed.data;
|
||||||
|
if (body.healthCheckCron) {
|
||||||
|
assertValidHealthCron(body.healthCheckCron);
|
||||||
|
}
|
||||||
|
const fallbacks = healthEngineFallbacksFromConfig(request.server.config);
|
||||||
|
const current = getAppSettings(request.server.db, fallbacks);
|
||||||
|
const nextDegraded =
|
||||||
|
body.healthDegradedFailures ?? current.healthDegradedFailures;
|
||||||
|
const nextDown = body.healthDownFailures ?? current.healthDownFailures;
|
||||||
|
if (nextDown < nextDegraded) {
|
||||||
|
throw AppError.validation(
|
||||||
|
"ошибок до down не меньше, чем до degraded",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
updateAppSettings(request.server.db, body, fallbacks);
|
||||||
|
if (body.healthCheckCron !== undefined) {
|
||||||
|
request.server.reloadHealthCheckJob?.();
|
||||||
|
const after = getAppSettings(request.server.db, fallbacks);
|
||||||
|
if (after.healthWorkerKvNamespaceId) {
|
||||||
|
try {
|
||||||
|
await ensureHealthWorker(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
fallbacks,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// error stored in settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getAppSettings(request.server.db, fallbacks);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/settings/health/worker/ensure", async (request) => {
|
||||||
|
const fallbacks = healthEngineFallbacksFromConfig(request.server.config);
|
||||||
|
await ensureHealthWorker(request.server.db, request.server.cf, fallbacks);
|
||||||
|
return getAppSettings(request.server.db, fallbacks);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/settings/vps-tracker/test", async (request) => {
|
app.post("/settings/vps-tracker/test", async (request) => {
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { Db } from "@cfdm/db";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type { ChangeDomainInput } from "@cfdm/shared";
|
||||||
|
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||||
|
import { AppError } from "../errors.js";
|
||||||
|
import * as dnsService from "./dns-service.js";
|
||||||
|
import { withBindingLock } from "./routing/index.js";
|
||||||
|
import { applyBindingDesiredDns, fqdnToDisplay } from "./service-config-service.js";
|
||||||
|
|
||||||
|
export interface ChangeDomainItem {
|
||||||
|
binding_id: number;
|
||||||
|
hostname: string;
|
||||||
|
from_fqdn: string;
|
||||||
|
to_fqdn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangeDomainPreview {
|
||||||
|
from_domain_id: number;
|
||||||
|
to_domain_id: number;
|
||||||
|
from_zone: string;
|
||||||
|
to_zone: string;
|
||||||
|
items: ChangeDomainItem[];
|
||||||
|
dry_run: boolean;
|
||||||
|
applied: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeServiceDomain(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
serviceId: number,
|
||||||
|
input: ChangeDomainInput,
|
||||||
|
): Promise<ChangeDomainPreview> {
|
||||||
|
repos.getService(db, serviceId);
|
||||||
|
if (input.from_domain_id === input.to_domain_id) {
|
||||||
|
throw AppError.validation("укажите другой целевой домен");
|
||||||
|
}
|
||||||
|
const fromDomain = repos.getDomain(db, input.from_domain_id);
|
||||||
|
const toDomain = repos.getDomain(db, input.to_domain_id);
|
||||||
|
const bindings = repos
|
||||||
|
.listBindingsByService(db, serviceId)
|
||||||
|
.filter((b) => b.domain_id === input.from_domain_id);
|
||||||
|
const selected = input.hostnames?.length
|
||||||
|
? bindings.filter((b) => input.hostnames!.includes(b.hostname))
|
||||||
|
: bindings;
|
||||||
|
if (selected.length === 0) {
|
||||||
|
throw AppError.validation("нет привязок для переноса");
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: ChangeDomainItem[] = selected.map((binding) => ({
|
||||||
|
binding_id: binding.id,
|
||||||
|
hostname: binding.hostname,
|
||||||
|
from_fqdn: fqdnToDisplay(binding.hostname, fromDomain.zone_name),
|
||||||
|
to_fqdn: fqdnToDisplay(binding.hostname, toDomain.zone_name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const preview: ChangeDomainPreview = {
|
||||||
|
from_domain_id: fromDomain.id,
|
||||||
|
to_domain_id: toDomain.id,
|
||||||
|
from_zone: fromDomain.zone_name,
|
||||||
|
to_zone: toDomain.zone_name,
|
||||||
|
items,
|
||||||
|
dry_run: Boolean(input.dry_run),
|
||||||
|
applied: false,
|
||||||
|
message: `Перенос ${items.length} привязок ${fromDomain.zone_name} → ${toDomain.zone_name}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.dry_run) return preview;
|
||||||
|
|
||||||
|
const createdRecordIds: number[] = [];
|
||||||
|
try {
|
||||||
|
for (const binding of selected) {
|
||||||
|
const existing = repos.findBinding(
|
||||||
|
db,
|
||||||
|
serviceId,
|
||||||
|
toDomain.id,
|
||||||
|
binding.hostname,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw AppError.conflict(
|
||||||
|
`привязка ${fqdnToDisplay(binding.hostname, toDomain.zone_name)} уже существует`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await withBindingLock(binding.id, async () => {
|
||||||
|
repos.bumpBindingVersion(db, binding.id);
|
||||||
|
const ips = repos.listBindingIps(db, binding.id);
|
||||||
|
repos.updateBindingDomain(db, binding.id, toDomain.id, binding.hostname);
|
||||||
|
await applyBindingDesiredDns(db, cf, binding.id, ips);
|
||||||
|
const newRecords = repos.listRecordsForBinding(db, binding.id);
|
||||||
|
createdRecordIds.push(...newRecords.map((r) => r.id));
|
||||||
|
|
||||||
|
const oldRecords = newRecords.filter((r) => r.domain_id === fromDomain.id);
|
||||||
|
for (const record of oldRecords) {
|
||||||
|
repos.unlinkBindingRecord(db, binding.id, record.id);
|
||||||
|
try {
|
||||||
|
await dnsService.deleteRecord(db, cf, fromDomain.id, record.id);
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup of old zone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw err instanceof AppError
|
||||||
|
? err
|
||||||
|
: AppError.syncFailed(
|
||||||
|
err instanceof Error ? err.message : "не удалось перенести привязки",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void createdRecordIds;
|
||||||
|
return {
|
||||||
|
...preview,
|
||||||
|
dry_run: false,
|
||||||
|
applied: true,
|
||||||
|
message: `Привязки перенесены в ${toDomain.zone_name}. Старые записи зоны удалены.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { Db } from "@cfdm/db";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type { ChangeIpInput } from "@cfdm/shared";
|
||||||
|
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||||
|
import { AppError } from "../errors.js";
|
||||||
|
import { isValidIpv4 } from "../lib/validators.js";
|
||||||
|
import { withBindingLock } from "./routing/index.js";
|
||||||
|
import { applyBindingDesiredDns } from "./service-config-service.js";
|
||||||
|
|
||||||
|
export interface ChangeIpPreview {
|
||||||
|
binding_id: number;
|
||||||
|
hostname: string;
|
||||||
|
zone_name: string;
|
||||||
|
from_ip: string;
|
||||||
|
to_ip: string;
|
||||||
|
dry_run: boolean;
|
||||||
|
applied: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchRecordContent(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
domainId: number,
|
||||||
|
recordId: number,
|
||||||
|
content: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const domain = repos.getDomain(db, domainId);
|
||||||
|
const record = repos.getDnsRecord(db, domainId, recordId);
|
||||||
|
if (!record.cf_record_id) {
|
||||||
|
throw AppError.dnsUpdateFailed("у DNS-записи нет идентификатора Cloudflare");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const patched = await cf.patchDnsRecord(domain.cf_zone_id, record.cf_record_id, {
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
repos.updateDnsFields(
|
||||||
|
db,
|
||||||
|
record.id,
|
||||||
|
patched.type ?? record.record_type,
|
||||||
|
patched.name ?? record.name,
|
||||||
|
patched.content ?? content,
|
||||||
|
patched.ttl ?? record.ttl,
|
||||||
|
patched.proxied ?? record.proxied,
|
||||||
|
patched.priority ?? record.priority,
|
||||||
|
"synced",
|
||||||
|
patched.id ?? record.cf_record_id,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AppError) throw err;
|
||||||
|
throw AppError.dnsUpdateFailed(
|
||||||
|
err instanceof Error ? err.message : "не удалось обновить запись в Cloudflare",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeBindingIp(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
bindingId: number,
|
||||||
|
input: ChangeIpInput,
|
||||||
|
): Promise<ChangeIpPreview> {
|
||||||
|
const binding = repos.getBinding(db, bindingId);
|
||||||
|
const domain = repos.getDomain(db, binding.domain_id);
|
||||||
|
const current = repos.listBindingIpsWithMeta(db, bindingId);
|
||||||
|
if (current.length === 0) {
|
||||||
|
throw AppError.validation("у привязки нет IP для замены");
|
||||||
|
}
|
||||||
|
|
||||||
|
let fromIp = input.from_ip?.trim();
|
||||||
|
let toIp = input.to_ip?.trim();
|
||||||
|
|
||||||
|
if (input.node_id) {
|
||||||
|
const node = repos.getNode(db, input.node_id);
|
||||||
|
if (node.service_id !== binding.service_id) {
|
||||||
|
throw AppError.validation("нода не принадлежит сервису этой привязки");
|
||||||
|
}
|
||||||
|
toIp = node.address;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fromIp) {
|
||||||
|
fromIp = current[0]!.ip;
|
||||||
|
}
|
||||||
|
if (!toIp) {
|
||||||
|
throw AppError.invalidIp("укажите новый IP или ноду");
|
||||||
|
}
|
||||||
|
if (!isValidIpv4(toIp)) {
|
||||||
|
throw AppError.invalidIp(`Некорректный IP-адрес: ${toIp}`);
|
||||||
|
}
|
||||||
|
if (!current.some((row) => row.ip === fromIp)) {
|
||||||
|
throw AppError.validation(`IP ${fromIp} нет в привязке`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview: ChangeIpPreview = {
|
||||||
|
binding_id: bindingId,
|
||||||
|
hostname: binding.hostname,
|
||||||
|
zone_name: domain.zone_name,
|
||||||
|
from_ip: fromIp,
|
||||||
|
to_ip: toIp,
|
||||||
|
dry_run: Boolean(input.dry_run),
|
||||||
|
applied: false,
|
||||||
|
message: `${fromIp} → ${toIp}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.dry_run || fromIp === toIp) {
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
return withBindingLock(bindingId, async () => {
|
||||||
|
repos.bumpBindingVersion(db, bindingId);
|
||||||
|
const next = current.map((row) =>
|
||||||
|
row.ip === fromIp ? { ...row, ip: toIp } : row,
|
||||||
|
);
|
||||||
|
repos.replaceBindingIpsWithMeta(db, bindingId, next);
|
||||||
|
|
||||||
|
const records = repos.listRecordsForBinding(db, bindingId);
|
||||||
|
const match = records.find(
|
||||||
|
(record) =>
|
||||||
|
record.content === fromIp &&
|
||||||
|
(record.record_type.toUpperCase() === "A" ||
|
||||||
|
record.record_type.toUpperCase() === "AAAA"),
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
await patchRecordContent(db, cf, binding.domain_id, match.id, toIp);
|
||||||
|
} else {
|
||||||
|
await applyBindingDesiredDns(
|
||||||
|
db,
|
||||||
|
cf,
|
||||||
|
bindingId,
|
||||||
|
next.map((row) => row.ip),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...preview,
|
||||||
|
dry_run: false,
|
||||||
|
applied: true,
|
||||||
|
message: `Запись обновлена в Cloudflare (${fromIp} → ${toIp}). Распространение зависит от TTL.`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Db } from "@cfdm/db";
|
import type { Db } from "@cfdm/db";
|
||||||
import { repos, type DnsListFilter } from "@cfdm/db";
|
import { repos, type DnsListFilter } from "@cfdm/db";
|
||||||
import type { CreateDnsRecordPayload, DnsRecord } from "@cfdm/shared";
|
import type { CreateDnsRecordPayload, DnsRecord, PatchDnsRecordPayload } from "@cfdm/shared";
|
||||||
import {
|
import {
|
||||||
SYNC_CONFLICT,
|
SYNC_CONFLICT,
|
||||||
SYNC_ERROR,
|
SYNC_ERROR,
|
||||||
@@ -179,6 +179,52 @@ export async function update(
|
|||||||
return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);
|
return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function patchContent(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
domainId: number,
|
||||||
|
recordId: number,
|
||||||
|
payload: PatchDnsRecordPayload,
|
||||||
|
): Promise<DnsRecord> {
|
||||||
|
const domain = repos.getDomain(db, domainId);
|
||||||
|
const existing = repos.getDnsRecord(db, domainId, recordId);
|
||||||
|
if (!existing.cf_record_id) {
|
||||||
|
throw AppError.dnsUpdateFailed("у DNS-записи нет идентификатора Cloudflare");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cfRec = await cf.patchDnsRecord(
|
||||||
|
domain.cf_zone_id,
|
||||||
|
existing.cf_record_id,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
repos.updateDnsFields(
|
||||||
|
db,
|
||||||
|
existing.id,
|
||||||
|
cfRec.type ?? existing.record_type,
|
||||||
|
cfRec.name ?? existing.name,
|
||||||
|
cfRec.content ?? existing.content,
|
||||||
|
cfRec.ttl ?? existing.ttl,
|
||||||
|
cfRec.proxied ?? existing.proxied,
|
||||||
|
cfRec.priority ?? existing.priority,
|
||||||
|
SYNC_SYNCED,
|
||||||
|
cfRec.id ?? existing.cf_record_id,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
return repos.getDnsRecord(db, domainId, existing.id);
|
||||||
|
} catch (e) {
|
||||||
|
repos.setDnsSyncStatus(
|
||||||
|
db,
|
||||||
|
existing.id,
|
||||||
|
SYNC_ERROR,
|
||||||
|
existing.cf_record_id,
|
||||||
|
e instanceof Error ? e.message : String(e),
|
||||||
|
);
|
||||||
|
throw e instanceof AppError
|
||||||
|
? e
|
||||||
|
: AppError.dnsUpdateFailed(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteRecord(
|
export async function deleteRecord(
|
||||||
db: Db,
|
db: Db,
|
||||||
cf: CloudflareClient,
|
cf: CloudflareClient,
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||||
|
import {
|
||||||
|
getAppSettings,
|
||||||
|
getAppSettingsSecrets,
|
||||||
|
updateAppSettings,
|
||||||
|
type HealthEngineFallbacks,
|
||||||
|
} from "@cfdm/db";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type { AppConfig } from "../config.js";
|
||||||
|
import { AppError } from "../errors.js";
|
||||||
|
import * as healthCheckService from "./health-check-service.js";
|
||||||
|
import * as serviceConfigService from "./service-config-service.js";
|
||||||
|
import {
|
||||||
|
mailboxFromSettings,
|
||||||
|
} from "./health/health-worker-deploy.js";
|
||||||
|
import { cronStaleAfterMs } from "./health/mailbox.js";
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyInstance {
|
||||||
|
reloadHealthCheckJob?: () => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HEALTH_CHECK_JOB_ID = "health-check";
|
||||||
|
|
||||||
|
export function healthEngineFallbacksFromConfig(
|
||||||
|
config: AppConfig,
|
||||||
|
): HealthEngineFallbacks {
|
||||||
|
return {
|
||||||
|
healthCheckCron: config.healthCheckCron,
|
||||||
|
healthDegradedFailures: config.healthDegradedFailures,
|
||||||
|
healthDownFailures: config.healthDownFailures,
|
||||||
|
healthLatencyWarnMs: config.healthLatencyWarnMs,
|
||||||
|
healthSuccessRecoveries: config.healthSuccessRecoveries,
|
||||||
|
healthWorkerUrl: config.healthWorkerUrl,
|
||||||
|
healthWorkerTokenSet: Boolean(config.healthWorkerToken),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertValidHealthCron(expr: string): void {
|
||||||
|
const cronExpression = expr.trim();
|
||||||
|
const parts = cronExpression.split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length < 5 || parts.length > 6) {
|
||||||
|
throw AppError.validation("некорректное cron-выражение");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const job = new CronJob(
|
||||||
|
{ cronExpression },
|
||||||
|
new AsyncTask("validate-cron", async () => undefined),
|
||||||
|
{ id: "validate-cron" },
|
||||||
|
);
|
||||||
|
job.stop();
|
||||||
|
} catch {
|
||||||
|
throw AppError.validation("некорректное cron-выражение");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHealthCheckTask(
|
||||||
|
app: FastifyInstance,
|
||||||
|
config: AppConfig,
|
||||||
|
): AsyncTask {
|
||||||
|
const fallbacks = healthEngineFallbacksFromConfig(config);
|
||||||
|
return new AsyncTask(
|
||||||
|
HEALTH_CHECK_JOB_ID,
|
||||||
|
async () => {
|
||||||
|
const settings = getAppSettings(app.db, fallbacks);
|
||||||
|
const thresholds = {
|
||||||
|
degradedFailures: settings.healthDegradedFailures,
|
||||||
|
downFailures: settings.healthDownFailures,
|
||||||
|
latencyWarnMs: settings.healthLatencyWarnMs,
|
||||||
|
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 =
|
||||||
|
next === "up"
|
||||||
|
? "OK"
|
||||||
|
: next === "degraded"
|
||||||
|
? "Slow"
|
||||||
|
: next === "down"
|
||||||
|
? "Down"
|
||||||
|
: "—";
|
||||||
|
repos.insertNotificationLog(
|
||||||
|
app.db,
|
||||||
|
"ip_health",
|
||||||
|
target.scope,
|
||||||
|
target.ref_id,
|
||||||
|
`${target.hostname || target.ip}: ${label}`,
|
||||||
|
`IP ${target.ip}: ${prev ?? "—"} → ${label}`,
|
||||||
|
);
|
||||||
|
await serviceConfigService.reconcileDnsForTarget(
|
||||||
|
app.db,
|
||||||
|
app.cf,
|
||||||
|
target.scope,
|
||||||
|
target.ref_id,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn(
|
||||||
|
{ err, scope: target.scope, refId: target.ref_id },
|
||||||
|
"health-check reconcile failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (mailbox) {
|
||||||
|
updateAppSettings(
|
||||||
|
app.db,
|
||||||
|
{ healthWorkerLastIngestAt: new Date().toISOString() },
|
||||||
|
fallbacks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const monitors = await healthCheckService.runDomainMonitors(
|
||||||
|
app.db,
|
||||||
|
thresholds,
|
||||||
|
);
|
||||||
|
app.log.info({ checked: n, monitors }, "health check completed");
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
app.log.warn({ err }, "health check failed");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleHealthCheckJob(
|
||||||
|
app: FastifyInstance,
|
||||||
|
config: AppConfig,
|
||||||
|
task: AsyncTask,
|
||||||
|
): void {
|
||||||
|
const scheduler = app.scheduler;
|
||||||
|
if (!scheduler) return;
|
||||||
|
if (scheduler.existsById(HEALTH_CHECK_JOB_ID)) {
|
||||||
|
scheduler.removeById(HEALTH_CHECK_JOB_ID);
|
||||||
|
}
|
||||||
|
const settings = getAppSettings(
|
||||||
|
app.db,
|
||||||
|
healthEngineFallbacksFromConfig(config),
|
||||||
|
);
|
||||||
|
scheduler.addCronJob(
|
||||||
|
new CronJob(
|
||||||
|
{ cronExpression: settings.healthCheckCron },
|
||||||
|
task,
|
||||||
|
{ preventOverrun: true, id: HEALTH_CHECK_JOB_ID },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,19 +3,41 @@ import { resolve4, resolve6 } from "node:dns/promises";
|
|||||||
import { Agent, buildConnector, fetch as undiciFetch } from "undici";
|
import { Agent, buildConnector, fetch as undiciFetch } from "undici";
|
||||||
import type { Db } from "@cfdm/db";
|
import type { Db } from "@cfdm/db";
|
||||||
import { repos } from "@cfdm/db";
|
import { repos } from "@cfdm/db";
|
||||||
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
|
import type { HealthCheckTarget, IpHealthState, HealthCheckProvider } from "@cfdm/shared";
|
||||||
|
import {
|
||||||
|
aggregateHealthOk,
|
||||||
|
parseHealthAggregate,
|
||||||
|
targetProviders,
|
||||||
|
} from "@cfdm/shared";
|
||||||
import { AppError } from "../errors.js";
|
import { AppError } from "../errors.js";
|
||||||
|
import { nextHealthState } from "./health/state-machine.js";
|
||||||
|
import { LocalHealthCheckProvider } from "./health/local.js";
|
||||||
|
import { workerNotConfiguredResult } from "./health/worker.js";
|
||||||
|
import {
|
||||||
|
globalpingNotConfiguredResult,
|
||||||
|
probeWithGlobalping,
|
||||||
|
} from "./health/globalping.js";
|
||||||
|
import {
|
||||||
|
buildTargetsDoc,
|
||||||
|
indexResults,
|
||||||
|
isResultsStale,
|
||||||
|
originProbeKey,
|
||||||
|
type HealthMailbox,
|
||||||
|
} from "./health/mailbox.js";
|
||||||
|
import type { GlobalpingClientOptions } from "../lib/globalping-client.js";
|
||||||
|
|
||||||
export interface HealthCheckThresholds {
|
export interface HealthCheckThresholds {
|
||||||
degradedFailures: number;
|
degradedFailures: number;
|
||||||
downFailures: number;
|
downFailures: number;
|
||||||
latencyWarnMs: number;
|
latencyWarnMs: number;
|
||||||
|
successRecoveries?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProbeResult {
|
export interface ProbeResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
latencyMs: number;
|
latencyMs: number;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
colo?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bracket IPv6 for URL authority; leave IPv4/hostname as-is. */
|
/** Bracket IPv6 for URL authority; leave IPv4/hostname as-is. */
|
||||||
@@ -234,29 +256,36 @@ export async function probeTarget(
|
|||||||
function deriveState(
|
function deriveState(
|
||||||
ok: boolean,
|
ok: boolean,
|
||||||
latencyMs: number,
|
latencyMs: number,
|
||||||
prev: { consecutive_failures: number; status: string } | null,
|
prev: {
|
||||||
|
consecutive_failures: number;
|
||||||
|
consecutive_successes?: number;
|
||||||
|
status: string;
|
||||||
|
} | null,
|
||||||
thresholds: HealthCheckThresholds,
|
thresholds: HealthCheckThresholds,
|
||||||
): { state: IpHealthState; failures: number } {
|
): { state: IpHealthState; failures: number; successes: number; node: string } {
|
||||||
if (!ok) {
|
const next = nextHealthState(ok, latencyMs, prev, {
|
||||||
const failures = (prev?.consecutive_failures ?? 0) + 1;
|
degradedFailures: thresholds.degradedFailures,
|
||||||
if (failures >= thresholds.downFailures) {
|
downFailures: thresholds.downFailures,
|
||||||
return { state: "down", failures };
|
latencyWarnMs: thresholds.latencyWarnMs,
|
||||||
}
|
successRecoveries: thresholds.successRecoveries ?? 2,
|
||||||
if (failures >= thresholds.degradedFailures) {
|
});
|
||||||
return { state: "degraded", failures };
|
return {
|
||||||
}
|
state: next.legacy,
|
||||||
return { state: "degraded", failures };
|
failures: next.failures,
|
||||||
}
|
successes: next.successes,
|
||||||
if (latencyMs > thresholds.latencyWarnMs) {
|
node: next.node,
|
||||||
return { state: "degraded", failures: 0 };
|
};
|
||||||
}
|
|
||||||
return { state: "up", failures: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunAllChecksOptions {
|
export interface RunAllChecksOptions {
|
||||||
thresholds: HealthCheckThresholds;
|
thresholds: HealthCheckThresholds;
|
||||||
/** Pause between unique physical probes (default 2000). Same IP is only probed once. */
|
/** Pause between unique physical probes (default 2000). Same IP is only probed once. */
|
||||||
probeGapMs?: number;
|
probeGapMs?: number;
|
||||||
|
/** KV mailbox with Worker results. Missing → cloudflare targets fail, never Local fallback. */
|
||||||
|
mailbox?: HealthMailbox | null;
|
||||||
|
/** Results older than this are stale (default 10 min). */
|
||||||
|
staleAfterMs?: number;
|
||||||
|
globalping?: GlobalpingClientOptions | null;
|
||||||
onStatusChange?: (
|
onStatusChange?: (
|
||||||
target: HealthCheckTarget,
|
target: HealthCheckTarget,
|
||||||
prevState: IpHealthState | null,
|
prevState: IpHealthState | null,
|
||||||
@@ -269,25 +298,115 @@ function sleep(ms: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One network hit per key. Group+binding on the same IP share a single TCP/HTTP probe
|
* One network hit per origin+provider. Group+binding on the same IP share a probe.
|
||||||
* so anti-bot / rate-limit on the origin is not tripped by back-to-back checks.
|
|
||||||
*/
|
*/
|
||||||
export function physicalProbeKey(target: HealthCheckTarget): string {
|
export function physicalProbeKey(
|
||||||
const port = target.port ?? (target.type === "http" ? 80 : 80);
|
target: HealthCheckTarget,
|
||||||
const ip = String(target.ip || "").trim().toLowerCase();
|
provider: HealthCheckProvider = target.provider,
|
||||||
if (target.type === "http") {
|
): string {
|
||||||
const path = (target.path?.trim() || "/") || "/";
|
return `${provider}|${originProbeKey(target)}`;
|
||||||
const expected = target.expected_status ?? "";
|
}
|
||||||
return `http|${ip}|${port}|${path}|${expected}`;
|
|
||||||
|
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,
|
||||||
|
target.ref_id,
|
||||||
|
target.ip,
|
||||||
|
);
|
||||||
|
const { state, failures, successes, node } = deriveState(
|
||||||
|
aggregatedOk,
|
||||||
|
latencyMs,
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
consecutive_failures: prev.consecutive_failures,
|
||||||
|
consecutive_successes: prev.consecutive_successes,
|
||||||
|
status: prev.status,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
options.thresholds,
|
||||||
|
);
|
||||||
|
const prevState: IpHealthState | null = prev
|
||||||
|
? (prev.status as IpHealthState)
|
||||||
|
: null;
|
||||||
|
repos.upsertIpHealthStatus(
|
||||||
|
db,
|
||||||
|
target.scope,
|
||||||
|
target.ref_id,
|
||||||
|
target.ip,
|
||||||
|
state,
|
||||||
|
latencyMs,
|
||||||
|
failures,
|
||||||
|
error,
|
||||||
|
successes,
|
||||||
|
{ colo, provider: statusProvider },
|
||||||
|
);
|
||||||
|
const matchedNode = repos.findNodeByIp(db, target.ip);
|
||||||
|
if (matchedNode && matchedNode.enabled) {
|
||||||
|
repos.updateNode(db, matchedNode.id, {
|
||||||
|
health_status: node,
|
||||||
|
consecutive_failures: failures,
|
||||||
|
consecutive_successes: successes,
|
||||||
|
last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
last_failure_reason: error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (target.type === "tcp") return `tcp|${ip}|${port}`;
|
if (prevState !== state) {
|
||||||
if (target.type === "ping") {
|
options.onStatusChange?.(target, prevState, state);
|
||||||
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
|
||||||
}
|
}
|
||||||
if (target.type === "dns") {
|
}
|
||||||
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
|
||||||
}
|
function staleWorkerResult(colo: string | null): ProbeResult {
|
||||||
return `${target.type}|${ip}|${port}`;
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: "Cloudflare Worker: результаты устарели или KV пуст",
|
||||||
|
colo,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runAllChecks(
|
export async function runAllChecks(
|
||||||
@@ -296,64 +415,107 @@ export async function runAllChecks(
|
|||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const targets = repos.listHealthCheckTargets(db);
|
const targets = repos.listHealthCheckTargets(db);
|
||||||
const gapMs = Math.max(0, options.probeGapMs ?? 2000);
|
const gapMs = Math.max(0, options.probeGapMs ?? 2000);
|
||||||
|
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) {
|
for (const target of targets) {
|
||||||
const key = physicalProbeKey(target);
|
const key = originProbeKey(target);
|
||||||
const list = byPhysical.get(key);
|
const list = byOrigin.get(key);
|
||||||
if (list) list.push(target);
|
if (list) list.push(target);
|
||||||
else byPhysical.set(key, [target]);
|
else byOrigin.set(key, [target]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let probeIndex = 0;
|
const needsCloudflare = targets.some((t) =>
|
||||||
for (const group of byPhysical.values()) {
|
targetProviders(t).includes("cloudflare"),
|
||||||
if (probeIndex > 0 && gapMs > 0) {
|
);
|
||||||
await sleep(gapMs);
|
let mailboxResults = new Map<string, { ok: boolean; latencyMs: number; error: string | null }>();
|
||||||
}
|
let mailboxColo: string | null = null;
|
||||||
probeIndex += 1;
|
let mailboxStale = true;
|
||||||
|
const mailbox = options.mailbox ?? null;
|
||||||
// Prefer binding hostname for SNI when several scopes share one IP.
|
if (needsCloudflare) {
|
||||||
const representative =
|
const resultsDoc = mailbox ? await mailbox.getResults() : null;
|
||||||
group.find((t) => t.scope === "binding") ?? group[0]!;
|
mailboxResults = indexResults(resultsDoc);
|
||||||
const result = await probeTarget(representative);
|
mailboxStale = !mailbox || isResultsStale(resultsDoc, staleAfterMs);
|
||||||
|
mailboxColo = resultsDoc?.colo ?? null;
|
||||||
for (const target of group) {
|
if (mailbox) {
|
||||||
const prev = repos.getIpHealthStatusRow(
|
try {
|
||||||
db,
|
const next = buildTargetsDoc(targets);
|
||||||
target.scope,
|
const current = await mailbox.getTargets();
|
||||||
target.ref_id,
|
if (current?.fingerprint !== next.fingerprint) {
|
||||||
target.ip,
|
await mailbox.putTargets(next);
|
||||||
);
|
}
|
||||||
const { state, failures } = deriveState(
|
} catch {
|
||||||
result.ok,
|
// ingest still proceeds
|
||||||
result.latencyMs,
|
|
||||||
prev
|
|
||||||
? {
|
|
||||||
consecutive_failures: prev.consecutive_failures,
|
|
||||||
status: prev.status,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
options.thresholds,
|
|
||||||
);
|
|
||||||
const prevState: IpHealthState | null = prev
|
|
||||||
? (prev.status as IpHealthState)
|
|
||||||
: null;
|
|
||||||
repos.upsertIpHealthStatus(
|
|
||||||
db,
|
|
||||||
target.scope,
|
|
||||||
target.ref_id,
|
|
||||||
target.ip,
|
|
||||||
state,
|
|
||||||
result.latencyMs,
|
|
||||||
failures,
|
|
||||||
result.error,
|
|
||||||
);
|
|
||||||
if (prevState !== state) {
|
|
||||||
options.onStatusChange?.(target, prevState, state);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Orphan rows (old IPs / hostname keys) still feed MAX latency on group badge.
|
|
||||||
|
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: mailboxColo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} 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);
|
repos.pruneStaleIpHealthStatus(db, targets);
|
||||||
return targets.length;
|
return targets.length;
|
||||||
}
|
}
|
||||||
@@ -376,6 +538,9 @@ export async function runDomainMonitors(
|
|||||||
expected_status: monitor.expected_status,
|
expected_status: monitor.expected_status,
|
||||||
timeout_ms: monitor.timeout_ms,
|
timeout_ms: monitor.timeout_ms,
|
||||||
verify_tls: false,
|
verify_tls: false,
|
||||||
|
provider: "local",
|
||||||
|
providers: ["local"],
|
||||||
|
aggregate: "majority",
|
||||||
};
|
};
|
||||||
let result: ProbeResult;
|
let result: ProbeResult;
|
||||||
if (monitor.type === "http") {
|
if (monitor.type === "http") {
|
||||||
@@ -402,6 +567,7 @@ export async function runDomainMonitors(
|
|||||||
result.latencyMs,
|
result.latencyMs,
|
||||||
{
|
{
|
||||||
consecutive_failures: result.ok ? 0 : 1,
|
consecutive_failures: result.ok ? 0 : 1,
|
||||||
|
consecutive_successes: result.ok ? 1 : 0,
|
||||||
status: prevStatus,
|
status: prevStatus,
|
||||||
},
|
},
|
||||||
thresholds,
|
thresholds,
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { Db } from "@cfdm/db";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type { CfHealthCheck, HealthCheckTarget, OriginHealthCheck } from "@cfdm/shared";
|
||||||
|
import type { CloudflareClient } from "../../lib/cf-client.js";
|
||||||
|
import type { CfHealthCheckPayload } from "../../lib/cloudflare/healthcheck-service.js";
|
||||||
|
import { AppError } from "../../errors.js";
|
||||||
|
import type { ProbeResult } from "../health-check-service.js";
|
||||||
|
import type { HealthCheckProvider } from "./provider.js";
|
||||||
|
|
||||||
|
function toPayload(
|
||||||
|
check: OriginHealthCheck,
|
||||||
|
address: string,
|
||||||
|
): CfHealthCheckPayload {
|
||||||
|
const type = (check.protocol || "TCP").toUpperCase() as "HTTP" | "HTTPS" | "TCP";
|
||||||
|
const payload: CfHealthCheckPayload = {
|
||||||
|
address,
|
||||||
|
name: check.name,
|
||||||
|
type,
|
||||||
|
interval: check.interval_sec,
|
||||||
|
timeout: check.timeout,
|
||||||
|
retries: check.retries,
|
||||||
|
consecutive_fails: check.consecutive_fails,
|
||||||
|
consecutive_successes: check.consecutive_successes,
|
||||||
|
suspended: check.suspended,
|
||||||
|
};
|
||||||
|
if (type === "HTTP" || type === "HTTPS") {
|
||||||
|
payload.http_config = {
|
||||||
|
method: check.method ?? "GET",
|
||||||
|
path: check.path ?? "/",
|
||||||
|
expected_codes: check.expected_status != null ? [String(check.expected_status)] : ["200"],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
payload.tcp_config = { method: "connection_established" };
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CloudflareHealthCheckProvider implements HealthCheckProvider {
|
||||||
|
readonly kind = "cloudflare" as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly db: Db,
|
||||||
|
private readonly cf: CloudflareClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async probe(target: HealthCheckTarget): Promise<ProbeResult> {
|
||||||
|
const node = repos.findNodeByIp(this.db, target.ip);
|
||||||
|
if (!node?.health_check_id) {
|
||||||
|
return { ok: false, latencyMs: 0, error: "нет Cloudflare Health Check" };
|
||||||
|
}
|
||||||
|
const check = repos.getHealthCheck(this.db, node.health_check_id);
|
||||||
|
if (!check.cf_zone_id || !check.cf_healthcheck_id) {
|
||||||
|
return { ok: false, latencyMs: 0, error: "Cloudflare Health Check не синхронизирован" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const remote = await this.cf.getHealthCheck(check.cf_zone_id, check.cf_healthcheck_id);
|
||||||
|
const status = (remote.status ?? "").toLowerCase();
|
||||||
|
const ok = status === "healthy" || status === "ok";
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: ok ? null : remote.status ?? "unhealthy",
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncCreate(
|
||||||
|
check: OriginHealthCheck,
|
||||||
|
zoneId: string,
|
||||||
|
address: string,
|
||||||
|
): Promise<CfHealthCheck> {
|
||||||
|
try {
|
||||||
|
const remote = await this.cf.createHealthCheck(zoneId, toPayload(check, address));
|
||||||
|
repos.updateHealthCheck(this.db, check.id, {
|
||||||
|
cf_healthcheck_id: remote.id,
|
||||||
|
cf_zone_id: zoneId,
|
||||||
|
provider: "cloudflare",
|
||||||
|
});
|
||||||
|
return remote;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AppError) throw err;
|
||||||
|
throw AppError.healthcheckCreateFailed(
|
||||||
|
err instanceof Error ? err.message : "не удалось создать Cloudflare Health Check",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncUpdate(
|
||||||
|
check: OriginHealthCheck,
|
||||||
|
address: string,
|
||||||
|
): Promise<CfHealthCheck> {
|
||||||
|
if (!check.cf_zone_id || !check.cf_healthcheck_id) {
|
||||||
|
throw AppError.healthcheckCreateFailed("Cloudflare Health Check не привязан к зоне");
|
||||||
|
}
|
||||||
|
return this.cf.updateHealthCheck(
|
||||||
|
check.cf_zone_id,
|
||||||
|
check.cf_healthcheck_id,
|
||||||
|
toPayload(check, address),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncDelete(check: OriginHealthCheck): Promise<void> {
|
||||||
|
if (!check.cf_zone_id || !check.cf_healthcheck_id) return;
|
||||||
|
await this.cf.deleteHealthCheck(check.cf_zone_id, check.cf_healthcheck_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export function loadHealthProbeWorkerSource(): string {
|
||||||
|
const dir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const candidates = [
|
||||||
|
join(dir, "health-probe-worker.mjs"),
|
||||||
|
join(process.cwd(), "dist/health-probe-worker.mjs"),
|
||||||
|
join(process.cwd(), "health-probe-worker.mjs"),
|
||||||
|
join(dir, "../../../../../workers/health-probe/src/index.mjs"),
|
||||||
|
join(process.cwd(), "../../workers/health-probe/src/index.mjs"),
|
||||||
|
join(process.cwd(), "workers/health-probe/src/index.mjs"),
|
||||||
|
];
|
||||||
|
for (const path of candidates) {
|
||||||
|
if (existsSync(path)) {
|
||||||
|
return readFileSync(path, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("не найден исходник Worker health-probe");
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { getAppSettings, repos, updateAppSettings, type HealthEngineFallbacks } from "@cfdm/db";
|
||||||
|
import type { Db } from "@cfdm/db";
|
||||||
|
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";
|
||||||
|
import {
|
||||||
|
buildTargetsDoc,
|
||||||
|
createCloudflareKvMailbox,
|
||||||
|
toCloudflareCron,
|
||||||
|
type HealthMailbox,
|
||||||
|
} from "./mailbox.js";
|
||||||
|
|
||||||
|
export const DEFAULT_HEALTH_FALLBACKS: HealthEngineFallbacks = {
|
||||||
|
healthCheckCron: "0 */2 * * * *",
|
||||||
|
healthDegradedFailures: 1,
|
||||||
|
healthDownFailures: 2,
|
||||||
|
healthLatencyWarnMs: 1000,
|
||||||
|
healthSuccessRecoveries: 2,
|
||||||
|
healthWorkerUrl: "",
|
||||||
|
healthWorkerTokenSet: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveAccountId(
|
||||||
|
cf: CloudflareClient,
|
||||||
|
db: Db,
|
||||||
|
cached?: string | null,
|
||||||
|
): Promise<string> {
|
||||||
|
const trimmed = cached?.trim();
|
||||||
|
if (trimmed) return trimmed;
|
||||||
|
const domains = repos.listDomains(db);
|
||||||
|
for (const domain of domains) {
|
||||||
|
if (!domain.cf_zone_id) continue;
|
||||||
|
try {
|
||||||
|
const zone = await cf.getZone(domain.cf_zone_id);
|
||||||
|
const id = zone.account?.id?.trim();
|
||||||
|
if (id) return id;
|
||||||
|
} catch {
|
||||||
|
// try next zone / accounts list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const accounts = await cf.listAccounts();
|
||||||
|
const id = accounts[0]?.id?.trim();
|
||||||
|
if (!id) {
|
||||||
|
throw AppError.cloudflare(
|
||||||
|
"Не удалось определить Cloudflare account_id. Добавьте зону или расширьте права токена (Account Settings Read).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureKvNamespace(
|
||||||
|
cf: CloudflareClient,
|
||||||
|
accountId: string,
|
||||||
|
existingId?: string | null,
|
||||||
|
): Promise<string> {
|
||||||
|
if (existingId?.trim()) return existingId.trim();
|
||||||
|
const listed = await cf.listKvNamespaces(accountId);
|
||||||
|
const found = listed.find((ns) => ns.title === HEALTH_PROBE_KV_TITLE);
|
||||||
|
if (found?.id) return found.id;
|
||||||
|
const created = await cf.createKvNamespace(accountId, HEALTH_PROBE_KV_TITLE);
|
||||||
|
if (!created.id) {
|
||||||
|
throw AppError.cloudflare("Cloudflare не вернул id KV namespace");
|
||||||
|
}
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureHealthWorker(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
fallbacks: HealthEngineFallbacks,
|
||||||
|
): Promise<{ url: string; kvNamespaceId: string; accountId: string }> {
|
||||||
|
const settings = getAppSettings(db, fallbacks);
|
||||||
|
try {
|
||||||
|
const accountId = await resolveAccountId(cf, db, settings.healthWorkerAccountId);
|
||||||
|
const kvNamespaceId = await ensureKvNamespace(
|
||||||
|
cf,
|
||||||
|
accountId,
|
||||||
|
settings.healthWorkerKvNamespaceId,
|
||||||
|
);
|
||||||
|
const source = loadHealthProbeWorkerSource();
|
||||||
|
await cf.putWorkerScript({
|
||||||
|
accountId,
|
||||||
|
scriptName: HEALTH_PROBE_SCRIPT_NAME,
|
||||||
|
source,
|
||||||
|
kvNamespaceId,
|
||||||
|
});
|
||||||
|
await cf.putWorkerSchedules(accountId, HEALTH_PROBE_SCRIPT_NAME, [
|
||||||
|
toCloudflareCron(settings.healthCheckCron),
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
await cf.enableWorkersDev(accountId, HEALTH_PROBE_SCRIPT_NAME);
|
||||||
|
} catch {
|
||||||
|
// workers.dev may already be on
|
||||||
|
}
|
||||||
|
const subdomain = await cf.getWorkersSubdomain(accountId);
|
||||||
|
const url = subdomain
|
||||||
|
? `https://${HEALTH_PROBE_SCRIPT_NAME}.${subdomain}.workers.dev`
|
||||||
|
: settings.healthWorkerUrl || `https://${HEALTH_PROBE_SCRIPT_NAME}.workers.dev`;
|
||||||
|
|
||||||
|
updateAppSettings(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
healthWorkerAccountId: accountId,
|
||||||
|
healthWorkerKvNamespaceId: kvNamespaceId,
|
||||||
|
healthWorkerUrl: url,
|
||||||
|
healthWorkerError: null,
|
||||||
|
healthWorkerDeployedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
fallbacks,
|
||||||
|
);
|
||||||
|
|
||||||
|
await syncCloudflareTargetsToKv(db, cf, fallbacks);
|
||||||
|
return { url, kvNamespaceId, accountId };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
updateAppSettings(db, { healthWorkerError: message }, fallbacks);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function maybeEnsureHealthWorker(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
fallbacks: HealthEngineFallbacks,
|
||||||
|
): Promise<void> {
|
||||||
|
const hasCloudflare = repos
|
||||||
|
.listHealthCheckTargets(db)
|
||||||
|
.some((target) => targetHasProvider(target, "cloudflare"));
|
||||||
|
if (!hasCloudflare) return;
|
||||||
|
const settings = getAppSettings(db, fallbacks);
|
||||||
|
if (settings.healthWorkerKvNamespaceId.trim() && !settings.healthWorkerError) {
|
||||||
|
await syncCloudflareTargetsToKv(db, cf, fallbacks);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ensureHealthWorker(db, cf, fallbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mailboxFromSettings(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
fallbacks: HealthEngineFallbacks,
|
||||||
|
): HealthMailbox | null {
|
||||||
|
const settings = getAppSettings(db, fallbacks);
|
||||||
|
const accountId = settings.healthWorkerAccountId.trim();
|
||||||
|
const ns = settings.healthWorkerKvNamespaceId.trim();
|
||||||
|
if (!accountId || !ns) return null;
|
||||||
|
return createCloudflareKvMailbox(cf, accountId, ns);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncCloudflareTargetsToKv(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
fallbacks: HealthEngineFallbacks,
|
||||||
|
mailbox?: HealthMailbox | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const box = mailbox ?? mailboxFromSettings(db, cf, fallbacks);
|
||||||
|
if (!box) return;
|
||||||
|
const next = buildTargetsDoc(repos.listHealthCheckTargets(db));
|
||||||
|
const current = await box.getTargets();
|
||||||
|
if (current?.fingerprint === next.fingerprint) return;
|
||||||
|
await box.putTargets(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fireEnsureHealthWorker(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
fallbacks: HealthEngineFallbacks,
|
||||||
|
log?: { warn: (obj: unknown, msg: string) => void },
|
||||||
|
): void {
|
||||||
|
if (process.env.VITEST) return;
|
||||||
|
if (!cf.isConfigured) return;
|
||||||
|
const hasCloudflare = repos
|
||||||
|
.listHealthCheckTargets(db)
|
||||||
|
.some((target) => targetHasProvider(target, "cloudflare"));
|
||||||
|
if (!hasCloudflare) {
|
||||||
|
void syncCloudflareTargetsToKv(db, cf, fallbacks).catch((err) => {
|
||||||
|
log?.warn({ err }, "health worker KV sync failed");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void maybeEnsureHealthWorker(db, cf, fallbacks).catch((err) => {
|
||||||
|
log?.warn({ err }, "health worker ensure failed");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||||
|
import { probeTarget, type ProbeResult } from "../health-check-service.js";
|
||||||
|
import type { HealthCheckProvider } from "./provider.js";
|
||||||
|
|
||||||
|
export class LocalHealthCheckProvider implements HealthCheckProvider {
|
||||||
|
readonly kind = "local" as const;
|
||||||
|
|
||||||
|
probe(target: HealthCheckTarget): Promise<ProbeResult> {
|
||||||
|
return probeTarget(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import type {
|
||||||
|
HealthCheckTarget,
|
||||||
|
HealthProbeResultItem,
|
||||||
|
HealthProbeResultsDoc,
|
||||||
|
HealthProbeTargetItem,
|
||||||
|
HealthProbeTargetsDoc,
|
||||||
|
} 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 {
|
||||||
|
getTargets(): Promise<HealthProbeTargetsDoc | null>;
|
||||||
|
putTargets(doc: HealthProbeTargetsDoc): Promise<void>;
|
||||||
|
getResults(): Promise<HealthProbeResultsDoc | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCloudflareKvMailbox(
|
||||||
|
cf: CloudflareClient,
|
||||||
|
accountId: string,
|
||||||
|
namespaceId: string,
|
||||||
|
): HealthMailbox {
|
||||||
|
return {
|
||||||
|
async getTargets() {
|
||||||
|
return readJson<HealthProbeTargetsDoc>(cf, accountId, namespaceId, HEALTH_KV_TARGETS_KEY);
|
||||||
|
},
|
||||||
|
async putTargets(doc) {
|
||||||
|
await cf.kvPut(accountId, namespaceId, HEALTH_KV_TARGETS_KEY, JSON.stringify(doc));
|
||||||
|
},
|
||||||
|
async getResults() {
|
||||||
|
return readJson<HealthProbeResultsDoc>(cf, accountId, namespaceId, HEALTH_KV_RESULTS_KEY);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson<T>(
|
||||||
|
cf: CloudflareClient,
|
||||||
|
accountId: string,
|
||||||
|
namespaceId: string,
|
||||||
|
key: string,
|
||||||
|
): Promise<T | null> {
|
||||||
|
const raw = await cf.kvGet(accountId, namespaceId, key);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function originProbeKey(target: HealthCheckTarget): string {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
if (target.type === "tcp") return `tcp|${ip}|${port}`;
|
||||||
|
if (target.type === "ping") {
|
||||||
|
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
if (target.type === "dns") {
|
||||||
|
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
return `${target.type}|${ip}|${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloudflareMailboxTargets(
|
||||||
|
targets: HealthCheckTarget[],
|
||||||
|
): HealthProbeTargetItem[] {
|
||||||
|
const unique = new Map<string, HealthProbeTargetItem>();
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!targetHasProvider(target, "cloudflare")) continue;
|
||||||
|
if (target.type !== "tcp" && target.type !== "http") continue;
|
||||||
|
const key = originProbeKey(target);
|
||||||
|
if (unique.has(key)) continue;
|
||||||
|
unique.set(key, {
|
||||||
|
key,
|
||||||
|
ip: target.ip,
|
||||||
|
hostname: target.hostname || target.ip,
|
||||||
|
type: target.type,
|
||||||
|
port: target.port ?? (target.type === "http" ? 80 : 80),
|
||||||
|
path: target.path ?? "/",
|
||||||
|
expectedStatus: target.expected_status,
|
||||||
|
timeoutMs: target.timeout_ms ?? 3000,
|
||||||
|
verifyTls: Boolean(target.verify_tls),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...unique.values()].sort((a, b) => a.key.localeCompare(b.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fingerprintTargets(items: HealthProbeTargetItem[]): string {
|
||||||
|
return items
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
`${item.key}|${item.hostname}|${item.timeoutMs ?? ""}|${item.verifyTls ? "1" : "0"}`,
|
||||||
|
)
|
||||||
|
.join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTargetsDoc(targets: HealthCheckTarget[]): HealthProbeTargetsDoc {
|
||||||
|
const items = cloudflareMailboxTargets(targets);
|
||||||
|
return {
|
||||||
|
fingerprint: fingerprintTargets(items),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function indexResults(
|
||||||
|
doc: HealthProbeResultsDoc | null,
|
||||||
|
): Map<string, HealthProbeResultItem> {
|
||||||
|
const map = new Map<string, HealthProbeResultItem>();
|
||||||
|
if (!doc?.items) return map;
|
||||||
|
for (const item of doc.items) {
|
||||||
|
map.set(item.key, item);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isResultsStale(doc: HealthProbeResultsDoc | null, staleAfterMs: number): boolean {
|
||||||
|
if (!doc?.probedAt) return true;
|
||||||
|
const ts = Date.parse(doc.probedAt);
|
||||||
|
if (!Number.isFinite(ts)) return true;
|
||||||
|
return Date.now() - ts > staleAfterMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop seconds from toad 6-field cron for Cloudflare Workers (5-field). */
|
||||||
|
export function toCloudflareCron(expr: string): string {
|
||||||
|
const parts = expr.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 6) return parts.slice(1).join(" ");
|
||||||
|
if (parts.length === 5) return parts.join(" ");
|
||||||
|
throw new Error("некорректное cron-выражение");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cronStaleAfterMs(expr: string): number {
|
||||||
|
const cf = toCloudflareCron(expr);
|
||||||
|
const minute = cf.split(/\s+/)[0] ?? "*";
|
||||||
|
if (minute.startsWith("*/")) {
|
||||||
|
const n = Number(minute.slice(2));
|
||||||
|
if (Number.isFinite(n) && n > 0) return Math.max(n * 2, 5) * 60_000;
|
||||||
|
}
|
||||||
|
if (minute === "*") return 10 * 60_000;
|
||||||
|
return 10 * 60_000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||||
|
import type { ProbeResult } from "../health-check-service.js";
|
||||||
|
|
||||||
|
export interface HealthCheckProvider {
|
||||||
|
readonly kind: "local" | "cloudflare";
|
||||||
|
probe(target: HealthCheckTarget): Promise<ProbeResult>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { IpHealthState, NodeHealthState } from "@cfdm/shared";
|
||||||
|
|
||||||
|
export interface HealthThresholds {
|
||||||
|
degradedFailures: number;
|
||||||
|
downFailures: number;
|
||||||
|
successRecoveries: number;
|
||||||
|
latencyWarnMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthCounters {
|
||||||
|
status: string;
|
||||||
|
consecutive_failures: number;
|
||||||
|
consecutive_successes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NextHealth {
|
||||||
|
legacy: IpHealthState;
|
||||||
|
node: NodeHealthState;
|
||||||
|
failures: number;
|
||||||
|
successes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wasHealthy(status: string | undefined): boolean {
|
||||||
|
return status === "up" || status === "healthy";
|
||||||
|
}
|
||||||
|
|
||||||
|
function wasUnhealthy(status: string | undefined): boolean {
|
||||||
|
return (
|
||||||
|
status === "down" ||
|
||||||
|
status === "unhealthy" ||
|
||||||
|
status === "checking" ||
|
||||||
|
status === "degraded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextHealthState(
|
||||||
|
ok: boolean,
|
||||||
|
latencyMs: number,
|
||||||
|
prev: HealthCounters | null,
|
||||||
|
thresholds: HealthThresholds,
|
||||||
|
): NextHealth {
|
||||||
|
if (!ok) {
|
||||||
|
const failures = (prev?.consecutive_failures ?? 0) + 1;
|
||||||
|
if (failures >= thresholds.downFailures) {
|
||||||
|
return { legacy: "down", node: "unhealthy", failures, successes: 0 };
|
||||||
|
}
|
||||||
|
return { legacy: "degraded", node: "degraded", failures, successes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latencyMs > thresholds.latencyWarnMs) {
|
||||||
|
return { legacy: "degraded", node: "degraded", failures: 0, successes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prev || wasHealthy(prev.status) || !wasUnhealthy(prev.status)) {
|
||||||
|
return {
|
||||||
|
legacy: "up",
|
||||||
|
node: "healthy",
|
||||||
|
failures: 0,
|
||||||
|
successes: (prev?.consecutive_successes ?? 0) + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const successes = (prev.consecutive_successes ?? 0) + 1;
|
||||||
|
if (successes >= thresholds.successRecoveries) {
|
||||||
|
return { legacy: "up", node: "healthy", failures: 0, successes };
|
||||||
|
}
|
||||||
|
return { legacy: "unknown", node: "checking", failures: 0, successes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toLegacyHealth(status: NodeHealthState | IpHealthState): IpHealthState {
|
||||||
|
if (status === "healthy" || status === "up") return "up";
|
||||||
|
if (status === "unhealthy" || status === "down") return "down";
|
||||||
|
if (status === "degraded") return "degraded";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export function workerNotConfiguredResult(): {
|
||||||
|
ok: false;
|
||||||
|
latencyMs: number;
|
||||||
|
error: string;
|
||||||
|
colo: null;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: "Cloudflare Worker не настроен (нет KV mailbox)",
|
||||||
|
colo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import type { Db } from "@cfdm/db";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type {
|
||||||
|
CreateServiceNodeInput,
|
||||||
|
ServiceNode,
|
||||||
|
ServiceOverview,
|
||||||
|
UpdateServiceNodeInput,
|
||||||
|
} from "@cfdm/shared";
|
||||||
|
import { AppError } from "../errors.js";
|
||||||
|
import { isValidIpv4 } from "../lib/validators.js";
|
||||||
|
import { getView } from "./service-config-service.js";
|
||||||
|
import { selectActiveIpsByMode } from "./routing/index.js";
|
||||||
|
|
||||||
|
function assertAddress(address: string): void {
|
||||||
|
if (!isValidIpv4(address)) {
|
||||||
|
throw AppError.invalidIp(`Некорректный IP-адрес: ${address}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listNodes(db: Db, serviceId: number): ServiceNode[] {
|
||||||
|
repos.getService(db, serviceId);
|
||||||
|
return repos.listNodes(db, serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNode(
|
||||||
|
db: Db,
|
||||||
|
serviceId: number,
|
||||||
|
input: CreateServiceNodeInput,
|
||||||
|
): ServiceNode {
|
||||||
|
repos.getService(db, serviceId);
|
||||||
|
assertAddress(input.address);
|
||||||
|
try {
|
||||||
|
return repos.createNode(db, serviceId, {
|
||||||
|
address: input.address,
|
||||||
|
protocol: input.protocol,
|
||||||
|
port: input.port,
|
||||||
|
enabled: input.enabled,
|
||||||
|
priority: input.priority,
|
||||||
|
weight: input.weight,
|
||||||
|
health_check_id: input.health_check_id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === "ConflictError") {
|
||||||
|
throw AppError.conflict(err.message);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateNode(
|
||||||
|
db: Db,
|
||||||
|
serviceId: number,
|
||||||
|
nodeId: number,
|
||||||
|
patch: UpdateServiceNodeInput,
|
||||||
|
): ServiceNode {
|
||||||
|
const node = repos.getNode(db, nodeId);
|
||||||
|
if (node.service_id !== serviceId) {
|
||||||
|
throw AppError.notFound(`node ${nodeId}`);
|
||||||
|
}
|
||||||
|
if (patch.address) assertAddress(patch.address);
|
||||||
|
return repos.updateNode(db, nodeId, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteNode(db: Db, serviceId: number, nodeId: number): void {
|
||||||
|
const node = repos.getNode(db, nodeId);
|
||||||
|
if (node.service_id !== serviceId) {
|
||||||
|
throw AppError.notFound(`node ${nodeId}`);
|
||||||
|
}
|
||||||
|
repos.deleteNode(db, nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOverview(
|
||||||
|
db: Db,
|
||||||
|
serviceId: number,
|
||||||
|
): Promise<ServiceOverview> {
|
||||||
|
const service = await getView(db, serviceId);
|
||||||
|
const nodes = repos.listNodes(db, serviceId);
|
||||||
|
const bindings = repos.listBindingsByService(db, serviceId);
|
||||||
|
const first = bindings[0];
|
||||||
|
const routing = first?.routing_strategy ?? first?.lb_mode ?? "round_robin";
|
||||||
|
const healthCheck =
|
||||||
|
nodes
|
||||||
|
.map((n) => n.health_check_id)
|
||||||
|
.find((id): id is number => id != null) != null
|
||||||
|
? repos.getHealthCheck(
|
||||||
|
db,
|
||||||
|
nodes.find((n) => n.health_check_id != null)!.health_check_id!,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const active = new Set<string>();
|
||||||
|
for (const binding of bindings) {
|
||||||
|
const metas = repos.listBindingIpsWithMeta(db, binding.id);
|
||||||
|
const rows = metas.map((entry) => {
|
||||||
|
const status = repos.getIpHealthStatusRow(db, "binding", binding.id, entry.ip);
|
||||||
|
return {
|
||||||
|
ip: entry.ip,
|
||||||
|
weight: entry.weight,
|
||||||
|
priority: entry.priority,
|
||||||
|
health: status ? status.status : ("unknown" as const),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
for (const ip of selectActiveIpsByMode(
|
||||||
|
{
|
||||||
|
lb_mode: binding.lb_mode,
|
||||||
|
health_check_enabled: binding.health_check_enabled,
|
||||||
|
},
|
||||||
|
rows,
|
||||||
|
)) {
|
||||||
|
active.add(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
nodes,
|
||||||
|
health_check: healthCheck,
|
||||||
|
routing_strategy: routing,
|
||||||
|
active_addresses: [...active],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function opsSummary(db: Db) {
|
||||||
|
const allNodes = repos.listAllNodes(db);
|
||||||
|
const services = repos.listServices(db);
|
||||||
|
const domains = repos.listDomains(db);
|
||||||
|
const healthy = allNodes.filter(
|
||||||
|
(n) => n.health_status === "healthy" || n.health_status === "up",
|
||||||
|
).length;
|
||||||
|
const unhealthy = allNodes.filter(
|
||||||
|
(n) => n.health_status === "unhealthy" || n.health_status === "down",
|
||||||
|
).length;
|
||||||
|
const failoverActive = repos.listAllBindings(db).filter((binding) => {
|
||||||
|
if (binding.lb_mode !== "failover" || !binding.health_check_enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return binding.target_ips.some((ip) => {
|
||||||
|
const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip);
|
||||||
|
return row?.status === "down";
|
||||||
|
});
|
||||||
|
}).length;
|
||||||
|
return {
|
||||||
|
domains: domains.length,
|
||||||
|
services: services.length,
|
||||||
|
nodes: allNodes.length,
|
||||||
|
healthy,
|
||||||
|
unhealthy,
|
||||||
|
active_failovers: failoverActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { Db } from "@cfdm/db";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type {
|
||||||
|
CreateOriginHealthCheckInput,
|
||||||
|
OriginHealthCheck,
|
||||||
|
} from "@cfdm/shared";
|
||||||
|
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||||
|
import { AppError } from "../errors.js";
|
||||||
|
import { CloudflareHealthCheckProvider } from "./health/cloudflare.js";
|
||||||
|
|
||||||
|
export function listOriginHealthChecks(db: Db): OriginHealthCheck[] {
|
||||||
|
return repos.listHealthChecks(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOriginHealthCheck(db: Db, id: number): OriginHealthCheck {
|
||||||
|
return repos.getHealthCheck(db, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOriginHealthCheck(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
input: CreateOriginHealthCheckInput,
|
||||||
|
): Promise<OriginHealthCheck> {
|
||||||
|
const protocol = (input.protocol ?? "tcp").toLowerCase();
|
||||||
|
const check = repos.createHealthCheck(db, {
|
||||||
|
provider: input.provider,
|
||||||
|
name: input.name,
|
||||||
|
cf_zone_id: input.cf_zone_id ?? null,
|
||||||
|
protocol,
|
||||||
|
path: input.path,
|
||||||
|
method: input.method,
|
||||||
|
timeout: input.timeout,
|
||||||
|
interval_sec: input.interval_sec,
|
||||||
|
retries: input.retries,
|
||||||
|
expected_status: input.expected_status,
|
||||||
|
consecutive_fails: input.consecutive_fails,
|
||||||
|
consecutive_successes: input.consecutive_successes,
|
||||||
|
suspended: input.suspended,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.node_id) {
|
||||||
|
const node = repos.getNode(db, input.node_id);
|
||||||
|
repos.updateNode(db, node.id, { health_check_id: check.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.provider === "cloudflare") {
|
||||||
|
const zoneId = input.cf_zone_id;
|
||||||
|
if (!zoneId) {
|
||||||
|
throw AppError.zoneNotFound("укажите зону Cloudflare для Health Check");
|
||||||
|
}
|
||||||
|
const address = input.node_id
|
||||||
|
? repos.getNode(db, input.node_id).address
|
||||||
|
: check.name;
|
||||||
|
const provider = new CloudflareHealthCheckProvider(db, cf);
|
||||||
|
await provider.syncCreate(check, zoneId, address);
|
||||||
|
return repos.getHealthCheck(db, check.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return check;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOriginHealthCheck(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
id: number,
|
||||||
|
patch: Partial<CreateOriginHealthCheckInput>,
|
||||||
|
): Promise<OriginHealthCheck> {
|
||||||
|
const current = repos.getHealthCheck(db, id);
|
||||||
|
const updated = repos.updateHealthCheck(db, id, {
|
||||||
|
provider: patch.provider,
|
||||||
|
name: patch.name,
|
||||||
|
cf_zone_id: patch.cf_zone_id,
|
||||||
|
protocol: patch.protocol?.toLowerCase(),
|
||||||
|
path: patch.path,
|
||||||
|
method: patch.method,
|
||||||
|
timeout: patch.timeout,
|
||||||
|
interval_sec: patch.interval_sec,
|
||||||
|
retries: patch.retries,
|
||||||
|
expected_status: patch.expected_status,
|
||||||
|
consecutive_fails: patch.consecutive_fails,
|
||||||
|
consecutive_successes: patch.consecutive_successes,
|
||||||
|
suspended: patch.suspended,
|
||||||
|
});
|
||||||
|
if (updated.provider === "cloudflare" && updated.cf_healthcheck_id) {
|
||||||
|
const address = repos.findNodeByIp(db, updated.name)?.address ?? updated.name;
|
||||||
|
const provider = new CloudflareHealthCheckProvider(db, cf);
|
||||||
|
await provider.syncUpdate(updated, address);
|
||||||
|
}
|
||||||
|
void current;
|
||||||
|
return repos.getHealthCheck(db, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOriginHealthCheck(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
id: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const check = repos.getHealthCheck(db, id);
|
||||||
|
if (check.provider === "cloudflare") {
|
||||||
|
const provider = new CloudflareHealthCheckProvider(db, cf);
|
||||||
|
await provider.syncDelete(check);
|
||||||
|
}
|
||||||
|
repos.deleteHealthCheck(db, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncOriginHealthCheck(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
id: number,
|
||||||
|
): Promise<OriginHealthCheck> {
|
||||||
|
const check = repos.getHealthCheck(db, id);
|
||||||
|
if (check.provider !== "cloudflare") {
|
||||||
|
throw AppError.validation("синхронизация доступна только для Cloudflare Health Checks");
|
||||||
|
}
|
||||||
|
if (!check.cf_zone_id) {
|
||||||
|
throw AppError.zoneNotFound();
|
||||||
|
}
|
||||||
|
const provider = new CloudflareHealthCheckProvider(db, cf);
|
||||||
|
const address = repos.findNodeByIp(db, check.name)?.address ?? check.name;
|
||||||
|
if (check.cf_healthcheck_id) {
|
||||||
|
await provider.syncUpdate(check, address);
|
||||||
|
} else {
|
||||||
|
await provider.syncCreate(check, check.cf_zone_id, address);
|
||||||
|
}
|
||||||
|
return repos.getHealthCheck(db, id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const locks = new Map<number, Promise<void>>();
|
||||||
|
|
||||||
|
export async function withBindingLock<T>(
|
||||||
|
bindingId: number,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const previous = locks.get(bindingId) ?? Promise.resolve();
|
||||||
|
let release!: () => void;
|
||||||
|
const current = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
locks.set(
|
||||||
|
bindingId,
|
||||||
|
previous.then(() => current).catch(() => current),
|
||||||
|
);
|
||||||
|
await previous.catch(() => undefined);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
if (locks.get(bindingId) === current) {
|
||||||
|
locks.delete(bindingId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { LbIpRow } from "./types.js";
|
||||||
|
import { isHealthy } from "./health.js";
|
||||||
|
|
||||||
|
export function failoverDesired(rows: LbIpRow[]): string[] {
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
const healthy = rows.filter((r) => isHealthy(r.health));
|
||||||
|
const pool = healthy.length > 0 ? healthy : rows;
|
||||||
|
const sorted = [...pool].sort(
|
||||||
|
(a, b) => a.priority - b.priority || a.weight - b.weight,
|
||||||
|
);
|
||||||
|
const minPriority = sorted[0]!.priority;
|
||||||
|
const primaries = sorted.filter((r) => r.priority === minPriority);
|
||||||
|
if (healthy.length > 0) {
|
||||||
|
return primaries.map((r) => r.ip);
|
||||||
|
}
|
||||||
|
return [sorted[0]!.ip];
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { IpHealthState, NodeHealthState } from "@cfdm/shared";
|
||||||
|
|
||||||
|
export function isHealthy(state: IpHealthState | NodeHealthState | string): boolean {
|
||||||
|
return state === "up" || state === "healthy";
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { LbMode } from "@cfdm/shared";
|
||||||
|
import { failoverDesired } from "./failover.js";
|
||||||
|
import { roundRobinDesired } from "./round-robin.js";
|
||||||
|
import type { LbIpRow, LbTargetConfig } from "./types.js";
|
||||||
|
|
||||||
|
export type { LbIpRow, LbTargetConfig } from "./types.js";
|
||||||
|
export { isHealthy } from "./health.js";
|
||||||
|
export { withBindingLock } from "./binding-lock.js";
|
||||||
|
|
||||||
|
export function selectActiveIpsByMode(
|
||||||
|
config: LbTargetConfig,
|
||||||
|
rows: LbIpRow[],
|
||||||
|
): string[] {
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
if (config.lb_mode === "failover") {
|
||||||
|
return failoverDesired(rows);
|
||||||
|
}
|
||||||
|
// weighted = round_robin on DNS (one A per IP)
|
||||||
|
return roundRobinDesired(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strategyLabel(mode: LbMode): string {
|
||||||
|
if (mode === "failover") return "Failover";
|
||||||
|
if (mode === "weighted") return "Round Robin (weighted alias)";
|
||||||
|
return "Round Robin";
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { LbIpRow } from "./types.js";
|
||||||
|
import { isHealthy } from "./health.js";
|
||||||
|
|
||||||
|
export function roundRobinDesired(rows: LbIpRow[]): string[] {
|
||||||
|
const healthy = rows.filter((r) => isHealthy(r.health));
|
||||||
|
const pool = healthy.length > 0 ? healthy : rows;
|
||||||
|
return pool.map((r) => r.ip);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { IpHealthState, LbMode } from "@cfdm/shared";
|
||||||
|
|
||||||
|
export interface LbTargetConfig {
|
||||||
|
lb_mode: LbMode;
|
||||||
|
health_check_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LbIpRow {
|
||||||
|
ip: string;
|
||||||
|
weight: number;
|
||||||
|
priority: number;
|
||||||
|
health: IpHealthState;
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import type { Db } from "@cfdm/db";
|
|||||||
import { repos } from "@cfdm/db";
|
import { repos } from "@cfdm/db";
|
||||||
import type {
|
import type {
|
||||||
DnsRecord,
|
DnsRecord,
|
||||||
|
HealthCheckAggregate,
|
||||||
|
HealthCheckProvider,
|
||||||
HealthCheckScope,
|
HealthCheckScope,
|
||||||
HealthCheckType,
|
HealthCheckType,
|
||||||
IpHealthState,
|
IpHealthState,
|
||||||
@@ -24,6 +26,16 @@ import { isValidIpv4 } from "../lib/validators.js";
|
|||||||
import * as dnsService from "./dns-service.js";
|
import * as dnsService from "./dns-service.js";
|
||||||
import * as domainService from "./domain-service.js";
|
import * as domainService from "./domain-service.js";
|
||||||
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
||||||
|
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
|
||||||
|
import {
|
||||||
|
selectActiveIpsByMode,
|
||||||
|
withBindingLock,
|
||||||
|
type LbIpRow,
|
||||||
|
type LbTargetConfig,
|
||||||
|
} from "./routing/index.js";
|
||||||
|
|
||||||
|
export type { LbIpRow, LbTargetConfig };
|
||||||
|
export { selectActiveIpsByMode };
|
||||||
|
|
||||||
export interface ServiceDomainInput {
|
export interface ServiceDomainInput {
|
||||||
fqdn: string;
|
fqdn: string;
|
||||||
@@ -41,6 +53,9 @@ export interface ServiceDomainInput {
|
|||||||
health_check_interval_sec?: number;
|
health_check_interval_sec?: number;
|
||||||
health_check_timeout_ms?: number;
|
health_check_timeout_ms?: number;
|
||||||
health_check_verify_tls?: boolean;
|
health_check_verify_tls?: boolean;
|
||||||
|
health_check_provider?: HealthCheckProvider;
|
||||||
|
health_check_providers?: HealthCheckProvider[];
|
||||||
|
health_check_aggregate?: HealthCheckAggregate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToggleRequest {
|
export interface ToggleRequest {
|
||||||
@@ -61,6 +76,9 @@ export interface ServiceGroupBody {
|
|||||||
health_check_interval_sec?: number;
|
health_check_interval_sec?: number;
|
||||||
health_check_timeout_ms?: number;
|
health_check_timeout_ms?: number;
|
||||||
health_check_verify_tls?: boolean;
|
health_check_verify_tls?: boolean;
|
||||||
|
health_check_provider?: HealthCheckProvider;
|
||||||
|
health_check_providers?: HealthCheckProvider[];
|
||||||
|
health_check_aggregate?: HealthCheckAggregate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateServiceGroupBody {
|
export interface UpdateServiceGroupBody {
|
||||||
@@ -77,6 +95,9 @@ export interface UpdateServiceGroupBody {
|
|||||||
health_check_interval_sec?: number;
|
health_check_interval_sec?: number;
|
||||||
health_check_timeout_ms?: number;
|
health_check_timeout_ms?: number;
|
||||||
health_check_verify_tls?: boolean;
|
health_check_verify_tls?: boolean;
|
||||||
|
health_check_provider?: HealthCheckProvider;
|
||||||
|
health_check_providers?: HealthCheckProvider[];
|
||||||
|
health_check_aggregate?: HealthCheckAggregate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateServiceConfigRequest {
|
export interface UpdateServiceConfigRequest {
|
||||||
@@ -137,54 +158,6 @@ function aggregateSyncStatus(statuses: string[]): string | null {
|
|||||||
return statuses[0] ?? null;
|
return statuses[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHealthy(state: IpHealthState): boolean {
|
|
||||||
return state === "up" || state === "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LbTargetConfig {
|
|
||||||
lb_mode: LbMode;
|
|
||||||
health_check_enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LbIpRow {
|
|
||||||
ip: string;
|
|
||||||
weight: number;
|
|
||||||
priority: number;
|
|
||||||
health: IpHealthState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectActiveIpsByMode(
|
|
||||||
config: LbTargetConfig,
|
|
||||||
rows: LbIpRow[],
|
|
||||||
): string[] {
|
|
||||||
if (rows.length === 0) return [];
|
|
||||||
|
|
||||||
const healthy = rows.filter((r) => isHealthy(r.health));
|
|
||||||
const pool = healthy.length > 0 ? healthy : rows;
|
|
||||||
|
|
||||||
if (config.lb_mode === "failover") {
|
|
||||||
const sorted = [...pool].sort(
|
|
||||||
(a, b) => a.priority - b.priority || a.weight - b.weight,
|
|
||||||
);
|
|
||||||
const minPriority = sorted[0]!.priority;
|
|
||||||
const primaries = sorted.filter((r) => r.priority === minPriority);
|
|
||||||
if (healthy.length > 0) {
|
|
||||||
return primaries.map((r) => r.ip);
|
|
||||||
}
|
|
||||||
return [sorted[0]!.ip];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.lb_mode === "weighted") {
|
|
||||||
// Cloudflare не допускает дублирования A-записей с одинаковым name+content,
|
|
||||||
// поэтому weighted на уровне DNS реализован как RR по одному A на IP.
|
|
||||||
// Веса сохраняются в БД и используются для приоритизации/отображения;
|
|
||||||
// точное weighted-распределение требует CF Load Balancer (см. README).
|
|
||||||
return pool.map((r) => r.ip);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pool.map((r) => r.ip);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBindingLbState(
|
function getBindingLbState(
|
||||||
db: Db,
|
db: Db,
|
||||||
bindingId: number,
|
bindingId: number,
|
||||||
@@ -281,7 +254,11 @@ async function collectKnownZones(
|
|||||||
|
|
||||||
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||||
const service = repos.getService(db, serviceId);
|
const service = repos.getService(db, serviceId);
|
||||||
const ips = repos.listServiceIps(db, serviceId);
|
const ipRows = repos.listServiceIpRows(db, serviceId);
|
||||||
|
const ips = ipRows.map((row) => row.ip);
|
||||||
|
const ip_enabled = Object.fromEntries(
|
||||||
|
ipRows.map((row) => [row.ip, row.enabled]),
|
||||||
|
);
|
||||||
const bindings = repos.listBindingsByService(db, serviceId);
|
const bindings = repos.listBindingsByService(db, serviceId);
|
||||||
|
|
||||||
const domainViews = bindings.map((binding) => {
|
const domainViews = bindings.map((binding) => {
|
||||||
@@ -326,6 +303,11 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
|||||||
health_check_interval_sec: binding.health_check_interval_sec,
|
health_check_interval_sec: binding.health_check_interval_sec,
|
||||||
health_check_timeout_ms: binding.health_check_timeout_ms,
|
health_check_timeout_ms: binding.health_check_timeout_ms,
|
||||||
health_check_verify_tls: binding.health_check_verify_tls,
|
health_check_verify_tls: binding.health_check_verify_tls,
|
||||||
|
health_check_provider: binding.health_check_provider ?? "local",
|
||||||
|
health_check_providers: binding.health_check_providers ?? [
|
||||||
|
binding.health_check_provider ?? "local",
|
||||||
|
],
|
||||||
|
health_check_aggregate: binding.health_check_aggregate ?? "majority",
|
||||||
sync_status: aggregateSyncStatus(statuses),
|
sync_status: aggregateSyncStatus(statuses),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -343,9 +325,11 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
|||||||
created_at: service.created_at,
|
created_at: service.created_at,
|
||||||
updated_at: service.updated_at,
|
updated_at: service.updated_at,
|
||||||
ips,
|
ips,
|
||||||
|
ip_enabled,
|
||||||
domains: domainViews,
|
domains: domainViews,
|
||||||
health_status: "unknown",
|
health_status: "unknown",
|
||||||
health_latency_ms: null,
|
health_latency_ms: null,
|
||||||
|
ip_health: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,16 +337,31 @@ function attachServiceHealth(
|
|||||||
db: Db,
|
db: Db,
|
||||||
views: ServiceView[],
|
views: ServiceView[],
|
||||||
): ServiceView[] {
|
): ServiceView[] {
|
||||||
const healthByService = repos.aggregateIpHealthByServiceIds(
|
const ids = views.map((v) => v.id);
|
||||||
db,
|
const healthByService = repos.aggregateIpHealthByServiceIds(db, ids);
|
||||||
views.map((v) => v.id),
|
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
|
||||||
);
|
|
||||||
return views.map((view) => {
|
return views.map((view) => {
|
||||||
const health = healthByService.get(view.id);
|
const health = healthByService.get(view.id);
|
||||||
|
const byIp = new Map(
|
||||||
|
(ipHealthByService.get(view.id) ?? []).map((row) => [row.ip, row]),
|
||||||
|
);
|
||||||
|
const ip_health = (view.ips ?? []).map((ip) => {
|
||||||
|
const row = byIp.get(ip);
|
||||||
|
return {
|
||||||
|
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 {
|
return {
|
||||||
...view,
|
...view,
|
||||||
health_status: health?.health_status ?? "unknown",
|
health_status: health?.health_status ?? "unknown",
|
||||||
health_latency_ms: health?.health_latency_ms ?? null,
|
health_latency_ms: health?.health_latency_ms ?? null,
|
||||||
|
ip_health,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -411,7 +410,7 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
|
|||||||
|
|
||||||
const groupViews = groupViewsRaw.map((group) => {
|
const groupViews = groupViewsRaw.map((group) => {
|
||||||
const services = group.services.map(
|
const services = group.services.map(
|
||||||
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null },
|
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null, ip_health: [], ip_enabled: {} },
|
||||||
);
|
);
|
||||||
const groupScopeHealth = groupHealthById.get(group.id);
|
const groupScopeHealth = groupHealthById.get(group.id);
|
||||||
// Only enabled services feed the group badge — a disabled service with a
|
// Only enabled services feed the group badge — a disabled service with a
|
||||||
@@ -440,6 +439,8 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
|
|||||||
...s,
|
...s,
|
||||||
health_status: "unknown" as const,
|
health_status: "unknown" as const,
|
||||||
health_latency_ms: null,
|
health_latency_ms: null,
|
||||||
|
ip_health: [],
|
||||||
|
ip_enabled: {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1174,7 +1175,10 @@ export async function updateConfig(
|
|||||||
input.health_check_expected_status !== undefined ||
|
input.health_check_expected_status !== undefined ||
|
||||||
input.health_check_interval_sec !== undefined ||
|
input.health_check_interval_sec !== undefined ||
|
||||||
input.health_check_timeout_ms !== undefined ||
|
input.health_check_timeout_ms !== undefined ||
|
||||||
input.health_check_verify_tls !== undefined
|
input.health_check_verify_tls !== undefined ||
|
||||||
|
input.health_check_provider !== undefined ||
|
||||||
|
input.health_check_providers !== undefined ||
|
||||||
|
input.health_check_aggregate !== undefined
|
||||||
) {
|
) {
|
||||||
repos.updateBindingLbConfig(db, binding.id, {
|
repos.updateBindingLbConfig(db, binding.id, {
|
||||||
lb_mode: input.lb_mode,
|
lb_mode: input.lb_mode,
|
||||||
@@ -1186,6 +1190,9 @@ export async function updateConfig(
|
|||||||
health_check_interval_sec: input.health_check_interval_sec,
|
health_check_interval_sec: input.health_check_interval_sec,
|
||||||
health_check_timeout_ms: input.health_check_timeout_ms,
|
health_check_timeout_ms: input.health_check_timeout_ms,
|
||||||
health_check_verify_tls: input.health_check_verify_tls,
|
health_check_verify_tls: input.health_check_verify_tls,
|
||||||
|
health_check_provider: input.health_check_provider,
|
||||||
|
health_check_providers: input.health_check_providers,
|
||||||
|
health_check_aggregate: input.health_check_aggregate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1260,6 +1267,8 @@ export async function updateConfig(
|
|||||||
|
|
||||||
void syncServiceToVpsTracker(db, id, removedBindingIds);
|
void syncServiceToVpsTracker(db, id, removedBindingIds);
|
||||||
|
|
||||||
|
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||||
|
|
||||||
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
|
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
|
||||||
return view!;
|
return view!;
|
||||||
}
|
}
|
||||||
@@ -1271,7 +1280,7 @@ export async function createGroup(
|
|||||||
): Promise<ServiceGroup> {
|
): Promise<ServiceGroup> {
|
||||||
const groupType = body.type?.trim() || "custom";
|
const groupType = body.type?.trim() || "custom";
|
||||||
const domain = await normalizeGroupDomain(db, cf, body.domain);
|
const domain = await normalizeGroupDomain(db, cf, body.domain);
|
||||||
return repos.createServiceGroup(
|
const group = repos.createServiceGroup(
|
||||||
db,
|
db,
|
||||||
body.name,
|
body.name,
|
||||||
groupType,
|
groupType,
|
||||||
@@ -1287,8 +1296,13 @@ export async function createGroup(
|
|||||||
health_check_interval_sec: body.health_check_interval_sec,
|
health_check_interval_sec: body.health_check_interval_sec,
|
||||||
health_check_timeout_ms: body.health_check_timeout_ms,
|
health_check_timeout_ms: body.health_check_timeout_ms,
|
||||||
health_check_verify_tls: body.health_check_verify_tls,
|
health_check_verify_tls: body.health_check_verify_tls,
|
||||||
|
health_check_provider: body.health_check_provider,
|
||||||
|
health_check_providers: body.health_check_providers,
|
||||||
|
health_check_aggregate: body.health_check_aggregate,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||||
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateGroup(
|
export async function updateGroup(
|
||||||
@@ -1323,6 +1337,9 @@ export async function updateGroup(
|
|||||||
health_check_interval_sec: body.health_check_interval_sec,
|
health_check_interval_sec: body.health_check_interval_sec,
|
||||||
health_check_timeout_ms: body.health_check_timeout_ms,
|
health_check_timeout_ms: body.health_check_timeout_ms,
|
||||||
health_check_verify_tls: body.health_check_verify_tls,
|
health_check_verify_tls: body.health_check_verify_tls,
|
||||||
|
health_check_provider: body.health_check_provider,
|
||||||
|
health_check_providers: body.health_check_providers,
|
||||||
|
health_check_aggregate: body.health_check_aggregate,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!domain && group.enabled) {
|
if (!domain && group.enabled) {
|
||||||
@@ -1330,6 +1347,7 @@ export async function updateGroup(
|
|||||||
group = repos.getServiceGroup(db, id);
|
group = repos.getServiceGroup(db, id);
|
||||||
}
|
}
|
||||||
await syncEnabledServicesInGroup(db, cf, id);
|
await syncEnabledServicesInGroup(db, cf, id);
|
||||||
|
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1371,6 +1389,59 @@ export async function toggleService(
|
|||||||
return enabledView!;
|
return enabledView!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleServiceIp(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
serviceId: number,
|
||||||
|
ip: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<ServiceView> {
|
||||||
|
repos.getService(db, serviceId);
|
||||||
|
const pool = repos.listServiceIps(db, serviceId);
|
||||||
|
if (!pool.includes(ip)) {
|
||||||
|
throw AppError.validation(`IP ${ip} не входит в пул адресов сервиса`);
|
||||||
|
}
|
||||||
|
|
||||||
|
repos.setServiceIpEnabled(db, serviceId, ip, enabled);
|
||||||
|
const node = repos
|
||||||
|
.listNodes(db, serviceId)
|
||||||
|
.find((entry) => entry.address === ip);
|
||||||
|
if (node) {
|
||||||
|
repos.updateNode(db, node.id, { enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindings = repos.listBindingsByService(db, serviceId);
|
||||||
|
for (const binding of bindings) {
|
||||||
|
if (binding.cname_target?.trim()) continue;
|
||||||
|
const current = repos.listBindingIpsWithMeta(db, binding.id);
|
||||||
|
const hasIp = current.some((entry) => entry.ip === ip);
|
||||||
|
if (enabled && !hasIp) {
|
||||||
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||||
|
...current,
|
||||||
|
{ ip, weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!enabled && hasIp) {
|
||||||
|
repos.replaceBindingIpsWithMeta(
|
||||||
|
db,
|
||||||
|
binding.id,
|
||||||
|
current.filter((entry) => entry.ip !== ip),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = repos.getService(db, serviceId);
|
||||||
|
if (shouldPushDns(db, service)) {
|
||||||
|
await syncServiceBindingsToDns(db, cf, serviceId);
|
||||||
|
await syncGroupDomainForService(db, cf, serviceId);
|
||||||
|
}
|
||||||
|
void syncServiceToVpsTracker(db, serviceId);
|
||||||
|
|
||||||
|
const [view] = attachServiceHealth(db, [await buildView(db, serviceId)]);
|
||||||
|
return view!;
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleGroup(
|
export async function toggleGroup(
|
||||||
db: Db,
|
db: Db,
|
||||||
cf: CloudflareClient,
|
cf: CloudflareClient,
|
||||||
@@ -1411,6 +1482,25 @@ export function reorderServices(
|
|||||||
repos.reorderServices(db, groupId, serviceIds);
|
repos.reorderServices(db, groupId, serviceIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function applyBindingDesiredDns(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
bindingId: number,
|
||||||
|
desiredIps: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const binding = repos.getBinding(db, bindingId);
|
||||||
|
const cnameTarget = binding.cname_target?.trim() || null;
|
||||||
|
await syncBindingDns(
|
||||||
|
db,
|
||||||
|
cf,
|
||||||
|
binding.id,
|
||||||
|
binding.domain_id,
|
||||||
|
binding.hostname,
|
||||||
|
desiredIps,
|
||||||
|
cnameTarget,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function reconcileDnsForTarget(
|
export async function reconcileDnsForTarget(
|
||||||
db: Db,
|
db: Db,
|
||||||
cf: CloudflareClient,
|
cf: CloudflareClient,
|
||||||
@@ -1418,26 +1508,28 @@ export async function reconcileDnsForTarget(
|
|||||||
refId: number,
|
refId: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (scope === "binding") {
|
if (scope === "binding") {
|
||||||
const binding = repos.getBinding(db, refId);
|
await withBindingLock(refId, async () => {
|
||||||
if (!binding.health_check_enabled) return;
|
const binding = repos.getBinding(db, refId);
|
||||||
const service = repos.getService(db, binding.service_id);
|
if (!binding.health_check_enabled) return;
|
||||||
if (!shouldPushDns(db, service)) return;
|
const service = repos.getService(db, binding.service_id);
|
||||||
const cnameTarget = binding.cname_target?.trim() || null;
|
if (!shouldPushDns(db, service)) return;
|
||||||
if (cnameTarget) return;
|
const cnameTarget = binding.cname_target?.trim() || null;
|
||||||
const ips = repos.listServiceIps(db, service.id);
|
if (cnameTarget) return;
|
||||||
const targetIps = repos.listBindingIps(db, binding.id);
|
const ips = repos.listServiceIps(db, service.id);
|
||||||
validateTargetIpsInPool(targetIps, ips);
|
const targetIps = repos.listBindingIps(db, binding.id);
|
||||||
const activeIps = computeActiveIps(db, "binding", refId);
|
validateTargetIpsInPool(targetIps, ips);
|
||||||
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
|
const activeIps = computeActiveIps(db, "binding", refId);
|
||||||
await syncBindingDns(
|
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
|
||||||
db,
|
await syncBindingDns(
|
||||||
cf,
|
db,
|
||||||
binding.id,
|
cf,
|
||||||
binding.domain_id,
|
binding.id,
|
||||||
binding.hostname,
|
binding.domain_id,
|
||||||
desiredIps,
|
binding.hostname,
|
||||||
null,
|
desiredIps,
|
||||||
);
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ function resolveIpsLocally(
|
|||||||
const binding = index.byFqdn.get(key);
|
const binding = index.byFqdn.get(key);
|
||||||
if (!binding) return [];
|
if (!binding) return [];
|
||||||
|
|
||||||
if (binding.target_ips.some(isIpLiteral)) {
|
const ips = (binding.target_ips ?? []).filter(isIpLiteral);
|
||||||
return binding.target_ips.filter(isIpLiteral);
|
if (ips.length > 0) {
|
||||||
|
return ips;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cname = binding.cname_target?.trim();
|
const cname = binding.cname_target?.trim();
|
||||||
@@ -79,7 +80,7 @@ export async function resolveBindingIpsForSync(
|
|||||||
index: BindingIpIndex,
|
index: BindingIpIndex,
|
||||||
db?: Db,
|
db?: Db,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const directIps = binding.target_ips.filter(isIpLiteral);
|
const directIps = (binding.target_ips ?? []).filter(isIpLiteral);
|
||||||
if (directIps.length > 0) {
|
if (directIps.length > 0) {
|
||||||
return [...directIps];
|
return [...directIps];
|
||||||
}
|
}
|
||||||
@@ -124,7 +125,7 @@ export async function buildServiceSyncBindingsAsync(
|
|||||||
const serviceIps = repos.listServiceIps(db, serviceId);
|
const serviceIps = repos.listServiceIps(db, serviceId);
|
||||||
const allBindings = repos.listAllBindings(db);
|
const allBindings = repos.listAllBindings(db);
|
||||||
const index = buildBindingIndex(allBindings);
|
const index = buildBindingIndex(allBindings);
|
||||||
const bindings = repos.listBindingsByService(db, serviceId);
|
const bindings = allBindings.filter((row) => row.service_id === serviceId);
|
||||||
|
|
||||||
const items: CfdmBindingSyncItem[] = [];
|
const items: CfdmBindingSyncItem[] = [];
|
||||||
for (const binding of bindings) {
|
for (const binding of bindings) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
|
||||||
|
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||||
|
const config = loadConfig();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/login",
|
||||||
|
payload: { username: config.adminUsername, password: "admin" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const { token } = res.json() as { token: string };
|
||||||
|
return { authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("origin health checks", () => {
|
||||||
|
it("creates a local health check", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/health-checks",
|
||||||
|
headers,
|
||||||
|
payload: { provider: "local", name: "origin-1", protocol: "tcp" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as { provider: string; name: string };
|
||||||
|
expect(body.provider).toBe("local");
|
||||||
|
expect(body.name).toBe("origin-1");
|
||||||
|
expect(repos.listHealthChecks(app.db)).toHaveLength(1);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { changeServiceDomain } from "../src/services/change-domain-service.js";
|
||||||
|
import { updateConfig } from "../src/services/service-config-service.js";
|
||||||
|
|
||||||
|
function mockCf(): CloudflareClient {
|
||||||
|
return {
|
||||||
|
listDnsRecords: async () => [],
|
||||||
|
createDnsRecord: async (_zoneId: string, payload: { type: string; name: string; content: string }) => ({
|
||||||
|
id: `cf-${payload.name}-${payload.content}`,
|
||||||
|
type: payload.type,
|
||||||
|
name: payload.name,
|
||||||
|
content: payload.content,
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
updateDnsRecord: async (
|
||||||
|
_zoneId: string,
|
||||||
|
id: string,
|
||||||
|
payload: { type: string; name: string; content: string },
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
type: payload.type,
|
||||||
|
name: payload.name,
|
||||||
|
content: payload.content,
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
patchDnsRecord: async (
|
||||||
|
_zoneId: string,
|
||||||
|
id: string,
|
||||||
|
payload: { content?: string },
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: payload.content ?? "1.1.1.1",
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
deleteDnsRecord: async () => undefined,
|
||||||
|
listZones: async () => [
|
||||||
|
{ id: "zone-1", name: "example.com", status: "active" },
|
||||||
|
{ id: "zone-2", name: "other.com", status: "active" },
|
||||||
|
],
|
||||||
|
} as unknown as CloudflareClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("change-domain", () => {
|
||||||
|
it("dry-run lists FQDN from → to", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const cf = mockCf();
|
||||||
|
const from = repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||||
|
const to = repos.createDomain(app.db, null, "other.com", "zone-2");
|
||||||
|
const service = repos.createService(app.db, "App", "app");
|
||||||
|
await updateConfig(app.db, cf, service.id, {
|
||||||
|
ips: ["1.1.1.1"],
|
||||||
|
domains: [{ fqdn: "app.example.com", target_ips: ["1.1.1.1"] }],
|
||||||
|
});
|
||||||
|
const preview = await changeServiceDomain(app.db, cf, service.id, {
|
||||||
|
from_domain_id: from.id,
|
||||||
|
to_domain_id: to.id,
|
||||||
|
dry_run: true,
|
||||||
|
});
|
||||||
|
expect(preview.applied).toBe(false);
|
||||||
|
expect(preview.items[0]?.from_fqdn).toBe("app.example.com");
|
||||||
|
expect(preview.items[0]?.to_fqdn).toBe("app.other.com");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { changeBindingIp } from "../src/services/change-ip-service.js";
|
||||||
|
import { withBindingLock } from "../src/services/routing/index.js";
|
||||||
|
import { updateConfig } from "../src/services/service-config-service.js";
|
||||||
|
|
||||||
|
function mockCf(): CloudflareClient {
|
||||||
|
return {
|
||||||
|
listDnsRecords: async () => [],
|
||||||
|
createDnsRecord: async (_zoneId: string, payload: { type: string; name: string; content: string }) => ({
|
||||||
|
id: `cf-${payload.name}-${payload.content}`,
|
||||||
|
type: payload.type,
|
||||||
|
name: payload.name,
|
||||||
|
content: payload.content,
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
updateDnsRecord: async (
|
||||||
|
_zoneId: string,
|
||||||
|
id: string,
|
||||||
|
payload: { type: string; name: string; content: string },
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
type: payload.type,
|
||||||
|
name: payload.name,
|
||||||
|
content: payload.content,
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
patchDnsRecord: async (
|
||||||
|
_zoneId: string,
|
||||||
|
id: string,
|
||||||
|
payload: { content?: string },
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
type: "A",
|
||||||
|
name: "panel.example.com",
|
||||||
|
content: payload.content ?? "0.0.0.0",
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
deleteDnsRecord: async () => undefined,
|
||||||
|
listZones: async () => [{ id: "zone-1", name: "example.com", status: "active" }],
|
||||||
|
} as unknown as CloudflareClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("change-ip", () => {
|
||||||
|
it("dry-run previews from → to without writing", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const cf = mockCf();
|
||||||
|
repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||||
|
const service = repos.createService(app.db, "Panel", "panel");
|
||||||
|
await updateConfig(app.db, cf, service.id, {
|
||||||
|
ips: ["10.0.0.10"],
|
||||||
|
domains: [{ fqdn: "panel.example.com", target_ips: ["10.0.0.10"] }],
|
||||||
|
});
|
||||||
|
const bindings = repos.listBindingsByService(app.db, service.id);
|
||||||
|
const preview = await changeBindingIp(app.db, cf, bindings[0]!.id, {
|
||||||
|
from_ip: "10.0.0.10",
|
||||||
|
to_ip: "10.0.0.20",
|
||||||
|
dry_run: true,
|
||||||
|
});
|
||||||
|
expect(preview.applied).toBe(false);
|
||||||
|
expect(preview.message).toBe("10.0.0.10 → 10.0.0.20");
|
||||||
|
expect(repos.listBindingIps(app.db, bindings[0]!.id)).toEqual(["10.0.0.10"]);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("apply patches binding IP", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const cf = mockCf();
|
||||||
|
repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||||
|
const service = repos.createService(app.db, "Panel", "panel");
|
||||||
|
await updateConfig(app.db, cf, service.id, {
|
||||||
|
ips: ["10.0.0.10"],
|
||||||
|
domains: [{ fqdn: "panel.example.com", target_ips: ["10.0.0.10"] }],
|
||||||
|
});
|
||||||
|
const binding = repos.listBindingsByService(app.db, service.id)[0]!;
|
||||||
|
const result = await changeBindingIp(app.db, cf, binding.id, {
|
||||||
|
from_ip: "10.0.0.10",
|
||||||
|
to_ip: "10.0.0.20",
|
||||||
|
});
|
||||||
|
expect(result.applied).toBe(true);
|
||||||
|
expect(repos.listBindingIps(app.db, binding.id)).toEqual(["10.0.0.20"]);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes concurrent binding locks", async () => {
|
||||||
|
const order: number[] = [];
|
||||||
|
await Promise.all([
|
||||||
|
withBindingLock(1, async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
order.push(1);
|
||||||
|
}),
|
||||||
|
withBindingLock(1, async () => {
|
||||||
|
order.push(2);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(order).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,6 +56,7 @@ describe("health-check probeTarget", () => {
|
|||||||
expected_status: null,
|
expected_status: null,
|
||||||
timeout_ms: 1000,
|
timeout_ms: 1000,
|
||||||
verify_tls: false,
|
verify_tls: false,
|
||||||
|
provider: "local",
|
||||||
};
|
};
|
||||||
const result = await healthCheckService.probeTarget(target);
|
const result = await healthCheckService.probeTarget(target);
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
@@ -75,6 +76,7 @@ describe("health-check probeTarget", () => {
|
|||||||
expected_status: null,
|
expected_status: null,
|
||||||
timeout_ms: 500,
|
timeout_ms: 500,
|
||||||
verify_tls: false,
|
verify_tls: false,
|
||||||
|
provider: "local",
|
||||||
};
|
};
|
||||||
const result = await healthCheckService.probeTarget(target);
|
const result = await healthCheckService.probeTarget(target);
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
@@ -107,6 +109,7 @@ describe("health-check probeTarget", () => {
|
|||||||
expected_status: 200,
|
expected_status: 200,
|
||||||
timeout_ms: 1000,
|
timeout_ms: 1000,
|
||||||
verify_tls: false,
|
verify_tls: false,
|
||||||
|
provider: "local",
|
||||||
};
|
};
|
||||||
const bindingTarget: HealthCheckTarget = {
|
const bindingTarget: HealthCheckTarget = {
|
||||||
...groupTarget,
|
...groupTarget,
|
||||||
@@ -142,6 +145,7 @@ describe("health-check probeTarget", () => {
|
|||||||
expected_status: null,
|
expected_status: null,
|
||||||
timeout_ms: 3000,
|
timeout_ms: 3000,
|
||||||
verify_tls: false,
|
verify_tls: false,
|
||||||
|
provider: "local",
|
||||||
};
|
};
|
||||||
const binding: HealthCheckTarget = {
|
const binding: HealthCheckTarget = {
|
||||||
...group,
|
...group,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { nextHealthState } from "../src/services/health/state-machine.js";
|
||||||
|
|
||||||
|
const thresholds = {
|
||||||
|
degradedFailures: 1,
|
||||||
|
downFailures: 2,
|
||||||
|
successRecoveries: 2,
|
||||||
|
latencyWarnMs: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("health state machine", () => {
|
||||||
|
it("first success from unknown is healthy immediately", () => {
|
||||||
|
const next = nextHealthState(true, 20, null, thresholds);
|
||||||
|
expect(next.legacy).toBe("up");
|
||||||
|
expect(next.node).toBe("healthy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovery from down goes checking until consecutive successes", () => {
|
||||||
|
const first = nextHealthState(
|
||||||
|
true,
|
||||||
|
10,
|
||||||
|
{ status: "down", consecutive_failures: 3, consecutive_successes: 0 },
|
||||||
|
thresholds,
|
||||||
|
);
|
||||||
|
expect(first.node).toBe("checking");
|
||||||
|
expect(first.legacy).toBe("unknown");
|
||||||
|
const second = nextHealthState(
|
||||||
|
true,
|
||||||
|
10,
|
||||||
|
{
|
||||||
|
status: "checking",
|
||||||
|
consecutive_failures: 0,
|
||||||
|
consecutive_successes: first.successes,
|
||||||
|
},
|
||||||
|
thresholds,
|
||||||
|
);
|
||||||
|
expect(second.node).toBe("healthy");
|
||||||
|
expect(second.legacy).toBe("up");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two failures mark unhealthy", () => {
|
||||||
|
const first = nextHealthState(
|
||||||
|
false,
|
||||||
|
5,
|
||||||
|
{ status: "up", consecutive_failures: 0, consecutive_successes: 1 },
|
||||||
|
thresholds,
|
||||||
|
);
|
||||||
|
expect(first.node).toBe("degraded");
|
||||||
|
const second = nextHealthState(
|
||||||
|
false,
|
||||||
|
5,
|
||||||
|
{
|
||||||
|
status: "degraded",
|
||||||
|
consecutive_failures: first.failures,
|
||||||
|
consecutive_successes: 0,
|
||||||
|
},
|
||||||
|
thresholds,
|
||||||
|
);
|
||||||
|
expect(second.node).toBe("unhealthy");
|
||||||
|
expect(second.legacy).toBe("down");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { toCloudflareCron } from "../src/services/health/mailbox.js";
|
||||||
|
import { HEALTH_PROBE_SCRIPT_NAME } from "@cfdm/shared";
|
||||||
|
|
||||||
|
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 jsonOk(result: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify({ success: true, result }), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("health worker deploy", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 6-field toad cron to 5-field Cloudflare cron", () => {
|
||||||
|
expect(toCloudflareCron("0 */2 * * * *")).toBe("*/2 * * * *");
|
||||||
|
expect(toCloudflareCron("*/5 * * * *")).toBe("*/5 * * * *");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST ensure creates KV+script; 403 is not local fallback", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url =
|
||||||
|
typeof input === "string" || input instanceof URL
|
||||||
|
? String(input)
|
||||||
|
: input.url;
|
||||||
|
const method = (
|
||||||
|
init?.method ??
|
||||||
|
(typeof Request !== "undefined" && input instanceof Request
|
||||||
|
? input.method
|
||||||
|
: "GET")
|
||||||
|
).toUpperCase();
|
||||||
|
calls.push(`${method} ${url}`);
|
||||||
|
if (
|
||||||
|
(url.includes("/accounts?") || /\/accounts$/.test(url.split("?")[0] ?? "")) &&
|
||||||
|
!url.includes("/storage/") &&
|
||||||
|
!url.includes("/workers/")
|
||||||
|
) {
|
||||||
|
return jsonOk([{ id: "acc-1", name: "Test" }]);
|
||||||
|
}
|
||||||
|
if (url.includes("/storage/kv/namespaces") && method === "GET" && !url.includes("/values/")) {
|
||||||
|
return jsonOk([]);
|
||||||
|
}
|
||||||
|
if (url.includes("/storage/kv/namespaces") && method === "POST") {
|
||||||
|
return jsonOk({ id: "kv-1", title: "cfdm-health-probe" });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
url.includes(`/workers/scripts/${HEALTH_PROBE_SCRIPT_NAME}`) &&
|
||||||
|
method === "PUT" &&
|
||||||
|
!url.includes("/schedules")
|
||||||
|
) {
|
||||||
|
return jsonOk({ id: "script-1" });
|
||||||
|
}
|
||||||
|
if (url.includes("/schedules") && method === "PUT") {
|
||||||
|
return jsonOk([{ cron: "*/2 * * * *" }]);
|
||||||
|
}
|
||||||
|
if (url.includes("/subdomain") && method === "POST") {
|
||||||
|
return jsonOk({ enabled: true });
|
||||||
|
}
|
||||||
|
if (url.includes("/workers/subdomain") && method === "GET") {
|
||||||
|
return jsonOk({ subdomain: "example" });
|
||||||
|
}
|
||||||
|
if (url.includes("/values/") && method === "GET") {
|
||||||
|
return new Response("null", { status: 404 });
|
||||||
|
}
|
||||||
|
if (url.includes("/values/") && method === "PUT") {
|
||||||
|
return jsonOk(null);
|
||||||
|
}
|
||||||
|
return jsonOk({});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null, cloudflareApiToken: "cf-token" },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/settings/health/worker/ensure",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as {
|
||||||
|
healthWorkerStatus: string;
|
||||||
|
healthWorkerUrl: string;
|
||||||
|
healthWorkerKvNamespaceId: string;
|
||||||
|
healthWorkerError: string | null;
|
||||||
|
};
|
||||||
|
expect(body.healthWorkerStatus).toBe("ready");
|
||||||
|
expect(body.healthWorkerKvNamespaceId).toBe("kv-1");
|
||||||
|
expect(body.healthWorkerUrl).toContain("cfdm-health-probe.example.workers.dev");
|
||||||
|
expect(body.healthWorkerError).toBeNull();
|
||||||
|
expect(calls.some((c) => c.includes("/workers/scripts/"))).toBe(true);
|
||||||
|
await app.close();
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
it("POST ensure 403 stores error, does not probe as local", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("/storage/kv/namespaces")) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 10000, message: "Authentication error" }],
|
||||||
|
}),
|
||||||
|
{ status: 403, headers: { "content-type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.includes("/accounts")) {
|
||||||
|
return jsonOk([{ id: "acc-1" }]);
|
||||||
|
}
|
||||||
|
return jsonOk({});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null, cloudflareApiToken: "zone-only" },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/settings/health/worker/ensure",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
const again = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
const body = again.json() as {
|
||||||
|
healthWorkerStatus: string;
|
||||||
|
healthWorkerError: string | null;
|
||||||
|
};
|
||||||
|
expect(body.healthWorkerStatus).toBe("error");
|
||||||
|
expect(body.healthWorkerError).toMatch(/Workers Scripts Write|токен/i);
|
||||||
|
await app.close();
|
||||||
|
}, 20_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
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 type { HealthMailbox } from "../src/services/health/mailbox.js";
|
||||||
|
import { originProbeKey } from "../src/services/health/mailbox.js";
|
||||||
|
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||||
|
|
||||||
|
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}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
function memoryMailbox(opts?: {
|
||||||
|
resultsOk?: boolean;
|
||||||
|
colo?: string;
|
||||||
|
probedAt?: string;
|
||||||
|
}): HealthMailbox {
|
||||||
|
let targets: unknown = null;
|
||||||
|
return {
|
||||||
|
async getTargets() {
|
||||||
|
return targets as never;
|
||||||
|
},
|
||||||
|
async putTargets(doc) {
|
||||||
|
targets = doc;
|
||||||
|
},
|
||||||
|
async getResults() {
|
||||||
|
if (!opts) return null;
|
||||||
|
const dummy: HealthCheckTarget = {
|
||||||
|
scope: "binding",
|
||||||
|
ref_id: 1,
|
||||||
|
ip: "203.0.113.10",
|
||||||
|
hostname: "panel.example.com",
|
||||||
|
type: "tcp",
|
||||||
|
port: 1,
|
||||||
|
path: null,
|
||||||
|
expected_status: null,
|
||||||
|
timeout_ms: 400,
|
||||||
|
verify_tls: false,
|
||||||
|
provider: "cloudflare",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
probedAt: opts.probedAt ?? new Date().toISOString(),
|
||||||
|
colo: opts.colo ?? "AMS",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: originProbeKey(dummy),
|
||||||
|
ok: opts.resultsOk !== false,
|
||||||
|
latencyMs: 42,
|
||||||
|
error: opts.resultsOk === false ? "down" : null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("health-check XOR worker mailbox", () => {
|
||||||
|
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 mailbox 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,
|
||||||
|
mailbox: 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("KV results write colo and last_checked_at without HTTP /probe", async () => {
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
const targets = repos.listHealthCheckTargets(app.db);
|
||||||
|
const cfTarget = targets.find((t) => t.ip === "203.0.113.10")!;
|
||||||
|
const mailbox: HealthMailbox = {
|
||||||
|
async getTargets() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async putTargets() {
|
||||||
|
/* fingerprint sync */
|
||||||
|
},
|
||||||
|
async getResults() {
|
||||||
|
return {
|
||||||
|
probedAt: new Date().toISOString(),
|
||||||
|
colo: "AMS",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: originProbeKey(cfTarget),
|
||||||
|
ok: true,
|
||||||
|
latencyMs: 42,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await healthCheckService.runAllChecks(app.db, {
|
||||||
|
thresholds,
|
||||||
|
probeGapMs: 0,
|
||||||
|
mailbox,
|
||||||
|
});
|
||||||
|
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?.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 app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stale KV results are recorded, not local probe", async () => {
|
||||||
|
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,
|
||||||
|
mailbox: memoryMailbox({
|
||||||
|
resultsOk: true,
|
||||||
|
colo: "SIN",
|
||||||
|
probedAt: new Date(Date.now() - 60 * 60_000).toISOString(),
|
||||||
|
}),
|
||||||
|
staleAfterMs: 60_000,
|
||||||
|
});
|
||||||
|
const row = repos.getIpHealthStatusRow(
|
||||||
|
app.db,
|
||||||
|
"binding",
|
||||||
|
binding.id,
|
||||||
|
"203.0.113.20",
|
||||||
|
);
|
||||||
|
expect(row?.last_error).toMatch(/устарели|KV/i);
|
||||||
|
expect(row?.provider).toBe("cloudflare");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,7 +39,10 @@ describe("selectActiveIpsByMode", () => {
|
|||||||
lb_mode: "round_robin",
|
lb_mode: "round_robin",
|
||||||
health_check_enabled: true,
|
health_check_enabled: true,
|
||||||
};
|
};
|
||||||
const rows = [row("1.1.1.1", { health: "unknown" }), row("2.2.2.2")];
|
const rows = [
|
||||||
|
row("1.1.1.1", { health: "unknown" }),
|
||||||
|
row("2.2.2.2", { health: "unknown" }),
|
||||||
|
];
|
||||||
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
||||||
"1.1.1.1",
|
"1.1.1.1",
|
||||||
"2.2.2.2",
|
"2.2.2.2",
|
||||||
@@ -88,6 +91,18 @@ describe("selectActiveIpsByMode", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round_robin excludes unknown when another ip is up", () => {
|
||||||
|
const config: LbTargetConfig = {
|
||||||
|
lb_mode: "round_robin",
|
||||||
|
health_check_enabled: true,
|
||||||
|
};
|
||||||
|
const rows = [
|
||||||
|
row("1.1.1.1", { health: "up" }),
|
||||||
|
row("2.2.2.2", { health: "unknown" }),
|
||||||
|
];
|
||||||
|
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns empty array for no rows", () => {
|
it("returns empty array for no rows", () => {
|
||||||
const config: LbTargetConfig = {
|
const config: LbTargetConfig = {
|
||||||
lb_mode: "round_robin",
|
lb_mode: "round_robin",
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
|
||||||
|
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||||
|
const config = loadConfig();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/login",
|
||||||
|
payload: { username: config.adminUsername, password: "admin" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const { token } = res.json() as { token: string };
|
||||||
|
return { authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("service nodes API", () => {
|
||||||
|
it("creates and lists nodes without changing empty DNS pool until bound", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const service = repos.createService(app.db, "Panel", "panel");
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/v1/services/${service.id}/nodes`,
|
||||||
|
headers,
|
||||||
|
payload: { address: "10.0.0.8", protocol: "tcp" },
|
||||||
|
});
|
||||||
|
expect(created.statusCode).toBe(200);
|
||||||
|
const node = created.json() as { address: string };
|
||||||
|
expect(node.address).toBe("10.0.0.8");
|
||||||
|
expect(repos.listServiceIps(app.db, service.id)).toContain("10.0.0.8");
|
||||||
|
|
||||||
|
const listed = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v1/services/${service.id}/nodes`,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(listed.statusCode).toBe(200);
|
||||||
|
expect(listed.json()).toHaveLength(1);
|
||||||
|
|
||||||
|
const overview = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v1/services/${service.id}/overview`,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(overview.statusCode).toBe(200);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,6 +39,7 @@ describe("service groups health enrichment", () => {
|
|||||||
const service = repos.createService(app.db, "Panel", "panel");
|
const service = repos.createService(app.db, "Panel", "panel");
|
||||||
repos.setServiceGroup(app.db, service.id, group.id);
|
repos.setServiceGroup(app.db, service.id, group.id);
|
||||||
repos.setServiceEnabled(app.db, service.id, true);
|
repos.setServiceEnabled(app.db, service.id, true);
|
||||||
|
repos.replaceServiceIps(app.db, service.id, ["1.2.3.4"]);
|
||||||
const binding = repos.insertBinding(
|
const binding = repos.insertBinding(
|
||||||
app.db,
|
app.db,
|
||||||
domain.id,
|
domain.id,
|
||||||
@@ -85,6 +86,11 @@ describe("service groups health enrichment", () => {
|
|||||||
id: number;
|
id: number;
|
||||||
health_status: string;
|
health_status: string;
|
||||||
health_latency_ms: number | null;
|
health_latency_ms: number | null;
|
||||||
|
ip_health: Array<{
|
||||||
|
ip: string;
|
||||||
|
status: string;
|
||||||
|
latency_ms: number | null;
|
||||||
|
}>;
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@@ -92,6 +98,17 @@ describe("service groups health enrichment", () => {
|
|||||||
expect(groupView).toBeDefined();
|
expect(groupView).toBeDefined();
|
||||||
expect(groupView!.services[0]?.health_status).toBe("degraded");
|
expect(groupView!.services[0]?.health_status).toBe("degraded");
|
||||||
expect(groupView!.services[0]?.health_latency_ms).toBe(120);
|
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,
|
||||||
|
last_checked_at: expect.any(String),
|
||||||
|
last_error: null,
|
||||||
|
provider: "local",
|
||||||
|
colo: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
// group worst = degraded (from service) over up (group scope)
|
// group worst = degraded (from service) over up (group scope)
|
||||||
expect(groupView!.health_status).toBe("degraded");
|
expect(groupView!.health_status).toBe("degraded");
|
||||||
|
|
||||||
|
|||||||
@@ -165,4 +165,94 @@ describe("create service then list groups", () => {
|
|||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("PATCH /services/:id/ips/toggle keeps IP in pool and removes it from A-binding", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const cf = mockCf();
|
||||||
|
|
||||||
|
repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||||
|
const group = repos.createServiceGroup(
|
||||||
|
app.db,
|
||||||
|
"VPN",
|
||||||
|
"vpn",
|
||||||
|
null,
|
||||||
|
"vpn.example.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
const createRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/services",
|
||||||
|
headers,
|
||||||
|
payload: {
|
||||||
|
name: "Panel",
|
||||||
|
slug: "panel-ip-toggle",
|
||||||
|
service_group_id: group.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createRes.statusCode).toBe(200);
|
||||||
|
const created = createRes.json() as { id: number };
|
||||||
|
|
||||||
|
await updateConfig(app.db, cf, created.id, {
|
||||||
|
ips: ["1.2.3.4", "5.6.7.8"],
|
||||||
|
service_group_id: group.id,
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
fqdn: "panel.example.com",
|
||||||
|
target_ips: ["1.2.3.4", "5.6.7.8"],
|
||||||
|
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
||||||
|
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
||||||
|
lb_mode: "round_robin",
|
||||||
|
health_check_enabled: false,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: 443,
|
||||||
|
health_check_path: null,
|
||||||
|
health_check_expected_status: null,
|
||||||
|
health_check_interval_sec: 30,
|
||||||
|
health_check_timeout_ms: 3000,
|
||||||
|
health_check_verify_tls: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// HTTP toggle uses request.server.cf; disable DNS push so the test
|
||||||
|
// does not call the real Cloudflare client.
|
||||||
|
repos.setServiceEnabled(app.db, created.id, false);
|
||||||
|
|
||||||
|
const offRes = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/v1/services/${created.id}/ips/toggle`,
|
||||||
|
headers,
|
||||||
|
payload: { ip: "1.2.3.4", enabled: false },
|
||||||
|
});
|
||||||
|
expect(offRes.statusCode, JSON.stringify(offRes.json())).toBe(200);
|
||||||
|
const offView = offRes.json() as {
|
||||||
|
ips: string[];
|
||||||
|
ip_enabled: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
expect(offView.ips).toEqual(expect.arrayContaining(["1.2.3.4", "5.6.7.8"]));
|
||||||
|
expect(offView.ip_enabled["1.2.3.4"]).toBe(false);
|
||||||
|
expect(offView.ip_enabled["5.6.7.8"]).toBe(true);
|
||||||
|
|
||||||
|
const binding = repos.listBindingsByService(app.db, created.id)[0]!;
|
||||||
|
expect(repos.listBindingIps(app.db, binding.id)).toEqual(["5.6.7.8"]);
|
||||||
|
|
||||||
|
const onRes = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/v1/services/${created.id}/ips/toggle`,
|
||||||
|
headers,
|
||||||
|
payload: { ip: "1.2.3.4", enabled: true },
|
||||||
|
});
|
||||||
|
expect(onRes.statusCode).toBe(200);
|
||||||
|
const onView = onRes.json() as { ip_enabled: Record<string, boolean> };
|
||||||
|
expect(onView.ip_enabled["1.2.3.4"]).toBe(true);
|
||||||
|
expect(repos.listBindingIps(app.db, binding.id)).toEqual(
|
||||||
|
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.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}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("settings health engine", () => {
|
||||||
|
it("GET /api/v1/settings returns env fallbacks for health fields", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: {
|
||||||
|
...loadConfig(),
|
||||||
|
staticDir: null,
|
||||||
|
healthCheckCron: "*/30 * * * * *",
|
||||||
|
healthDegradedFailures: 3,
|
||||||
|
healthDownFailures: 4,
|
||||||
|
healthLatencyWarnMs: 1500,
|
||||||
|
healthSuccessRecoveries: 5,
|
||||||
|
},
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as {
|
||||||
|
healthCheckCron: string;
|
||||||
|
healthDegradedFailures: number;
|
||||||
|
healthDownFailures: number;
|
||||||
|
healthLatencyWarnMs: number;
|
||||||
|
healthSuccessRecoveries: number;
|
||||||
|
healthWorkerStatus?: string;
|
||||||
|
};
|
||||||
|
expect(body.healthCheckCron).toBe("*/30 * * * * *");
|
||||||
|
expect(body.healthDegradedFailures).toBe(3);
|
||||||
|
expect(body.healthDownFailures).toBe(4);
|
||||||
|
expect(body.healthLatencyWarnMs).toBe(1500);
|
||||||
|
expect(body.healthSuccessRecoveries).toBe(5);
|
||||||
|
expect(body.healthWorkerStatus).toBe("missing");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH persists health engine settings", 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: {
|
||||||
|
healthCheckCron: "0 */5 * * * *",
|
||||||
|
healthDegradedFailures: 2,
|
||||||
|
healthDownFailures: 4,
|
||||||
|
healthLatencyWarnMs: 800,
|
||||||
|
healthSuccessRecoveries: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as {
|
||||||
|
healthCheckCron: string;
|
||||||
|
healthDegradedFailures: number;
|
||||||
|
healthDownFailures: number;
|
||||||
|
healthLatencyWarnMs: number;
|
||||||
|
healthSuccessRecoveries: number;
|
||||||
|
};
|
||||||
|
expect(body.healthCheckCron).toBe("0 */5 * * * *");
|
||||||
|
expect(body.healthDegradedFailures).toBe(2);
|
||||||
|
expect(body.healthDownFailures).toBe(4);
|
||||||
|
expect(body.healthLatencyWarnMs).toBe(800);
|
||||||
|
expect(body.healthSuccessRecoveries).toBe(3);
|
||||||
|
|
||||||
|
const again = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(again.json()).toMatchObject({
|
||||||
|
healthCheckCron: "0 */5 * * * *",
|
||||||
|
healthDegradedFailures: 2,
|
||||||
|
healthDownFailures: 4,
|
||||||
|
healthLatencyWarnMs: 800,
|
||||||
|
healthSuccessRecoveries: 3,
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH rejects invalid cron", 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: { healthCheckCron: "not-a-cron" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json()).toMatchObject({
|
||||||
|
error: { code: "VALIDATION_ERROR" },
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH rejects down < degraded", 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: {
|
||||||
|
healthDegradedFailures: 5,
|
||||||
|
healthDownFailures: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -134,6 +134,19 @@ describe("resolveBindingIpsForSync", () => {
|
|||||||
expect(ips).toEqual(["203.0.113.10"]);
|
expect(ips).toEqual(["203.0.113.10"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats missing target_ips as empty instead of throwing", async () => {
|
||||||
|
const cname = binding({
|
||||||
|
id: 2,
|
||||||
|
hostname: "imsk",
|
||||||
|
zone_name: "rkns.top",
|
||||||
|
cname_target: "ihome.rkns.top",
|
||||||
|
});
|
||||||
|
delete (cname as { target_ips?: string[] }).target_ips;
|
||||||
|
const index = { byFqdn: new Map([["imsk.rkns.top", cname]]) };
|
||||||
|
const ips = await resolveBindingIpsForSync(cname, ["198.51.100.9"], index);
|
||||||
|
expect(ips).toEqual(["198.51.100.9"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("prefers service IPs over empty CNAME resolution chain", async () => {
|
it("prefers service IPs over empty CNAME resolution chain", async () => {
|
||||||
const cname = binding({
|
const cname = binding({
|
||||||
id: 2,
|
id: 2,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { copyFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/server.ts"],
|
||||||
|
format: ["esm"],
|
||||||
|
dts: true,
|
||||||
|
async onSuccess() {
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
copyFileSync(
|
||||||
|
join(root, "workers/health-probe/src/index.mjs"),
|
||||||
|
join(dirname(fileURLToPath(import.meta.url)), "dist/health-probe-worker.mjs"),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
include: ["test/**/*.test.ts"],
|
include: ["test/**/*.test.ts"],
|
||||||
|
testTimeout: 20_000,
|
||||||
typecheck: {
|
typecheck: {
|
||||||
tsconfig: "./tsconfig.test.json",
|
tsconfig: "./tsconfig.test.json",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,15 +25,18 @@ import {
|
|||||||
const infrastructureNav = [
|
const infrastructureNav = [
|
||||||
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
|
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
|
||||||
{ to: '/domains', label: 'Домены', icon: GlobeIcon, exact: false },
|
{ to: '/domains', label: 'Домены', icon: GlobeIcon, exact: false },
|
||||||
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const operationsNav = [
|
const operationsNav = [
|
||||||
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
|
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
|
||||||
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
|
|
||||||
{ to: '/settings/appearance', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' },
|
{ to: '/settings/appearance', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const secondaryNav = [
|
||||||
|
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
|
||||||
|
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
|
||||||
|
] as const
|
||||||
|
|
||||||
function isNavActive(
|
function isNavActive(
|
||||||
pathname: string,
|
pathname: string,
|
||||||
to: string,
|
to: string,
|
||||||
@@ -106,6 +109,7 @@ export function AppSidebar() {
|
|||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
|
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
|
||||||
<NavSection label="Операции" items={operationsNav} pathname={pathname} />
|
<NavSection label="Операции" items={operationsNav} pathname={pathname} />
|
||||||
|
<NavSection label="Прочее" items={secondaryNav} pathname={pathname} />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser />
|
<NavUser />
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@cfdm/ui/components/select'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import { changeServiceDomain, domainsListQueryOptions } from '@/queries'
|
||||||
|
|
||||||
|
interface ChangeDomainSheetProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
serviceId: number
|
||||||
|
fromDomainId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
from_domain_id: string
|
||||||
|
to_domain_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChangeDomainSheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
serviceId,
|
||||||
|
fromDomainId,
|
||||||
|
}: ChangeDomainSheetProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const domainsQuery = useQuery(domainsListQueryOptions())
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
from_domain_id: fromDomainId ? String(fromDomainId) : '',
|
||||||
|
to_domain_id: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
|
const [preview, setPreview] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.reset({
|
||||||
|
from_domain_id: fromDomainId ? String(fromDomainId) : '',
|
||||||
|
to_domain_id: '',
|
||||||
|
})
|
||||||
|
setPreview(null)
|
||||||
|
}
|
||||||
|
}, [open, fromDomainId, form])
|
||||||
|
|
||||||
|
const domains = domainsQuery.data ?? []
|
||||||
|
const fromId = form.watch('from_domain_id')
|
||||||
|
const toId = form.watch('to_domain_id')
|
||||||
|
const fromZone = domains.find((d) => String(d.id) === fromId)?.zone_name
|
||||||
|
const toZone = domains.find((d) => String(d.id) === toId)?.zone_name
|
||||||
|
|
||||||
|
const mutate = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
changeServiceDomain(serviceId, {
|
||||||
|
from_domain_id: Number(fromId),
|
||||||
|
to_domain_id: Number(toId),
|
||||||
|
dry_run: false,
|
||||||
|
}),
|
||||||
|
onSuccess: async (result: { message?: string }) => {
|
||||||
|
toast.success(result.message ?? 'Привязки перенесены')
|
||||||
|
await queryClient.invalidateQueries()
|
||||||
|
setConfirmOpen(false)
|
||||||
|
onOpenChange(false)
|
||||||
|
},
|
||||||
|
onError: (e: unknown) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось перенести домен'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormSheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title="Сменить домен"
|
||||||
|
description="Перенос привязок между зонами Cloudflare без orphan-записей."
|
||||||
|
form={form}
|
||||||
|
onSubmit={() => {
|
||||||
|
setPreview(
|
||||||
|
fromZone && toZone
|
||||||
|
? `${fromZone} → ${toZone}`
|
||||||
|
: 'Проверьте выбранные зоны',
|
||||||
|
)
|
||||||
|
setConfirmOpen(true)
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!fromId || !toId || fromId === toId}>
|
||||||
|
Предпросмотр
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormFieldSimple label="Исходная зона" htmlFor="from_domain_id">
|
||||||
|
<Select
|
||||||
|
value={fromId || null}
|
||||||
|
onValueChange={(value) => form.setValue('from_domain_id', value ?? '')}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="from_domain_id">
|
||||||
|
<SelectValue placeholder="Откуда" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{domains.map((domain) => (
|
||||||
|
<SelectItem key={domain.id} value={String(domain.id)}>
|
||||||
|
{domain.zone_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormFieldSimple>
|
||||||
|
<FormFieldSimple label="Целевая зона" htmlFor="to_domain_id">
|
||||||
|
<Select
|
||||||
|
value={toId || null}
|
||||||
|
onValueChange={(value) => form.setValue('to_domain_id', value ?? '')}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="to_domain_id">
|
||||||
|
<SelectValue placeholder="Куда" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{domains.map((domain) => (
|
||||||
|
<SelectItem key={domain.id} value={String(domain.id)}>
|
||||||
|
{domain.zone_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormFieldSimple>
|
||||||
|
{fromZone && toZone ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Предпросмотр FQDN</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Привязки будут перенесены из {fromZone} в {toZone}. Старые DNS-записи
|
||||||
|
исходной зоны будут удалены.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</FormSheet>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmOpen}
|
||||||
|
onOpenChange={setConfirmOpen}
|
||||||
|
title="Подтвердить перенос"
|
||||||
|
description={preview ?? 'Перенести привязки в другую зону?'}
|
||||||
|
confirmLabel="Перенести"
|
||||||
|
onConfirm={() => mutate.mutate()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@cfdm/ui/components/select'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
|
import { changeBindingIp, serviceNodesQueryOptions } from '@/queries'
|
||||||
|
|
||||||
|
interface ChangeIpSheetProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
bindingId: number | null
|
||||||
|
serviceId?: number | null
|
||||||
|
currentIp?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
from_ip: string
|
||||||
|
to_ip: string
|
||||||
|
node_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChangeIpSheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
bindingId,
|
||||||
|
serviceId,
|
||||||
|
currentIp,
|
||||||
|
}: ChangeIpSheetProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: { from_ip: currentIp ?? '', to_ip: '', node_id: '' },
|
||||||
|
})
|
||||||
|
const [preview, setPreview] = useState<string | null>(null)
|
||||||
|
const nodesQuery = useQuery({
|
||||||
|
...serviceNodesQueryOptions(serviceId ?? 0),
|
||||||
|
enabled: open && serviceId != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.reset({ from_ip: currentIp ?? '', to_ip: '', node_id: '' })
|
||||||
|
setPreview(null)
|
||||||
|
}
|
||||||
|
}, [open, currentIp, form])
|
||||||
|
|
||||||
|
const fromIp = form.watch('from_ip')
|
||||||
|
const toIp = form.watch('to_ip')
|
||||||
|
const nodeId = form.watch('node_id')
|
||||||
|
const nodes = (nodesQuery.data ?? []) as Array<{ id: number; address: string }>
|
||||||
|
|
||||||
|
const previewText = useMemo(() => {
|
||||||
|
const next = nodeId
|
||||||
|
? nodes.find((n) => String(n.id) === nodeId)?.address
|
||||||
|
: toIp
|
||||||
|
if (!fromIp || !next) return null
|
||||||
|
return `${fromIp} → ${next}`
|
||||||
|
}, [fromIp, toIp, nodeId, nodes])
|
||||||
|
|
||||||
|
const mutate = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (bindingId == null) throw new Error('нет привязки')
|
||||||
|
const selectedNode = nodeId ? Number(nodeId) : undefined
|
||||||
|
return changeBindingIp(bindingId, {
|
||||||
|
from_ip: fromIp || undefined,
|
||||||
|
to_ip: selectedNode ? undefined : toIp || undefined,
|
||||||
|
node_id: selectedNode,
|
||||||
|
dry_run: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
setPreview(result.message)
|
||||||
|
toast.success(result.message)
|
||||||
|
await queryClient.invalidateQueries()
|
||||||
|
onOpenChange(false)
|
||||||
|
},
|
||||||
|
onError: (e: unknown) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось сменить IP'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title="Сменить IP"
|
||||||
|
description="Обновить A-запись в Cloudflare без перехода на страницу DNS."
|
||||||
|
form={form}
|
||||||
|
onSubmit={() => mutate.mutate()}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<LoadingButton
|
||||||
|
type="submit"
|
||||||
|
isLoading={mutate.isPending}
|
||||||
|
loadingLabel="Updating…"
|
||||||
|
>
|
||||||
|
Сменить IP
|
||||||
|
</LoadingButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormFieldSimple label="Текущий IP" htmlFor="from_ip">
|
||||||
|
<Input id="from_ip" {...form.register('from_ip')} />
|
||||||
|
</FormFieldSimple>
|
||||||
|
{nodes.length > 0 ? (
|
||||||
|
<FormFieldSimple label="Нода" htmlFor="node_id">
|
||||||
|
<Select
|
||||||
|
value={nodeId || null}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
form.setValue('node_id', value ?? '')
|
||||||
|
const node = nodes.find((n) => String(n.id) === value)
|
||||||
|
if (node) form.setValue('to_ip', node.address)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="node_id">
|
||||||
|
<SelectValue placeholder="Выберите ноду" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<SelectItem key={node.id} value={String(node.id)}>
|
||||||
|
{node.address}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormFieldSimple>
|
||||||
|
) : null}
|
||||||
|
<FormFieldSimple label="Новый IP" htmlFor="to_ip" hint="IPv4">
|
||||||
|
<Input id="to_ip" {...form.register('to_ip')} placeholder="10.0.0.20" />
|
||||||
|
</FormFieldSimple>
|
||||||
|
{previewText ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Предпросмотр</AlertTitle>
|
||||||
|
<AlertDescription>{preview ?? previewText}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</FormSheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
Timeline,
|
||||||
|
TimelineContent,
|
||||||
|
TimelineHeader,
|
||||||
|
TimelineIndicator,
|
||||||
|
TimelineItem,
|
||||||
|
TimelineSeparator,
|
||||||
|
TimelineTitle,
|
||||||
|
} from '@/components/reui/timeline'
|
||||||
|
|
||||||
|
export interface FailoverEvent {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
detail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FailoverTimeline({ events }: { events: FailoverEvent[] }) {
|
||||||
|
if (events.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Событий failover пока нет.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline defaultValue={events.length} className="w-full">
|
||||||
|
{events.map((event, index) => (
|
||||||
|
<TimelineItem key={event.id} step={index + 1}>
|
||||||
|
<TimelineSeparator />
|
||||||
|
<TimelineIndicator />
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineTitle>{event.title}</TimelineTitle>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineContent>{event.detail}</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
))}
|
||||||
|
</Timeline>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,20 @@ import { formatDate } from '@/lib/format'
|
|||||||
|
|
||||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||||
|
|
||||||
|
type HealthStatus =
|
||||||
|
| IpHealthStatus['status']
|
||||||
|
| 'healthy'
|
||||||
|
| 'unhealthy'
|
||||||
|
| 'checking'
|
||||||
|
| 'disabled'
|
||||||
|
|
||||||
|
function normalizeHealth(status: HealthStatus): IpHealthStatus['status'] {
|
||||||
|
if (status === 'healthy') return 'up'
|
||||||
|
if (status === 'unhealthy' || status === 'disabled') return 'down'
|
||||||
|
if (status === 'checking') return 'unknown'
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
const healthVariants: Record<IpHealthStatus['status'], BadgeVariant> = {
|
const healthVariants: Record<IpHealthStatus['status'], BadgeVariant> = {
|
||||||
up: 'success-light',
|
up: 'success-light',
|
||||||
degraded: 'warning-light',
|
degraded: 'warning-light',
|
||||||
@@ -21,6 +35,13 @@ const healthLabels: Record<IpHealthStatus['status'], string> = {
|
|||||||
unknown: '—',
|
unknown: '—',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extraLabels: Partial<Record<HealthStatus, string>> = {
|
||||||
|
healthy: 'Healthy',
|
||||||
|
unhealthy: 'Unhealthy',
|
||||||
|
checking: 'Checking',
|
||||||
|
disabled: 'Disabled',
|
||||||
|
}
|
||||||
|
|
||||||
const dotColor: Record<IpHealthStatus['status'], string> = {
|
const dotColor: Record<IpHealthStatus['status'], string> = {
|
||||||
up: 'bg-success',
|
up: 'bg-success',
|
||||||
degraded: 'bg-warning',
|
degraded: 'bg-warning',
|
||||||
@@ -29,10 +50,12 @@ const dotColor: Record<IpHealthStatus['status'], string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface HealthCheckBadgeProps {
|
interface HealthCheckBadgeProps {
|
||||||
status: IpHealthStatus['status']
|
status: HealthStatus
|
||||||
latencyMs?: number | null
|
latencyMs?: number | null
|
||||||
lastCheckedAt?: string | null
|
lastCheckedAt?: string | null
|
||||||
lastError?: string | null
|
lastError?: string | null
|
||||||
|
colo?: string | null
|
||||||
|
provider?: 'local' | 'cloudflare' | string | null
|
||||||
title?: string
|
title?: string
|
||||||
showLatency?: boolean
|
showLatency?: boolean
|
||||||
size?: 'xs' | 'sm'
|
size?: 'xs' | 'sm'
|
||||||
@@ -44,19 +67,25 @@ export function HealthCheckBadge({
|
|||||||
latencyMs,
|
latencyMs,
|
||||||
lastCheckedAt,
|
lastCheckedAt,
|
||||||
lastError,
|
lastError,
|
||||||
|
colo,
|
||||||
|
provider,
|
||||||
title,
|
title,
|
||||||
showLatency = false,
|
showLatency = false,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
className,
|
className,
|
||||||
}: HealthCheckBadgeProps) {
|
}: HealthCheckBadgeProps) {
|
||||||
const variant = healthVariants[status]
|
const normalized = normalizeHealth(status)
|
||||||
const label = healthLabels[status]
|
const variant = healthVariants[normalized]
|
||||||
|
const label = extraLabels[status] ?? healthLabels[normalized]
|
||||||
|
|
||||||
const tooltipParts: string[] = []
|
const tooltipParts: string[] = []
|
||||||
if (title) tooltipParts.push(title)
|
if (title) tooltipParts.push(title)
|
||||||
tooltipParts.push(`Статус: ${label}`)
|
tooltipParts.push(`Статус: ${label}`)
|
||||||
if (latencyMs != null) tooltipParts.push(`Задержка: ${latencyMs} мс`)
|
if (latencyMs != null) tooltipParts.push(`Задержка: ${latencyMs} мс`)
|
||||||
if (lastCheckedAt) tooltipParts.push(`Проверка: ${formatDate(lastCheckedAt)}`)
|
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}`)
|
if (lastError) tooltipParts.push(`Ошибка: ${lastError}`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -74,7 +103,7 @@ export function HealthCheckBadge({
|
|||||||
className={cn('gap-1.5', className)}
|
className={cn('gap-1.5', className)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn('size-1.5 shrink-0 rounded-full', dotColor[status])}
|
className={cn('size-1.5 shrink-0 rounded-full', dotColor[normalized])}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -18,10 +18,19 @@ import {
|
|||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
HealthAggregateTiles,
|
||||||
|
HealthSourceTiles,
|
||||||
|
type HealthAggregate,
|
||||||
|
type HealthProvider,
|
||||||
|
} from '@/components/reui-kit/health-source-tiles'
|
||||||
|
import { uniqueHealthProviders } from '@cfdm/shared'
|
||||||
|
|
||||||
export type LbMode = 'round_robin' | 'failover' | 'weighted'
|
export type LbMode = 'round_robin' | 'failover' | 'weighted'
|
||||||
export type HealthCheckType = 'tcp' | 'http'
|
export type HealthCheckType = 'tcp' | 'http'
|
||||||
|
export type { HealthProvider, HealthAggregate }
|
||||||
|
|
||||||
export interface HealthCheckConfig {
|
export interface HealthCheckConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -32,6 +41,13 @@ export interface HealthCheckConfig {
|
|||||||
interval_sec: number
|
interval_sec: number
|
||||||
timeout_ms: number
|
timeout_ms: number
|
||||||
verify_tls: boolean
|
verify_tls: boolean
|
||||||
|
provider: HealthProvider
|
||||||
|
providers: HealthProvider[]
|
||||||
|
aggregate: HealthAggregate
|
||||||
|
method?: string | null
|
||||||
|
retries?: number
|
||||||
|
consecutive_fails?: number
|
||||||
|
consecutive_successes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LbAndHealthConfig extends HealthCheckConfig {
|
export interface LbAndHealthConfig extends HealthCheckConfig {
|
||||||
@@ -44,21 +60,6 @@ const defaultLbModeOptions = [
|
|||||||
{ value: 'weighted', label: 'Weighted (веса)' },
|
{ value: 'weighted', label: 'Weighted (веса)' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const healthCheckTypes = [
|
|
||||||
{ value: 'tcp', label: 'TCP connect' },
|
|
||||||
{ value: 'http', label: 'HTTP' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
interface HealthCheckConfigFieldsProps {
|
|
||||||
value: LbAndHealthConfig
|
|
||||||
onChange: (next: LbAndHealthConfig) => void
|
|
||||||
lbModeLabel?: string
|
|
||||||
lbModeOptions?: { value: string; label: string }[]
|
|
||||||
idPrefix?: string
|
|
||||||
showLbMode?: boolean
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function CompactNumberField({
|
function CompactNumberField({
|
||||||
id,
|
id,
|
||||||
value,
|
value,
|
||||||
@@ -100,11 +101,24 @@ export function HealthCheckConfigFields({
|
|||||||
idPrefix = 'health',
|
idPrefix = 'health',
|
||||||
showLbMode = true,
|
showLbMode = true,
|
||||||
className,
|
className,
|
||||||
}: HealthCheckConfigFieldsProps) {
|
}: {
|
||||||
|
value: LbAndHealthConfig
|
||||||
|
onChange: (next: LbAndHealthConfig) => void
|
||||||
|
lbModeLabel?: string
|
||||||
|
lbModeOptions?: { value: string; label: string }[]
|
||||||
|
idPrefix?: string
|
||||||
|
showLbMode?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
function patch(next: Partial<LbAndHealthConfig>) {
|
function patch(next: Partial<LbAndHealthConfig>) {
|
||||||
onChange({ ...value, ...next })
|
onChange({ ...value, ...next })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const providers =
|
||||||
|
value.providers?.length > 0
|
||||||
|
? uniqueHealthProviders(value.providers)
|
||||||
|
: uniqueHealthProviders([value.provider ?? 'local'])
|
||||||
|
const aggregate = value.aggregate ?? 'majority'
|
||||||
const isHttp = value.type === 'http'
|
const isHttp = value.type === 'http'
|
||||||
const rowClass = 'gap-3 px-0 py-3'
|
const rowClass = 'gap-3 px-0 py-3'
|
||||||
|
|
||||||
@@ -119,6 +133,7 @@ export function HealthCheckConfigFields({
|
|||||||
className={rowClass}
|
className={rowClass}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
|
modal={false}
|
||||||
value={value.lb_mode}
|
value={value.lb_mode}
|
||||||
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
|
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
|
||||||
>
|
>
|
||||||
@@ -136,6 +151,43 @@ export function HealthCheckConfigFields({
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Провайдер health-check"
|
||||||
|
description="Кто пробирует цель. Можно выбрать несколько источников."
|
||||||
|
labelFor={`${idPrefix}-provider`}
|
||||||
|
compact
|
||||||
|
stacked
|
||||||
|
className={rowClass}
|
||||||
|
contentClassName="min-w-0"
|
||||||
|
>
|
||||||
|
<HealthSourceTiles
|
||||||
|
value={providers}
|
||||||
|
onChange={(next) =>
|
||||||
|
patch({
|
||||||
|
providers: next,
|
||||||
|
provider: next[0] ?? 'local',
|
||||||
|
enabled: next.includes('cloudflare') ? true : value.enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{providers.length > 1 ? (
|
||||||
|
<SettingRow
|
||||||
|
title="Агрегация"
|
||||||
|
description="Как свести результаты источников в один статус IP для failover"
|
||||||
|
compact
|
||||||
|
stacked
|
||||||
|
className={rowClass}
|
||||||
|
contentClassName="min-w-0"
|
||||||
|
>
|
||||||
|
<HealthAggregateTiles
|
||||||
|
value={aggregate}
|
||||||
|
onChange={(next) => patch({ aggregate: next })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Health-check"
|
title="Health-check"
|
||||||
description="TCP/HTTP проверка цели DNS"
|
description="TCP/HTTP проверка цели DNS"
|
||||||
@@ -163,21 +215,28 @@ export function HealthCheckConfigFields({
|
|||||||
<div className="flex flex-col gap-3 pt-1 pb-1">
|
<div className="flex flex-col gap-3 pt-1 pb-1">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
|
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
|
||||||
<Select
|
<ToggleGroup
|
||||||
value={value.type}
|
id={`${idPrefix}-type`}
|
||||||
onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })}
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
spacing={0}
|
||||||
|
multiple={false}
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={[value.type]}
|
||||||
|
onValueChange={(next) => {
|
||||||
|
const picked = next[0]
|
||||||
|
if (picked === 'tcp' || picked === 'http') {
|
||||||
|
patch({ type: picked })
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger id={`${idPrefix}-type`} className="w-full">
|
<ToggleGroupItem value="tcp" className="flex-1">
|
||||||
<SelectValue placeholder="Тип" />
|
TCP
|
||||||
</SelectTrigger>
|
</ToggleGroupItem>
|
||||||
<SelectContent>
|
<ToggleGroupItem value="http" className="flex-1">
|
||||||
{healthCheckTypes.map((item) => (
|
HTTP
|
||||||
<SelectItem key={item.value} value={item.value}>
|
</ToggleGroupItem>
|
||||||
{item.label}
|
</ToggleGroup>
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormFieldSimple>
|
</FormFieldSimple>
|
||||||
|
|
||||||
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
|
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
|
||||||
@@ -239,32 +298,18 @@ export function HealthCheckConfigFields({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
|
||||||
<FormFieldSimple label="Интервал, сек" htmlFor={`${idPrefix}-interval`}>
|
<CompactNumberField
|
||||||
<CompactNumberField
|
id={`${idPrefix}-timeout`}
|
||||||
id={`${idPrefix}-interval`}
|
value={value.timeout_ms}
|
||||||
value={value.interval_sec}
|
min={100}
|
||||||
min={5}
|
max={30000}
|
||||||
max={3600}
|
placeholder="3000"
|
||||||
placeholder="30"
|
onValueChange={(next) =>
|
||||||
onValueChange={(next) =>
|
patch({ timeout_ms: next ?? 3000 })
|
||||||
patch({ interval_sec: next ?? 30 })
|
}
|
||||||
}
|
/>
|
||||||
/>
|
</FormFieldSimple>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface HealthTimelineEvent {
|
|||||||
latency_ms?: number | null
|
latency_ms?: number | null
|
||||||
error?: string | null
|
error?: string | null
|
||||||
checked_at: string
|
checked_at: string
|
||||||
|
colo?: string | null
|
||||||
|
provider?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HealthTimelineProps {
|
interface HealthTimelineProps {
|
||||||
@@ -63,6 +65,8 @@ export function HealthTimeline({ events }: HealthTimelineProps) {
|
|||||||
<HealthCheckBadge
|
<HealthCheckBadge
|
||||||
status={event.status}
|
status={event.status}
|
||||||
latencyMs={event.latency_ms}
|
latencyMs={event.latency_ms}
|
||||||
|
colo={event.colo}
|
||||||
|
provider={event.provider}
|
||||||
size="xs"
|
size="xs"
|
||||||
showLatency
|
showLatency
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
|||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
|
import { ServiceFqdnList, ServiceIpList } from '@/components/services/service-fqdn-list'
|
||||||
import type { ServiceView } from '@/lib/schemas'
|
import type { ServiceView } from '@/lib/schemas'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import {
|
import {
|
||||||
@@ -78,7 +78,22 @@ export function ServiceKanbanCard({
|
|||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
|
|
||||||
<ItemContent className="min-w-0 gap-2">
|
<ItemContent className="min-w-0 gap-2">
|
||||||
<ServiceFqdnList service={service} />
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="text-muted-foreground text-xs">Общий домен</span>
|
||||||
|
<ServiceFqdnList
|
||||||
|
copyable
|
||||||
|
service={service}
|
||||||
|
emptyLabel="Не задан"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="text-muted-foreground text-xs">IP</span>
|
||||||
|
<ServiceIpList
|
||||||
|
copyable
|
||||||
|
ips={service.ips ?? []}
|
||||||
|
ipHealth={service.ip_health ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
||||||
<ItemFooter className="min-w-0 justify-between gap-2">
|
<ItemFooter className="min-w-0 justify-between gap-2">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link, useNavigate } from '@tanstack/react-router'
|
|||||||
import {
|
import {
|
||||||
FolderTreeIcon,
|
FolderTreeIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
|
HeartPulseIcon,
|
||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
@@ -57,6 +58,12 @@ const NAV_ITEMS = [
|
|||||||
keywords: ['certificates', 'ssl', 'tls'],
|
keywords: ['certificates', 'ssl', 'tls'],
|
||||||
icon: ShieldCheckIcon,
|
icon: ShieldCheckIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/settings/health',
|
||||||
|
label: 'Health-check',
|
||||||
|
keywords: ['health', 'health-check', 'cron', 'пороги', 'настройки'],
|
||||||
|
icon: HeartPulseIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/integrations',
|
to: '/settings/integrations',
|
||||||
label: 'Настройки',
|
label: 'Настройки',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const routeTitles: Record<string, string> = {
|
|||||||
'/services': 'Сервисы',
|
'/services': 'Сервисы',
|
||||||
'/certificates': 'Сертификаты',
|
'/certificates': 'Сертификаты',
|
||||||
'/settings/appearance': 'Внешний вид',
|
'/settings/appearance': 'Внешний вид',
|
||||||
|
'/settings/health': 'Health-check',
|
||||||
'/settings/integrations': 'Интеграции',
|
'/settings/integrations': 'Интеграции',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +65,8 @@ function getBreadcrumbs(
|
|||||||
{ label: 'Настройки', href: '/settings/appearance' },
|
{ label: 'Настройки', href: '/settings/appearance' },
|
||||||
...(pathname === '/settings/integrations'
|
...(pathname === '/settings/integrations'
|
||||||
? [{ label: 'Интеграции', href: pathname }]
|
? [{ label: 'Интеграции', href: pathname }]
|
||||||
|
: pathname === '/settings/health'
|
||||||
|
? [{ label: 'Health-check', href: pathname }]
|
||||||
: pathname === '/settings/appearance'
|
: pathname === '/settings/appearance'
|
||||||
? [{ label: 'Внешний вид', href: pathname }]
|
? [{ label: 'Внешний вид', href: pathname }]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import type { KeyboardEvent, ReactNode } from 'react'
|
||||||
|
import { CheckIcon, ServerIcon, 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'
|
||||||
|
import { kpiCols } from './kpi-cols'
|
||||||
|
|
||||||
|
export type HealthProvider = HealthCheckProvider
|
||||||
|
export type HealthAggregate = HealthCheckAggregate
|
||||||
|
|
||||||
|
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||||
|
|
||||||
|
/** Official Cloudflare mark (Simple Icons). */
|
||||||
|
function CloudflareMark() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Official Globalping mark (globalping.io favicon). */
|
||||||
|
function GlobalpingMark() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 26 26" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M10.354 6.081a2.636 2.636 0 0 1 4.32.257 31.5 31.5 0 0 1 8.55-1.349A12.97 12.97 0 0 0 3.247 4.42a33.6 33.6 0 0 1 7.107 1.661m13.647.003-.764.02a30.6 30.6 0 0 0-8.235 1.3q.005.08 0 .16c0 .6-.222 1.178-.624 1.624a42 42 0 0 1 2.382 3.695q.478.835.884 1.645h.134c.348-.003.689.102.975.302q3.286-2.798 5.83-7.517l.052-.094m-11.16 2.616a2.65 2.65 0 0 1-2.997-.644c-3.276 1.658-6.702 4.284-9.022 8.45 1.579.6 3.232.982 4.914 1.135a1.566 1.566 0 0 1 2.811.026h.14a16 16 0 0 0 6.935-2.188 1.5 1.5 0 0 1-.097-.52 1.53 1.53 0 0 1 .475-1.105 39 39 0 0 0-3.16-5.154Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M18.866 24.463c.28-2.216-.22-4.582-1.06-6.825h-.019a1.66 1.66 0 0 1-.812-.211 17 17 0 0 1-7.622 2.467h-.14a1.563 1.563 0 0 1-2.902-.026 19 19 0 0 1-4.94-1.086 13 13 0 0 0 17.479 5.801zm6.324-15.97a26.5 26.5 0 0 1-5.82 7.218 1.4 1.4 0 0 1 .049.374 1.55 1.55 0 0 1-.56 1.18c.907 2.417 1.3 4.644 1.174 6.659a12.98 12.98 0 0 0 5.158-15.431Zm-15.258-.28a2.35 2.35 0 0 1-.045-1.108 33 33 0 0 0-7.46-1.658 12.97 12.97 0 0 0-1.829 11.45c2.106-3.682 5.268-6.627 9.334-8.684"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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: <CloudflareMark />,
|
||||||
|
iconClassName: 'text-warning [&_svg]:text-current',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'globalping',
|
||||||
|
title: 'Globalping',
|
||||||
|
description: 'Пробы из сети globalping.io',
|
||||||
|
icon: <GlobalpingMark />,
|
||||||
|
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 min-w-0 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 min-w-0 items-start gap-3">
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn('size-10.5 shrink-0', iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</IconTile>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||||
|
<span className="text-foreground truncate text-sm font-medium">{title}</span>
|
||||||
|
{selected ? (
|
||||||
|
<CheckIcon className="text-foreground size-4 shrink-0" aria-hidden />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TileGrid({ children, count }: { children: ReactNode; count: number }) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className="@container w-full min-w-0 overflow-hidden">
|
||||||
|
<div className={cn('grid min-w-0 gap-2', kpiCols(count))}>{children}</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мультивыбор источников проб (Local / Cloudflare / Globalping).
|
||||||
|
* Preview: https://reui.io/preview/base/card-12 · https://reui.io/preview/base/stats-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 (
|
||||||
|
<TileGrid count={PROVIDER_ITEMS.length}>
|
||||||
|
{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)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TileGrid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Правило агрегации (ровно одно): 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 (
|
||||||
|
<TileGrid count={AGGREGATE_ITEMS.length}>
|
||||||
|
{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)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TileGrid>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,3 +18,9 @@ export { OpsDashboard } from './ops-dashboard'
|
|||||||
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
|
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
|
||||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||||
|
export {
|
||||||
|
HealthSourceTiles,
|
||||||
|
HealthAggregateTiles,
|
||||||
|
type HealthProvider,
|
||||||
|
type HealthAggregate,
|
||||||
|
} from './health-source-tiles'
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||||
import { PaletteIcon, SettingsIcon } from 'lucide-react'
|
import { HeartPulseIcon, PaletteIcon, SettingsIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
|
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
@@ -21,6 +21,12 @@ const DEFAULT_TABS: SettingsTabConfig[] = [
|
|||||||
label: 'Внешний вид',
|
label: 'Внешний вид',
|
||||||
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
|
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'health',
|
||||||
|
to: '/settings/health',
|
||||||
|
label: 'Health-check',
|
||||||
|
icon: <HeartPulseIcon className="size-4" aria-hidden="true" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'integrations',
|
id: 'integrations',
|
||||||
to: '/settings/integrations',
|
to: '/settings/integrations',
|
||||||
@@ -37,7 +43,7 @@ interface SettingsShellProps {
|
|||||||
|
|
||||||
export function SettingsShell({
|
export function SettingsShell({
|
||||||
title = 'Настройки',
|
title = 'Настройки',
|
||||||
description = 'Внешний вид и интеграции',
|
description = 'Внешний вид, health-check и интеграции',
|
||||||
tabs = DEFAULT_TABS,
|
tabs = DEFAULT_TABS,
|
||||||
}: SettingsShellProps) {
|
}: SettingsShellProps) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link2Icon, PlusIcon, Trash2Icon } from 'lucide-react'
|
import { PlusIcon, Trash2Icon } from 'lucide-react'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
|
||||||
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
||||||
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
|
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +8,8 @@ import {
|
|||||||
type LbAndHealthConfig,
|
type LbAndHealthConfig,
|
||||||
type LbMode,
|
type LbMode,
|
||||||
type HealthCheckType,
|
type HealthCheckType,
|
||||||
|
type HealthProvider,
|
||||||
|
type HealthAggregate,
|
||||||
} from '@/components/health-check-config-fields'
|
} from '@/components/health-check-config-fields'
|
||||||
import type {
|
import type {
|
||||||
CreateServiceWithConfigInput,
|
CreateServiceWithConfigInput,
|
||||||
@@ -38,7 +38,6 @@ import {
|
|||||||
ItemGroup,
|
ItemGroup,
|
||||||
} from '@cfdm/ui/components/item'
|
} from '@cfdm/ui/components/item'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { TabsContent } from '@cfdm/ui/components/tabs'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -46,7 +45,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
import { Separator } from '@cfdm/ui/components/separator'
|
|
||||||
|
|
||||||
interface BindingHealthConfig {
|
interface BindingHealthConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -57,6 +55,9 @@ interface BindingHealthConfig {
|
|||||||
interval_sec: number
|
interval_sec: number
|
||||||
timeout_ms: number
|
timeout_ms: number
|
||||||
verify_tls: boolean
|
verify_tls: boolean
|
||||||
|
provider: HealthProvider
|
||||||
|
providers: HealthProvider[]
|
||||||
|
aggregate: HealthAggregate
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceBindingDraft {
|
export interface ServiceBindingDraft {
|
||||||
@@ -79,6 +80,9 @@ const defaultHealth: BindingHealthConfig = {
|
|||||||
interval_sec: 30,
|
interval_sec: 30,
|
||||||
timeout_ms: 3000,
|
timeout_ms: 3000,
|
||||||
verify_tls: false,
|
verify_tls: false,
|
||||||
|
provider: 'local',
|
||||||
|
providers: ['local'],
|
||||||
|
aggregate: 'majority',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServiceEditSheetProps {
|
interface ServiceEditSheetProps {
|
||||||
@@ -112,6 +116,12 @@ function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
|||||||
interval_sec: binding.health_check_interval_sec,
|
interval_sec: binding.health_check_interval_sec,
|
||||||
timeout_ms: binding.health_check_timeout_ms,
|
timeout_ms: binding.health_check_timeout_ms,
|
||||||
verify_tls: binding.health_check_verify_tls ?? false,
|
verify_tls: binding.health_check_verify_tls ?? false,
|
||||||
|
provider: binding.health_check_provider ?? 'local',
|
||||||
|
providers:
|
||||||
|
binding.health_check_providers?.length > 0
|
||||||
|
? binding.health_check_providers
|
||||||
|
: [binding.health_check_provider ?? 'local'],
|
||||||
|
aggregate: binding.health_check_aggregate ?? 'majority',
|
||||||
},
|
},
|
||||||
target_ip_weights: binding.target_ip_weights ?? {},
|
target_ip_weights: binding.target_ip_weights ?? {},
|
||||||
target_ip_priorities: binding.target_ip_priorities ?? {},
|
target_ip_priorities: binding.target_ip_priorities ?? {},
|
||||||
@@ -139,6 +149,9 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
|||||||
health_check_interval_sec: binding.health.interval_sec,
|
health_check_interval_sec: binding.health.interval_sec,
|
||||||
health_check_timeout_ms: binding.health.timeout_ms,
|
health_check_timeout_ms: binding.health.timeout_ms,
|
||||||
health_check_verify_tls: binding.health.verify_tls,
|
health_check_verify_tls: binding.health.verify_tls,
|
||||||
|
health_check_provider: binding.health.provider,
|
||||||
|
health_check_providers: binding.health.providers,
|
||||||
|
health_check_aggregate: binding.health.aggregate,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
fqdn: binding.fqdn.trim(),
|
fqdn: binding.fqdn.trim(),
|
||||||
@@ -154,10 +167,40 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
|||||||
health_check_interval_sec: binding.health.interval_sec,
|
health_check_interval_sec: binding.health.interval_sec,
|
||||||
health_check_timeout_ms: binding.health.timeout_ms,
|
health_check_timeout_ms: binding.health.timeout_ms,
|
||||||
health_check_verify_tls: binding.health.verify_tls,
|
health_check_verify_tls: binding.health.verify_tls,
|
||||||
|
health_check_provider: binding.health.provider,
|
||||||
|
health_check_providers: binding.health.providers,
|
||||||
|
health_check_aggregate: binding.health.aggregate,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
|
||||||
|
return {
|
||||||
|
fqdn,
|
||||||
|
record_type: 'A',
|
||||||
|
target_ips: [],
|
||||||
|
target_cname: '',
|
||||||
|
lb_mode: 'round_robin',
|
||||||
|
health: { ...defaultHealth },
|
||||||
|
target_ip_weights: {},
|
||||||
|
target_ip_priorities: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withPoolIps(draft: ServiceBindingDraft, pool: string[]): ServiceBindingDraft {
|
||||||
|
if (draft.record_type !== 'A' || draft.target_ips.length > 0 || pool.length === 0) {
|
||||||
|
return draft
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...draft,
|
||||||
|
target_ips: pool,
|
||||||
|
target_ip_weights: Object.fromEntries(pool.map((ip) => [ip, draft.target_ip_weights[ip] ?? 1])),
|
||||||
|
target_ip_priorities: Object.fromEntries(
|
||||||
|
pool.map((ip) => [ip, draft.target_ip_priorities[ip] ?? 1]),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ServiceEditSheet({
|
export function ServiceEditSheet({
|
||||||
mode,
|
mode,
|
||||||
service,
|
service,
|
||||||
@@ -176,10 +219,10 @@ export function ServiceEditSheet({
|
|||||||
const [slug, setSlug] = useState('')
|
const [slug, setSlug] = useState('')
|
||||||
const [serviceGroupId, setServiceGroupId] = useState('none')
|
const [serviceGroupId, setServiceGroupId] = useState('none')
|
||||||
const [ips, setIps] = useState<string[]>([])
|
const [ips, setIps] = useState<string[]>([])
|
||||||
|
const [commonFqdn, setCommonFqdn] = useState('')
|
||||||
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
|
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
|
||||||
const [lbWeight, setLbWeight] = useState(1)
|
const [lbWeight, setLbWeight] = useState(1)
|
||||||
const [lbPriority, setLbPriority] = useState(1)
|
const [lbPriority, setLbPriority] = useState(1)
|
||||||
const [activeTab, setActiveTab] = useState('general')
|
|
||||||
|
|
||||||
const groupItems = useMemo(
|
const groupItems = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -189,16 +232,8 @@ export function ServiceEditSheet({
|
|||||||
[groups],
|
[groups],
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedGroup = useMemo(() => {
|
|
||||||
if (serviceGroupId === 'none') return null
|
|
||||||
return groups.find((g) => String(g.id) === serviceGroupId) ?? null
|
|
||||||
}, [groups, serviceGroupId])
|
|
||||||
|
|
||||||
const groupHasDomain = Boolean(selectedGroup?.domain?.trim())
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setActiveTab('general')
|
|
||||||
if (mode === 'edit' && service) {
|
if (mode === 'edit' && service) {
|
||||||
setName(service.name)
|
setName(service.name)
|
||||||
setSlug(service.slug)
|
setSlug(service.slug)
|
||||||
@@ -206,7 +241,9 @@ export function ServiceEditSheet({
|
|||||||
service.service_group_id != null ? String(service.service_group_id) : 'none',
|
service.service_group_id != null ? String(service.service_group_id) : 'none',
|
||||||
)
|
)
|
||||||
setIps(service.ips ?? [])
|
setIps(service.ips ?? [])
|
||||||
setBindings(toBindingDrafts(service))
|
const drafts = toBindingDrafts(service)
|
||||||
|
setBindings(drafts)
|
||||||
|
setCommonFqdn(drafts[0]?.fqdn ?? '')
|
||||||
setLbWeight(service.lb_weight ?? 1)
|
setLbWeight(service.lb_weight ?? 1)
|
||||||
setLbPriority(service.lb_priority ?? 1)
|
setLbPriority(service.lb_priority ?? 1)
|
||||||
return
|
return
|
||||||
@@ -218,6 +255,7 @@ export function ServiceEditSheet({
|
|||||||
defaultGroupId != null ? String(defaultGroupId) : 'none',
|
defaultGroupId != null ? String(defaultGroupId) : 'none',
|
||||||
)
|
)
|
||||||
setIps([])
|
setIps([])
|
||||||
|
setCommonFqdn('')
|
||||||
setBindings([])
|
setBindings([])
|
||||||
setLbWeight(1)
|
setLbWeight(1)
|
||||||
setLbPriority(1)
|
setLbPriority(1)
|
||||||
@@ -229,23 +267,28 @@ export function ServiceEditSheet({
|
|||||||
[knownDomains],
|
[knownDomains],
|
||||||
)
|
)
|
||||||
|
|
||||||
function handleAddBinding() {
|
const extraBindings = bindings.slice(1)
|
||||||
setBindings((current) => [
|
|
||||||
...current,
|
function handleCommonFqdnChange(value: string) {
|
||||||
{
|
setCommonFqdn(value)
|
||||||
fqdn: '',
|
setBindings((current) => {
|
||||||
record_type: 'A',
|
if (current.length === 0) return current
|
||||||
target_ips: [],
|
return current.map((item, i) => (i === 0 ? { ...item, fqdn: value } : item))
|
||||||
target_cname: '',
|
})
|
||||||
lb_mode: 'round_robin',
|
|
||||||
health: { ...defaultHealth },
|
|
||||||
target_ip_weights: {},
|
|
||||||
target_ip_priorities: {},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRemoveBinding(index: number) {
|
function handleAddExtraBinding() {
|
||||||
|
setBindings((current) => {
|
||||||
|
const extra = withPoolIps(emptyBindingDraft(), ips)
|
||||||
|
if (current.length === 0) {
|
||||||
|
return [emptyBindingDraft(commonFqdn), extra]
|
||||||
|
}
|
||||||
|
return [...current, extra]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveExtraBinding(extraIndex: number) {
|
||||||
|
const index = extraIndex + 1
|
||||||
setBindings((current) => current.filter((_, i) => i !== index))
|
setBindings((current) => current.filter((_, i) => i !== index))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,68 +338,77 @@ export function ServiceEditSheet({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBindingMetaChange(
|
function healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
|
||||||
index: number,
|
return {
|
||||||
ip: string,
|
enabled: next.enabled,
|
||||||
meta: { weight?: number; priority?: number },
|
type: next.type,
|
||||||
) {
|
port: next.port,
|
||||||
setBindings((current) =>
|
path: next.path,
|
||||||
current.map((item, i) => {
|
expected_status: next.expected_status,
|
||||||
if (i !== index) return item
|
interval_sec: next.interval_sec,
|
||||||
const weights = { ...item.target_ip_weights }
|
timeout_ms: next.timeout_ms,
|
||||||
const priorities = { ...item.target_ip_priorities }
|
verify_tls: next.verify_tls,
|
||||||
if (meta.weight !== undefined) weights[ip] = meta.weight
|
provider: next.provider,
|
||||||
if (meta.priority !== undefined) priorities[ip] = meta.priority
|
providers: next.providers,
|
||||||
return { ...item, target_ip_weights: weights, target_ip_priorities: priorities }
|
aggregate: next.aggregate,
|
||||||
}),
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBindingHealthChange(index: number, next: LbAndHealthConfig) {
|
function handlePrimaryHealthChange(next: LbAndHealthConfig) {
|
||||||
setBindings((current) =>
|
const health = healthFromConfig(next)
|
||||||
current.map((item, i) =>
|
setBindings((current) => {
|
||||||
i === index
|
if (current.length === 0) {
|
||||||
? {
|
return [
|
||||||
...item,
|
{
|
||||||
lb_mode: next.lb_mode,
|
...withPoolIps(emptyBindingDraft(commonFqdn), ips),
|
||||||
health: {
|
lb_mode: next.lb_mode,
|
||||||
enabled: next.enabled,
|
health,
|
||||||
type: next.type,
|
},
|
||||||
port: next.port,
|
]
|
||||||
path: next.path,
|
}
|
||||||
expected_status: next.expected_status,
|
return current.map((item, index) =>
|
||||||
interval_sec: next.interval_sec,
|
index === 0 ? { ...item, lb_mode: next.lb_mode, health } : item,
|
||||||
timeout_ms: next.timeout_ms,
|
)
|
||||||
verify_tls: next.verify_tls,
|
})
|
||||||
},
|
}
|
||||||
}
|
|
||||||
: item,
|
const primaryHealthValue: LbAndHealthConfig = {
|
||||||
),
|
lb_mode: bindings[0]?.lb_mode ?? 'round_robin',
|
||||||
)
|
...(bindings[0]?.health ?? defaultHealth),
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveServiceGroupId(): number | null {
|
function resolveServiceGroupId(): number | null {
|
||||||
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
|
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncCommonDomain(current: ServiceBindingDraft[]): ServiceBindingDraft[] {
|
||||||
|
const trimmed = commonFqdn.trim()
|
||||||
|
if (!trimmed) return current
|
||||||
|
if (current.length === 0) {
|
||||||
|
return [withPoolIps(emptyBindingDraft(trimmed), ips)]
|
||||||
|
}
|
||||||
|
return current.map((item, index) => {
|
||||||
|
if (index !== 0) return item
|
||||||
|
return withPoolIps({ ...item, fqdn: trimmed }, ips)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
const domains = buildDomainsPayload(bindings)
|
const syncedBindings = syncCommonDomain(bindings)
|
||||||
|
const domains = buildDomainsPayload(syncedBindings)
|
||||||
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
|
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
|
||||||
const hasDuplicateFqdn =
|
const hasDuplicateFqdn =
|
||||||
new Set(normalizedFqdns).size !== normalizedFqdns.length
|
new Set(normalizedFqdns).size !== normalizedFqdns.length
|
||||||
if (hasDuplicateFqdn) {
|
if (hasDuplicateFqdn) {
|
||||||
toast.error('Укажите уникальные FQDN — дубликаты привязок недопустимы')
|
toast.error('Укажите уникальные FQDN — дубликаты привязок недопустимы')
|
||||||
setActiveTab('bindings')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const groupId = resolveServiceGroupId()
|
const groupId = resolveServiceGroupId()
|
||||||
const lbFields = groupHasDomain
|
|
||||||
? { lb_weight: lbWeight, lb_priority: lbPriority }
|
|
||||||
: {}
|
|
||||||
const configPayload = {
|
const configPayload = {
|
||||||
ips,
|
ips,
|
||||||
domains,
|
domains,
|
||||||
...lbFields,
|
lb_weight: lbWeight,
|
||||||
|
lb_priority: lbPriority,
|
||||||
}
|
}
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
onCreate?.({
|
onCreate?.({
|
||||||
@@ -364,7 +416,8 @@ export function ServiceEditSheet({
|
|||||||
slug: slug.trim(),
|
slug: slug.trim(),
|
||||||
service_group_id: groupId,
|
service_group_id: groupId,
|
||||||
ips,
|
ips,
|
||||||
...lbFields,
|
lb_weight: lbWeight,
|
||||||
|
lb_priority: lbPriority,
|
||||||
domains,
|
domains,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -394,29 +447,16 @@ export function ServiceEditSheet({
|
|||||||
<SheetHeader className="shrink-0 border-b pb-4">
|
<SheetHeader className="shrink-0 border-b pb-4">
|
||||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Настройте параметры сервиса и привязки FQDN → IP или CNAME. Один
|
Общий домен и IP задаются у сервиса. Дополнительные FQDN — ниже, зона
|
||||||
сервис может иметь несколько FQDN в разных зонах; зона определяется
|
определяется автоматически.
|
||||||
из FQDN автоматически.
|
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 py-4">
|
||||||
<CountedLineTabs
|
<section className="flex flex-col gap-3">
|
||||||
tabs={[
|
<h3 className="text-sm font-medium">Сервис</h3>
|
||||||
{ id: 'general', label: 'Основное' },
|
<FieldGroup className="flex flex-col gap-3">
|
||||||
{
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
id: 'bindings',
|
|
||||||
label: 'Привязки',
|
|
||||||
count: bindings.length > 0 ? bindings.length : undefined,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={setActiveTab}
|
|
||||||
className="flex w-full flex-col gap-4"
|
|
||||||
listClassName="mb-0 w-full"
|
|
||||||
>
|
|
||||||
<TabsContent value="general" className="flex flex-col gap-4">
|
|
||||||
<FieldGroup className="flex flex-col gap-4">
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
|
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
@@ -435,238 +475,182 @@ export function ServiceEditSheet({
|
|||||||
onChange={(e) => setSlug(e.target.value)}
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
|
|
||||||
<Select
|
|
||||||
items={groupItems}
|
|
||||||
value={serviceGroupId}
|
|
||||||
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="edit-service-group" className="w-full">
|
|
||||||
<SelectValue placeholder="Без группы" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{groupItems.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
|
||||||
<TaggedInput
|
|
||||||
id="edit-service-ips"
|
|
||||||
value={ips}
|
|
||||||
onChange={setIps}
|
|
||||||
placeholder="192.168.1.1"
|
|
||||||
validate={isValidIpv4}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
{groupHasDomain && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Балансировка внутри группы «{selectedGroup?.name}»: вес и приоритет
|
|
||||||
сервиса для общего домена группы.
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="service-lb-weight">Вес</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="service-lb-weight"
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
value={lbWeight}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLbWeight(Math.max(1, Number(e.target.value) || 1))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="service-lb-priority">Приоритет</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="service-lb-priority"
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
value={lbPriority}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLbPriority(Math.max(1, Number(e.target.value) || 1))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="bindings" className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Несколько FQDN в разных зонах → IP или CNAME для DNS Cloudflare
|
|
||||||
</p>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
|
||||||
<PlusIcon data-icon="inline-start" />
|
|
||||||
Добавить
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Field>
|
||||||
{bindings.length === 0 ? (
|
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
|
||||||
<EmptyState
|
<Select
|
||||||
icon={Link2Icon}
|
items={groupItems}
|
||||||
title="Нет привязок"
|
value={serviceGroupId}
|
||||||
description="Необязательно. Можно добавить несколько FQDN: api.ivx.su и www.other.su — зоны определятся автоматически."
|
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
|
||||||
centered={false}
|
>
|
||||||
action={
|
<SelectTrigger id="edit-service-group" className="w-full">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
<SelectValue placeholder="Без группы" />
|
||||||
<PlusIcon data-icon="inline-start" />
|
</SelectTrigger>
|
||||||
Добавить привязку
|
<SelectContent>
|
||||||
</Button>
|
{groupItems.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="edit-service-common-domain">
|
||||||
|
Общий домен (FQDN)
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="edit-service-common-domain"
|
||||||
|
className="font-mono"
|
||||||
|
value={commonFqdn}
|
||||||
|
placeholder={
|
||||||
|
zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'
|
||||||
}
|
}
|
||||||
|
onChange={(e) => handleCommonFqdnChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
) : (
|
</Field>
|
||||||
<ItemGroup className="gap-2">
|
<Field>
|
||||||
{bindings.map((binding, index) => {
|
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
||||||
const showLbBlock =
|
<TaggedInput
|
||||||
(binding.record_type === 'A' && binding.target_ips.length > 0) ||
|
id="edit-service-ips"
|
||||||
(binding.record_type === 'CNAME' && binding.target_cname.trim().length > 0)
|
value={ips}
|
||||||
const showMeta =
|
onChange={setIps}
|
||||||
binding.record_type === 'A' &&
|
placeholder="192.168.1.1"
|
||||||
binding.target_ips.length > 1 &&
|
validate={isValidIpv4}
|
||||||
binding.lb_mode !== 'round_robin'
|
/>
|
||||||
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
|
</Field>
|
||||||
return (
|
</FieldGroup>
|
||||||
<Item key={`binding-${index}`} variant="outline" className="items-stretch">
|
</section>
|
||||||
<ItemContent className="w-full flex flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
Привязка {index + 1}
|
|
||||||
</span>
|
|
||||||
{parsedZone ? (
|
|
||||||
<Badge variant="outline" size="xs" className="font-mono">
|
|
||||||
{parsedZone.zoneName}
|
|
||||||
</Badge>
|
|
||||||
) : binding.fqdn.trim() ? (
|
|
||||||
<Badge variant="warning-light" size="xs">
|
|
||||||
зона не найдена
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="shrink-0"
|
|
||||||
aria-label="Удалить привязку"
|
|
||||||
onClick={() => handleRemoveBinding(index)}
|
|
||||||
>
|
|
||||||
<Trash2Icon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Field className="min-w-0">
|
|
||||||
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id={`binding-fqdn-${index}`}
|
|
||||||
className="font-mono"
|
|
||||||
value={binding.fqdn}
|
|
||||||
onChange={(event) =>
|
|
||||||
handleFqdnChange(index, event.target.value)
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor={`binding-type-${index}`}>Тип записи</FieldLabel>
|
|
||||||
<Select
|
|
||||||
items={[
|
|
||||||
{ label: 'A (IP)', value: 'A' },
|
|
||||||
{ label: 'CNAME', value: 'CNAME' },
|
|
||||||
]}
|
|
||||||
value={binding.record_type}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
handleRecordTypeChange(index, (value ?? 'A') as 'A' | 'CNAME')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={`binding-type-${index}`} className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="A">A (IP)</SelectItem>
|
|
||||||
<SelectItem value="CNAME">CNAME</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
{binding.record_type === 'CNAME' ? (
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor={`binding-cname-${index}`}>
|
|
||||||
CNAME-цель
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id={`binding-cname-${index}`}
|
|
||||||
value={binding.target_cname}
|
|
||||||
placeholder="mmsk.rkns.top"
|
|
||||||
onChange={(event) => handleCnameChange(index, event.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
) : (
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
|
|
||||||
<ServiceBindingIpInput
|
|
||||||
id={`binding-ip-${index}`}
|
|
||||||
value={binding.target_ips}
|
|
||||||
pool={ips}
|
|
||||||
onChange={(targetIps) => handleIpsChange(index, targetIps)}
|
|
||||||
showMeta={showLbBlock && showMeta}
|
|
||||||
weights={binding.target_ip_weights}
|
|
||||||
priorities={binding.target_ip_priorities}
|
|
||||||
onMetaChange={(ip, meta) =>
|
|
||||||
handleBindingMetaChange(index, ip, meta)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showLbBlock ? (
|
<section className="flex flex-col gap-3">
|
||||||
<HealthCheckConfigFields
|
<h3 className="text-sm font-medium">Health check</h3>
|
||||||
value={{
|
<HealthCheckConfigFields
|
||||||
lb_mode: binding.lb_mode,
|
idPrefix="service-health"
|
||||||
enabled: binding.health.enabled,
|
value={primaryHealthValue}
|
||||||
type: binding.health.type,
|
onChange={handlePrimaryHealthChange}
|
||||||
port: binding.health.port,
|
/>
|
||||||
path: binding.health.path,
|
</section>
|
||||||
expected_status: binding.health.expected_status,
|
|
||||||
interval_sec: binding.health.interval_sec,
|
<section className="flex flex-col gap-3">
|
||||||
timeout_ms: binding.health.timeout_ms,
|
<div className="flex items-center justify-between gap-2">
|
||||||
verify_tls: binding.health.verify_tls,
|
<h3 className="text-sm font-medium">Доп. FQDN</h3>
|
||||||
}}
|
<Button
|
||||||
onChange={(next) => handleBindingHealthChange(index, next)}
|
type="button"
|
||||||
lbModeLabel="Режим балансировки"
|
variant="outline"
|
||||||
showLbMode={
|
size="sm"
|
||||||
binding.record_type === 'A' && binding.target_ips.length > 1
|
onClick={handleAddExtraBinding}
|
||||||
}
|
>
|
||||||
idPrefix={`binding-${index}-health`}
|
<PlusIcon data-icon="inline-start" />
|
||||||
/>
|
Добавить
|
||||||
) : null}
|
</Button>
|
||||||
</ItemContent>
|
</div>
|
||||||
</Item>
|
{extraBindings.length === 0 ? (
|
||||||
)
|
<p className="text-muted-foreground text-sm">
|
||||||
})}
|
Нет дополнительных FQDN
|
||||||
</ItemGroup>
|
</p>
|
||||||
)}
|
) : (
|
||||||
</TabsContent>
|
<ItemGroup className="gap-2">
|
||||||
</CountedLineTabs>
|
{extraBindings.map((binding, extraIndex) => {
|
||||||
|
const index = extraIndex + 1
|
||||||
|
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
key={`extra-binding-${index}`}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="items-stretch"
|
||||||
|
>
|
||||||
|
<ItemContent className="flex w-full min-w-0 flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{parsedZone ? (
|
||||||
|
<Badge variant="outline" size="xs" className="font-mono">
|
||||||
|
{parsedZone.zoneName}
|
||||||
|
</Badge>
|
||||||
|
) : binding.fqdn.trim() ? (
|
||||||
|
<Badge variant="warning-light" size="xs">
|
||||||
|
зона не найдена
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
FQDN
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="ml-auto shrink-0"
|
||||||
|
aria-label="Удалить FQDN"
|
||||||
|
onClick={() => handleRemoveExtraBinding(extraIndex)}
|
||||||
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_7.5rem]">
|
||||||
|
<Input
|
||||||
|
id={`extra-fqdn-${index}`}
|
||||||
|
className="font-mono"
|
||||||
|
value={binding.fqdn}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleFqdnChange(index, event.target.value)
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
zoneHints[0]
|
||||||
|
? `api.${zoneHints[0]}`
|
||||||
|
: 'api.ivx.su'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
items={[
|
||||||
|
{ label: 'A (IP)', value: 'A' },
|
||||||
|
{ label: 'CNAME', value: 'CNAME' },
|
||||||
|
]}
|
||||||
|
value={binding.record_type}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleRecordTypeChange(
|
||||||
|
index,
|
||||||
|
(value ?? 'A') as 'A' | 'CNAME',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id={`extra-type-${index}`}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="A">A (IP)</SelectItem>
|
||||||
|
<SelectItem value="CNAME">CNAME</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{binding.record_type === 'CNAME' ? (
|
||||||
|
<Input
|
||||||
|
id={`extra-cname-${index}`}
|
||||||
|
value={binding.target_cname}
|
||||||
|
placeholder="mmsk.rkns.top"
|
||||||
|
onChange={(event) =>
|
||||||
|
handleCnameChange(index, event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ServiceBindingIpInput
|
||||||
|
id={`extra-ip-${index}`}
|
||||||
|
value={binding.target_ips}
|
||||||
|
pool={ips}
|
||||||
|
onChange={(targetIps) =>
|
||||||
|
handleIpsChange(index, targetIps)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ItemGroup>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
|
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useForm, Controller } from 'react-hook-form'
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import {
|
import {
|
||||||
@@ -12,10 +12,6 @@ import { FormFieldSimple } from '@/components/form-field'
|
|||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { AppFieldGroup } from '@/components/app-field'
|
import { AppFieldGroup } from '@/components/app-field'
|
||||||
import { AppInput } from '@/components/app-input'
|
import { AppInput } from '@/components/app-input'
|
||||||
import {
|
|
||||||
HealthCheckConfigFields,
|
|
||||||
type LbAndHealthConfig,
|
|
||||||
} from '@/components/health-check-config-fields'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -44,18 +40,6 @@ interface ServiceGroupEditSheetProps {
|
|||||||
onSave?: (id: number, body: CreateServiceGroupInput) => void
|
onSave?: (id: number, body: CreateServiceGroupInput) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultLbHealth: LbAndHealthConfig = {
|
|
||||||
lb_mode: 'round_robin',
|
|
||||||
enabled: false,
|
|
||||||
type: 'tcp',
|
|
||||||
port: null,
|
|
||||||
path: null,
|
|
||||||
expected_status: null,
|
|
||||||
interval_sec: 30,
|
|
||||||
timeout_ms: 3000,
|
|
||||||
verify_tls: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ServiceGroupEditSheet({
|
export function ServiceGroupEditSheet({
|
||||||
mode,
|
mode,
|
||||||
group,
|
group,
|
||||||
@@ -73,7 +57,6 @@ export function ServiceGroupEditSheet({
|
|||||||
domain: null,
|
domain: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const [lbHealth, setLbHealth] = useState<LbAndHealthConfig>(defaultLbHealth)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -81,43 +64,19 @@ export function ServiceGroupEditSheet({
|
|||||||
form.reset({
|
form.reset({
|
||||||
name: group.name,
|
name: group.name,
|
||||||
type: group.type,
|
type: group.type,
|
||||||
domain: group.domain ?? null,
|
domain: null,
|
||||||
})
|
|
||||||
setLbHealth({
|
|
||||||
lb_mode: group.lb_mode,
|
|
||||||
enabled: group.health_check_enabled,
|
|
||||||
type: group.health_check_type === 'http' ? 'http' : 'tcp',
|
|
||||||
port: group.health_check_port,
|
|
||||||
path: group.health_check_path,
|
|
||||||
expected_status: group.health_check_expected_status,
|
|
||||||
interval_sec: group.health_check_interval_sec,
|
|
||||||
timeout_ms: group.health_check_timeout_ms,
|
|
||||||
verify_tls: group.health_check_verify_tls,
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
form.reset({ name: '', type: 'custom', domain: null })
|
form.reset({ name: '', type: 'custom', domain: null })
|
||||||
setLbHealth(defaultLbHealth)
|
|
||||||
}
|
}
|
||||||
}, [open, mode, group, form])
|
}, [open, mode, group, form])
|
||||||
|
|
||||||
const domainValue = form.watch('domain')
|
|
||||||
const hasDomain = Boolean(domainValue?.trim())
|
|
||||||
|
|
||||||
function handleSubmit(values: ServiceGroupFormValues) {
|
function handleSubmit(values: ServiceGroupFormValues) {
|
||||||
const body: CreateServiceGroupInput = {
|
const body: CreateServiceGroupInput = {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
type: values.type ?? 'custom',
|
type: values.type ?? 'custom',
|
||||||
icon: values.icon,
|
icon: values.icon,
|
||||||
domain: values.domain?.trim() || null,
|
domain: null,
|
||||||
lb_mode: lbHealth.lb_mode,
|
|
||||||
health_check_enabled: lbHealth.enabled,
|
|
||||||
health_check_type: lbHealth.type,
|
|
||||||
health_check_port: lbHealth.port,
|
|
||||||
health_check_path: lbHealth.path,
|
|
||||||
health_check_expected_status: lbHealth.expected_status,
|
|
||||||
health_check_interval_sec: lbHealth.interval_sec,
|
|
||||||
health_check_timeout_ms: lbHealth.timeout_ms,
|
|
||||||
health_check_verify_tls: lbHealth.verify_tls,
|
|
||||||
}
|
}
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
onCreate?.(body)
|
onCreate?.(body)
|
||||||
@@ -131,7 +90,7 @@ export function ServiceGroupEditSheet({
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title={mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
|
title={mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
|
||||||
description="Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов."
|
description="Группа нужна только для сортировки каталога. Общий домен и IP задаются у каждого сервиса."
|
||||||
form={form}
|
form={form}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
contentClassName="gap-6"
|
contentClassName="gap-6"
|
||||||
@@ -184,26 +143,7 @@ export function ServiceGroupEditSheet({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</FormFieldSimple>
|
</FormFieldSimple>
|
||||||
<FormFieldSimple
|
|
||||||
label="Домен группы (FQDN, необязательно)"
|
|
||||||
htmlFor="group-domain"
|
|
||||||
>
|
|
||||||
<AppInput
|
|
||||||
id="group-domain"
|
|
||||||
placeholder="domain.new.ivx.su"
|
|
||||||
{...form.register('domain')}
|
|
||||||
/>
|
|
||||||
</FormFieldSimple>
|
|
||||||
</AppFieldGroup>
|
</AppFieldGroup>
|
||||||
|
|
||||||
{hasDomain ? (
|
|
||||||
<HealthCheckConfigFields
|
|
||||||
value={lbHealth}
|
|
||||||
onChange={setLbHealth}
|
|
||||||
lbModeLabel="Режим балансировки общего домена"
|
|
||||||
idPrefix="group-lb-health"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</FormSheet>
|
</FormSheet>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { FolderOpenIcon, MoreHorizontalIcon, PlusIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import { ServiceGroupIcon } from '@/components/service-group-icon'
|
||||||
|
import { ServiceUnitCard } from '@/components/services/service-unit-card'
|
||||||
|
import type { ServiceGroup, ServiceGroupView, ServiceView } from '@/lib/schemas'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@cfdm/ui/components/dropdown-menu'
|
||||||
|
|
||||||
|
const GROUP_TYPE_LABELS: Record<ServiceGroup['type'], string> = {
|
||||||
|
vpn: 'VPN',
|
||||||
|
network: 'Сеть',
|
||||||
|
internet: 'Интернет',
|
||||||
|
bgp: 'BGP',
|
||||||
|
custom: 'Другое',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceCatalogSectionProps {
|
||||||
|
group: ServiceGroupView | null
|
||||||
|
services: ServiceView[]
|
||||||
|
togglingId: number | null
|
||||||
|
togglingIp: { serviceId: number; ip: string } | null
|
||||||
|
onEditService: (service: ServiceView) => void
|
||||||
|
onDeleteService: (service: ServiceView) => void
|
||||||
|
onToggleService: (serviceId: number, enabled: boolean) => void
|
||||||
|
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
|
||||||
|
onEditGroup: (group: ServiceGroupView) => void
|
||||||
|
onDeleteGroup: (group: ServiceGroupView) => void
|
||||||
|
onAddServiceToGroup: (groupId: number | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceCatalogSection({
|
||||||
|
group,
|
||||||
|
services,
|
||||||
|
togglingId,
|
||||||
|
togglingIp,
|
||||||
|
onEditService,
|
||||||
|
onDeleteService,
|
||||||
|
onToggleService,
|
||||||
|
onToggleServiceIp,
|
||||||
|
onEditGroup,
|
||||||
|
onDeleteGroup,
|
||||||
|
onAddServiceToGroup,
|
||||||
|
}: ServiceCatalogSectionProps) {
|
||||||
|
const title = group?.name ?? 'Без группы'
|
||||||
|
const typeLabel = group ? GROUP_TYPE_LABELS[group.type] : null
|
||||||
|
const groupId = group?.id ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="@container flex w-full flex-col gap-2"
|
||||||
|
aria-labelledby={`group-${groupId ?? 'none'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{group ? (
|
||||||
|
<ServiceGroupIcon type={group.type} />
|
||||||
|
) : (
|
||||||
|
<FolderOpenIcon />
|
||||||
|
)}
|
||||||
|
</IconTile>
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||||
|
<h2
|
||||||
|
id={`group-${groupId ?? 'none'}`}
|
||||||
|
className="truncate text-sm font-medium"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{typeLabel ? (
|
||||||
|
<span className="text-muted-foreground text-xs">{typeLabel}</span>
|
||||||
|
) : null}
|
||||||
|
<Badge variant="outline" size="xs" className="shrink-0 tabular-nums">
|
||||||
|
{services.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Добавить сервис в ${title}`}
|
||||||
|
onClick={() => onAddServiceToGroup(groupId)}
|
||||||
|
>
|
||||||
|
<PlusIcon aria-hidden />
|
||||||
|
</Button>
|
||||||
|
{group ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Действия группы ${group.name}`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon aria-hidden />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onAddServiceToGroup(group.id)}>
|
||||||
|
<PlusIcon aria-hidden />
|
||||||
|
Добавить сервис
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onEditGroup(group)}>
|
||||||
|
Изменить группу
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onDeleteGroup(group)}
|
||||||
|
>
|
||||||
|
Удалить группу
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{services.length === 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 py-1">
|
||||||
|
<span className="text-muted-foreground text-sm">Нет сервисов</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onAddServiceToGroup(groupId)}
|
||||||
|
>
|
||||||
|
<PlusIcon data-icon="inline-start" aria-hidden />
|
||||||
|
Добавить сервис
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-2 @xl:grid-cols-2 @4xl:grid-cols-3">
|
||||||
|
{services.map((service) => (
|
||||||
|
<ServiceUnitCard
|
||||||
|
key={service.id}
|
||||||
|
service={service}
|
||||||
|
togglingId={togglingId}
|
||||||
|
togglingIp={
|
||||||
|
togglingIp?.serviceId === service.id ? togglingIp.ip : null
|
||||||
|
}
|
||||||
|
onEditService={onEditService}
|
||||||
|
onDeleteService={onDeleteService}
|
||||||
|
onToggleService={onToggleService}
|
||||||
|
onToggleServiceIp={onToggleServiceIp}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
|
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { TruncatedText } from '@/components/truncated-text'
|
import { TruncatedText } from '@/components/truncated-text'
|
||||||
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
||||||
import type { ServiceView } from '@/lib/schemas'
|
import type { ServiceView } from '@/lib/schemas'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -10,16 +17,53 @@ import {
|
|||||||
} from '@cfdm/ui/components/tooltip'
|
} from '@cfdm/ui/components/tooltip'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
export function CopyFqdnButton({
|
||||||
|
value,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { isCopied, copyToClipboard } = useCopyToClipboard({
|
||||||
|
onCopy: () => toast.success('Скопировано'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className={className}
|
||||||
|
aria-label={isCopied ? 'Скопировано' : `Скопировать ${value}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
copyToClipboard(value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<CheckIcon className="text-success" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<CopyIcon aria-hidden />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface ServiceFqdnListProps {
|
interface ServiceFqdnListProps {
|
||||||
service: ServiceView
|
service: ServiceView
|
||||||
className?: string
|
className?: string
|
||||||
emptyLabel?: string
|
emptyLabel?: string
|
||||||
|
copyable?: boolean
|
||||||
|
textClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServiceFqdnList({
|
export function ServiceFqdnList({
|
||||||
service,
|
service,
|
||||||
className,
|
className,
|
||||||
emptyLabel = 'FQDN не задан',
|
emptyLabel = 'Нет FQDN',
|
||||||
|
copyable = false,
|
||||||
|
textClassName,
|
||||||
}: ServiceFqdnListProps) {
|
}: ServiceFqdnListProps) {
|
||||||
const fqdns = serviceDisplayFqdns(service)
|
const fqdns = serviceDisplayFqdns(service)
|
||||||
if (fqdns.length === 0) {
|
if (fqdns.length === 0) {
|
||||||
@@ -32,10 +76,16 @@ export function ServiceFqdnList({
|
|||||||
|
|
||||||
const [first, ...rest] = fqdns
|
const [first, ...rest] = fqdns
|
||||||
const extraCount = rest.length
|
const extraCount = rest.length
|
||||||
|
const copyValue = fqdns.join('\n')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex min-w-0 items-center gap-1.5', className)}>
|
<div className={cn('flex min-w-0 items-center gap-1.5', className)}>
|
||||||
<TruncatedText className="text-muted-foreground min-w-0 font-mono text-xs">
|
<TruncatedText
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground min-w-0 font-mono text-xs',
|
||||||
|
textClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{first}
|
{first}
|
||||||
</TruncatedText>
|
</TruncatedText>
|
||||||
{extraCount > 0 ? (
|
{extraCount > 0 ? (
|
||||||
@@ -62,6 +112,118 @@ export function ServiceFqdnList({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : null}
|
) : null}
|
||||||
|
{copyable ? <CopyFqdnButton value={copyValue} /> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const VISIBLE_IP_LIMIT = 6
|
||||||
|
|
||||||
|
interface ServiceIpListProps {
|
||||||
|
ips: string[]
|
||||||
|
ipHealth?: ServiceView['ip_health']
|
||||||
|
ipEnabled?: Record<string, boolean>
|
||||||
|
togglingIp?: string | null
|
||||||
|
ipToggleDisabled?: boolean
|
||||||
|
onToggleIp?: (ip: string, enabled: boolean) => void
|
||||||
|
className?: string
|
||||||
|
emptyLabel?: string
|
||||||
|
copyable?: boolean
|
||||||
|
textClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceIpList({
|
||||||
|
ips,
|
||||||
|
ipHealth = [],
|
||||||
|
ipEnabled = {},
|
||||||
|
togglingIp = null,
|
||||||
|
ipToggleDisabled = false,
|
||||||
|
onToggleIp,
|
||||||
|
className,
|
||||||
|
emptyLabel = 'Нет IP',
|
||||||
|
copyable = false,
|
||||||
|
textClassName,
|
||||||
|
}: ServiceIpListProps) {
|
||||||
|
if (ips.length === 0) {
|
||||||
|
return (
|
||||||
|
<span className={cn('text-muted-foreground text-xs', className)}>
|
||||||
|
{emptyLabel}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
|
||||||
|
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
|
||||||
|
const extraCount = ips.length - visible.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex min-w-0 flex-col gap-1', className)}>
|
||||||
|
{visible.map((ip) => {
|
||||||
|
const health = healthByIp.get(ip)
|
||||||
|
const enabled = ipEnabled[ip] !== false
|
||||||
|
return (
|
||||||
|
<div key={ip} className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<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
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 font-mono text-xs',
|
||||||
|
enabled ? 'text-muted-foreground' : 'text-muted-foreground/60',
|
||||||
|
textClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ip}
|
||||||
|
</TruncatedText>
|
||||||
|
{copyable ? <CopyFqdnButton value={ip} /> : null}
|
||||||
|
{onToggleIp ? (
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
className="ml-auto shrink-0"
|
||||||
|
checked={enabled}
|
||||||
|
disabled={ipToggleDisabled || togglingIp === ip}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onCheckedChange={(checked) => onToggleIp(ip, Boolean(checked))}
|
||||||
|
aria-label={
|
||||||
|
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{extraCount > 0 ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="w-fit shrink-0 tabular-nums"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
ещё {extraCount}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<ul className="flex flex-col gap-0.5 font-mono text-xs">
|
||||||
|
{ips.slice(VISIBLE_IP_LIMIT).map((ip) => (
|
||||||
|
<li key={ip}>{ip}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import {
|
||||||
|
CopyFqdnButton,
|
||||||
|
ServiceIpList,
|
||||||
|
} from '@/components/services/service-fqdn-list'
|
||||||
|
import { serviceDisplayFqdn, serviceDisplayFqdns } from '@/lib/service-utils'
|
||||||
|
import type { ServiceView } from '@/lib/schemas'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@cfdm/ui/components/dropdown-menu'
|
||||||
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
|
||||||
|
interface ServiceUnitCardProps {
|
||||||
|
service: ServiceView
|
||||||
|
togglingId: number | null
|
||||||
|
togglingIp: string | null
|
||||||
|
onEditService: (service: ServiceView) => void
|
||||||
|
onDeleteService: (service: ServiceView) => void
|
||||||
|
onToggleService: (serviceId: number, enabled: boolean) => void
|
||||||
|
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceUnitCard({
|
||||||
|
service,
|
||||||
|
togglingId,
|
||||||
|
togglingIp,
|
||||||
|
onEditService,
|
||||||
|
onDeleteService,
|
||||||
|
onToggleService,
|
||||||
|
onToggleServiceIp,
|
||||||
|
}: ServiceUnitCardProps) {
|
||||||
|
const fqdns = serviceDisplayFqdns(service)
|
||||||
|
const primaryDomain = serviceDisplayFqdn(service)
|
||||||
|
const extraCount = Math.max(0, fqdns.length - 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden">
|
||||||
|
<FrameHeader className="flex-row items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<ServerIcon />
|
||||||
|
</IconTile>
|
||||||
|
<div className="flex min-w-0 flex-col gap-px">
|
||||||
|
<FrameTitle className="min-w-0 truncate text-sm">
|
||||||
|
<Link
|
||||||
|
to="/services/$serviceId"
|
||||||
|
params={{ serviceId: String(service.id) }}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{service.name}
|
||||||
|
</Link>
|
||||||
|
</FrameTitle>
|
||||||
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
|
<FrameDescription className="min-w-0 truncate font-mono text-xs">
|
||||||
|
{primaryDomain}
|
||||||
|
</FrameDescription>
|
||||||
|
{extraCount > 0 ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="shrink-0 tabular-nums"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+{extraCount}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<ul className="flex flex-col gap-0.5 font-mono text-xs">
|
||||||
|
{fqdns.map((fqdn) => (
|
||||||
|
<li key={fqdn}>{fqdn}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : null}
|
||||||
|
{primaryDomain !== '—' ? (
|
||||||
|
<CopyFqdnButton value={fqdns.join('\n')} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
checked={service.enabled}
|
||||||
|
disabled={togglingId === service.id}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onToggleService(service.id, Boolean(checked))
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
service.enabled ? 'Выключить сервис' : 'Включить сервис'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Действия ${service.name}`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon aria-hidden />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
render={
|
||||||
|
<Link
|
||||||
|
to="/services/$serviceId"
|
||||||
|
params={{ serviceId: String(service.id) }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Обзор
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onEditService(service)}>
|
||||||
|
Изменить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onDeleteService(service)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</FrameHeader>
|
||||||
|
|
||||||
|
<FramePanel className="flex min-w-0 flex-col gap-1 pt-0 shadow-none!">
|
||||||
|
<ServiceIpList
|
||||||
|
copyable
|
||||||
|
ips={service.ips ?? []}
|
||||||
|
ipHealth={service.ip_health ?? []}
|
||||||
|
ipEnabled={service.ip_enabled ?? {}}
|
||||||
|
ipToggleDisabled={togglingId === service.id}
|
||||||
|
togglingIp={togglingIp}
|
||||||
|
onToggleIp={(ip, enabled) =>
|
||||||
|
onToggleServiceIp(service.id, ip, enabled)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,45 +1,26 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
import {
|
|
||||||
getCoreRowModel,
|
|
||||||
getExpandedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
type ExpandedState,
|
|
||||||
} from '@tanstack/react-table'
|
|
||||||
import {
|
import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
FilterIcon,
|
|
||||||
FolderPlusIcon,
|
FolderPlusIcon,
|
||||||
FunnelXIcon,
|
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
SearchIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
|
||||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
|
||||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
|
||||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
|
||||||
import { Filters, type Filter } from '@/components/reui/filters'
|
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
FrameDescription,
|
||||||
FrameFooter,
|
|
||||||
FrameHeader,
|
FrameHeader,
|
||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { applyFiltersToData } from '@/components/reui-kit/filter-utils'
|
import { ServiceCatalogSection } from '@/components/services/service-catalog-section'
|
||||||
import { createServicesGroupedColumns } from '@/components/services/services-grouped-columns'
|
|
||||||
import type { ServiceCatalogTreeRow } from '@/components/services/services-grouped-columns'
|
|
||||||
import {
|
import {
|
||||||
SERVICE_TABS,
|
|
||||||
createDefaultServiceFilters,
|
|
||||||
serviceFilterFieldValue,
|
|
||||||
serviceTabFilter,
|
serviceTabFilter,
|
||||||
useServiceFilterFields,
|
|
||||||
type ServiceCatalogRow,
|
type ServiceCatalogRow,
|
||||||
} from '@/components/columns/services-columns'
|
} from '@/components/columns/services-columns'
|
||||||
|
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
||||||
import type {
|
import type {
|
||||||
ServiceGroupView,
|
ServiceGroupView,
|
||||||
ServiceGroupsResponse,
|
ServiceGroupsResponse,
|
||||||
@@ -52,23 +33,29 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@cfdm/ui/components/dropdown-menu'
|
} from '@cfdm/ui/components/dropdown-menu'
|
||||||
import { Separator } from '@cfdm/ui/components/separator'
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupText,
|
||||||
|
} from '@cfdm/ui/components/input-group'
|
||||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||||
|
|
||||||
const HEALTH_TABS = [
|
|
||||||
{ id: 'health-ok', label: 'OK' },
|
|
||||||
{ id: 'health-slow', label: 'Slow' },
|
|
||||||
{ id: 'health-down', label: 'Down' },
|
|
||||||
{ id: 'health-unknown', label: '—' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const ALL_TABS = [...SERVICE_TABS, ...HEALTH_TABS] as const
|
|
||||||
|
|
||||||
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
|
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
|
||||||
if (domainId == null) return true
|
if (domainId == null) return true
|
||||||
return service.domains.some((d) => d.domain_id === domainId)
|
return service.domains.some((d) => d.domain_id === domainId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serviceMatchesQuery(service: ServiceView, query: string) {
|
||||||
|
const needle = query.trim().toLowerCase()
|
||||||
|
if (!needle) return true
|
||||||
|
if (service.name.toLowerCase().includes(needle)) return true
|
||||||
|
if (service.slug.toLowerCase().includes(needle)) return true
|
||||||
|
return serviceDisplayFqdns(service).some((fqdn) =>
|
||||||
|
fqdn.toLowerCase().includes(needle),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function toCatalogRow(
|
function toCatalogRow(
|
||||||
service: ServiceView,
|
service: ServiceView,
|
||||||
groupId: number | null,
|
groupId: number | null,
|
||||||
@@ -86,72 +73,51 @@ function toCatalogRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function catalogTabFilter(row: ServiceCatalogRow, tabId: string) {
|
interface GroupUnitData {
|
||||||
if (tabId.startsWith('health-')) {
|
id: string
|
||||||
const status = row.service.health_status ?? 'unknown'
|
group: ServiceGroupView | null
|
||||||
if (tabId === 'health-ok') return status === 'up'
|
services: ServiceView[]
|
||||||
if (tabId === 'health-slow') return status === 'degraded'
|
|
||||||
if (tabId === 'health-down') return status === 'down'
|
|
||||||
if (tabId === 'health-unknown') return status === 'unknown'
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return serviceTabFilter(row, tabId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTreeRows(
|
function buildGroupUnits(
|
||||||
data: ServiceGroupsResponse,
|
data: ServiceGroupsResponse,
|
||||||
filteredServiceIds: Set<number>,
|
filteredServiceIds: Set<number>,
|
||||||
domainId?: number,
|
domainId: number | undefined,
|
||||||
): ServiceCatalogTreeRow[] {
|
showEmptyGroups: boolean,
|
||||||
const rows: ServiceCatalogTreeRow[] = []
|
): GroupUnitData[] {
|
||||||
|
const units: GroupUnitData[] = []
|
||||||
|
|
||||||
for (const group of data.groups) {
|
for (const group of data.groups) {
|
||||||
const services = group.services
|
const matchingDomain = group.services.filter((service) =>
|
||||||
.filter((s) => serviceMatchesDomain(s, domainId))
|
serviceMatchesDomain(service, domainId),
|
||||||
.filter((s) => filteredServiceIds.has(s.id))
|
)
|
||||||
if (services.length === 0) continue
|
const services = matchingDomain.filter((service) =>
|
||||||
|
filteredServiceIds.has(service.id),
|
||||||
|
)
|
||||||
|
|
||||||
rows.push({
|
if (services.length === 0) {
|
||||||
kind: 'group',
|
if (showEmptyGroups && matchingDomain.length === 0) {
|
||||||
id: `group-${group.id}`,
|
units.push({ id: `group-${group.id}`, group, services: [] })
|
||||||
group,
|
}
|
||||||
subRows: services.map((service) => ({
|
continue
|
||||||
kind: 'service' as const,
|
}
|
||||||
id: `service-${service.id}`,
|
|
||||||
service,
|
units.push({ id: `group-${group.id}`, group, services })
|
||||||
groupId: group.id,
|
|
||||||
groupName: group.name,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ungrouped = data.ungrouped
|
const ungrouped = data.ungrouped
|
||||||
.filter((s) => serviceMatchesDomain(s, domainId))
|
.filter((service) => serviceMatchesDomain(service, domainId))
|
||||||
.filter((s) => filteredServiceIds.has(s.id))
|
.filter((service) => filteredServiceIds.has(service.id))
|
||||||
|
|
||||||
if (ungrouped.length > 0) {
|
if (ungrouped.length > 0) {
|
||||||
rows.push({
|
units.push({
|
||||||
kind: 'group',
|
|
||||||
id: 'group-ungrouped',
|
id: 'group-ungrouped',
|
||||||
group: null,
|
group: null,
|
||||||
subRows: ungrouped.map((service) => ({
|
services: ungrouped,
|
||||||
kind: 'service' as const,
|
|
||||||
id: `service-${service.id}`,
|
|
||||||
service,
|
|
||||||
groupId: null,
|
|
||||||
groupName: null,
|
|
||||||
})),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows
|
return units
|
||||||
}
|
|
||||||
|
|
||||||
function defaultExpanded(rows: ServiceCatalogTreeRow[]): ExpandedState {
|
|
||||||
return rows.reduce<Record<string, boolean>>((acc, row) => {
|
|
||||||
acc[row.id] = true
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServicesAddMenu({
|
export function ServicesAddMenu({
|
||||||
@@ -194,11 +160,13 @@ interface ServicesGroupedCatalogProps {
|
|||||||
primaryAction?: ReactNode
|
primaryAction?: ReactNode
|
||||||
hideHeader?: boolean
|
hideHeader?: boolean
|
||||||
togglingId: number | null
|
togglingId: number | null
|
||||||
|
togglingIp: { serviceId: number; ip: string } | null
|
||||||
activeTab?: string
|
activeTab?: string
|
||||||
onTabChange?: (tabId: string) => void
|
onTabChange?: (tabId: string) => void
|
||||||
onEditService: (service: ServiceView) => void
|
onEditService: (service: ServiceView) => void
|
||||||
onDeleteService: (service: ServiceView) => void
|
onDeleteService: (service: ServiceView) => void
|
||||||
onToggleService: (serviceId: number, enabled: boolean) => void
|
onToggleService: (serviceId: number, enabled: boolean) => void
|
||||||
|
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
|
||||||
onEditGroup: (group: ServiceGroupView) => void
|
onEditGroup: (group: ServiceGroupView) => void
|
||||||
onDeleteGroup: (group: ServiceGroupView) => void
|
onDeleteGroup: (group: ServiceGroupView) => void
|
||||||
onAddServiceToGroup: (groupId: number | null) => void
|
onAddServiceToGroup: (groupId: number | null) => void
|
||||||
@@ -213,24 +181,18 @@ export function ServicesGroupedCatalog({
|
|||||||
primaryAction,
|
primaryAction,
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
togglingId,
|
togglingId,
|
||||||
activeTab: controlledTab,
|
togglingIp,
|
||||||
onTabChange,
|
activeTab = 'all',
|
||||||
onEditService,
|
onEditService,
|
||||||
onDeleteService,
|
onDeleteService,
|
||||||
onToggleService,
|
onToggleService,
|
||||||
|
onToggleServiceIp,
|
||||||
onEditGroup,
|
onEditGroup,
|
||||||
onDeleteGroup,
|
onDeleteGroup,
|
||||||
onAddServiceToGroup,
|
onAddServiceToGroup,
|
||||||
emptyAction,
|
emptyAction,
|
||||||
}: ServicesGroupedCatalogProps) {
|
}: ServicesGroupedCatalogProps) {
|
||||||
const [internalTab, setInternalTab] = useState('all')
|
const [query, setQuery] = useState('')
|
||||||
const tab = controlledTab ?? internalTab
|
|
||||||
const setTab = onTabChange ?? setInternalTab
|
|
||||||
const [filters, setFilters] = useState<Filter[]>(() =>
|
|
||||||
createDefaultServiceFilters(),
|
|
||||||
)
|
|
||||||
const [expanded, setExpanded] = useState<ExpandedState>({})
|
|
||||||
const filterFields = useServiceFilterFields()
|
|
||||||
|
|
||||||
const flatRows = useMemo(() => {
|
const flatRows = useMemo(() => {
|
||||||
const rows: ServiceCatalogRow[] = []
|
const rows: ServiceCatalogRow[] = []
|
||||||
@@ -247,76 +209,33 @@ export function ServicesGroupedCatalog({
|
|||||||
return rows
|
return rows
|
||||||
}, [data, domainId])
|
}, [data, domainId])
|
||||||
|
|
||||||
const tabCounts = useMemo(() => {
|
|
||||||
const counts: Record<string, number> = {}
|
|
||||||
for (const t of ALL_TABS) {
|
|
||||||
counts[t.id] = flatRows.filter((row) => catalogTabFilter(row, t.id)).length
|
|
||||||
}
|
|
||||||
return counts
|
|
||||||
}, [flatRows])
|
|
||||||
|
|
||||||
const filteredIds = useMemo(() => {
|
const filteredIds = useMemo(() => {
|
||||||
const afterTab = flatRows.filter((row) => catalogTabFilter(row, tab))
|
const afterTab = flatRows.filter((row) => serviceTabFilter(row, activeTab))
|
||||||
const afterFilters = applyFiltersToData(afterTab, filters, (item, field) =>
|
const afterQuery = afterTab.filter((row) =>
|
||||||
serviceFilterFieldValue(item, field),
|
serviceMatchesQuery(row.service, query),
|
||||||
)
|
)
|
||||||
return new Set(afterFilters.map((r) => r.id))
|
return new Set(afterQuery.map((r) => r.id))
|
||||||
}, [flatRows, tab, filters])
|
}, [flatRows, activeTab, query])
|
||||||
|
|
||||||
const treeData = useMemo(
|
const showEmptyGroups =
|
||||||
() => buildTreeRows(data, filteredIds, domainId),
|
activeTab === 'all' && domainId == null && query.trim().length === 0
|
||||||
[data, filteredIds, domainId],
|
|
||||||
|
const units = useMemo(
|
||||||
|
() => buildGroupUnits(data, filteredIds, domainId, showEmptyGroups),
|
||||||
|
[data, filteredIds, domainId, showEmptyGroups],
|
||||||
)
|
)
|
||||||
|
|
||||||
const expandedKey = treeData.map((r) => r.id).join(',')
|
|
||||||
useEffect(() => {
|
|
||||||
setExpanded(defaultExpanded(treeData))
|
|
||||||
}, [expandedKey, treeData])
|
|
||||||
|
|
||||||
const columns = useMemo(
|
|
||||||
() =>
|
|
||||||
createServicesGroupedColumns({
|
|
||||||
onEditService,
|
|
||||||
onDeleteService,
|
|
||||||
onToggleService,
|
|
||||||
onEditGroup,
|
|
||||||
onDeleteGroup,
|
|
||||||
onAddServiceToGroup,
|
|
||||||
togglingId,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
onEditService,
|
|
||||||
onDeleteService,
|
|
||||||
onToggleService,
|
|
||||||
onEditGroup,
|
|
||||||
onDeleteGroup,
|
|
||||||
onAddServiceToGroup,
|
|
||||||
togglingId,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: treeData,
|
|
||||||
columns,
|
|
||||||
state: { expanded },
|
|
||||||
onExpandedChange: setExpanded,
|
|
||||||
getSubRows: (row) => (row.kind === 'group' ? row.subRows : undefined),
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getExpandedRowModel: getExpandedRowModel(),
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className="w-full">
|
<Frame dense spacing="sm" className="w-full">
|
||||||
<FrameHeader>
|
<FrameHeader>
|
||||||
<Skeleton className="h-5 w-48" />
|
<Skeleton className="h-5 w-48" />
|
||||||
<Skeleton className="mt-1 h-4 w-72" />
|
<Skeleton className="h-4 w-72" />
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
<FramePanel className="flex flex-col gap-3 p-4">
|
<FramePanel className="flex flex-col gap-3 p-4">
|
||||||
<Skeleton className="h-9 w-full max-w-md" />
|
<Skeleton className="h-8 w-full max-w-md" />
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<Skeleton key={i} className="h-10 w-full" />
|
<Skeleton key={i} className="h-36 w-full rounded-xl" />
|
||||||
))}
|
))}
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
@@ -342,112 +261,66 @@ export function ServicesGroupedCatalog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGrid
|
<Frame dense spacing="sm" className="w-full">
|
||||||
table={table}
|
{!hideHeader ? (
|
||||||
recordCount={filteredIds.size}
|
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||||
emptyMessage="Нет записей по выбранным фильтрам."
|
<div className="flex min-w-0 flex-col gap-px">
|
||||||
tableLayout={{ dense: true }}
|
<FrameTitle>Сервисы</FrameTitle>
|
||||||
>
|
<FrameDescription>
|
||||||
<Frame dense spacing="sm" className="w-full">
|
{domainLabel
|
||||||
{!hideHeader ? (
|
? `Каталог сервисов с привязками к ${domainLabel}`
|
||||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'}
|
||||||
<div className="flex min-w-0 flex-col gap-px">
|
</FrameDescription>
|
||||||
<FrameTitle>Сервисы</FrameTitle>
|
</div>
|
||||||
<FrameDescription>
|
{primaryAction ? (
|
||||||
{domainLabel
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
? `Каталог сервисов с привязками к ${domainLabel}`
|
{primaryAction}
|
||||||
: 'Группы, FQDN и доступность сервисов'}
|
|
||||||
</FrameDescription>
|
|
||||||
</div>
|
</div>
|
||||||
{primaryAction ? (
|
) : null}
|
||||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
</FrameHeader>
|
||||||
{primaryAction}
|
) : null}
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</FrameHeader>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<FramePanel className="p-0 shadow-none!">
|
<FramePanel className="flex flex-col gap-4">
|
||||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
<InputGroup className="max-w-md">
|
||||||
<CountedLineTabs
|
<InputGroupAddon>
|
||||||
tabs={ALL_TABS.map((t) => ({
|
<InputGroupText>
|
||||||
id: t.id,
|
<SearchIcon aria-hidden />
|
||||||
label: t.label,
|
</InputGroupText>
|
||||||
count: tabCounts[t.id] ?? 0,
|
</InputGroupAddon>
|
||||||
}))}
|
<InputGroupInput
|
||||||
value={tab}
|
value={query}
|
||||||
onValueChange={setTab}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
/>
|
placeholder="Поиск по названию"
|
||||||
</div>
|
aria-label="Поиск по названию"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
<Separator />
|
{units.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
title="Нет совпадений"
|
||||||
<Filters
|
description="Измените запрос поиска."
|
||||||
filters={filters}
|
/>
|
||||||
fields={filterFields}
|
) : (
|
||||||
onChange={setFilters}
|
<div className="flex flex-col gap-6">
|
||||||
size="default"
|
{units.map((unit) => (
|
||||||
trigger={
|
<ServiceCatalogSection
|
||||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
key={unit.id}
|
||||||
<FilterIcon className="size-4" aria-hidden />
|
group={unit.group}
|
||||||
Фильтры
|
services={unit.services}
|
||||||
</Button>
|
togglingId={togglingId}
|
||||||
}
|
togglingIp={togglingIp}
|
||||||
/>
|
onEditService={onEditService}
|
||||||
<Button
|
onDeleteService={onDeleteService}
|
||||||
type="button"
|
onToggleService={onToggleService}
|
||||||
variant="outline"
|
onToggleServiceIp={onToggleServiceIp}
|
||||||
onClick={() => {
|
onEditGroup={onEditGroup}
|
||||||
setTab('all')
|
onDeleteGroup={onDeleteGroup}
|
||||||
setFilters(createDefaultServiceFilters())
|
onAddServiceToGroup={onAddServiceToGroup}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FunnelXIcon className="size-4" aria-hidden />
|
|
||||||
Сбросить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{treeData.length === 0 ? (
|
|
||||||
<div className="p-6">
|
|
||||||
<EmptyState
|
|
||||||
title="Нет совпадений"
|
|
||||||
description="Измените фильтры или вкладку."
|
|
||||||
action={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setTab('all')
|
|
||||||
setFilters(createDefaultServiceFilters())
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сбросить
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
))}
|
||||||
) : (
|
</div>
|
||||||
<>
|
)}
|
||||||
<DataGridScrollArea>
|
</FramePanel>
|
||||||
<DataGridTable />
|
</Frame>
|
||||||
</DataGridScrollArea>
|
|
||||||
<Separator />
|
|
||||||
<FrameFooter>
|
|
||||||
<DataGridPagination
|
|
||||||
sizes={[5, 10, 20, 50]}
|
|
||||||
rowsPerPageLabel="Строк на странице"
|
|
||||||
info="{from} - {to} of {count}"
|
|
||||||
previousPageLabel="Предыдущая"
|
|
||||||
nextPageLabel="Следующая"
|
|
||||||
/>
|
|
||||||
</FrameFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
</DataGrid>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
import { useMemo } from 'react'
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import {
|
|
||||||
ChevronRightIcon,
|
|
||||||
FolderIcon,
|
|
||||||
MoreHorizontalIcon,
|
|
||||||
PlusIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
|
|
||||||
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
|
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@cfdm/ui/components/dropdown-menu'
|
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
|
||||||
|
|
||||||
export type ServiceTreeServiceRow = {
|
|
||||||
kind: 'service'
|
|
||||||
id: string
|
|
||||||
service: ServiceView
|
|
||||||
groupId: number | null
|
|
||||||
groupName: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServiceTreeGroupRow = {
|
|
||||||
kind: 'group'
|
|
||||||
id: string
|
|
||||||
group: ServiceGroupView | null
|
|
||||||
subRows: ServiceTreeServiceRow[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServiceCatalogTreeRow = ServiceTreeGroupRow | ServiceTreeServiceRow
|
|
||||||
|
|
||||||
function isServiceRow(row: ServiceCatalogTreeRow): row is ServiceTreeServiceRow {
|
|
||||||
return row.kind === 'service'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createServicesGroupedColumns({
|
|
||||||
onEditService,
|
|
||||||
onDeleteService,
|
|
||||||
onToggleService,
|
|
||||||
onEditGroup,
|
|
||||||
onDeleteGroup,
|
|
||||||
onAddServiceToGroup,
|
|
||||||
togglingId,
|
|
||||||
}: {
|
|
||||||
onEditService: (service: ServiceView) => void
|
|
||||||
onDeleteService: (service: ServiceView) => void
|
|
||||||
onToggleService: (serviceId: number, enabled: boolean) => void
|
|
||||||
onEditGroup: (group: ServiceGroupView) => void
|
|
||||||
onDeleteGroup: (group: ServiceGroupView) => void
|
|
||||||
onAddServiceToGroup: (groupId: number | null) => void
|
|
||||||
togglingId: number | null
|
|
||||||
}): ColumnDef<ServiceCatalogTreeRow>[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'name',
|
|
||||||
accessorFn: (row) =>
|
|
||||||
isServiceRow(row) ? row.service.name : (row.group?.name ?? 'Без группы'),
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Группа / сервис" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const original = row.original
|
|
||||||
if (!isServiceRow(original)) {
|
|
||||||
const title = original.group?.name ?? 'Без группы'
|
|
||||||
const domain = original.group?.domain
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label={
|
|
||||||
row.getIsExpanded() ? `Свернуть ${title}` : `Развернуть ${title}`
|
|
||||||
}
|
|
||||||
aria-expanded={row.getIsExpanded()}
|
|
||||||
className="text-muted-foreground hover:text-foreground size-6 shrink-0 p-0 shadow-none"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
row.getToggleExpandedHandler()()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronRightIcon
|
|
||||||
className={cn(
|
|
||||||
'size-3.5 shrink-0 transition-transform duration-150',
|
|
||||||
row.getIsExpanded() && 'rotate-90',
|
|
||||||
)}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
<FolderIcon
|
|
||||||
className="text-muted-foreground size-4 shrink-0"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<span className="truncate text-sm font-semibold">{title}</span>
|
|
||||||
<Badge variant="outline" size="xs" className="shrink-0">
|
|
||||||
{original.subRows.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{domain ? (
|
|
||||||
<span className="text-muted-foreground truncate font-mono text-xs">
|
|
||||||
{domain}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 flex-col gap-0.5 pl-8">
|
|
||||||
<span className="truncate text-sm font-medium">
|
|
||||||
{original.service.name}
|
|
||||||
</span>
|
|
||||||
<ServiceFqdnList service={original.service} emptyLabel="—" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enableSorting: false,
|
|
||||||
minSize: 260,
|
|
||||||
meta: { headerTitle: 'Группа / сервис', autoSize: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'health',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Доступность" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const original = row.original
|
|
||||||
if (!isServiceRow(original)) {
|
|
||||||
return (
|
|
||||||
<HealthCheckBadge
|
|
||||||
status={original.group?.health_status ?? 'unknown'}
|
|
||||||
latencyMs={original.group?.health_latency_ms}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<HealthCheckBadge
|
|
||||||
status={original.service.health_status ?? 'unknown'}
|
|
||||||
latencyMs={original.service.health_latency_ms}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
size: 120,
|
|
||||||
enableSorting: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'enabled',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Статус" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const original = row.original
|
|
||||||
if (!isServiceRow(original)) return null
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
checked={original.service.enabled}
|
|
||||||
disabled={togglingId === original.service.id}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onToggleService(original.service.id, Boolean(checked))
|
|
||||||
}
|
|
||||||
aria-label={
|
|
||||||
original.service.enabled
|
|
||||||
? 'Выключить сервис'
|
|
||||||
: 'Включить сервис'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatusBadge
|
|
||||||
status={original.service.enabled ? 'active' : 'disabled'}
|
|
||||||
label={original.service.enabled ? 'Вкл' : 'Выкл'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
size: 140,
|
|
||||||
enableSorting: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
enableSorting: false,
|
|
||||||
header: () => <span className="sr-only">Действия</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const original = row.original
|
|
||||||
if (!isServiceRow(original)) {
|
|
||||||
if (!original.group) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label="Добавить сервис без группы"
|
|
||||||
onClick={() => onAddServiceToGroup(null)}
|
|
||||||
>
|
|
||||||
<PlusIcon aria-hidden />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Действия группы ${original.group.name}`}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon aria-hidden />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onAddServiceToGroup(original.group!.id)}
|
|
||||||
>
|
|
||||||
<PlusIcon aria-hidden />
|
|
||||||
Добавить сервис
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => onEditGroup(original.group!)}>
|
|
||||||
Изменить группу
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => onDeleteGroup(original.group!)}
|
|
||||||
>
|
|
||||||
Удалить группу
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Действия ${original.service.name}`}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon aria-hidden />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => onEditService(original.service)}>
|
|
||||||
Изменить
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => onDeleteService(original.service)}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
size: 56,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useServicesGroupedColumns(
|
|
||||||
args: Parameters<typeof createServicesGroupedColumns>[0],
|
|
||||||
) {
|
|
||||||
return useMemo(() => createServicesGroupedColumns(args), [
|
|
||||||
args.onEditService,
|
|
||||||
args.onDeleteService,
|
|
||||||
args.onToggleService,
|
|
||||||
args.onEditGroup,
|
|
||||||
args.onDeleteGroup,
|
|
||||||
args.onAddServiceToGroup,
|
|
||||||
args.togglingId,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
@@ -73,7 +73,7 @@ export function SettingRow({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full justify-start',
|
'flex w-full min-w-0 justify-start',
|
||||||
stacked ? 'justify-start' : '@md/field-group:justify-end',
|
stacked ? 'justify-start' : '@md/field-group:justify-end',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export const serviceGroupSchema = z.object({
|
|||||||
health_check_interval_sec: z.number().default(30),
|
health_check_interval_sec: z.number().default(30),
|
||||||
health_check_timeout_ms: z.number().default(3000),
|
health_check_timeout_ms: z.number().default(3000),
|
||||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||||
|
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||||
|
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||||
|
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
updated_at: z.string(),
|
updated_at: z.string(),
|
||||||
})
|
})
|
||||||
@@ -76,6 +79,9 @@ export const serviceDomainBindingSchema = z
|
|||||||
health_check_interval_sec: z.number().default(30),
|
health_check_interval_sec: z.number().default(30),
|
||||||
health_check_timeout_ms: z.number().default(3000),
|
health_check_timeout_ms: z.number().default(3000),
|
||||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||||
|
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||||
|
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||||
|
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||||
sync_status: z.string().nullable().default(null),
|
sync_status: z.string().nullable().default(null),
|
||||||
})
|
})
|
||||||
.transform((binding) => ({
|
.transform((binding) => ({
|
||||||
@@ -94,6 +100,30 @@ export const serviceDomainBindingSchema = z
|
|||||||
: (binding.record_type ?? 'A'),
|
: (binding.record_type ?? 'A'),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
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', 'globalping', 'aggregate']).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', 'globalping']),
|
||||||
|
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({
|
export const serviceViewSchema = serviceSchema.extend({
|
||||||
subdomain: z.string().default(''),
|
subdomain: z.string().default(''),
|
||||||
enabled: z.coerce.boolean().default(false),
|
enabled: z.coerce.boolean().default(false),
|
||||||
@@ -101,6 +131,8 @@ export const serviceViewSchema = serviceSchema.extend({
|
|||||||
domains: z.array(serviceDomainBindingSchema).default([]),
|
domains: z.array(serviceDomainBindingSchema).default([]),
|
||||||
health_status: z.enum(['up', 'down', 'degraded', 'unknown']).default('unknown'),
|
health_status: z.enum(['up', 'down', 'degraded', 'unknown']).default('unknown'),
|
||||||
health_latency_ms: z.number().nullable().default(null),
|
health_latency_ms: z.number().nullable().default(null),
|
||||||
|
ip_health: z.array(serviceIpHealthSchema).default([]),
|
||||||
|
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||||
@@ -160,6 +192,9 @@ export const serviceBindingSchema = z
|
|||||||
health_check_interval_sec: z.number().default(30),
|
health_check_interval_sec: z.number().default(30),
|
||||||
health_check_timeout_ms: z.number().default(3000),
|
health_check_timeout_ms: z.number().default(3000),
|
||||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||||
|
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||||
|
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||||
|
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||||
sync_status: z.string().nullable().default(null),
|
sync_status: z.string().nullable().default(null),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
updated_at: z.string(),
|
updated_at: z.string(),
|
||||||
@@ -244,6 +279,9 @@ const healthCheckConfigFields = {
|
|||||||
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
|
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
|
||||||
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
|
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
|
||||||
health_check_verify_tls: z.boolean().optional(),
|
health_check_verify_tls: z.boolean().optional(),
|
||||||
|
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).optional(),
|
||||||
|
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).optional(),
|
||||||
|
health_check_aggregate: z.enum(['any', 'all', 'majority']).optional(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const serviceDomainInputSchema = z
|
const serviceDomainInputSchema = z
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query'
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import {
|
import {
|
||||||
|
healthProbeLogSchema,
|
||||||
serviceBindingSchema,
|
serviceBindingSchema,
|
||||||
serviceGroupsResponseSchema,
|
serviceGroupsResponseSchema,
|
||||||
serviceViewSchema,
|
serviceViewSchema,
|
||||||
@@ -83,3 +84,112 @@ export async function createServiceBinding(body: CreateServiceBindingBody) {
|
|||||||
export async function deleteServiceBinding(id: number) {
|
export async function deleteServiceBinding(id: number) {
|
||||||
return api.delete<{ deleted: boolean }>(`/api/v1/service-bindings/${id}`)
|
return api.delete<{ deleted: boolean }>(`/api/v1/service-bindings/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
queryFn: () => api.get(`/api/v1/services/${id}/overview`),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const serviceNodesQueryOptions = (id: number) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: serviceDetailKeys.nodes(id),
|
||||||
|
queryFn: () => api.get(`/api/v1/services/${id}/nodes`),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const opsSummaryQueryOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['ops-summary'] as const,
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<{
|
||||||
|
domains: number
|
||||||
|
services: number
|
||||||
|
nodes: number
|
||||||
|
healthy: number
|
||||||
|
unhealthy: number
|
||||||
|
active_failovers: number
|
||||||
|
}>('/api/v1/ops-summary'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function createServiceNode(
|
||||||
|
serviceId: number,
|
||||||
|
body: {
|
||||||
|
address: string
|
||||||
|
protocol?: string
|
||||||
|
port?: number | null
|
||||||
|
priority?: number
|
||||||
|
weight?: number
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return api.post(`/api/v1/services/${serviceId}/nodes`, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteServiceNode(serviceId: number, nodeId: number) {
|
||||||
|
return api.delete(`/api/v1/services/${serviceId}/nodes/${nodeId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeBindingIp(
|
||||||
|
bindingId: number,
|
||||||
|
body: {
|
||||||
|
from_ip?: string
|
||||||
|
to_ip?: string
|
||||||
|
node_id?: number
|
||||||
|
dry_run?: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return api.post<{
|
||||||
|
from_ip: string
|
||||||
|
to_ip: string
|
||||||
|
applied: boolean
|
||||||
|
dry_run: boolean
|
||||||
|
message: string
|
||||||
|
}>(`/api/v1/service-bindings/${bindingId}/change-ip`, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeServiceDomain(
|
||||||
|
serviceId: number,
|
||||||
|
body: {
|
||||||
|
from_domain_id: number
|
||||||
|
to_domain_id: number
|
||||||
|
hostnames?: string[]
|
||||||
|
dry_run?: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return api.post<{ message?: string }>(
|
||||||
|
`/api/v1/services/${serviceId}/change-domain`,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOriginHealthCheck(body: Record<string, unknown>) {
|
||||||
|
return api.post('/api/v1/health-checks', body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOriginHealthChecks() {
|
||||||
|
return api.get('/api/v1/health-checks')
|
||||||
|
}
|
||||||
+250
-87
@@ -9,49 +9,56 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as AuthRouteImport } from './routes/_auth'
|
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
|
import { Route as AuthRouteImport } from './routes/_auth'
|
||||||
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
|
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
|
||||||
import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates'
|
|
||||||
import { Route as AuthGroupsRouteImport } from './routes/_auth/groups'
|
|
||||||
import { Route as AuthServicesRouteImport } from './routes/_auth/services'
|
|
||||||
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
|
|
||||||
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
|
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
|
||||||
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
|
import { Route as AuthGroupsRouteImport } from './routes/_auth/groups'
|
||||||
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
|
import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates'
|
||||||
|
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
|
||||||
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
|
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
|
||||||
import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
|
import { Route as AuthServicesIndexRouteImport } from './routes/_auth/services/index'
|
||||||
|
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
|
||||||
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
|
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
|
||||||
|
import { Route as AuthSettingsHealthRouteImport } from './routes/_auth/settings/health'
|
||||||
|
import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
|
||||||
|
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
|
||||||
|
import { Route as AuthServicesServiceIdRouteRouteImport } from './routes/_auth/services/$serviceId/route'
|
||||||
|
import { Route as AuthServicesServiceIdIndexRouteImport } from './routes/_auth/services/$serviceId/index'
|
||||||
import { Route as AuthDomainsDomainIdIndexRouteImport } from './routes/_auth/domains/$domainId/index'
|
import { Route as AuthDomainsDomainIdIndexRouteImport } from './routes/_auth/domains/$domainId/index'
|
||||||
|
import { Route as AuthServicesServiceIdSubdomainsRouteImport } from './routes/_auth/services/$serviceId/subdomains'
|
||||||
|
import { Route as AuthServicesServiceIdRoutingRouteImport } from './routes/_auth/services/$serviceId/routing'
|
||||||
|
import { Route as AuthServicesServiceIdNodesRouteImport } from './routes/_auth/services/$serviceId/nodes'
|
||||||
|
import { Route as AuthServicesServiceIdHealthRouteImport } from './routes/_auth/services/$serviceId/health'
|
||||||
import { Route as AuthDomainsDomainIdDnsRouteImport } from './routes/_auth/domains/$domainId/dns'
|
import { Route as AuthDomainsDomainIdDnsRouteImport } from './routes/_auth/domains/$domainId/dns'
|
||||||
|
|
||||||
const AuthRoute = AuthRouteImport.update({
|
|
||||||
id: '/_auth',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
id: '/login',
|
id: '/login',
|
||||||
path: '/login',
|
path: '/login',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthRoute = AuthRouteImport.update({
|
||||||
|
id: '/_auth',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AuthIndexRoute = AuthIndexRouteImport.update({
|
const AuthIndexRoute = AuthIndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthCertificatesRoute = AuthCertificatesRouteImport.update({
|
const AuthCallbackRoute = AuthCallbackRouteImport.update({
|
||||||
id: '/certificates',
|
id: '/auth/callback',
|
||||||
path: '/certificates',
|
path: '/auth/callback',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthGroupsRoute = AuthGroupsRouteImport.update({
|
const AuthGroupsRoute = AuthGroupsRouteImport.update({
|
||||||
id: '/groups',
|
id: '/groups',
|
||||||
path: '/groups',
|
path: '/groups',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthServicesRoute = AuthServicesRouteImport.update({
|
const AuthCertificatesRoute = AuthCertificatesRouteImport.update({
|
||||||
id: '/services',
|
id: '/certificates',
|
||||||
path: '/services',
|
path: '/certificates',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthSettingsRouteRoute = AuthSettingsRouteRouteImport.update({
|
const AuthSettingsRouteRoute = AuthSettingsRouteRouteImport.update({
|
||||||
@@ -59,30 +66,20 @@ const AuthSettingsRouteRoute = AuthSettingsRouteRouteImport.update({
|
|||||||
path: '/settings',
|
path: '/settings',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthCallbackRoute = AuthCallbackRouteImport.update({
|
|
||||||
id: '/auth/callback',
|
|
||||||
path: '/auth/callback',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const AuthDomainsIndexRoute = AuthDomainsIndexRouteImport.update({
|
|
||||||
id: '/domains/',
|
|
||||||
path: '/domains/',
|
|
||||||
getParentRoute: () => AuthRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthGroupsGroupIdRoute = AuthGroupsGroupIdRouteImport.update({
|
|
||||||
id: '/$groupId',
|
|
||||||
path: '/$groupId',
|
|
||||||
getParentRoute: () => AuthGroupsRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({
|
const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AuthSettingsRouteRoute,
|
getParentRoute: () => AuthSettingsRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
|
const AuthServicesIndexRoute = AuthServicesIndexRouteImport.update({
|
||||||
id: '/appearance',
|
id: '/services/',
|
||||||
path: '/appearance',
|
path: '/services/',
|
||||||
getParentRoute: () => AuthSettingsRouteRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthDomainsIndexRoute = AuthDomainsIndexRouteImport.update({
|
||||||
|
id: '/domains/',
|
||||||
|
path: '/domains/',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthSettingsIntegrationsRoute =
|
const AuthSettingsIntegrationsRoute =
|
||||||
AuthSettingsIntegrationsRouteImport.update({
|
AuthSettingsIntegrationsRouteImport.update({
|
||||||
@@ -90,12 +87,63 @@ const AuthSettingsIntegrationsRoute =
|
|||||||
path: '/integrations',
|
path: '/integrations',
|
||||||
getParentRoute: () => AuthSettingsRouteRoute,
|
getParentRoute: () => AuthSettingsRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthSettingsHealthRoute = AuthSettingsHealthRouteImport.update({
|
||||||
|
id: '/health',
|
||||||
|
path: '/health',
|
||||||
|
getParentRoute: () => AuthSettingsRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
|
||||||
|
id: '/appearance',
|
||||||
|
path: '/appearance',
|
||||||
|
getParentRoute: () => AuthSettingsRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthGroupsGroupIdRoute = AuthGroupsGroupIdRouteImport.update({
|
||||||
|
id: '/$groupId',
|
||||||
|
path: '/$groupId',
|
||||||
|
getParentRoute: () => AuthGroupsRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthServicesServiceIdRouteRoute =
|
||||||
|
AuthServicesServiceIdRouteRouteImport.update({
|
||||||
|
id: '/services/$serviceId',
|
||||||
|
path: '/services/$serviceId',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthServicesServiceIdIndexRoute =
|
||||||
|
AuthServicesServiceIdIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => AuthServicesServiceIdRouteRoute,
|
||||||
|
} as any)
|
||||||
const AuthDomainsDomainIdIndexRoute =
|
const AuthDomainsDomainIdIndexRoute =
|
||||||
AuthDomainsDomainIdIndexRouteImport.update({
|
AuthDomainsDomainIdIndexRouteImport.update({
|
||||||
id: '/domains/$domainId/',
|
id: '/domains/$domainId/',
|
||||||
path: '/domains/$domainId/',
|
path: '/domains/$domainId/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthServicesServiceIdSubdomainsRoute =
|
||||||
|
AuthServicesServiceIdSubdomainsRouteImport.update({
|
||||||
|
id: '/subdomains',
|
||||||
|
path: '/subdomains',
|
||||||
|
getParentRoute: () => AuthServicesServiceIdRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthServicesServiceIdRoutingRoute =
|
||||||
|
AuthServicesServiceIdRoutingRouteImport.update({
|
||||||
|
id: '/routing',
|
||||||
|
path: '/routing',
|
||||||
|
getParentRoute: () => AuthServicesServiceIdRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthServicesServiceIdNodesRoute =
|
||||||
|
AuthServicesServiceIdNodesRouteImport.update({
|
||||||
|
id: '/nodes',
|
||||||
|
path: '/nodes',
|
||||||
|
getParentRoute: () => AuthServicesServiceIdRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthServicesServiceIdHealthRoute =
|
||||||
|
AuthServicesServiceIdHealthRouteImport.update({
|
||||||
|
id: '/health',
|
||||||
|
path: '/health',
|
||||||
|
getParentRoute: () => AuthServicesServiceIdRouteRoute,
|
||||||
|
} as any)
|
||||||
const AuthDomainsDomainIdDnsRoute = AuthDomainsDomainIdDnsRouteImport.update({
|
const AuthDomainsDomainIdDnsRoute = AuthDomainsDomainIdDnsRouteImport.update({
|
||||||
id: '/domains/$domainId/dns',
|
id: '/domains/$domainId/dns',
|
||||||
path: '/domains/$domainId/dns',
|
path: '/domains/$domainId/dns',
|
||||||
@@ -108,30 +156,43 @@ export interface FileRoutesByFullPath {
|
|||||||
'/settings': typeof AuthSettingsRouteRouteWithChildren
|
'/settings': typeof AuthSettingsRouteRouteWithChildren
|
||||||
'/certificates': typeof AuthCertificatesRoute
|
'/certificates': typeof AuthCertificatesRoute
|
||||||
'/groups': typeof AuthGroupsRouteWithChildren
|
'/groups': typeof AuthGroupsRouteWithChildren
|
||||||
'/services': typeof AuthServicesRoute
|
|
||||||
'/auth/callback': typeof AuthCallbackRoute
|
'/auth/callback': typeof AuthCallbackRoute
|
||||||
|
'/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
|
||||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||||
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||||
|
'/settings/health': typeof AuthSettingsHealthRoute
|
||||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
'/domains/': typeof AuthDomainsIndexRoute
|
'/domains/': typeof AuthDomainsIndexRoute
|
||||||
|
'/services/': typeof AuthServicesIndexRoute
|
||||||
'/settings/': typeof AuthSettingsIndexRoute
|
'/settings/': typeof AuthSettingsIndexRoute
|
||||||
'/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
|
'/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
|
||||||
|
'/services/$serviceId/health': typeof AuthServicesServiceIdHealthRoute
|
||||||
|
'/services/$serviceId/nodes': typeof AuthServicesServiceIdNodesRoute
|
||||||
|
'/services/$serviceId/routing': typeof AuthServicesServiceIdRoutingRoute
|
||||||
|
'/services/$serviceId/subdomains': typeof AuthServicesServiceIdSubdomainsRoute
|
||||||
'/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute
|
'/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute
|
||||||
|
'/services/$serviceId/': typeof AuthServicesServiceIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/certificates': typeof AuthCertificatesRoute
|
'/certificates': typeof AuthCertificatesRoute
|
||||||
'/groups': typeof AuthGroupsRouteWithChildren
|
'/groups': typeof AuthGroupsRouteWithChildren
|
||||||
'/services': typeof AuthServicesRoute
|
|
||||||
'/auth/callback': typeof AuthCallbackRoute
|
'/auth/callback': typeof AuthCallbackRoute
|
||||||
'/': typeof AuthIndexRoute
|
'/': typeof AuthIndexRoute
|
||||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||||
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||||
|
'/settings/health': typeof AuthSettingsHealthRoute
|
||||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
'/domains': typeof AuthDomainsIndexRoute
|
'/domains': typeof AuthDomainsIndexRoute
|
||||||
|
'/services': typeof AuthServicesIndexRoute
|
||||||
'/settings': typeof AuthSettingsIndexRoute
|
'/settings': typeof AuthSettingsIndexRoute
|
||||||
'/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
|
'/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
|
||||||
|
'/services/$serviceId/health': typeof AuthServicesServiceIdHealthRoute
|
||||||
|
'/services/$serviceId/nodes': typeof AuthServicesServiceIdNodesRoute
|
||||||
|
'/services/$serviceId/routing': typeof AuthServicesServiceIdRoutingRoute
|
||||||
|
'/services/$serviceId/subdomains': typeof AuthServicesServiceIdSubdomainsRoute
|
||||||
'/domains/$domainId': typeof AuthDomainsDomainIdIndexRoute
|
'/domains/$domainId': typeof AuthDomainsDomainIdIndexRoute
|
||||||
|
'/services/$serviceId': typeof AuthServicesServiceIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -140,16 +201,23 @@ export interface FileRoutesById {
|
|||||||
'/_auth/settings': typeof AuthSettingsRouteRouteWithChildren
|
'/_auth/settings': typeof AuthSettingsRouteRouteWithChildren
|
||||||
'/_auth/certificates': typeof AuthCertificatesRoute
|
'/_auth/certificates': typeof AuthCertificatesRoute
|
||||||
'/_auth/groups': typeof AuthGroupsRouteWithChildren
|
'/_auth/groups': typeof AuthGroupsRouteWithChildren
|
||||||
'/_auth/services': typeof AuthServicesRoute
|
|
||||||
'/auth/callback': typeof AuthCallbackRoute
|
'/auth/callback': typeof AuthCallbackRoute
|
||||||
'/_auth/': typeof AuthIndexRoute
|
'/_auth/': typeof AuthIndexRoute
|
||||||
|
'/_auth/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
|
||||||
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||||
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
|
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||||
|
'/_auth/settings/health': typeof AuthSettingsHealthRoute
|
||||||
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
'/_auth/domains/': typeof AuthDomainsIndexRoute
|
'/_auth/domains/': typeof AuthDomainsIndexRoute
|
||||||
|
'/_auth/services/': typeof AuthServicesIndexRoute
|
||||||
'/_auth/settings/': typeof AuthSettingsIndexRoute
|
'/_auth/settings/': typeof AuthSettingsIndexRoute
|
||||||
'/_auth/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
|
'/_auth/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
|
||||||
|
'/_auth/services/$serviceId/health': typeof AuthServicesServiceIdHealthRoute
|
||||||
|
'/_auth/services/$serviceId/nodes': typeof AuthServicesServiceIdNodesRoute
|
||||||
|
'/_auth/services/$serviceId/routing': typeof AuthServicesServiceIdRoutingRoute
|
||||||
|
'/_auth/services/$serviceId/subdomains': typeof AuthServicesServiceIdSubdomainsRoute
|
||||||
'/_auth/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute
|
'/_auth/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute
|
||||||
|
'/_auth/services/$serviceId/': typeof AuthServicesServiceIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -159,30 +227,43 @@ export interface FileRouteTypes {
|
|||||||
| '/settings'
|
| '/settings'
|
||||||
| '/certificates'
|
| '/certificates'
|
||||||
| '/groups'
|
| '/groups'
|
||||||
| '/services'
|
|
||||||
| '/auth/callback'
|
| '/auth/callback'
|
||||||
|
| '/services/$serviceId'
|
||||||
| '/groups/$groupId'
|
| '/groups/$groupId'
|
||||||
| '/settings/appearance'
|
| '/settings/appearance'
|
||||||
|
| '/settings/health'
|
||||||
| '/settings/integrations'
|
| '/settings/integrations'
|
||||||
| '/domains/'
|
| '/domains/'
|
||||||
|
| '/services/'
|
||||||
| '/settings/'
|
| '/settings/'
|
||||||
| '/domains/$domainId/dns'
|
| '/domains/$domainId/dns'
|
||||||
|
| '/services/$serviceId/health'
|
||||||
|
| '/services/$serviceId/nodes'
|
||||||
|
| '/services/$serviceId/routing'
|
||||||
|
| '/services/$serviceId/subdomains'
|
||||||
| '/domains/$domainId/'
|
| '/domains/$domainId/'
|
||||||
|
| '/services/$serviceId/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/certificates'
|
| '/certificates'
|
||||||
| '/groups'
|
| '/groups'
|
||||||
| '/services'
|
|
||||||
| '/auth/callback'
|
| '/auth/callback'
|
||||||
| '/'
|
| '/'
|
||||||
| '/groups/$groupId'
|
| '/groups/$groupId'
|
||||||
| '/settings/appearance'
|
| '/settings/appearance'
|
||||||
|
| '/settings/health'
|
||||||
| '/settings/integrations'
|
| '/settings/integrations'
|
||||||
| '/domains'
|
| '/domains'
|
||||||
|
| '/services'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/domains/$domainId/dns'
|
| '/domains/$domainId/dns'
|
||||||
|
| '/services/$serviceId/health'
|
||||||
|
| '/services/$serviceId/nodes'
|
||||||
|
| '/services/$serviceId/routing'
|
||||||
|
| '/services/$serviceId/subdomains'
|
||||||
| '/domains/$domainId'
|
| '/domains/$domainId'
|
||||||
|
| '/services/$serviceId'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_auth'
|
| '/_auth'
|
||||||
@@ -190,16 +271,23 @@ export interface FileRouteTypes {
|
|||||||
| '/_auth/settings'
|
| '/_auth/settings'
|
||||||
| '/_auth/certificates'
|
| '/_auth/certificates'
|
||||||
| '/_auth/groups'
|
| '/_auth/groups'
|
||||||
| '/_auth/services'
|
|
||||||
| '/auth/callback'
|
| '/auth/callback'
|
||||||
| '/_auth/'
|
| '/_auth/'
|
||||||
|
| '/_auth/services/$serviceId'
|
||||||
| '/_auth/groups/$groupId'
|
| '/_auth/groups/$groupId'
|
||||||
| '/_auth/settings/appearance'
|
| '/_auth/settings/appearance'
|
||||||
|
| '/_auth/settings/health'
|
||||||
| '/_auth/settings/integrations'
|
| '/_auth/settings/integrations'
|
||||||
| '/_auth/domains/'
|
| '/_auth/domains/'
|
||||||
|
| '/_auth/services/'
|
||||||
| '/_auth/settings/'
|
| '/_auth/settings/'
|
||||||
| '/_auth/domains/$domainId/dns'
|
| '/_auth/domains/$domainId/dns'
|
||||||
|
| '/_auth/services/$serviceId/health'
|
||||||
|
| '/_auth/services/$serviceId/nodes'
|
||||||
|
| '/_auth/services/$serviceId/routing'
|
||||||
|
| '/_auth/services/$serviceId/subdomains'
|
||||||
| '/_auth/domains/$domainId/'
|
| '/_auth/domains/$domainId/'
|
||||||
|
| '/_auth/services/$serviceId/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -210,13 +298,6 @@ export interface RootRouteChildren {
|
|||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/_auth': {
|
|
||||||
id: '/_auth'
|
|
||||||
path: ''
|
|
||||||
fullPath: '/'
|
|
||||||
preLoaderRoute: typeof AuthRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/login': {
|
'/login': {
|
||||||
id: '/login'
|
id: '/login'
|
||||||
path: '/login'
|
path: '/login'
|
||||||
@@ -224,6 +305,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LoginRouteImport
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/_auth': {
|
||||||
|
id: '/_auth'
|
||||||
|
path: ''
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof AuthRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/_auth/': {
|
'/_auth/': {
|
||||||
id: '/_auth/'
|
id: '/_auth/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -231,12 +319,12 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthIndexRouteImport
|
preLoaderRoute: typeof AuthIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
'/_auth/certificates': {
|
'/auth/callback': {
|
||||||
id: '/_auth/certificates'
|
id: '/auth/callback'
|
||||||
path: '/certificates'
|
path: '/auth/callback'
|
||||||
fullPath: '/certificates'
|
fullPath: '/auth/callback'
|
||||||
preLoaderRoute: typeof AuthCertificatesRouteImport
|
preLoaderRoute: typeof AuthCallbackRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_auth/groups': {
|
'/_auth/groups': {
|
||||||
id: '/_auth/groups'
|
id: '/_auth/groups'
|
||||||
@@ -245,11 +333,11 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthGroupsRouteImport
|
preLoaderRoute: typeof AuthGroupsRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
'/_auth/services': {
|
'/_auth/certificates': {
|
||||||
id: '/_auth/services'
|
id: '/_auth/certificates'
|
||||||
path: '/services'
|
path: '/certificates'
|
||||||
fullPath: '/services'
|
fullPath: '/certificates'
|
||||||
preLoaderRoute: typeof AuthServicesRouteImport
|
preLoaderRoute: typeof AuthCertificatesRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
'/_auth/settings': {
|
'/_auth/settings': {
|
||||||
@@ -259,12 +347,19 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthSettingsRouteRouteImport
|
preLoaderRoute: typeof AuthSettingsRouteRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
'/auth/callback': {
|
'/_auth/settings/': {
|
||||||
id: '/auth/callback'
|
id: '/_auth/settings/'
|
||||||
path: '/auth/callback'
|
path: '/'
|
||||||
fullPath: '/auth/callback'
|
fullPath: '/settings/'
|
||||||
preLoaderRoute: typeof AuthCallbackRouteImport
|
preLoaderRoute: typeof AuthSettingsIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof AuthSettingsRouteRoute
|
||||||
|
}
|
||||||
|
'/_auth/services/': {
|
||||||
|
id: '/_auth/services/'
|
||||||
|
path: '/services'
|
||||||
|
fullPath: '/services/'
|
||||||
|
preLoaderRoute: typeof AuthServicesIndexRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
'/_auth/domains/': {
|
'/_auth/domains/': {
|
||||||
id: '/_auth/domains/'
|
id: '/_auth/domains/'
|
||||||
@@ -273,18 +368,18 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthDomainsIndexRouteImport
|
preLoaderRoute: typeof AuthDomainsIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
'/_auth/groups/$groupId': {
|
'/_auth/settings/integrations': {
|
||||||
id: '/_auth/groups/$groupId'
|
id: '/_auth/settings/integrations'
|
||||||
path: '/$groupId'
|
path: '/integrations'
|
||||||
fullPath: '/groups/$groupId'
|
fullPath: '/settings/integrations'
|
||||||
preLoaderRoute: typeof AuthGroupsGroupIdRouteImport
|
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
|
||||||
parentRoute: typeof AuthGroupsRoute
|
parentRoute: typeof AuthSettingsRouteRoute
|
||||||
}
|
}
|
||||||
'/_auth/settings/': {
|
'/_auth/settings/health': {
|
||||||
id: '/_auth/settings/'
|
id: '/_auth/settings/health'
|
||||||
path: '/'
|
path: '/health'
|
||||||
fullPath: '/settings/'
|
fullPath: '/settings/health'
|
||||||
preLoaderRoute: typeof AuthSettingsIndexRouteImport
|
preLoaderRoute: typeof AuthSettingsHealthRouteImport
|
||||||
parentRoute: typeof AuthSettingsRouteRoute
|
parentRoute: typeof AuthSettingsRouteRoute
|
||||||
}
|
}
|
||||||
'/_auth/settings/appearance': {
|
'/_auth/settings/appearance': {
|
||||||
@@ -294,12 +389,26 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthSettingsAppearanceRouteImport
|
preLoaderRoute: typeof AuthSettingsAppearanceRouteImport
|
||||||
parentRoute: typeof AuthSettingsRouteRoute
|
parentRoute: typeof AuthSettingsRouteRoute
|
||||||
}
|
}
|
||||||
'/_auth/settings/integrations': {
|
'/_auth/groups/$groupId': {
|
||||||
id: '/_auth/settings/integrations'
|
id: '/_auth/groups/$groupId'
|
||||||
path: '/integrations'
|
path: '/$groupId'
|
||||||
fullPath: '/settings/integrations'
|
fullPath: '/groups/$groupId'
|
||||||
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
|
preLoaderRoute: typeof AuthGroupsGroupIdRouteImport
|
||||||
parentRoute: typeof AuthSettingsRouteRoute
|
parentRoute: typeof AuthGroupsRoute
|
||||||
|
}
|
||||||
|
'/_auth/services/$serviceId': {
|
||||||
|
id: '/_auth/services/$serviceId'
|
||||||
|
path: '/services/$serviceId'
|
||||||
|
fullPath: '/services/$serviceId'
|
||||||
|
preLoaderRoute: typeof AuthServicesServiceIdRouteRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
|
'/_auth/services/$serviceId/': {
|
||||||
|
id: '/_auth/services/$serviceId/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/services/$serviceId/'
|
||||||
|
preLoaderRoute: typeof AuthServicesServiceIdIndexRouteImport
|
||||||
|
parentRoute: typeof AuthServicesServiceIdRouteRoute
|
||||||
}
|
}
|
||||||
'/_auth/domains/$domainId/': {
|
'/_auth/domains/$domainId/': {
|
||||||
id: '/_auth/domains/$domainId/'
|
id: '/_auth/domains/$domainId/'
|
||||||
@@ -308,6 +417,34 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthDomainsDomainIdIndexRouteImport
|
preLoaderRoute: typeof AuthDomainsDomainIdIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
|
'/_auth/services/$serviceId/subdomains': {
|
||||||
|
id: '/_auth/services/$serviceId/subdomains'
|
||||||
|
path: '/subdomains'
|
||||||
|
fullPath: '/services/$serviceId/subdomains'
|
||||||
|
preLoaderRoute: typeof AuthServicesServiceIdSubdomainsRouteImport
|
||||||
|
parentRoute: typeof AuthServicesServiceIdRouteRoute
|
||||||
|
}
|
||||||
|
'/_auth/services/$serviceId/routing': {
|
||||||
|
id: '/_auth/services/$serviceId/routing'
|
||||||
|
path: '/routing'
|
||||||
|
fullPath: '/services/$serviceId/routing'
|
||||||
|
preLoaderRoute: typeof AuthServicesServiceIdRoutingRouteImport
|
||||||
|
parentRoute: typeof AuthServicesServiceIdRouteRoute
|
||||||
|
}
|
||||||
|
'/_auth/services/$serviceId/nodes': {
|
||||||
|
id: '/_auth/services/$serviceId/nodes'
|
||||||
|
path: '/nodes'
|
||||||
|
fullPath: '/services/$serviceId/nodes'
|
||||||
|
preLoaderRoute: typeof AuthServicesServiceIdNodesRouteImport
|
||||||
|
parentRoute: typeof AuthServicesServiceIdRouteRoute
|
||||||
|
}
|
||||||
|
'/_auth/services/$serviceId/health': {
|
||||||
|
id: '/_auth/services/$serviceId/health'
|
||||||
|
path: '/health'
|
||||||
|
fullPath: '/services/$serviceId/health'
|
||||||
|
preLoaderRoute: typeof AuthServicesServiceIdHealthRouteImport
|
||||||
|
parentRoute: typeof AuthServicesServiceIdRouteRoute
|
||||||
|
}
|
||||||
'/_auth/domains/$domainId/dns': {
|
'/_auth/domains/$domainId/dns': {
|
||||||
id: '/_auth/domains/$domainId/dns'
|
id: '/_auth/domains/$domainId/dns'
|
||||||
path: '/domains/$domainId/dns'
|
path: '/domains/$domainId/dns'
|
||||||
@@ -320,12 +457,14 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
interface AuthSettingsRouteRouteChildren {
|
interface AuthSettingsRouteRouteChildren {
|
||||||
AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute
|
AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute
|
||||||
|
AuthSettingsHealthRoute: typeof AuthSettingsHealthRoute
|
||||||
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
|
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
|
||||||
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
|
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
|
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
|
||||||
AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute,
|
AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute,
|
||||||
|
AuthSettingsHealthRoute: AuthSettingsHealthRoute,
|
||||||
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
|
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
|
||||||
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
|
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
|
||||||
}
|
}
|
||||||
@@ -345,13 +484,36 @@ const AuthGroupsRouteWithChildren = AuthGroupsRoute._addFileChildren(
|
|||||||
AuthGroupsRouteChildren,
|
AuthGroupsRouteChildren,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
interface AuthServicesServiceIdRouteRouteChildren {
|
||||||
|
AuthServicesServiceIdHealthRoute: typeof AuthServicesServiceIdHealthRoute
|
||||||
|
AuthServicesServiceIdNodesRoute: typeof AuthServicesServiceIdNodesRoute
|
||||||
|
AuthServicesServiceIdRoutingRoute: typeof AuthServicesServiceIdRoutingRoute
|
||||||
|
AuthServicesServiceIdSubdomainsRoute: typeof AuthServicesServiceIdSubdomainsRoute
|
||||||
|
AuthServicesServiceIdIndexRoute: typeof AuthServicesServiceIdIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthServicesServiceIdRouteRouteChildren: AuthServicesServiceIdRouteRouteChildren =
|
||||||
|
{
|
||||||
|
AuthServicesServiceIdHealthRoute: AuthServicesServiceIdHealthRoute,
|
||||||
|
AuthServicesServiceIdNodesRoute: AuthServicesServiceIdNodesRoute,
|
||||||
|
AuthServicesServiceIdRoutingRoute: AuthServicesServiceIdRoutingRoute,
|
||||||
|
AuthServicesServiceIdSubdomainsRoute: AuthServicesServiceIdSubdomainsRoute,
|
||||||
|
AuthServicesServiceIdIndexRoute: AuthServicesServiceIdIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthServicesServiceIdRouteRouteWithChildren =
|
||||||
|
AuthServicesServiceIdRouteRoute._addFileChildren(
|
||||||
|
AuthServicesServiceIdRouteRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
interface AuthRouteChildren {
|
interface AuthRouteChildren {
|
||||||
AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren
|
AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren
|
||||||
AuthCertificatesRoute: typeof AuthCertificatesRoute
|
AuthCertificatesRoute: typeof AuthCertificatesRoute
|
||||||
AuthGroupsRoute: typeof AuthGroupsRouteWithChildren
|
AuthGroupsRoute: typeof AuthGroupsRouteWithChildren
|
||||||
AuthServicesRoute: typeof AuthServicesRoute
|
|
||||||
AuthIndexRoute: typeof AuthIndexRoute
|
AuthIndexRoute: typeof AuthIndexRoute
|
||||||
|
AuthServicesServiceIdRouteRoute: typeof AuthServicesServiceIdRouteRouteWithChildren
|
||||||
AuthDomainsIndexRoute: typeof AuthDomainsIndexRoute
|
AuthDomainsIndexRoute: typeof AuthDomainsIndexRoute
|
||||||
|
AuthServicesIndexRoute: typeof AuthServicesIndexRoute
|
||||||
AuthDomainsDomainIdDnsRoute: typeof AuthDomainsDomainIdDnsRoute
|
AuthDomainsDomainIdDnsRoute: typeof AuthDomainsDomainIdDnsRoute
|
||||||
AuthDomainsDomainIdIndexRoute: typeof AuthDomainsDomainIdIndexRoute
|
AuthDomainsDomainIdIndexRoute: typeof AuthDomainsDomainIdIndexRoute
|
||||||
}
|
}
|
||||||
@@ -360,9 +522,10 @@ const AuthRouteChildren: AuthRouteChildren = {
|
|||||||
AuthSettingsRouteRoute: AuthSettingsRouteRouteWithChildren,
|
AuthSettingsRouteRoute: AuthSettingsRouteRouteWithChildren,
|
||||||
AuthCertificatesRoute: AuthCertificatesRoute,
|
AuthCertificatesRoute: AuthCertificatesRoute,
|
||||||
AuthGroupsRoute: AuthGroupsRouteWithChildren,
|
AuthGroupsRoute: AuthGroupsRouteWithChildren,
|
||||||
AuthServicesRoute: AuthServicesRoute,
|
|
||||||
AuthIndexRoute: AuthIndexRoute,
|
AuthIndexRoute: AuthIndexRoute,
|
||||||
|
AuthServicesServiceIdRouteRoute: AuthServicesServiceIdRouteRouteWithChildren,
|
||||||
AuthDomainsIndexRoute: AuthDomainsIndexRoute,
|
AuthDomainsIndexRoute: AuthDomainsIndexRoute,
|
||||||
|
AuthServicesIndexRoute: AuthServicesIndexRoute,
|
||||||
AuthDomainsDomainIdDnsRoute: AuthDomainsDomainIdDnsRoute,
|
AuthDomainsDomainIdDnsRoute: AuthDomainsDomainIdDnsRoute,
|
||||||
AuthDomainsDomainIdIndexRoute: AuthDomainsDomainIdIndexRoute,
|
AuthDomainsDomainIdIndexRoute: AuthDomainsDomainIdIndexRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
FolderTreeIcon,
|
FolderTreeIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
PlugIcon,
|
PlugIcon,
|
||||||
|
HeartPulseIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
certSummaryQueryOptions,
|
certSummaryQueryOptions,
|
||||||
domainsListQueryOptions,
|
domainsListQueryOptions,
|
||||||
groupsQueryOptions,
|
groupsQueryOptions,
|
||||||
|
opsSummaryQueryOptions,
|
||||||
serviceGroupsQueryOptions,
|
serviceGroupsQueryOptions,
|
||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
@@ -50,23 +52,18 @@ export const Route = createFileRoute('/_auth/')({
|
|||||||
queryClient.ensureQueryData(groupsQueryOptions()),
|
queryClient.ensureQueryData(groupsQueryOptions()),
|
||||||
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
|
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
|
||||||
queryClient.ensureQueryData(certificatesQueryOptions()),
|
queryClient.ensureQueryData(certificatesQueryOptions()),
|
||||||
|
queryClient.ensureQueryData(opsSummaryQueryOptions()),
|
||||||
]),
|
]),
|
||||||
component: DashboardPage,
|
component: DashboardPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function countByStatus(summary: [string, number][] | undefined, statuses: string[]) {
|
|
||||||
if (!summary) return 0
|
|
||||||
return summary
|
|
||||||
.filter(([status]) => statuses.includes(status))
|
|
||||||
.reduce((sum, [, count]) => sum + count, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashboardPage() {
|
function DashboardPage() {
|
||||||
const { data: domains, isLoading: domainsLoading } = useQuery(domainsListQueryOptions())
|
const { data: domains, isLoading: domainsLoading } = useQuery(domainsListQueryOptions())
|
||||||
const { data: summary, isLoading: summaryLoading } = useQuery(certSummaryQueryOptions())
|
const { data: summary, isLoading: summaryLoading } = useQuery(certSummaryQueryOptions())
|
||||||
const { data: certs } = useQuery(certificatesQueryOptions())
|
const { data: certs } = useQuery(certificatesQueryOptions())
|
||||||
const { data: groups } = useQuery(groupsQueryOptions())
|
const { data: groups } = useQuery(groupsQueryOptions())
|
||||||
const { data: serviceData } = useQuery(serviceGroupsQueryOptions())
|
const { data: serviceData } = useQuery(serviceGroupsQueryOptions())
|
||||||
|
const { data: ops } = useQuery(opsSummaryQueryOptions())
|
||||||
const { data: appSettings } = useQuery({
|
const { data: appSettings } = useQuery({
|
||||||
queryKey: ['app-settings'],
|
queryKey: ['app-settings'],
|
||||||
queryFn: () => api.get<{ showQuickActions?: boolean }>('/api/v1/settings'),
|
queryFn: () => api.get<{ showQuickActions?: boolean }>('/api/v1/settings'),
|
||||||
@@ -202,8 +199,7 @@ function DashboardPage() {
|
|||||||
|
|
||||||
const attentionCount = attentionServices.length
|
const attentionCount = attentionServices.length
|
||||||
|
|
||||||
const certWarnings = countByStatus(summary, ['warning', 'expired', 'error'])
|
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
|
||||||
const certOk = countByStatus(summary, ['active', 'ok'])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) return
|
if (isLoading) return
|
||||||
@@ -224,7 +220,6 @@ function DashboardPage() {
|
|||||||
)
|
)
|
||||||
}, [isLoading, summary, statusChartData, groupChartData, domains, groups])
|
}, [isLoading, summary, statusChartData, groupChartData, domains, groups])
|
||||||
|
|
||||||
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
|
|
||||||
const kpiCards: KpiStatCard[] = [
|
const kpiCards: KpiStatCard[] = [
|
||||||
{
|
{
|
||||||
id: 'domains',
|
id: 'domains',
|
||||||
@@ -239,19 +234,10 @@ function DashboardPage() {
|
|||||||
variant: ungroupedCount > 0 ? 'warning' : 'default',
|
variant: ungroupedCount > 0 ? 'warning' : 'default',
|
||||||
to: '/domains',
|
to: '/domains',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'groups',
|
|
||||||
label: 'Группы',
|
|
||||||
value: groups?.length ?? 0,
|
|
||||||
hint: `${groupChartData.filter((g) => g.count > 0).length} с зонами`,
|
|
||||||
icon: <FolderTreeIcon aria-hidden />,
|
|
||||||
iconClassName: 'text-primary',
|
|
||||||
to: '/groups',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'services',
|
id: 'services',
|
||||||
label: 'Сервисы',
|
label: 'Сервисы',
|
||||||
value: serviceCount,
|
value: ops?.services ?? serviceCount,
|
||||||
hint: attentionCount
|
hint: attentionCount
|
||||||
? `${attentionCount} требуют внимания`
|
? `${attentionCount} требуют внимания`
|
||||||
: ungroupedServiceCount
|
: ungroupedServiceCount
|
||||||
@@ -264,17 +250,37 @@ function DashboardPage() {
|
|||||||
search: { domainId: undefined },
|
search: { domainId: undefined },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'certs',
|
id: 'nodes',
|
||||||
label: 'Сертификаты',
|
label: 'Ноды',
|
||||||
value: certs?.length ?? 0,
|
value: ops?.nodes ?? 0,
|
||||||
hint:
|
hint: `${ops?.healthy ?? 0} healthy`,
|
||||||
certWarnings > 0
|
icon: <HeartPulseIcon aria-hidden />,
|
||||||
? `${certOk} в норме · ${certWarnings} внимания`
|
iconClassName: 'text-info',
|
||||||
: `${certOk} в норме`,
|
to: '/services',
|
||||||
icon: <ShieldCheckIcon aria-hidden />,
|
},
|
||||||
|
{
|
||||||
|
id: 'healthy',
|
||||||
|
label: 'Healthy',
|
||||||
|
value: ops?.healthy ?? 0,
|
||||||
|
icon: <ActivityIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'unhealthy',
|
||||||
|
label: 'Unhealthy',
|
||||||
|
value: ops?.unhealthy ?? 0,
|
||||||
|
variant: (ops?.unhealthy ?? 0) > 0 ? 'destructive' : 'default',
|
||||||
|
icon: <AlertTriangleIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-destructive',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'failovers',
|
||||||
|
label: 'Failover',
|
||||||
|
value: ops?.active_failovers ?? 0,
|
||||||
|
hint: 'активные переключения',
|
||||||
|
variant: (ops?.active_failovers ?? 0) > 0 ? 'warning' : 'default',
|
||||||
|
icon: <ActivityIcon aria-hidden />,
|
||||||
iconClassName: 'text-warning',
|
iconClassName: 'text-warning',
|
||||||
variant: certWarnings > 0 ? 'warning' : 'default',
|
|
||||||
to: '/certificates',
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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 { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
|
import { HealthTimeline } from '@/components/health/health-timeline'
|
||||||
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
|
import {
|
||||||
|
serviceHealthLogQueryOptions,
|
||||||
|
serviceViewQueryOptions,
|
||||||
|
} from '@/queries'
|
||||||
|
import { formatDate } from '@/lib/format'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
|
||||||
|
component: ServiceHealthPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function ServiceHealthPage() {
|
||||||
|
const { serviceId } = Route.useParams()
|
||||||
|
const id = Number(serviceId)
|
||||||
|
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 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"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailPanel>
|
||||||
|
<DetailPanel.Header
|
||||||
|
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 этого сервиса'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
|
||||||
|
import { DetailPanel } from '@/components/reui-kit'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { serviceOverviewQueryOptions } from '@/queries'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/services/$serviceId/')({
|
||||||
|
component: ServiceOverviewPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ServiceOverviewPage() {
|
||||||
|
const { serviceId } = Route.useParams()
|
||||||
|
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
|
||||||
|
const overview = data as {
|
||||||
|
service: {
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
health_status: 'up' | 'down' | 'degraded' | 'unknown'
|
||||||
|
domains: Array<{ fqdn: string; zone_name: string }>
|
||||||
|
}
|
||||||
|
nodes: Array<{ id: number; address: string; health_status: string }>
|
||||||
|
routing_strategy: string
|
||||||
|
active_addresses: string[]
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
if (!overview) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="Сервис не найден"
|
||||||
|
description="Вернитесь в каталог и выберите сервис."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = overview.nodes ?? []
|
||||||
|
const domains = overview.service.domains ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailPanel>
|
||||||
|
<DetailPanel.Header
|
||||||
|
title={overview.service.name}
|
||||||
|
description={`Маршрутизация: ${overview.routing_strategy}. Активные IP: ${
|
||||||
|
overview.active_addresses.join(', ') || '—'
|
||||||
|
}`}
|
||||||
|
actions={
|
||||||
|
<HealthCheckBadge status={overview.service.health_status} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailPanel.Metrics
|
||||||
|
cards={[
|
||||||
|
{
|
||||||
|
id: 'subdomains',
|
||||||
|
icon: <GlobeIcon />,
|
||||||
|
label: 'Поддомены',
|
||||||
|
description:
|
||||||
|
domains.length > 0
|
||||||
|
? domains.map((d) => d.fqdn).join(', ')
|
||||||
|
: 'Нет привязанных FQDN',
|
||||||
|
footer: <Badge variant="outline">{domains.length}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nodes',
|
||||||
|
icon: <ServerIcon />,
|
||||||
|
label: 'Ноды',
|
||||||
|
description:
|
||||||
|
nodes.length > 0
|
||||||
|
? nodes.map((n) => n.address).join(', ')
|
||||||
|
: 'Добавьте ноду, чтобы публиковать DNS',
|
||||||
|
footer: <Badge variant="outline">{nodes.length}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'health',
|
||||||
|
icon: <ActivityIcon />,
|
||||||
|
label: 'Пул',
|
||||||
|
description:
|
||||||
|
overview.active_addresses.length > 0
|
||||||
|
? 'Здоровые адреса участвуют в DNS'
|
||||||
|
: 'unknown не попадает в пул, пока не станет healthy',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{domains.length === 0 && nodes.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Пустой сервис"
|
||||||
|
description="Добавьте поддомен и ноду, затем настройте health-check."
|
||||||
|
stackedIcon
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</DetailPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
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 { PlusIcon } from 'lucide-react'
|
||||||
|
import { DetailPanel } 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 { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
|
import { createServiceNode, deleteServiceNode, serviceNodesQueryOptions } from '@/queries'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({
|
||||||
|
component: ServiceNodesPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
interface NodeRow {
|
||||||
|
id: number
|
||||||
|
address: string
|
||||||
|
port: number | null
|
||||||
|
protocol: string
|
||||||
|
health_status: 'up' | 'down' | 'degraded' | 'unknown' | 'healthy' | 'unhealthy' | 'checking' | 'disabled'
|
||||||
|
weight: number
|
||||||
|
priority: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapHealth(
|
||||||
|
status: NodeRow['health_status'],
|
||||||
|
): 'up' | 'down' | 'degraded' | 'unknown' {
|
||||||
|
if (status === 'healthy' || status === 'up') return 'up'
|
||||||
|
if (status === 'unhealthy' || status === 'down') return 'down'
|
||||||
|
if (status === 'degraded') return 'degraded'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceNodesPage() {
|
||||||
|
const { serviceId } = Route.useParams()
|
||||||
|
const id = Number(serviceId)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
|
||||||
|
const nodes = (nodesQuery.data ?? []) as NodeRow[]
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const form = useForm<{ address: string; port: string }>({
|
||||||
|
defaultValues: { address: '', port: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (values: { address: string; port: string }) =>
|
||||||
|
createServiceNode(id, {
|
||||||
|
address: values.address.trim(),
|
||||||
|
port: values.port ? Number(values.port) : null,
|
||||||
|
}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('Нода добавлена, статус CHECKING')
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['services'] })
|
||||||
|
setOpen(false)
|
||||||
|
form.reset()
|
||||||
|
},
|
||||||
|
onError: (e: unknown) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('Нода удалена')
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['services'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailPanel>
|
||||||
|
<DetailPanel.Header
|
||||||
|
title="Ноды"
|
||||||
|
description="Адреса происхождения сервиса."
|
||||||
|
actions={
|
||||||
|
<Button size="sm" onClick={() => setOpen(true)}>
|
||||||
|
<PlusIcon className="size-4" aria-hidden />
|
||||||
|
Добавить ноду
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{nodes.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Нет нод"
|
||||||
|
description="Добавьте IP, затем настройте health-check."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<div
|
||||||
|
key={node.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">{node.address}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{node.protocol}
|
||||||
|
{node.port ? `:${node.port}` : ''} · вес {node.weight} · приоритет{' '}
|
||||||
|
{node.priority}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HealthCheckBadge status={mapHealth(node.health_status)} />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => deleteMut.mutate(node.id)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FormSheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
title="Добавить ноду"
|
||||||
|
description="IP станет CHECKING до порога успешных проверок."
|
||||||
|
form={form}
|
||||||
|
onSubmit={(values) => createMut.mutate(values)}
|
||||||
|
footer={
|
||||||
|
<LoadingButton type="submit" isLoading={createMut.isPending}>
|
||||||
|
Добавить
|
||||||
|
</LoadingButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormFieldSimple label="IP" htmlFor="address">
|
||||||
|
<Input id="address" {...form.register('address')} placeholder="10.0.0.10" />
|
||||||
|
</FormFieldSimple>
|
||||||
|
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
|
||||||
|
<Input id="port" {...form.register('port')} placeholder="443" />
|
||||||
|
</FormFieldSimple>
|
||||||
|
</FormSheet>
|
||||||
|
</DetailPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ArrowLeftIcon } from 'lucide-react'
|
||||||
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { serviceOverviewQueryOptions } from '@/queries'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/services/$serviceId')({
|
||||||
|
loader: ({ context: { queryClient }, params }) =>
|
||||||
|
queryClient.ensureQueryData(serviceOverviewQueryOptions(Number(params.serviceId))),
|
||||||
|
component: ServiceLayout,
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ to: '/services/$serviceId', label: 'Обзор', exact: true },
|
||||||
|
{ to: '/services/$serviceId/subdomains', label: 'Поддомены', exact: false },
|
||||||
|
{ to: '/services/$serviceId/nodes', label: 'Ноды', exact: false },
|
||||||
|
{ to: '/services/$serviceId/health', label: 'Health', exact: false },
|
||||||
|
{ to: '/services/$serviceId/routing', label: 'Маршрутизация', exact: false },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function ServiceLayout() {
|
||||||
|
const { serviceId } = Route.useParams()
|
||||||
|
const id = Number(serviceId)
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const overview = useQuery(serviceOverviewQueryOptions(id))
|
||||||
|
const name = (overview.data as { service?: { name?: string } } | undefined)?.service?.name
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader
|
||||||
|
title={name ?? 'Сервис'}
|
||||||
|
description="Domain → Service → Node → Health → Failover"
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
render={<Link to="/services" />}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-4" aria-hidden />
|
||||||
|
К каталогу
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<nav className="flex flex-wrap gap-4 border-b">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const href = tab.to.replace('$serviceId', serviceId)
|
||||||
|
const active = tab.exact
|
||||||
|
? pathname === `/services/${serviceId}` || pathname === `/services/${serviceId}/`
|
||||||
|
: pathname.startsWith(href)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.to}
|
||||||
|
to={tab.to}
|
||||||
|
params={{ serviceId }}
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground hover:text-foreground pb-3 text-sm font-medium',
|
||||||
|
active && 'text-foreground border-b-2 border-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<QueryState
|
||||||
|
isLoading={overview.isLoading}
|
||||||
|
isError={overview.isError}
|
||||||
|
error={overview.error}
|
||||||
|
onRetry={() => void overview.refetch()}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</QueryState>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { DetailPanel } from '@/components/reui-kit'
|
||||||
|
import { FailoverTimeline } from '@/components/failover-timeline'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { serviceOverviewQueryOptions } from '@/queries'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/services/$serviceId/routing')({
|
||||||
|
component: ServiceRoutingPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function ServiceRoutingPage() {
|
||||||
|
const { serviceId } = Route.useParams()
|
||||||
|
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
|
||||||
|
const overview = data as {
|
||||||
|
routing_strategy: string
|
||||||
|
active_addresses: string[]
|
||||||
|
nodes: Array<{
|
||||||
|
address: string
|
||||||
|
health_status: string
|
||||||
|
consecutive_failures: number
|
||||||
|
last_failure_reason: string | null
|
||||||
|
}>
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
const events =
|
||||||
|
overview?.nodes
|
||||||
|
.filter(
|
||||||
|
(node) =>
|
||||||
|
node.health_status === 'unhealthy' ||
|
||||||
|
node.health_status === 'down' ||
|
||||||
|
node.health_status === 'checking',
|
||||||
|
)
|
||||||
|
.map((node) => ({
|
||||||
|
id: node.address,
|
||||||
|
title: `${node.address}: ${node.health_status}`,
|
||||||
|
detail: node.last_failure_reason
|
||||||
|
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
|
||||||
|
: `fail ${node.consecutive_failures}`,
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailPanel>
|
||||||
|
<DetailPanel.Header
|
||||||
|
title="Маршрутизация"
|
||||||
|
description="Round Robin / Failover. Weighted на DNS = alias Round Robin."
|
||||||
|
actions={<Badge variant="outline">{overview?.routing_strategy ?? 'round_robin'}</Badge>}
|
||||||
|
/>
|
||||||
|
<p className="text-sm">
|
||||||
|
Активные адреса:{' '}
|
||||||
|
{overview?.active_addresses.join(', ') || 'нет (unknown не в пуле)'}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Запись обновляется в Cloudflare. Распространение зависит от TTL.
|
||||||
|
</p>
|
||||||
|
<FailoverTimeline events={events} />
|
||||||
|
</DetailPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ArrowRightLeftIcon } from 'lucide-react'
|
||||||
|
import { DetailPanel } from '@/components/reui-kit'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
||||||
|
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
|
||||||
|
import { serviceOverviewQueryOptions } from '@/queries'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({
|
||||||
|
component: ServiceSubdomainsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function ServiceSubdomainsPage() {
|
||||||
|
const { serviceId } = Route.useParams()
|
||||||
|
const id = Number(serviceId)
|
||||||
|
const { data } = useQuery(serviceOverviewQueryOptions(id))
|
||||||
|
const overview = data as {
|
||||||
|
service: {
|
||||||
|
domains: Array<{
|
||||||
|
binding_id: number
|
||||||
|
domain_id: number
|
||||||
|
fqdn: string
|
||||||
|
zone_name: string
|
||||||
|
target_ips: string[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
} | undefined
|
||||||
|
const rows = overview?.service.domains ?? []
|
||||||
|
const [changeIp, setChangeIp] = useState<{
|
||||||
|
bindingId: number
|
||||||
|
ip?: string
|
||||||
|
} | null>(null)
|
||||||
|
const [changeDomain, setChangeDomain] = useState(false)
|
||||||
|
const fromDomainId = useMemo(
|
||||||
|
() => rows[0]?.domain_id ?? null,
|
||||||
|
[rows],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailPanel>
|
||||||
|
<DetailPanel.Header
|
||||||
|
title="Поддомены"
|
||||||
|
description="FQDN сервиса в одной или нескольких зонах Cloudflare."
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setChangeDomain(true)}
|
||||||
|
disabled={rows.length === 0}
|
||||||
|
>
|
||||||
|
<ArrowRightLeftIcon className="size-4" aria-hidden />
|
||||||
|
Сменить домен
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Нет поддоменов"
|
||||||
|
description="Привяжите FQDN к сервису из карточки редактирования."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.binding_id}
|
||||||
|
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<span className="font-medium">{row.fqdn}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{row.zone_name} · {row.target_ips.join(', ') || 'нет IP'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline">{row.target_ips.length} IP</Badge>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
setChangeIp({
|
||||||
|
bindingId: row.binding_id,
|
||||||
|
ip: row.target_ips[0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Сменить IP
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ChangeIpSheet
|
||||||
|
open={changeIp != null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setChangeIp(null)
|
||||||
|
}}
|
||||||
|
bindingId={changeIp?.bindingId ?? null}
|
||||||
|
serviceId={id}
|
||||||
|
currentIp={changeIp?.ip}
|
||||||
|
/>
|
||||||
|
<ChangeDomainSheet
|
||||||
|
open={changeDomain}
|
||||||
|
onOpenChange={setChangeDomain}
|
||||||
|
serviceId={id}
|
||||||
|
fromDomainId={fromDomainId}
|
||||||
|
/>
|
||||||
|
</DetailPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
+90
-3
@@ -43,7 +43,7 @@ import {
|
|||||||
import { ServiceKanbanCard } from '@/components/kanban/service-kanban-card'
|
import { ServiceKanbanCard } from '@/components/kanban/service-kanban-card'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services')({
|
export const Route = createFileRoute('/_auth/services/')({
|
||||||
validateSearch: (
|
validateSearch: (
|
||||||
search: Record<string, unknown>,
|
search: Record<string, unknown>,
|
||||||
): { domainId?: number; serviceId?: number; view?: 'board' } => ({
|
): { domainId?: number; serviceId?: number; view?: 'board' } => ({
|
||||||
@@ -81,6 +81,29 @@ function setServiceEnabled(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setCatalogServiceIpEnabled(
|
||||||
|
data: ServiceGroupsResponse,
|
||||||
|
serviceId: number,
|
||||||
|
ip: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): ServiceGroupsResponse {
|
||||||
|
const patch = (service: ServiceView): ServiceView =>
|
||||||
|
service.id === serviceId
|
||||||
|
? {
|
||||||
|
...service,
|
||||||
|
ip_enabled: { ...(service.ip_enabled ?? {}), [ip]: enabled },
|
||||||
|
}
|
||||||
|
: service
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups: data.groups.map((group) => ({
|
||||||
|
...group,
|
||||||
|
services: group.services.map(patch),
|
||||||
|
})),
|
||||||
|
ungrouped: data.ungrouped.map(patch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const GROUP_DOT_COLORS = [
|
const GROUP_DOT_COLORS = [
|
||||||
'bg-chart-1',
|
'bg-chart-1',
|
||||||
'bg-chart-2',
|
'bg-chart-2',
|
||||||
@@ -150,6 +173,10 @@ function ServicesPage() {
|
|||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
|
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
|
||||||
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
||||||
|
const [togglingIp, setTogglingIp] = useState<{
|
||||||
|
serviceId: number
|
||||||
|
ip: string
|
||||||
|
} | null>(null)
|
||||||
const [bulkToggling, setBulkToggling] = useState(false)
|
const [bulkToggling, setBulkToggling] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState('all')
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -364,6 +391,54 @@ function ServicesPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toggleServiceIpMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
ip,
|
||||||
|
enabled,
|
||||||
|
}: {
|
||||||
|
id: number
|
||||||
|
ip: string
|
||||||
|
enabled: boolean
|
||||||
|
}) =>
|
||||||
|
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, {
|
||||||
|
ip,
|
||||||
|
enabled,
|
||||||
|
}),
|
||||||
|
onMutate: async ({ id, ip, enabled }) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
|
||||||
|
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
|
||||||
|
serviceGroupKeys.all,
|
||||||
|
)
|
||||||
|
if (previous) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
serviceGroupKeys.all,
|
||||||
|
setCatalogServiceIpEnabled(previous, id, ip, enabled),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return { previous }
|
||||||
|
},
|
||||||
|
onError: (err, _vars, context) => {
|
||||||
|
if (context?.previous) {
|
||||||
|
queryClient.setQueryData(serviceGroupKeys.all, context.previous)
|
||||||
|
}
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : 'Не удалось переключить IP',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSuccess: (_data, { enabled }) => {
|
||||||
|
toast.success(
|
||||||
|
enabled
|
||||||
|
? 'IP включён и добавлен в DNS-привязки'
|
||||||
|
: 'IP выключен и снят с DNS-привязок',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSettled: async () => {
|
||||||
|
setTogglingIp(null)
|
||||||
|
await invalidateAll()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const deleteGroupMutation = useMutation({
|
const deleteGroupMutation = useMutation({
|
||||||
mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`),
|
mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -398,6 +473,15 @@ function ServicesPage() {
|
|||||||
toggleServiceMutation.mutate({ id: serviceId, enabled })
|
toggleServiceMutation.mutate({ id: serviceId, enabled })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleServiceIpToggle(
|
||||||
|
serviceId: number,
|
||||||
|
ip: string,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
setTogglingIp({ serviceId, ip })
|
||||||
|
toggleServiceIpMutation.mutate({ id: serviceId, ip, enabled })
|
||||||
|
}
|
||||||
|
|
||||||
function handleOpenCreateService(groupId: number | null = null) {
|
function handleOpenCreateService(groupId: number | null = null) {
|
||||||
setDefaultGroupId(groupId)
|
setDefaultGroupId(groupId)
|
||||||
setCreateSheetOpen(true)
|
setCreateSheetOpen(true)
|
||||||
@@ -456,7 +540,6 @@ function ServicesPage() {
|
|||||||
board.columns.map((column, index) => ({
|
board.columns.map((column, index) => ({
|
||||||
id: column.id,
|
id: column.id,
|
||||||
title: column.title,
|
title: column.title,
|
||||||
description: column.domain ?? undefined,
|
|
||||||
healthStatus: column.group?.health_status,
|
healthStatus: column.group?.health_status,
|
||||||
healthLatencyMs: column.group?.health_latency_ms,
|
healthLatencyMs: column.group?.health_latency_ms,
|
||||||
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
|
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
|
||||||
@@ -494,7 +577,7 @@ function ServicesPage() {
|
|||||||
|
|
||||||
const pageDescription = filteredDomain
|
const pageDescription = filteredDomain
|
||||||
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
|
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
|
||||||
: 'Группы, FQDN и доступность сервисов'
|
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'
|
||||||
|
|
||||||
const sheets = (
|
const sheets = (
|
||||||
<>
|
<>
|
||||||
@@ -603,9 +686,11 @@ function ServicesPage() {
|
|||||||
isLoading
|
isLoading
|
||||||
hideHeader
|
hideHeader
|
||||||
togglingId={null}
|
togglingId={null}
|
||||||
|
togglingIp={null}
|
||||||
onEditService={() => {}}
|
onEditService={() => {}}
|
||||||
onDeleteService={() => {}}
|
onDeleteService={() => {}}
|
||||||
onToggleService={() => {}}
|
onToggleService={() => {}}
|
||||||
|
onToggleServiceIp={() => {}}
|
||||||
onEditGroup={() => {}}
|
onEditGroup={() => {}}
|
||||||
onDeleteGroup={() => {}}
|
onDeleteGroup={() => {}}
|
||||||
onAddServiceToGroup={() => {}}
|
onAddServiceToGroup={() => {}}
|
||||||
@@ -703,12 +788,14 @@ function ServicesPage() {
|
|||||||
domainId={domainId}
|
domainId={domainId}
|
||||||
domainLabel={filteredDomain?.zone_name}
|
domainLabel={filteredDomain?.zone_name}
|
||||||
togglingId={togglingServiceId}
|
togglingId={togglingServiceId}
|
||||||
|
togglingIp={togglingIp}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={setActiveTab}
|
onTabChange={setActiveTab}
|
||||||
hideHeader
|
hideHeader
|
||||||
onEditService={setEditingService}
|
onEditService={setEditingService}
|
||||||
onDeleteService={setDeletingService}
|
onDeleteService={setDeletingService}
|
||||||
onToggleService={handleServiceToggle}
|
onToggleService={handleServiceToggle}
|
||||||
|
onToggleServiceIp={handleServiceIpToggle}
|
||||||
onEditGroup={setEditingGroup}
|
onEditGroup={setEditingGroup}
|
||||||
onDeleteGroup={setDeletingGroup}
|
onDeleteGroup={setDeletingGroup}
|
||||||
onAddServiceToGroup={handleOpenCreateService}
|
onAddServiceToGroup={handleOpenCreateService}
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { HeartPulseIcon, GlobeIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { SettingRow } from '@/components/setting-row'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import {
|
||||||
|
NumberField,
|
||||||
|
NumberFieldDecrement,
|
||||||
|
NumberFieldGroup,
|
||||||
|
NumberFieldIncrement,
|
||||||
|
NumberFieldInput,
|
||||||
|
} 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'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
healthCheckCron: z.string().trim().min(1, 'Укажите cron').max(64),
|
||||||
|
healthDegradedFailures: z.number().int().min(1).max(20),
|
||||||
|
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),
|
||||||
|
}).superRefine((data, ctx) => {
|
||||||
|
if (data.healthDownFailures < data.healthDegradedFailures) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'Не меньше порога degraded',
|
||||||
|
path: ['healthDownFailures'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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 & {
|
||||||
|
id: string
|
||||||
|
healthWorkerUrl?: string
|
||||||
|
healthWorkerStatus?: HealthWorkerStatus
|
||||||
|
healthWorkerError?: string | null
|
||||||
|
healthWorkerDeployedAt?: string | null
|
||||||
|
healthWorkerLastIngestAt?: string | null
|
||||||
|
healthWorkerKvNamespaceId?: string
|
||||||
|
globalpingTokenSet?: boolean
|
||||||
|
globalpingLocations?: string
|
||||||
|
globalpingLimit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/settings/health')({
|
||||||
|
component: HealthSettingsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function CompactNumberInput({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
disabled,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
value: number
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
disabled?: boolean
|
||||||
|
onValueChange: (next: number) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NumberField
|
||||||
|
id={id}
|
||||||
|
size="sm"
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={(next) => {
|
||||||
|
if (next != null) onValueChange(next)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NumberFieldGroup className="w-36">
|
||||||
|
<NumberFieldDecrement />
|
||||||
|
<NumberFieldInput />
|
||||||
|
<NumberFieldIncrement />
|
||||||
|
</NumberFieldGroup>
|
||||||
|
</NumberField>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(status: HealthWorkerStatus | undefined) {
|
||||||
|
if (status === 'ready') {
|
||||||
|
return (
|
||||||
|
<Badge variant="success-light" size="sm">
|
||||||
|
Готов
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status === 'error') {
|
||||||
|
return (
|
||||||
|
<Badge variant="destructive-light" size="sm">
|
||||||
|
Ошибка
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
Не создан
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthSettingsPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['app-settings'],
|
||||||
|
queryFn: () => api.get<SettingsResponse>('/api/v1/settings'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
healthCheckCron: '0 */2 * * * *',
|
||||||
|
healthDegradedFailures: 1,
|
||||||
|
healthDownFailures: 2,
|
||||||
|
healthLatencyWarnMs: 1000,
|
||||||
|
healthSuccessRecoveries: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const gpForm = useForm<GlobalpingValues>({
|
||||||
|
resolver: zodResolver(globalpingSchema),
|
||||||
|
defaultValues: {
|
||||||
|
globalpingToken: '',
|
||||||
|
globalpingLocations: 'World',
|
||||||
|
globalpingLimit: 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return
|
||||||
|
form.reset({
|
||||||
|
healthCheckCron: data.healthCheckCron,
|
||||||
|
healthDegradedFailures: data.healthDegradedFailures,
|
||||||
|
healthDownFailures: data.healthDownFailures,
|
||||||
|
healthLatencyWarnMs: data.healthLatencyWarnMs,
|
||||||
|
healthSuccessRecoveries: data.healthSuccessRecoveries,
|
||||||
|
})
|
||||||
|
gpForm.reset({
|
||||||
|
globalpingToken: '',
|
||||||
|
globalpingLocations: data.globalpingLocations || 'World',
|
||||||
|
globalpingLimit: data.globalpingLimit ?? 3,
|
||||||
|
})
|
||||||
|
}, [data, form, gpForm])
|
||||||
|
|
||||||
|
const saveMut = useMutation({
|
||||||
|
mutationFn: (values: FormValues) =>
|
||||||
|
api.patch<SettingsResponse>('/api/v1/settings', {
|
||||||
|
healthCheckCron: values.healthCheckCron,
|
||||||
|
healthDegradedFailures: values.healthDegradedFailures,
|
||||||
|
healthDownFailures: values.healthDownFailures,
|
||||||
|
healthLatencyWarnMs: values.healthLatencyWarnMs,
|
||||||
|
healthSuccessRecoveries: values.healthSuccessRecoveries,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||||
|
toast.success('Настройки health-check сохранены')
|
||||||
|
},
|
||||||
|
onError: (e: unknown) =>
|
||||||
|
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'),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||||
|
toast.success('Worker создан или обновлён')
|
||||||
|
},
|
||||||
|
onError: (e: unknown) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось создать Worker'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-4">
|
||||||
|
<form
|
||||||
|
className="flex w-full flex-col gap-4"
|
||||||
|
onSubmit={(event) =>
|
||||||
|
void form.handleSubmit((values) => saveMut.mutate(values))(event)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle className="flex items-center gap-2">
|
||||||
|
<HeartPulseIcon className="size-4" aria-hidden />
|
||||||
|
Local health-check
|
||||||
|
</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Расписание и пороги движка — общие для Local, Cloudflare Worker и Globalping.
|
||||||
|
Тип/порт/path и правило агрегации задаются в карточке сервиса.
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-0">
|
||||||
|
<FieldGroup className="gap-0">
|
||||||
|
<SettingRow
|
||||||
|
title="Cron"
|
||||||
|
description="Расписание проб CFDM (6 полей). Worker на edge получает 5-польное cron без секунд. Env: HEALTH_CHECK_CRON."
|
||||||
|
labelFor="health-cron"
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="health-cron"
|
||||||
|
className="font-mono"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={isLoading || saveMut.isPending}
|
||||||
|
{...form.register('healthCheckCron')}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
{form.formState.errors.healthCheckCron ? (
|
||||||
|
<p className="text-destructive px-5 pb-2 text-sm">
|
||||||
|
{form.formState.errors.healthCheckCron.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Ошибок до Slow"
|
||||||
|
description="Подряд неуспешных проб до статуса degraded. Env: HEALTH_DEGRADED_FAILURES."
|
||||||
|
labelFor="health-degraded"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="healthDegradedFailures"
|
||||||
|
render={({ field }) => (
|
||||||
|
<CompactNumberInput
|
||||||
|
id="health-degraded"
|
||||||
|
value={field.value}
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
disabled={isLoading || saveMut.isPending}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Ошибок до Down"
|
||||||
|
description="Подряд неуспешных проб до статуса down. Env: HEALTH_DOWN_FAILURES."
|
||||||
|
labelFor="health-down"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="healthDownFailures"
|
||||||
|
render={({ field }) => (
|
||||||
|
<CompactNumberInput
|
||||||
|
id="health-down"
|
||||||
|
value={field.value}
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
disabled={isLoading || saveMut.isPending}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
{form.formState.errors.healthDownFailures ? (
|
||||||
|
<p className="text-destructive px-5 pb-2 text-sm">
|
||||||
|
{form.formState.errors.healthDownFailures.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Латентность Slow, мс"
|
||||||
|
description="Порог задержки для degraded при успешной пробе. Env: HEALTH_LATENCY_WARN_MS."
|
||||||
|
labelFor="health-latency"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="healthLatencyWarnMs"
|
||||||
|
render={({ field }) => (
|
||||||
|
<CompactNumberInput
|
||||||
|
id="health-latency"
|
||||||
|
value={field.value}
|
||||||
|
min={50}
|
||||||
|
max={60_000}
|
||||||
|
disabled={isLoading || saveMut.isPending}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Успехов для recovery"
|
||||||
|
description="Подряд успешных проб, чтобы выйти из Checking в Healthy. Env: HEALTH_SUCCESS_RECOVERIES."
|
||||||
|
labelFor="health-recoveries"
|
||||||
|
compact
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="healthSuccessRecoveries"
|
||||||
|
render={({ field }) => (
|
||||||
|
<CompactNumberInput
|
||||||
|
id="health-recoveries"
|
||||||
|
value={field.value}
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
disabled={isLoading || saveMut.isPending}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</FieldGroup>
|
||||||
|
<FrameFooter className="flex flex-row justify-end">
|
||||||
|
<LoadingButton
|
||||||
|
type="submit"
|
||||||
|
isLoading={saveMut.isPending}
|
||||||
|
disabled={isLoading || !form.formState.isDirty}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle className="flex items-center gap-2">
|
||||||
|
Cloudflare Worker
|
||||||
|
{statusBadge(data?.healthWorkerStatus)}
|
||||||
|
</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Worker сам опрашивает IP/порты с edge. CFDM создаёт скрипт через API
|
||||||
|
и забирает результаты из KV. Preview:{' '}
|
||||||
|
<a
|
||||||
|
href="https://reui.io/preview/base/settings-16"
|
||||||
|
className="underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
settings-16
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex flex-col gap-3 p-4">
|
||||||
|
<Alert variant={data?.healthWorkerStatus === 'error' ? 'destructive' : 'info'}>
|
||||||
|
<AlertTitle>Не Health Checks API</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
На Free-плане продукта Health Checks нет. Нужен Account-токен с
|
||||||
|
Workers Scripts Write и Workers KV Storage Write — Zone DNS
|
||||||
|
недостаточно. Лимиты Free: 5 cron на аккаунт, KV 1000 writes/сутки
|
||||||
|
(интервал ≥ 2 мин), до 48 целей за тик.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{data?.healthWorkerError ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Ошибка деплоя</AlertTitle>
|
||||||
|
<AlertDescription>{data.healthWorkerError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<FieldGroup className="gap-0">
|
||||||
|
<SettingRow title="Скрипт" compact>
|
||||||
|
<span className="font-mono text-sm">cfdm-health-probe</span>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="KV namespace"
|
||||||
|
description="id mailbox targets/results"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm break-all">
|
||||||
|
{data?.healthWorkerKvNamespaceId || '—'}
|
||||||
|
</span>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="URL"
|
||||||
|
description="workers.dev после автодеплоя"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm break-all">
|
||||||
|
{data?.healthWorkerUrl || '—'}
|
||||||
|
</span>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="Последний деплой"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{data?.healthWorkerDeployedAt || '—'}
|
||||||
|
</span>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="Последний ingest"
|
||||||
|
description="colo пишется в журнал проб"
|
||||||
|
compact
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{data?.healthWorkerLastIngestAt || '—'}
|
||||||
|
</span>
|
||||||
|
</SettingRow>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={ensureMut.isPending}
|
||||||
|
onClick={() => ensureMut.mutate()}
|
||||||
|
>
|
||||||
|
{ensureMut.isPending ? 'Создаём…' : 'Создать / обновить Worker'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ COPY apps/api apps/api
|
|||||||
COPY packages/ui packages/ui
|
COPY packages/ui packages/ui
|
||||||
COPY packages/shared packages/shared
|
COPY packages/shared packages/shared
|
||||||
COPY packages/db packages/db
|
COPY packages/db packages/db
|
||||||
|
COPY workers/health-probe workers/health-probe
|
||||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||||
pnpm turbo build --filter=web --filter=@cfdm/api \
|
pnpm turbo build --filter=web --filter=@cfdm/api \
|
||||||
&& pnpm --filter @cfdm/api deploy --prod /out \
|
&& pnpm --filter @cfdm/api deploy --prod /out \
|
||||||
|
|||||||
+56
-8
@@ -14,16 +14,17 @@ Manage Cloudflare zones, DNS records, domain groups, and TLS certificate expiry
|
|||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `CLOUDFLARE_API_TOKEN` | API token with Zone.DNS permissions |
|
| `CLOUDFLARE_API_TOKEN` | API token: Zone.DNS **и** для Worker — Account Workers Scripts Write + Workers KV Storage Write |
|
||||||
| `DATABASE_URL` | SQLite path (`sqlite:/data/app.db`) |
|
| `DATABASE_URL` | SQLite path (`sqlite:/data/app.db`) |
|
||||||
| `JWT_SECRET` | JWT signing secret |
|
| `JWT_SECRET` | JWT signing secret |
|
||||||
| `ADMIN_USERNAME` | Admin username |
|
| `ADMIN_USERNAME` | Admin username |
|
||||||
| `ADMIN_PASSWORD_HASH` | Argon2 hash (empty = dev `admin`/`admin`) |
|
| `ADMIN_PASSWORD_HASH` | Argon2 hash (empty = dev `admin`/`admin`) |
|
||||||
| `LOG_LEVEL` | Уровень логов API (`info`, `debug`) |
|
| `LOG_LEVEL` | Уровень логов API (`info`, `debug`) |
|
||||||
| `HEALTH_CHECK_CRON` | Cron для health-check (default `*/30 * * * * *`) |
|
| `HEALTH_CHECK_CRON` | Cron для health-check (default `0 */2 * * * *`). Переопределяется в **Настройки → Health-check**. |
|
||||||
| `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`) |
|
| `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`). То же в UI. |
|
||||||
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`) |
|
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`). То же в UI. |
|
||||||
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`) |
|
| `HEALTH_SUCCESS_RECOVERIES` | Успехов подряд для recovery `CHECKING → HEALTHY` (default `2`). То же в UI. |
|
||||||
|
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`). То же в UI. |
|
||||||
|
|
||||||
## Load balancing & health checks
|
## Load balancing & health checks
|
||||||
|
|
||||||
@@ -37,11 +38,58 @@ health-check работают на двух уровнях:
|
|||||||
`(domain_id, service_id, hostname)`).
|
`(domain_id, service_id, hostname)`).
|
||||||
- **Привязка сервиса с multi-A** — режим LB и health-check настраиваются в карточке
|
- **Привязка сервиса с multi-A** — режим LB и health-check настраиваются в карточке
|
||||||
сервиса для каждой привязки с несколькими IP; для IP задаются вес/приоритет.
|
сервиса для каждой привязки с несколькими IP; для IP задаются вес/приоритет.
|
||||||
|
- **Ноды** — first-class адреса сервиса (`nodes` + `binding_nodes`); IP-пулы
|
||||||
|
`service_ips` / `service_binding_ips` пишутся dual-write.
|
||||||
|
- **Change IP** — `POST /api/v1/service-bindings/:id/change-ip` (preview + PATCH DNS).
|
||||||
|
- **Change Domain** — перенос привязок между зонами `POST /api/v1/services/:id/change-domain`.
|
||||||
|
|
||||||
Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted`
|
Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted`
|
||||||
работает как `round_robin` (одна A на IP), веса хранятся в БД для будущих расширений
|
работает как `round_robin` (одна A на IP). `unknown` **не** считается healthy и
|
||||||
и отображения в UI. Reconcile DNS запускается cron-задачей `health-check` при смене
|
не попадает в пул, пока нет успешных проб; восстановление — `UNHEALTHY → CHECKING → HEALTHY`
|
||||||
статуса IP (`up` / `degraded` / `down` / `unknown`); `down`-IP убирается из A-записей.
|
после `HEALTH_SUCCESS_RECOVERIES` (default 2). Пороги и cron движка задаются в
|
||||||
|
**Настройки → Health-check** (env — fallback, пока значения не сохранены в UI).
|
||||||
|
|
||||||
|
### Источники проб: Local, Cloudflare Worker, Globalping
|
||||||
|
|
||||||
|
На привязке/группе задаётся **мультивыбор** источников (`health_check_providers` JSON)
|
||||||
|
и **правило агрегации** (`health_check_aggregate`: `any` | `all` | `majority`).
|
||||||
|
Failover читает одну строку `ip_health_status` (агрегат). Журнал `health_probe_log` —
|
||||||
|
строка на каждый источник.
|
||||||
|
|
||||||
|
| | 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-плане недоступен и **не используется**.
|
||||||
|
|
||||||
|
Worker **сам** опрашивает IP/порты/протоколы (TCP/HTTP, паттерн [UptimeFlare](https://github.com/lyc8503/UptimeFlare): `sockets.opened`, p-limit 5).
|
||||||
|
CFDM создаёт скрипт через Workers Scripts API, кладёт список целей в KV и читает результаты.
|
||||||
|
Публичный URL API не нужен. Если Worker/KV не готовы, cloudflare-цели **не** пробируются как Local.
|
||||||
|
|
||||||
|
Кнопка **Создать / обновить Worker** — **Настройки → Health-check**. Токен:
|
||||||
|
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/).
|
||||||
|
|
||||||
|
**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
|
## Docker
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ Workflows: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml), [.gitea/work
|
|||||||
|
|
||||||
Fallback для **git tag**: `gitea.token`, если PAT недоступен. Push образов в Container Registry — **только `ACTIONS_PAT`** (у job token Gitea нет права packages).
|
Fallback для **git tag**: `gitea.token`, если PAT недоступен. Push образов в Container Registry — **только `ACTIONS_PAT`** (у job token Gitea нет права packages).
|
||||||
|
|
||||||
Wiki: секрет **`GITEA_TOKEN`** (fallback `ACTIONS_PAT`) для clone/push `*.wiki.git`. Push идёт с HTTP `Authorization`, потому что git после clone вырезает токен из remote URL.
|
Wiki: секрет **`ACTIONS_PAT`** (fallback `GITEA_TOKEN`) для clone/push `*.wiki.git` на `https://git.shx.one` (не внутренний `GITEA_INSTANCE_URL` раннера). Токен передаётся в URL (`oauth2:<PAT>`): Gitea на неаутентифицированный wiki push отвечает 404, а не 401.
|
||||||
|
|
||||||
## Источник правды для версии
|
## Источник правды для версии
|
||||||
|
|
||||||
|
|||||||
Vendored
+2974
-7
File diff suppressed because it is too large.
Load diff
Vendored
+652
-32
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,83 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS health_checks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
provider TEXT NOT NULL DEFAULT 'local',
|
||||||
|
cf_healthcheck_id TEXT,
|
||||||
|
cf_zone_id TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'tcp',
|
||||||
|
path TEXT,
|
||||||
|
method TEXT,
|
||||||
|
timeout INTEGER NOT NULL DEFAULT 5,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 30,
|
||||||
|
retries INTEGER NOT NULL DEFAULT 2,
|
||||||
|
expected_status INTEGER,
|
||||||
|
consecutive_fails INTEGER NOT NULL DEFAULT 2,
|
||||||
|
consecutive_successes INTEGER NOT NULL DEFAULT 2,
|
||||||
|
suspended INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS nodes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'tcp',
|
||||||
|
port INTEGER,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 1,
|
||||||
|
weight INTEGER NOT NULL DEFAULT 1,
|
||||||
|
health_status TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
health_check_id INTEGER REFERENCES health_checks(id) ON DELETE SET NULL,
|
||||||
|
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||||
|
consecutive_successes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_check_at TEXT,
|
||||||
|
last_failure_reason TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(service_id, address)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS binding_nodes (
|
||||||
|
binding_id INTEGER NOT NULL REFERENCES service_bindings(id) ON DELETE CASCADE,
|
||||||
|
node_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||||
|
weight INTEGER NOT NULL DEFAULT 1,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (binding_id, node_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nodes_service ON nodes(service_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_binding_nodes_node ON binding_nodes(node_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_health_checks_cf ON health_checks(cf_healthcheck_id);
|
||||||
|
|
||||||
|
ALTER TABLE service_bindings ADD COLUMN routing_strategy TEXT NOT NULL DEFAULT 'round_robin';
|
||||||
|
ALTER TABLE service_bindings ADD COLUMN operation_version INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE ip_health_status ADD COLUMN consecutive_successes INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
INSERT INTO nodes (service_id, address, enabled, priority, weight, health_status)
|
||||||
|
SELECT service_id, ip, 1, 1, 1, 'unknown'
|
||||||
|
FROM service_ips
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM nodes n WHERE n.service_id = service_ips.service_id AND n.address = service_ips.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO nodes (service_id, address, enabled, priority, weight, health_status)
|
||||||
|
SELECT DISTINCT sb.service_id, sbi.ip, 1, sbi.priority, sbi.weight, 'unknown'
|
||||||
|
FROM service_binding_ips sbi
|
||||||
|
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM nodes n WHERE n.service_id = sb.service_id AND n.address = sbi.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO binding_nodes (binding_id, node_id, weight, priority)
|
||||||
|
SELECT sbi.binding_id, n.id, sbi.weight, sbi.priority
|
||||||
|
FROM service_binding_ips sbi
|
||||||
|
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||||
|
JOIN nodes n ON n.service_id = sb.service_id AND n.address = sbi.ip
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM binding_nodes bn
|
||||||
|
WHERE bn.binding_id = sbi.binding_id AND bn.node_id = n.id
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE service_bindings SET routing_strategy = lb_mode WHERE routing_strategy = 'round_robin';
|
||||||
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user