Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6008cd763a | ||
|
|
b9bea44dce | ||
|
|
4c4908558b | ||
|
|
2c92e78b24 | ||
|
|
d63c86065c | ||
|
|
4224db8eb3 | ||
|
|
4ca948292d | ||
|
|
50c5c21c18 | ||
|
|
ab4ccbd7a1 | ||
|
|
b2dbb4ad98 | ||
|
|
3f6f402872 |
@@ -36,3 +36,10 @@ RUST_LOG=info
|
||||
|
||||
# Certificate scheduler (cron)
|
||||
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).
|
||||
|
||||
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).
|
||||
|
||||
**`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
|
||||
if: steps.check_changes.outputs.changed == 'true'
|
||||
env:
|
||||
WIKI_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.ACTIONS_PAT }}
|
||||
SERVER_URL: ${{ gitea.server_url }}
|
||||
# ACTIONS_PAT уже пишет git (tags/releases). GITEA_TOKEN — опциональный
|
||||
# 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 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
fi
|
||||
# runner GITEA_INSTANCE_URL часто внутренний (http://192.168.x.x:3000).
|
||||
# git после clone вырезает userinfo из origin → push без токена даёт 404
|
||||
# «Repository not found». Authorization header переживает insteadOf/sanitize.
|
||||
WIKI_URL="${SERVER_URL}/${REPO}.wiki.git"
|
||||
AUTH_HEADER="Authorization: Basic $(printf '%s' "oauth2:${WIKI_TOKEN}" | base64 | tr -d '\n')"
|
||||
git -c http.extraHeader="${AUTH_HEADER}" clone "${WIKI_URL}" cfdm.wiki
|
||||
PUBLIC_URL="${GITEA_PUBLIC_URL%/}"
|
||||
TOKEN_ENC="$(python3 -c 'import urllib.parse,os; print(urllib.parse.quote(os.environ["WIKI_TOKEN"], safe=""))')"
|
||||
WIKI_URL="${PUBLIC_URL}/${REPO}.wiki.git"
|
||||
# Gitea на неаутентифицированный wiki push отвечает 404, не 401 —
|
||||
# extraHeader/ASKPASS не помогают: токен должен быть в URL с первого запроса.
|
||||
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
|
||||
cd cfdm.wiki
|
||||
git config user.name "Gitea Actions"
|
||||
@@ -65,7 +70,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
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:
|
||||
needs: [quality]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
"test": "vitest run"
|
||||
},
|
||||
|
||||
+21
-67
@@ -7,7 +7,6 @@ import {
|
||||
} from "@fastify/type-provider-zod";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { repos } from "@cfdm/db";
|
||||
import authPlugin from "./plugins/auth.js";
|
||||
import cfClientPlugin from "./plugins/cf-client.js";
|
||||
import { requireAuth } from "./plugins/auth.js";
|
||||
@@ -24,6 +23,7 @@ import { dnsRoutes } from "./routes/dns.js";
|
||||
import { subdomainRoutes } from "./routes/subdomains.js";
|
||||
import { certificateRoutes } from "./routes/certificates.js";
|
||||
import { syncRoutes } from "./routes/sync.js";
|
||||
import { originHealthCheckRoutes } from "./routes/origin-health-checks.js";
|
||||
import { healthCheckRoutes } from "./routes/health-check.js";
|
||||
import {
|
||||
domainMonitorRoutes,
|
||||
@@ -33,8 +33,12 @@ import { settingsRoutes } from "./routes/settings.js";
|
||||
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
|
||||
import { auditRoutes } from "./routes/audit.js";
|
||||
import * as certificateService from "./services/certificate-service.js";
|
||||
import * as healthCheckService from "./services/health-check-service.js";
|
||||
import * as serviceConfigService from "./services/service-config-service.js";
|
||||
import {
|
||||
createHealthCheckTask,
|
||||
healthEngineFallbacksFromConfig,
|
||||
scheduleHealthCheckJob,
|
||||
} from "./services/health-check-scheduler.js";
|
||||
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
|
||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||
|
||||
export interface BuildAppOptions {
|
||||
@@ -81,6 +85,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
await protectedApi.register(certificateRoutes);
|
||||
await protectedApi.register(syncRoutes);
|
||||
await protectedApi.register(healthCheckRoutes);
|
||||
await protectedApi.register(originHealthCheckRoutes);
|
||||
await protectedApi.register(domainMonitorRoutes);
|
||||
await protectedApi.register(notificationRoutes);
|
||||
await protectedApi.register(settingsRoutes);
|
||||
@@ -123,70 +128,19 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
),
|
||||
);
|
||||
|
||||
const healthTask = new AsyncTask(
|
||||
"health-check",
|
||||
async () => {
|
||||
const thresholds = {
|
||||
degradedFailures: config.healthDegradedFailures,
|
||||
downFailures: config.healthDownFailures,
|
||||
latencyWarnMs: config.healthLatencyWarnMs,
|
||||
};
|
||||
const n = await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
probeGapMs: config.healthProbeGapMs,
|
||||
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 },
|
||||
),
|
||||
);
|
||||
const healthTask = createHealthCheckTask(app, config);
|
||||
scheduleHealthCheckJob(app, config, healthTask);
|
||||
app.decorate("reloadHealthCheckJob", () => {
|
||||
scheduleHealthCheckJob(app, config, healthTask);
|
||||
});
|
||||
if (config.cloudflareApiToken) {
|
||||
fireEnsureHealthWorker(
|
||||
app.db,
|
||||
app.cf,
|
||||
healthEngineFallbacksFromConfig(config),
|
||||
app.log,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return app;
|
||||
|
||||
@@ -13,9 +13,12 @@ export interface AppConfig {
|
||||
healthCheckCron: string;
|
||||
healthDegradedFailures: number;
|
||||
healthDownFailures: number;
|
||||
healthSuccessRecoveries: number;
|
||||
healthLatencyWarnMs: number;
|
||||
/** Min pause between probes to different physical targets (same IP is probed once). */
|
||||
healthProbeGapMs: number;
|
||||
healthWorkerUrl: string;
|
||||
healthWorkerToken: string;
|
||||
logLevel: string;
|
||||
/** Portal SSO — when true, require portal JWT with apps includes cfdm */
|
||||
authRequired: boolean;
|
||||
@@ -55,9 +58,13 @@ export function loadConfig(): AppConfig {
|
||||
healthDegradedFailures:
|
||||
Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1,
|
||||
healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2,
|
||||
healthSuccessRecoveries:
|
||||
Number(process.env.HEALTH_SUCCESS_RECOVERIES ?? "2") || 2,
|
||||
healthLatencyWarnMs:
|
||||
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
|
||||
healthProbeGapMs: Number(process.env.HEALTH_PROBE_GAP_MS ?? "2000") || 2000,
|
||||
healthWorkerUrl: (process.env.HEALTH_WORKER_URL ?? "").trim(),
|
||||
healthWorkerToken: (process.env.HEALTH_WORKER_TOKEN ?? "").trim(),
|
||||
logLevel: process.env.LOG_LEVEL ?? "info",
|
||||
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||
authIssuer:
|
||||
|
||||
@@ -8,6 +8,14 @@ export type ErrorCode =
|
||||
| "FORBIDDEN"
|
||||
| "CONFLICT"
|
||||
| "CLOUDFLARE_ERROR"
|
||||
| "DNS_UPDATE_FAILED"
|
||||
| "HEALTHCHECK_CREATE_FAILED"
|
||||
| "ZONE_NOT_FOUND"
|
||||
| "INVALID_IP"
|
||||
| "INVALID_HOSTNAME"
|
||||
| "RATE_LIMITED"
|
||||
| "CLOUDFLARE_AUTH_FAILED"
|
||||
| "SYNC_FAILED"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export class AppError extends Error {
|
||||
@@ -44,6 +52,42 @@ export class AppError extends Error {
|
||||
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) {
|
||||
return new AppError("INTERNAL_ERROR", message, 500);
|
||||
}
|
||||
|
||||
+110
-125
@@ -1,152 +1,137 @@
|
||||
import type {
|
||||
CfDnsRecord,
|
||||
CfHealthCheck,
|
||||
CfZone,
|
||||
CreateDnsRecordPayload,
|
||||
PatchDnsRecordPayload,
|
||||
} from "@cfdm/shared";
|
||||
import { AppError } from "../errors.js";
|
||||
import { withRetry, parseRetryAfter } from "./cf-retry.js";
|
||||
import { createDnsAdapter } from "./cloudflare/dns-service.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";
|
||||
|
||||
interface CfResponse<T> {
|
||||
success: boolean;
|
||||
result?: T;
|
||||
errors?: Array<{ code: number; message: string }>;
|
||||
}
|
||||
export type { CfHealthCheckPayload };
|
||||
|
||||
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>(
|
||||
response: Response,
|
||||
operation: string,
|
||||
): Promise<T> {
|
||||
if (response.status === 429) {
|
||||
const wait = parseRetryAfter(response.headers) ?? 5000;
|
||||
throw AppError.cloudflare(`rate limited, retry after ${wait}ms`);
|
||||
}
|
||||
|
||||
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;
|
||||
constructor(token: string) {
|
||||
this.token = token.trim();
|
||||
this.zones = createZoneAdapter(token);
|
||||
this.dns = createDnsAdapter(token);
|
||||
this.healthchecks = createHealthCheckAdapter(token);
|
||||
this.kv = createKvAdapter(token);
|
||||
this.workers = createWorkersAdapter(token);
|
||||
}
|
||||
|
||||
async listZones(): Promise<CfZone[]> {
|
||||
return withRetry(async () => {
|
||||
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;
|
||||
});
|
||||
get isConfigured(): boolean {
|
||||
return this.token.length > 0;
|
||||
}
|
||||
|
||||
async getZone(zoneId: string): Promise<CfZone> {
|
||||
const response = await fetch(`${BASE_URL}/zones/${zoneId}`, {
|
||||
headers: { Authorization: `Bearer ${this.token}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
return this.handleResponse(response, "get_zone");
|
||||
listZones(): Promise<CfZone[]> {
|
||||
return this.zones.listZones();
|
||||
}
|
||||
|
||||
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
|
||||
return withRetry(async () => {
|
||||
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;
|
||||
});
|
||||
getZone(zoneId: string): Promise<CfZone> {
|
||||
return this.zones.getZone(zoneId);
|
||||
}
|
||||
|
||||
async createDnsRecord(
|
||||
zoneId: string,
|
||||
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");
|
||||
listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
|
||||
return this.dns.listDnsRecords(zoneId);
|
||||
}
|
||||
|
||||
async updateDnsRecord(
|
||||
createDnsRecord(zoneId: string, payload: CreateDnsRecordPayload): Promise<CfDnsRecord> {
|
||||
return this.dns.createDnsRecord(zoneId, payload);
|
||||
}
|
||||
|
||||
updateDnsRecord(
|
||||
zoneId: string,
|
||||
recordId: string,
|
||||
payload: CreateDnsRecordPayload,
|
||||
): Promise<CfDnsRecord> {
|
||||
const response = await fetch(
|
||||
`${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");
|
||||
return this.dns.updateDnsRecord(zoneId, recordId, payload);
|
||||
}
|
||||
|
||||
async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${this.token}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
await this.handleResponse(response, "delete_dns_record");
|
||||
patchDnsRecord(
|
||||
zoneId: string,
|
||||
recordId: string,
|
||||
payload: PatchDnsRecordPayload,
|
||||
): Promise<CfDnsRecord> {
|
||||
return this.dns.patchDnsRecord(zoneId, recordId, payload);
|
||||
}
|
||||
|
||||
deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
|
||||
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"],
|
||||
match: (p) =>
|
||||
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",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) =>
|
||||
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",
|
||||
},
|
||||
{
|
||||
@@ -114,8 +117,7 @@ const RULES: Rule[] = [
|
||||
match: (p) =>
|
||||
p.startsWith("/api/v1/settings") ||
|
||||
p.startsWith("/api/v1/notifications") ||
|
||||
p.startsWith("/api/v1/health-check") ||
|
||||
p.startsWith("/api/v1/health-checks"),
|
||||
p.startsWith("/api/v1/health-check"),
|
||||
permission: "cfdm:settings:admin",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { healthStatusQuerySchema } from "@cfdm/shared";
|
||||
import { repos } from "@cfdm/db";
|
||||
import { getAppSettings, repos } from "@cfdm/db";
|
||||
import * as healthCheckService from "../services/health-check-service.js";
|
||||
import * as serviceConfigService from "../services/service-config-service.js";
|
||||
import {
|
||||
healthEngineFallbacksFromConfig,
|
||||
} 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) {
|
||||
app.get("/health-status", async (request) => {
|
||||
@@ -16,14 +21,22 @@ export async function healthCheckRoutes(app: FastifyInstance) {
|
||||
|
||||
app.post("/health-check/run", async (request) => {
|
||||
const config = request.server.config;
|
||||
const fallbacks = healthEngineFallbacksFromConfig(config);
|
||||
const settings = getAppSettings(
|
||||
request.server.db,
|
||||
fallbacks,
|
||||
);
|
||||
const thresholds = {
|
||||
degradedFailures: config.healthDegradedFailures,
|
||||
downFailures: config.healthDownFailures,
|
||||
latencyWarnMs: config.healthLatencyWarnMs,
|
||||
degradedFailures: settings.healthDegradedFailures,
|
||||
downFailures: settings.healthDownFailures,
|
||||
latencyWarnMs: settings.healthLatencyWarnMs,
|
||||
successRecoveries: settings.healthSuccessRecoveries,
|
||||
};
|
||||
const checked = await healthCheckService.runAllChecks(request.server.db, {
|
||||
thresholds,
|
||||
probeGapMs: config.healthProbeGapMs,
|
||||
mailbox: mailboxFromSettings(request.server.db, request.server.cf, fallbacks),
|
||||
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
|
||||
onStatusChange: async (target, prev, next) => {
|
||||
try {
|
||||
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 { z } from "zod";
|
||||
import { changeIpSchema } from "@cfdm/shared";
|
||||
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) {
|
||||
const createSchema = z.object({
|
||||
@@ -52,6 +55,27 @@ export async function serviceBindingRoutes(app: FastifyInstance) {
|
||||
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) => {
|
||||
const { id } = request.params as { id: string };
|
||||
return bindingService.listByDomain(request.server.db, Number(id));
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
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 * 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";
|
||||
|
||||
export async function serviceRoutes(app: FastifyInstance) {
|
||||
@@ -56,6 +65,96 @@ export async function serviceRoutes(app: FastifyInstance) {
|
||||
return serviceConfig.getView(request.server.db, Number(id));
|
||||
});
|
||||
|
||||
app.get("/services/:id/health-log", async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
repos.getService(request.server.db, Number(id));
|
||||
return {
|
||||
items: repos.listHealthProbeLogForService(request.server.db, Number(id)),
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/services/:id/overview", async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
return nodeService.getOverview(request.server.db, Number(id));
|
||||
});
|
||||
|
||||
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) => {
|
||||
const { id } = request.params as { id: string };
|
||||
@@ -101,4 +200,26 @@ export async function serviceRoutes(app: FastifyInstance) {
|
||||
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,
|
||||
} from "@cfdm/db";
|
||||
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) {
|
||||
app.get("/settings", async (request) => {
|
||||
return getAppSettings(request.server.db);
|
||||
return getAppSettings(
|
||||
request.server.db,
|
||||
healthEngineFallbacksFromConfig(request.server.config),
|
||||
);
|
||||
});
|
||||
|
||||
app.patch("/settings", async (request) => {
|
||||
const body = appSettingsPatchSchema.parse(request.body);
|
||||
return updateAppSettings(request.server.db, body);
|
||||
const parsed = appSettingsPatchSchema.safeParse(request.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) => {
|
||||
|
||||
@@ -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 { repos, type DnsListFilter } from "@cfdm/db";
|
||||
import type { CreateDnsRecordPayload, DnsRecord } from "@cfdm/shared";
|
||||
import type { CreateDnsRecordPayload, DnsRecord, PatchDnsRecordPayload } from "@cfdm/shared";
|
||||
import {
|
||||
SYNC_CONFLICT,
|
||||
SYNC_ERROR,
|
||||
@@ -179,6 +179,52 @@ export async function update(
|
||||
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(
|
||||
db: Db,
|
||||
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 type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
|
||||
import type { HealthCheckTarget, IpHealthState, HealthCheckProvider } from "@cfdm/shared";
|
||||
import {
|
||||
aggregateHealthOk,
|
||||
parseHealthAggregate,
|
||||
targetProviders,
|
||||
} from "@cfdm/shared";
|
||||
import { AppError } from "../errors.js";
|
||||
import { nextHealthState } from "./health/state-machine.js";
|
||||
import { LocalHealthCheckProvider } from "./health/local.js";
|
||||
import { workerNotConfiguredResult } from "./health/worker.js";
|
||||
import {
|
||||
globalpingNotConfiguredResult,
|
||||
probeWithGlobalping,
|
||||
} from "./health/globalping.js";
|
||||
import {
|
||||
buildTargetsDoc,
|
||||
indexResults,
|
||||
isResultsStale,
|
||||
originProbeKey,
|
||||
type HealthMailbox,
|
||||
} from "./health/mailbox.js";
|
||||
import type { GlobalpingClientOptions } from "../lib/globalping-client.js";
|
||||
|
||||
export interface HealthCheckThresholds {
|
||||
degradedFailures: number;
|
||||
downFailures: number;
|
||||
latencyWarnMs: number;
|
||||
successRecoveries?: number;
|
||||
}
|
||||
|
||||
export interface ProbeResult {
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
error: string | null;
|
||||
colo?: string | null;
|
||||
}
|
||||
|
||||
/** Bracket IPv6 for URL authority; leave IPv4/hostname as-is. */
|
||||
@@ -234,29 +256,36 @@ export async function probeTarget(
|
||||
function deriveState(
|
||||
ok: boolean,
|
||||
latencyMs: number,
|
||||
prev: { consecutive_failures: number; status: string } | null,
|
||||
prev: {
|
||||
consecutive_failures: number;
|
||||
consecutive_successes?: number;
|
||||
status: string;
|
||||
} | null,
|
||||
thresholds: HealthCheckThresholds,
|
||||
): { state: IpHealthState; failures: number } {
|
||||
if (!ok) {
|
||||
const failures = (prev?.consecutive_failures ?? 0) + 1;
|
||||
if (failures >= thresholds.downFailures) {
|
||||
return { state: "down", failures };
|
||||
}
|
||||
if (failures >= thresholds.degradedFailures) {
|
||||
return { state: "degraded", failures };
|
||||
}
|
||||
return { state: "degraded", failures };
|
||||
}
|
||||
if (latencyMs > thresholds.latencyWarnMs) {
|
||||
return { state: "degraded", failures: 0 };
|
||||
}
|
||||
return { state: "up", failures: 0 };
|
||||
): { state: IpHealthState; failures: number; successes: number; node: string } {
|
||||
const next = nextHealthState(ok, latencyMs, prev, {
|
||||
degradedFailures: thresholds.degradedFailures,
|
||||
downFailures: thresholds.downFailures,
|
||||
latencyWarnMs: thresholds.latencyWarnMs,
|
||||
successRecoveries: thresholds.successRecoveries ?? 2,
|
||||
});
|
||||
return {
|
||||
state: next.legacy,
|
||||
failures: next.failures,
|
||||
successes: next.successes,
|
||||
node: next.node,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RunAllChecksOptions {
|
||||
thresholds: HealthCheckThresholds;
|
||||
/** Pause between unique physical probes (default 2000). Same IP is only probed once. */
|
||||
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?: (
|
||||
target: HealthCheckTarget,
|
||||
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
|
||||
* so anti-bot / rate-limit on the origin is not tripped by back-to-back checks.
|
||||
* One network hit per origin+provider. Group+binding on the same IP share a probe.
|
||||
*/
|
||||
export function physicalProbeKey(target: HealthCheckTarget): string {
|
||||
const 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}`;
|
||||
export function physicalProbeKey(
|
||||
target: HealthCheckTarget,
|
||||
provider: HealthCheckProvider = target.provider,
|
||||
): string {
|
||||
return `${provider}|${originProbeKey(target)}`;
|
||||
}
|
||||
|
||||
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 (target.type === "ping") {
|
||||
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||
if (prevState !== state) {
|
||||
options.onStatusChange?.(target, prevState, state);
|
||||
}
|
||||
if (target.type === "dns") {
|
||||
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||
}
|
||||
return `${target.type}|${ip}|${port}`;
|
||||
}
|
||||
|
||||
function staleWorkerResult(colo: string | null): ProbeResult {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: "Cloudflare Worker: результаты устарели или KV пуст",
|
||||
colo,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runAllChecks(
|
||||
@@ -296,64 +415,107 @@ export async function runAllChecks(
|
||||
): Promise<number> {
|
||||
const targets = repos.listHealthCheckTargets(db);
|
||||
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) {
|
||||
const key = physicalProbeKey(target);
|
||||
const list = byPhysical.get(key);
|
||||
const key = originProbeKey(target);
|
||||
const list = byOrigin.get(key);
|
||||
if (list) list.push(target);
|
||||
else byPhysical.set(key, [target]);
|
||||
else byOrigin.set(key, [target]);
|
||||
}
|
||||
|
||||
let probeIndex = 0;
|
||||
for (const group of byPhysical.values()) {
|
||||
if (probeIndex > 0 && gapMs > 0) {
|
||||
await sleep(gapMs);
|
||||
}
|
||||
probeIndex += 1;
|
||||
|
||||
// Prefer binding hostname for SNI when several scopes share one IP.
|
||||
const representative =
|
||||
group.find((t) => t.scope === "binding") ?? group[0]!;
|
||||
const result = await probeTarget(representative);
|
||||
|
||||
for (const target of group) {
|
||||
const prev = repos.getIpHealthStatusRow(
|
||||
db,
|
||||
target.scope,
|
||||
target.ref_id,
|
||||
target.ip,
|
||||
);
|
||||
const { state, failures } = deriveState(
|
||||
result.ok,
|
||||
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);
|
||||
const needsCloudflare = targets.some((t) =>
|
||||
targetProviders(t).includes("cloudflare"),
|
||||
);
|
||||
let mailboxResults = new Map<string, { ok: boolean; latencyMs: number; error: string | null }>();
|
||||
let mailboxColo: string | null = null;
|
||||
let mailboxStale = true;
|
||||
const mailbox = options.mailbox ?? null;
|
||||
if (needsCloudflare) {
|
||||
const resultsDoc = mailbox ? await mailbox.getResults() : null;
|
||||
mailboxResults = indexResults(resultsDoc);
|
||||
mailboxStale = !mailbox || isResultsStale(resultsDoc, staleAfterMs);
|
||||
mailboxColo = resultsDoc?.colo ?? null;
|
||||
if (mailbox) {
|
||||
try {
|
||||
const next = buildTargetsDoc(targets);
|
||||
const current = await mailbox.getTargets();
|
||||
if (current?.fingerprint !== next.fingerprint) {
|
||||
await mailbox.putTargets(next);
|
||||
}
|
||||
} catch {
|
||||
// ingest still proceeds
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
return targets.length;
|
||||
}
|
||||
@@ -376,6 +538,9 @@ export async function runDomainMonitors(
|
||||
expected_status: monitor.expected_status,
|
||||
timeout_ms: monitor.timeout_ms,
|
||||
verify_tls: false,
|
||||
provider: "local",
|
||||
providers: ["local"],
|
||||
aggregate: "majority",
|
||||
};
|
||||
let result: ProbeResult;
|
||||
if (monitor.type === "http") {
|
||||
@@ -402,6 +567,7 @@ export async function runDomainMonitors(
|
||||
result.latencyMs,
|
||||
{
|
||||
consecutive_failures: result.ok ? 0 : 1,
|
||||
consecutive_successes: result.ok ? 1 : 0,
|
||||
status: prevStatus,
|
||||
},
|
||||
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 type {
|
||||
DnsRecord,
|
||||
HealthCheckAggregate,
|
||||
HealthCheckProvider,
|
||||
HealthCheckScope,
|
||||
HealthCheckType,
|
||||
IpHealthState,
|
||||
@@ -24,6 +26,16 @@ import { isValidIpv4 } from "../lib/validators.js";
|
||||
import * as dnsService from "./dns-service.js";
|
||||
import * as domainService from "./domain-service.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 {
|
||||
fqdn: string;
|
||||
@@ -41,6 +53,9 @@ export interface ServiceDomainInput {
|
||||
health_check_interval_sec?: number;
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
export interface ToggleRequest {
|
||||
@@ -61,6 +76,9 @@ export interface ServiceGroupBody {
|
||||
health_check_interval_sec?: number;
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
export interface UpdateServiceGroupBody {
|
||||
@@ -77,6 +95,9 @@ export interface UpdateServiceGroupBody {
|
||||
health_check_interval_sec?: number;
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
export interface UpdateServiceConfigRequest {
|
||||
@@ -137,54 +158,6 @@ function aggregateSyncStatus(statuses: string[]): string | 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(
|
||||
db: Db,
|
||||
bindingId: number,
|
||||
@@ -281,7 +254,11 @@ async function collectKnownZones(
|
||||
|
||||
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
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 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_timeout_ms: binding.health_check_timeout_ms,
|
||||
health_check_verify_tls: binding.health_check_verify_tls,
|
||||
health_check_provider: binding.health_check_provider ?? "local",
|
||||
health_check_providers: binding.health_check_providers ?? [
|
||||
binding.health_check_provider ?? "local",
|
||||
],
|
||||
health_check_aggregate: binding.health_check_aggregate ?? "majority",
|
||||
sync_status: aggregateSyncStatus(statuses),
|
||||
};
|
||||
});
|
||||
@@ -343,9 +325,11 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
created_at: service.created_at,
|
||||
updated_at: service.updated_at,
|
||||
ips,
|
||||
ip_enabled,
|
||||
domains: domainViews,
|
||||
health_status: "unknown",
|
||||
health_latency_ms: null,
|
||||
ip_health: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -353,16 +337,31 @@ function attachServiceHealth(
|
||||
db: Db,
|
||||
views: ServiceView[],
|
||||
): ServiceView[] {
|
||||
const healthByService = repos.aggregateIpHealthByServiceIds(
|
||||
db,
|
||||
views.map((v) => v.id),
|
||||
);
|
||||
const ids = views.map((v) => v.id);
|
||||
const healthByService = repos.aggregateIpHealthByServiceIds(db, ids);
|
||||
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
|
||||
return views.map((view) => {
|
||||
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 {
|
||||
...view,
|
||||
health_status: health?.health_status ?? "unknown",
|
||||
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 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);
|
||||
// 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,
|
||||
health_status: "unknown" as const,
|
||||
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_interval_sec !== undefined ||
|
||||
input.health_check_timeout_ms !== undefined ||
|
||||
input.health_check_verify_tls !== undefined
|
||||
input.health_check_verify_tls !== undefined ||
|
||||
input.health_check_provider !== undefined ||
|
||||
input.health_check_providers !== undefined ||
|
||||
input.health_check_aggregate !== undefined
|
||||
) {
|
||||
repos.updateBindingLbConfig(db, binding.id, {
|
||||
lb_mode: input.lb_mode,
|
||||
@@ -1186,6 +1190,9 @@ export async function updateConfig(
|
||||
health_check_interval_sec: input.health_check_interval_sec,
|
||||
health_check_timeout_ms: input.health_check_timeout_ms,
|
||||
health_check_verify_tls: input.health_check_verify_tls,
|
||||
health_check_provider: input.health_check_provider,
|
||||
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);
|
||||
|
||||
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||
|
||||
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
|
||||
return view!;
|
||||
}
|
||||
@@ -1271,7 +1280,7 @@ export async function createGroup(
|
||||
): Promise<ServiceGroup> {
|
||||
const groupType = body.type?.trim() || "custom";
|
||||
const domain = await normalizeGroupDomain(db, cf, body.domain);
|
||||
return repos.createServiceGroup(
|
||||
const group = repos.createServiceGroup(
|
||||
db,
|
||||
body.name,
|
||||
groupType,
|
||||
@@ -1287,8 +1296,13 @@ export async function createGroup(
|
||||
health_check_interval_sec: body.health_check_interval_sec,
|
||||
health_check_timeout_ms: body.health_check_timeout_ms,
|
||||
health_check_verify_tls: body.health_check_verify_tls,
|
||||
health_check_provider: body.health_check_provider,
|
||||
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(
|
||||
@@ -1323,6 +1337,9 @@ export async function updateGroup(
|
||||
health_check_interval_sec: body.health_check_interval_sec,
|
||||
health_check_timeout_ms: body.health_check_timeout_ms,
|
||||
health_check_verify_tls: body.health_check_verify_tls,
|
||||
health_check_provider: body.health_check_provider,
|
||||
health_check_providers: body.health_check_providers,
|
||||
health_check_aggregate: body.health_check_aggregate,
|
||||
},
|
||||
);
|
||||
if (!domain && group.enabled) {
|
||||
@@ -1330,6 +1347,7 @@ export async function updateGroup(
|
||||
group = repos.getServiceGroup(db, id);
|
||||
}
|
||||
await syncEnabledServicesInGroup(db, cf, id);
|
||||
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -1371,6 +1389,59 @@ export async function toggleService(
|
||||
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(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
@@ -1411,6 +1482,25 @@ export function reorderServices(
|
||||
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(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
@@ -1418,26 +1508,28 @@ export async function reconcileDnsForTarget(
|
||||
refId: number,
|
||||
): Promise<void> {
|
||||
if (scope === "binding") {
|
||||
const binding = repos.getBinding(db, refId);
|
||||
if (!binding.health_check_enabled) return;
|
||||
const service = repos.getService(db, binding.service_id);
|
||||
if (!shouldPushDns(db, service)) return;
|
||||
const cnameTarget = binding.cname_target?.trim() || null;
|
||||
if (cnameTarget) return;
|
||||
const ips = repos.listServiceIps(db, service.id);
|
||||
const targetIps = repos.listBindingIps(db, binding.id);
|
||||
validateTargetIpsInPool(targetIps, ips);
|
||||
const activeIps = computeActiveIps(db, "binding", refId);
|
||||
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
|
||||
await syncBindingDns(
|
||||
db,
|
||||
cf,
|
||||
binding.id,
|
||||
binding.domain_id,
|
||||
binding.hostname,
|
||||
desiredIps,
|
||||
null,
|
||||
);
|
||||
await withBindingLock(refId, async () => {
|
||||
const binding = repos.getBinding(db, refId);
|
||||
if (!binding.health_check_enabled) return;
|
||||
const service = repos.getService(db, binding.service_id);
|
||||
if (!shouldPushDns(db, service)) return;
|
||||
const cnameTarget = binding.cname_target?.trim() || null;
|
||||
if (cnameTarget) return;
|
||||
const ips = repos.listServiceIps(db, service.id);
|
||||
const targetIps = repos.listBindingIps(db, binding.id);
|
||||
validateTargetIpsInPool(targetIps, ips);
|
||||
const activeIps = computeActiveIps(db, "binding", refId);
|
||||
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
|
||||
await syncBindingDns(
|
||||
db,
|
||||
cf,
|
||||
binding.id,
|
||||
binding.domain_id,
|
||||
binding.hostname,
|
||||
desiredIps,
|
||||
null,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,9 @@ function resolveIpsLocally(
|
||||
const binding = index.byFqdn.get(key);
|
||||
if (!binding) return [];
|
||||
|
||||
if (binding.target_ips.some(isIpLiteral)) {
|
||||
return binding.target_ips.filter(isIpLiteral);
|
||||
const ips = (binding.target_ips ?? []).filter(isIpLiteral);
|
||||
if (ips.length > 0) {
|
||||
return ips;
|
||||
}
|
||||
|
||||
const cname = binding.cname_target?.trim();
|
||||
@@ -79,7 +80,7 @@ export async function resolveBindingIpsForSync(
|
||||
index: BindingIpIndex,
|
||||
db?: Db,
|
||||
): Promise<string[]> {
|
||||
const directIps = binding.target_ips.filter(isIpLiteral);
|
||||
const directIps = (binding.target_ips ?? []).filter(isIpLiteral);
|
||||
if (directIps.length > 0) {
|
||||
return [...directIps];
|
||||
}
|
||||
@@ -124,7 +125,7 @@ export async function buildServiceSyncBindingsAsync(
|
||||
const serviceIps = repos.listServiceIps(db, serviceId);
|
||||
const allBindings = repos.listAllBindings(db);
|
||||
const index = buildBindingIndex(allBindings);
|
||||
const bindings = repos.listBindingsByService(db, serviceId);
|
||||
const bindings = allBindings.filter((row) => row.service_id === serviceId);
|
||||
|
||||
const items: CfdmBindingSyncItem[] = [];
|
||||
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,
|
||||
timeout_ms: 1000,
|
||||
verify_tls: false,
|
||||
provider: "local",
|
||||
};
|
||||
const result = await healthCheckService.probeTarget(target);
|
||||
expect(result.ok).toBe(true);
|
||||
@@ -75,6 +76,7 @@ describe("health-check probeTarget", () => {
|
||||
expected_status: null,
|
||||
timeout_ms: 500,
|
||||
verify_tls: false,
|
||||
provider: "local",
|
||||
};
|
||||
const result = await healthCheckService.probeTarget(target);
|
||||
expect(result.ok).toBe(false);
|
||||
@@ -107,6 +109,7 @@ describe("health-check probeTarget", () => {
|
||||
expected_status: 200,
|
||||
timeout_ms: 1000,
|
||||
verify_tls: false,
|
||||
provider: "local",
|
||||
};
|
||||
const bindingTarget: HealthCheckTarget = {
|
||||
...groupTarget,
|
||||
@@ -142,6 +145,7 @@ describe("health-check probeTarget", () => {
|
||||
expected_status: null,
|
||||
timeout_ms: 3000,
|
||||
verify_tls: false,
|
||||
provider: "local",
|
||||
};
|
||||
const binding: HealthCheckTarget = {
|
||||
...group,
|
||||
|
||||
@@ -0,0 +1,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",
|
||||
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([
|
||||
"1.1.1.1",
|
||||
"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", () => {
|
||||
const config: LbTargetConfig = {
|
||||
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");
|
||||
repos.setServiceGroup(app.db, service.id, group.id);
|
||||
repos.setServiceEnabled(app.db, service.id, true);
|
||||
repos.replaceServiceIps(app.db, service.id, ["1.2.3.4"]);
|
||||
const binding = repos.insertBinding(
|
||||
app.db,
|
||||
domain.id,
|
||||
@@ -85,6 +86,11 @@ describe("service groups health enrichment", () => {
|
||||
id: number;
|
||||
health_status: string;
|
||||
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!.services[0]?.health_status).toBe("degraded");
|
||||
expect(groupView!.services[0]?.health_latency_ms).toBe(120);
|
||||
expect(groupView!.services[0]?.ip_health).toEqual([
|
||||
{
|
||||
ip: "1.2.3.4",
|
||||
status: "degraded",
|
||||
latency_ms: 120,
|
||||
last_checked_at: expect.any(String),
|
||||
last_error: null,
|
||||
provider: "local",
|
||||
colo: null,
|
||||
},
|
||||
]);
|
||||
// group worst = degraded (from service) over up (group scope)
|
||||
expect(groupView!.health_status).toBe("degraded");
|
||||
|
||||
|
||||
@@ -165,4 +165,94 @@ describe("create service then list groups", () => {
|
||||
|
||||
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"]);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const cname = binding({
|
||||
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: {
|
||||
environment: "node",
|
||||
include: ["test/**/*.test.ts"],
|
||||
testTimeout: 20_000,
|
||||
typecheck: {
|
||||
tsconfig: "./tsconfig.test.json",
|
||||
},
|
||||
|
||||
@@ -25,15 +25,18 @@ import {
|
||||
const infrastructureNav = [
|
||||
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
|
||||
{ to: '/domains', label: 'Домены', icon: GlobeIcon, exact: false },
|
||||
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
|
||||
] as const
|
||||
|
||||
const operationsNav = [
|
||||
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
|
||||
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
|
||||
{ to: '/settings/appearance', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' },
|
||||
] as const
|
||||
|
||||
const secondaryNav = [
|
||||
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
|
||||
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
|
||||
] as const
|
||||
|
||||
function isNavActive(
|
||||
pathname: string,
|
||||
to: string,
|
||||
@@ -106,6 +109,7 @@ export function AppSidebar() {
|
||||
<SidebarContent>
|
||||
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
|
||||
<NavSection label="Операции" items={operationsNav} pathname={pathname} />
|
||||
<NavSection label="Прочее" items={secondaryNav} pathname={pathname} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<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 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> = {
|
||||
up: 'success-light',
|
||||
degraded: 'warning-light',
|
||||
@@ -21,6 +35,13 @@ const healthLabels: Record<IpHealthStatus['status'], string> = {
|
||||
unknown: '—',
|
||||
}
|
||||
|
||||
const extraLabels: Partial<Record<HealthStatus, string>> = {
|
||||
healthy: 'Healthy',
|
||||
unhealthy: 'Unhealthy',
|
||||
checking: 'Checking',
|
||||
disabled: 'Disabled',
|
||||
}
|
||||
|
||||
const dotColor: Record<IpHealthStatus['status'], string> = {
|
||||
up: 'bg-success',
|
||||
degraded: 'bg-warning',
|
||||
@@ -29,10 +50,12 @@ const dotColor: Record<IpHealthStatus['status'], string> = {
|
||||
}
|
||||
|
||||
interface HealthCheckBadgeProps {
|
||||
status: IpHealthStatus['status']
|
||||
status: HealthStatus
|
||||
latencyMs?: number | null
|
||||
lastCheckedAt?: string | null
|
||||
lastError?: string | null
|
||||
colo?: string | null
|
||||
provider?: 'local' | 'cloudflare' | string | null
|
||||
title?: string
|
||||
showLatency?: boolean
|
||||
size?: 'xs' | 'sm'
|
||||
@@ -44,19 +67,25 @@ export function HealthCheckBadge({
|
||||
latencyMs,
|
||||
lastCheckedAt,
|
||||
lastError,
|
||||
colo,
|
||||
provider,
|
||||
title,
|
||||
showLatency = false,
|
||||
size = 'sm',
|
||||
className,
|
||||
}: HealthCheckBadgeProps) {
|
||||
const variant = healthVariants[status]
|
||||
const label = healthLabels[status]
|
||||
const normalized = normalizeHealth(status)
|
||||
const variant = healthVariants[normalized]
|
||||
const label = extraLabels[status] ?? healthLabels[normalized]
|
||||
|
||||
const tooltipParts: string[] = []
|
||||
if (title) tooltipParts.push(title)
|
||||
tooltipParts.push(`Статус: ${label}`)
|
||||
if (latencyMs != null) tooltipParts.push(`Задержка: ${latencyMs} мс`)
|
||||
if (lastCheckedAt) tooltipParts.push(`Проверка: ${formatDate(lastCheckedAt)}`)
|
||||
if (colo) tooltipParts.push(`Colo: ${colo}`)
|
||||
if (provider === 'cloudflare') tooltipParts.push('Провайдер: Cloudflare Worker')
|
||||
if (provider === 'local') tooltipParts.push('Провайдер: Local')
|
||||
if (lastError) tooltipParts.push(`Ошибка: ${lastError}`)
|
||||
|
||||
return (
|
||||
@@ -74,7 +103,7 @@ export function HealthCheckBadge({
|
||||
className={cn('gap-1.5', className)}
|
||||
>
|
||||
<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
|
||||
/>
|
||||
{label}
|
||||
|
||||
@@ -18,10 +18,19 @@ import {
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import {
|
||||
HealthAggregateTiles,
|
||||
HealthSourceTiles,
|
||||
type HealthAggregate,
|
||||
type HealthProvider,
|
||||
} from '@/components/reui-kit/health-source-tiles'
|
||||
import { uniqueHealthProviders } from '@cfdm/shared'
|
||||
|
||||
export type LbMode = 'round_robin' | 'failover' | 'weighted'
|
||||
export type HealthCheckType = 'tcp' | 'http'
|
||||
export type { HealthProvider, HealthAggregate }
|
||||
|
||||
export interface HealthCheckConfig {
|
||||
enabled: boolean
|
||||
@@ -32,6 +41,13 @@ export interface HealthCheckConfig {
|
||||
interval_sec: number
|
||||
timeout_ms: number
|
||||
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 {
|
||||
@@ -44,21 +60,6 @@ const defaultLbModeOptions = [
|
||||
{ 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({
|
||||
id,
|
||||
value,
|
||||
@@ -100,11 +101,24 @@ export function HealthCheckConfigFields({
|
||||
idPrefix = 'health',
|
||||
showLbMode = true,
|
||||
className,
|
||||
}: HealthCheckConfigFieldsProps) {
|
||||
}: {
|
||||
value: LbAndHealthConfig
|
||||
onChange: (next: LbAndHealthConfig) => void
|
||||
lbModeLabel?: string
|
||||
lbModeOptions?: { value: string; label: string }[]
|
||||
idPrefix?: string
|
||||
showLbMode?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
function patch(next: Partial<LbAndHealthConfig>) {
|
||||
onChange({ ...value, ...next })
|
||||
}
|
||||
|
||||
const providers =
|
||||
value.providers?.length > 0
|
||||
? uniqueHealthProviders(value.providers)
|
||||
: uniqueHealthProviders([value.provider ?? 'local'])
|
||||
const aggregate = value.aggregate ?? 'majority'
|
||||
const isHttp = value.type === 'http'
|
||||
const rowClass = 'gap-3 px-0 py-3'
|
||||
|
||||
@@ -119,6 +133,7 @@ export function HealthCheckConfigFields({
|
||||
className={rowClass}
|
||||
>
|
||||
<Select
|
||||
modal={false}
|
||||
value={value.lb_mode}
|
||||
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
|
||||
>
|
||||
@@ -136,6 +151,43 @@ export function HealthCheckConfigFields({
|
||||
</SettingRow>
|
||||
) : 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
|
||||
title="Health-check"
|
||||
description="TCP/HTTP проверка цели DNS"
|
||||
@@ -163,21 +215,28 @@ export function HealthCheckConfigFields({
|
||||
<div className="flex flex-col gap-3 pt-1 pb-1">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
|
||||
<Select
|
||||
value={value.type}
|
||||
onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })}
|
||||
<ToggleGroup
|
||||
id={`${idPrefix}-type`}
|
||||
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">
|
||||
<SelectValue placeholder="Тип" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{healthCheckTypes.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ToggleGroupItem value="tcp" className="flex-1">
|
||||
TCP
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="http" className="flex-1">
|
||||
HTTP
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</FormFieldSimple>
|
||||
|
||||
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
|
||||
@@ -239,32 +298,18 @@ export function HealthCheckConfigFields({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormFieldSimple label="Интервал, сек" htmlFor={`${idPrefix}-interval`}>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-interval`}
|
||||
value={value.interval_sec}
|
||||
min={5}
|
||||
max={3600}
|
||||
placeholder="30"
|
||||
onValueChange={(next) =>
|
||||
patch({ interval_sec: next ?? 30 })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-timeout`}
|
||||
value={value.timeout_ms}
|
||||
min={100}
|
||||
max={30000}
|
||||
placeholder="3000"
|
||||
onValueChange={(next) =>
|
||||
patch({ timeout_ms: next ?? 3000 })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</div>
|
||||
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-timeout`}
|
||||
value={value.timeout_ms}
|
||||
min={100}
|
||||
max={30000}
|
||||
placeholder="3000"
|
||||
onValueChange={(next) =>
|
||||
patch({ timeout_ms: next ?? 3000 })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</div>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface HealthTimelineEvent {
|
||||
latency_ms?: number | null
|
||||
error?: string | null
|
||||
checked_at: string
|
||||
colo?: string | null
|
||||
provider?: string | null
|
||||
}
|
||||
|
||||
interface HealthTimelineProps {
|
||||
@@ -63,6 +65,8 @@ export function HealthTimeline({ events }: HealthTimelineProps) {
|
||||
<HealthCheckBadge
|
||||
status={event.status}
|
||||
latencyMs={event.latency_ms}
|
||||
colo={event.colo}
|
||||
provider={event.provider}
|
||||
size="xs"
|
||||
showLatency
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { HealthCheckBadge } from '@/components/health-check-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 { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
@@ -78,7 +78,22 @@ export function ServiceKanbanCard({
|
||||
</ItemHeader>
|
||||
|
||||
<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>
|
||||
|
||||
<ItemFooter className="min-w-0 justify-between gap-2">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
HeartPulseIcon,
|
||||
LayoutDashboardIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
@@ -57,6 +58,12 @@ const NAV_ITEMS = [
|
||||
keywords: ['certificates', 'ssl', 'tls'],
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
{
|
||||
to: '/settings/health',
|
||||
label: 'Health-check',
|
||||
keywords: ['health', 'health-check', 'cron', 'пороги', 'настройки'],
|
||||
icon: HeartPulseIcon,
|
||||
},
|
||||
{
|
||||
to: '/settings/integrations',
|
||||
label: 'Настройки',
|
||||
|
||||
@@ -24,6 +24,7 @@ const routeTitles: Record<string, string> = {
|
||||
'/services': 'Сервисы',
|
||||
'/certificates': 'Сертификаты',
|
||||
'/settings/appearance': 'Внешний вид',
|
||||
'/settings/health': 'Health-check',
|
||||
'/settings/integrations': 'Интеграции',
|
||||
}
|
||||
|
||||
@@ -64,6 +65,8 @@ function getBreadcrumbs(
|
||||
{ label: 'Настройки', href: '/settings/appearance' },
|
||||
...(pathname === '/settings/integrations'
|
||||
? [{ label: 'Интеграции', href: pathname }]
|
||||
: pathname === '/settings/health'
|
||||
? [{ label: 'Health-check', href: pathname }]
|
||||
: pathname === '/settings/appearance'
|
||||
? [{ 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 { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
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 { 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 { cn } from '@cfdm/ui/lib/utils'
|
||||
@@ -21,6 +21,12 @@ const DEFAULT_TABS: SettingsTabConfig[] = [
|
||||
label: 'Внешний вид',
|
||||
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',
|
||||
to: '/settings/integrations',
|
||||
@@ -37,7 +43,7 @@ interface SettingsShellProps {
|
||||
|
||||
export function SettingsShell({
|
||||
title = 'Настройки',
|
||||
description = 'Внешний вид и интеграции',
|
||||
description = 'Внешний вид, health-check и интеграции',
|
||||
tabs = DEFAULT_TABS,
|
||||
}: SettingsShellProps) {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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 { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
||||
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
|
||||
import {
|
||||
@@ -10,6 +8,8 @@ import {
|
||||
type LbAndHealthConfig,
|
||||
type LbMode,
|
||||
type HealthCheckType,
|
||||
type HealthProvider,
|
||||
type HealthAggregate,
|
||||
} from '@/components/health-check-config-fields'
|
||||
import type {
|
||||
CreateServiceWithConfigInput,
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
ItemGroup,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { TabsContent } from '@cfdm/ui/components/tabs'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -46,7 +45,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
|
||||
interface BindingHealthConfig {
|
||||
enabled: boolean
|
||||
@@ -57,6 +55,9 @@ interface BindingHealthConfig {
|
||||
interval_sec: number
|
||||
timeout_ms: number
|
||||
verify_tls: boolean
|
||||
provider: HealthProvider
|
||||
providers: HealthProvider[]
|
||||
aggregate: HealthAggregate
|
||||
}
|
||||
|
||||
export interface ServiceBindingDraft {
|
||||
@@ -79,6 +80,9 @@ const defaultHealth: BindingHealthConfig = {
|
||||
interval_sec: 30,
|
||||
timeout_ms: 3000,
|
||||
verify_tls: false,
|
||||
provider: 'local',
|
||||
providers: ['local'],
|
||||
aggregate: 'majority',
|
||||
}
|
||||
|
||||
interface ServiceEditSheetProps {
|
||||
@@ -112,6 +116,12 @@ function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||
interval_sec: binding.health_check_interval_sec,
|
||||
timeout_ms: binding.health_check_timeout_ms,
|
||||
verify_tls: binding.health_check_verify_tls ?? false,
|
||||
provider: binding.health_check_provider ?? 'local',
|
||||
providers:
|
||||
binding.health_check_providers?.length > 0
|
||||
? binding.health_check_providers
|
||||
: [binding.health_check_provider ?? 'local'],
|
||||
aggregate: binding.health_check_aggregate ?? 'majority',
|
||||
},
|
||||
target_ip_weights: binding.target_ip_weights ?? {},
|
||||
target_ip_priorities: binding.target_ip_priorities ?? {},
|
||||
@@ -139,6 +149,9 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||
health_check_interval_sec: binding.health.interval_sec,
|
||||
health_check_timeout_ms: binding.health.timeout_ms,
|
||||
health_check_verify_tls: binding.health.verify_tls,
|
||||
health_check_provider: binding.health.provider,
|
||||
health_check_providers: binding.health.providers,
|
||||
health_check_aggregate: binding.health.aggregate,
|
||||
}
|
||||
: {
|
||||
fqdn: binding.fqdn.trim(),
|
||||
@@ -154,10 +167,40 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||
health_check_interval_sec: binding.health.interval_sec,
|
||||
health_check_timeout_ms: binding.health.timeout_ms,
|
||||
health_check_verify_tls: binding.health.verify_tls,
|
||||
health_check_provider: binding.health.provider,
|
||||
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({
|
||||
mode,
|
||||
service,
|
||||
@@ -176,10 +219,10 @@ export function ServiceEditSheet({
|
||||
const [slug, setSlug] = useState('')
|
||||
const [serviceGroupId, setServiceGroupId] = useState('none')
|
||||
const [ips, setIps] = useState<string[]>([])
|
||||
const [commonFqdn, setCommonFqdn] = useState('')
|
||||
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
|
||||
const [lbWeight, setLbWeight] = useState(1)
|
||||
const [lbPriority, setLbPriority] = useState(1)
|
||||
const [activeTab, setActiveTab] = useState('general')
|
||||
|
||||
const groupItems = useMemo(
|
||||
() => [
|
||||
@@ -189,16 +232,8 @@ export function ServiceEditSheet({
|
||||
[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(() => {
|
||||
if (!open) return
|
||||
setActiveTab('general')
|
||||
if (mode === 'edit' && service) {
|
||||
setName(service.name)
|
||||
setSlug(service.slug)
|
||||
@@ -206,7 +241,9 @@ export function ServiceEditSheet({
|
||||
service.service_group_id != null ? String(service.service_group_id) : 'none',
|
||||
)
|
||||
setIps(service.ips ?? [])
|
||||
setBindings(toBindingDrafts(service))
|
||||
const drafts = toBindingDrafts(service)
|
||||
setBindings(drafts)
|
||||
setCommonFqdn(drafts[0]?.fqdn ?? '')
|
||||
setLbWeight(service.lb_weight ?? 1)
|
||||
setLbPriority(service.lb_priority ?? 1)
|
||||
return
|
||||
@@ -218,6 +255,7 @@ export function ServiceEditSheet({
|
||||
defaultGroupId != null ? String(defaultGroupId) : 'none',
|
||||
)
|
||||
setIps([])
|
||||
setCommonFqdn('')
|
||||
setBindings([])
|
||||
setLbWeight(1)
|
||||
setLbPriority(1)
|
||||
@@ -229,23 +267,28 @@ export function ServiceEditSheet({
|
||||
[knownDomains],
|
||||
)
|
||||
|
||||
function handleAddBinding() {
|
||||
setBindings((current) => [
|
||||
...current,
|
||||
{
|
||||
fqdn: '',
|
||||
record_type: 'A',
|
||||
target_ips: [],
|
||||
target_cname: '',
|
||||
lb_mode: 'round_robin',
|
||||
health: { ...defaultHealth },
|
||||
target_ip_weights: {},
|
||||
target_ip_priorities: {},
|
||||
},
|
||||
])
|
||||
const extraBindings = bindings.slice(1)
|
||||
|
||||
function handleCommonFqdnChange(value: string) {
|
||||
setCommonFqdn(value)
|
||||
setBindings((current) => {
|
||||
if (current.length === 0) return current
|
||||
return current.map((item, i) => (i === 0 ? { ...item, fqdn: value } : item))
|
||||
})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -295,68 +338,77 @@ export function ServiceEditSheet({
|
||||
)
|
||||
}
|
||||
|
||||
function handleBindingMetaChange(
|
||||
index: number,
|
||||
ip: string,
|
||||
meta: { weight?: number; priority?: number },
|
||||
) {
|
||||
setBindings((current) =>
|
||||
current.map((item, i) => {
|
||||
if (i !== index) return item
|
||||
const weights = { ...item.target_ip_weights }
|
||||
const priorities = { ...item.target_ip_priorities }
|
||||
if (meta.weight !== undefined) weights[ip] = meta.weight
|
||||
if (meta.priority !== undefined) priorities[ip] = meta.priority
|
||||
return { ...item, target_ip_weights: weights, target_ip_priorities: priorities }
|
||||
}),
|
||||
)
|
||||
function healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
|
||||
return {
|
||||
enabled: next.enabled,
|
||||
type: next.type,
|
||||
port: next.port,
|
||||
path: next.path,
|
||||
expected_status: next.expected_status,
|
||||
interval_sec: next.interval_sec,
|
||||
timeout_ms: next.timeout_ms,
|
||||
verify_tls: next.verify_tls,
|
||||
provider: next.provider,
|
||||
providers: next.providers,
|
||||
aggregate: next.aggregate,
|
||||
}
|
||||
}
|
||||
|
||||
function handleBindingHealthChange(index: number, next: LbAndHealthConfig) {
|
||||
setBindings((current) =>
|
||||
current.map((item, i) =>
|
||||
i === index
|
||||
? {
|
||||
...item,
|
||||
lb_mode: next.lb_mode,
|
||||
health: {
|
||||
enabled: next.enabled,
|
||||
type: next.type,
|
||||
port: next.port,
|
||||
path: next.path,
|
||||
expected_status: next.expected_status,
|
||||
interval_sec: next.interval_sec,
|
||||
timeout_ms: next.timeout_ms,
|
||||
verify_tls: next.verify_tls,
|
||||
},
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
function handlePrimaryHealthChange(next: LbAndHealthConfig) {
|
||||
const health = healthFromConfig(next)
|
||||
setBindings((current) => {
|
||||
if (current.length === 0) {
|
||||
return [
|
||||
{
|
||||
...withPoolIps(emptyBindingDraft(commonFqdn), ips),
|
||||
lb_mode: next.lb_mode,
|
||||
health,
|
||||
},
|
||||
]
|
||||
}
|
||||
return current.map((item, index) =>
|
||||
index === 0 ? { ...item, lb_mode: next.lb_mode, health } : item,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const primaryHealthValue: LbAndHealthConfig = {
|
||||
lb_mode: bindings[0]?.lb_mode ?? 'round_robin',
|
||||
...(bindings[0]?.health ?? defaultHealth),
|
||||
}
|
||||
|
||||
function resolveServiceGroupId(): number | null {
|
||||
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() {
|
||||
const domains = buildDomainsPayload(bindings)
|
||||
const syncedBindings = syncCommonDomain(bindings)
|
||||
const domains = buildDomainsPayload(syncedBindings)
|
||||
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
|
||||
const hasDuplicateFqdn =
|
||||
new Set(normalizedFqdns).size !== normalizedFqdns.length
|
||||
if (hasDuplicateFqdn) {
|
||||
toast.error('Укажите уникальные FQDN — дубликаты привязок недопустимы')
|
||||
setActiveTab('bindings')
|
||||
return
|
||||
}
|
||||
const groupId = resolveServiceGroupId()
|
||||
const lbFields = groupHasDomain
|
||||
? { lb_weight: lbWeight, lb_priority: lbPriority }
|
||||
: {}
|
||||
const configPayload = {
|
||||
ips,
|
||||
domains,
|
||||
...lbFields,
|
||||
lb_weight: lbWeight,
|
||||
lb_priority: lbPriority,
|
||||
}
|
||||
if (mode === 'create') {
|
||||
onCreate?.({
|
||||
@@ -364,7 +416,8 @@ export function ServiceEditSheet({
|
||||
slug: slug.trim(),
|
||||
service_group_id: groupId,
|
||||
ips,
|
||||
...lbFields,
|
||||
lb_weight: lbWeight,
|
||||
lb_priority: lbPriority,
|
||||
domains,
|
||||
})
|
||||
return
|
||||
@@ -394,29 +447,16 @@ export function ServiceEditSheet({
|
||||
<SheetHeader className="shrink-0 border-b pb-4">
|
||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Настройте параметры сервиса и привязки FQDN → IP или CNAME. Один
|
||||
сервис может иметь несколько FQDN в разных зонах; зона определяется
|
||||
из FQDN автоматически.
|
||||
Общий домен и IP задаются у сервиса. Дополнительные FQDN — ниже, зона
|
||||
определяется автоматически.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
||||
<CountedLineTabs
|
||||
tabs={[
|
||||
{ id: 'general', label: 'Основное' },
|
||||
{
|
||||
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">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 py-4">
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">Сервис</h3>
|
||||
<FieldGroup className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
|
||||
<Input
|
||||
@@ -435,238 +475,182 @@ export function ServiceEditSheet({
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{bindings.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Link2Icon}
|
||||
title="Нет привязок"
|
||||
description="Необязательно. Можно добавить несколько FQDN: api.ivx.su и www.other.su — зоны определятся автоматически."
|
||||
centered={false}
|
||||
action={
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить привязку
|
||||
</Button>
|
||||
<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-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)}
|
||||
/>
|
||||
) : (
|
||||
<ItemGroup className="gap-2">
|
||||
{bindings.map((binding, index) => {
|
||||
const showLbBlock =
|
||||
(binding.record_type === 'A' && binding.target_ips.length > 0) ||
|
||||
(binding.record_type === 'CNAME' && binding.target_cname.trim().length > 0)
|
||||
const showMeta =
|
||||
binding.record_type === 'A' &&
|
||||
binding.target_ips.length > 1 &&
|
||||
binding.lb_mode !== 'round_robin'
|
||||
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
|
||||
return (
|
||||
<Item key={`binding-${index}`} variant="outline" className="items-stretch">
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
</section>
|
||||
|
||||
{showLbBlock ? (
|
||||
<HealthCheckConfigFields
|
||||
value={{
|
||||
lb_mode: binding.lb_mode,
|
||||
enabled: binding.health.enabled,
|
||||
type: binding.health.type,
|
||||
port: binding.health.port,
|
||||
path: binding.health.path,
|
||||
expected_status: binding.health.expected_status,
|
||||
interval_sec: binding.health.interval_sec,
|
||||
timeout_ms: binding.health.timeout_ms,
|
||||
verify_tls: binding.health.verify_tls,
|
||||
}}
|
||||
onChange={(next) => handleBindingHealthChange(index, next)}
|
||||
lbModeLabel="Режим балансировки"
|
||||
showLbMode={
|
||||
binding.record_type === 'A' && binding.target_ips.length > 1
|
||||
}
|
||||
idPrefix={`binding-${index}-health`}
|
||||
/>
|
||||
) : null}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)
|
||||
})}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</TabsContent>
|
||||
</CountedLineTabs>
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">Health check</h3>
|
||||
<HealthCheckConfigFields
|
||||
idPrefix="service-health"
|
||||
value={primaryHealthValue}
|
||||
onChange={handlePrimaryHealthChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">Доп. FQDN</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddExtraBinding}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
{extraBindings.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет дополнительных FQDN
|
||||
</p>
|
||||
) : (
|
||||
<ItemGroup className="gap-2">
|
||||
{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>
|
||||
|
||||
<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 { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
@@ -12,10 +12,6 @@ import { FormFieldSimple } from '@/components/form-field'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { AppFieldGroup } from '@/components/app-field'
|
||||
import { AppInput } from '@/components/app-input'
|
||||
import {
|
||||
HealthCheckConfigFields,
|
||||
type LbAndHealthConfig,
|
||||
} from '@/components/health-check-config-fields'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -44,18 +40,6 @@ interface ServiceGroupEditSheetProps {
|
||||
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({
|
||||
mode,
|
||||
group,
|
||||
@@ -73,7 +57,6 @@ export function ServiceGroupEditSheet({
|
||||
domain: null,
|
||||
},
|
||||
})
|
||||
const [lbHealth, setLbHealth] = useState<LbAndHealthConfig>(defaultLbHealth)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -81,43 +64,19 @@ export function ServiceGroupEditSheet({
|
||||
form.reset({
|
||||
name: group.name,
|
||||
type: group.type,
|
||||
domain: group.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,
|
||||
domain: null,
|
||||
})
|
||||
} else {
|
||||
form.reset({ name: '', type: 'custom', domain: null })
|
||||
setLbHealth(defaultLbHealth)
|
||||
}
|
||||
}, [open, mode, group, form])
|
||||
|
||||
const domainValue = form.watch('domain')
|
||||
const hasDomain = Boolean(domainValue?.trim())
|
||||
|
||||
function handleSubmit(values: ServiceGroupFormValues) {
|
||||
const body: CreateServiceGroupInput = {
|
||||
name: values.name,
|
||||
type: values.type ?? 'custom',
|
||||
icon: values.icon,
|
||||
domain: values.domain?.trim() || 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,
|
||||
domain: null,
|
||||
}
|
||||
if (mode === 'create') {
|
||||
onCreate?.(body)
|
||||
@@ -131,7 +90,7 @@ export function ServiceGroupEditSheet({
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
|
||||
description="Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов."
|
||||
description="Группа нужна только для сортировки каталога. Общий домен и IP задаются у каждого сервиса."
|
||||
form={form}
|
||||
onSubmit={handleSubmit}
|
||||
contentClassName="gap-6"
|
||||
@@ -184,26 +143,7 @@ export function ServiceGroupEditSheet({
|
||||
)}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Домен группы (FQDN, необязательно)"
|
||||
htmlFor="group-domain"
|
||||
>
|
||||
<AppInput
|
||||
id="group-domain"
|
||||
placeholder="domain.new.ivx.su"
|
||||
{...form.register('domain')}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</AppFieldGroup>
|
||||
|
||||
{hasDomain ? (
|
||||
<HealthCheckConfigFields
|
||||
value={lbHealth}
|
||||
onChange={setLbHealth}
|
||||
lbModeLabel="Режим балансировки общего домена"
|
||||
idPrefix="group-lb-health"
|
||||
/>
|
||||
) : null}
|
||||
</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 { TruncatedText } from '@/components/truncated-text'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -10,16 +17,53 @@ import {
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
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 {
|
||||
service: ServiceView
|
||||
className?: string
|
||||
emptyLabel?: string
|
||||
copyable?: boolean
|
||||
textClassName?: string
|
||||
}
|
||||
|
||||
export function ServiceFqdnList({
|
||||
service,
|
||||
className,
|
||||
emptyLabel = 'FQDN не задан',
|
||||
emptyLabel = 'Нет FQDN',
|
||||
copyable = false,
|
||||
textClassName,
|
||||
}: ServiceFqdnListProps) {
|
||||
const fqdns = serviceDisplayFqdns(service)
|
||||
if (fqdns.length === 0) {
|
||||
@@ -32,10 +76,16 @@ export function ServiceFqdnList({
|
||||
|
||||
const [first, ...rest] = fqdns
|
||||
const extraCount = rest.length
|
||||
const copyValue = fqdns.join('\n')
|
||||
|
||||
return (
|
||||
<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}
|
||||
</TruncatedText>
|
||||
{extraCount > 0 ? (
|
||||
@@ -62,6 +112,118 @@ export function ServiceFqdnList({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
useReactTable,
|
||||
type ExpandedState,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
FilterIcon,
|
||||
FolderPlusIcon,
|
||||
FunnelXIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
} 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 {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { applyFiltersToData } from '@/components/reui-kit/filter-utils'
|
||||
import { createServicesGroupedColumns } from '@/components/services/services-grouped-columns'
|
||||
import type { ServiceCatalogTreeRow } from '@/components/services/services-grouped-columns'
|
||||
import { ServiceCatalogSection } from '@/components/services/service-catalog-section'
|
||||
import {
|
||||
SERVICE_TABS,
|
||||
createDefaultServiceFilters,
|
||||
serviceFilterFieldValue,
|
||||
serviceTabFilter,
|
||||
useServiceFilterFields,
|
||||
type ServiceCatalogRow,
|
||||
} from '@/components/columns/services-columns'
|
||||
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
||||
import type {
|
||||
ServiceGroupView,
|
||||
ServiceGroupsResponse,
|
||||
@@ -52,23 +33,29 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} 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'
|
||||
|
||||
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) {
|
||||
if (domainId == null) return true
|
||||
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(
|
||||
service: ServiceView,
|
||||
groupId: number | null,
|
||||
@@ -86,72 +73,51 @@ function toCatalogRow(
|
||||
}
|
||||
}
|
||||
|
||||
function catalogTabFilter(row: ServiceCatalogRow, tabId: string) {
|
||||
if (tabId.startsWith('health-')) {
|
||||
const status = row.service.health_status ?? 'unknown'
|
||||
if (tabId === 'health-ok') return status === 'up'
|
||||
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)
|
||||
interface GroupUnitData {
|
||||
id: string
|
||||
group: ServiceGroupView | null
|
||||
services: ServiceView[]
|
||||
}
|
||||
|
||||
function buildTreeRows(
|
||||
function buildGroupUnits(
|
||||
data: ServiceGroupsResponse,
|
||||
filteredServiceIds: Set<number>,
|
||||
domainId?: number,
|
||||
): ServiceCatalogTreeRow[] {
|
||||
const rows: ServiceCatalogTreeRow[] = []
|
||||
domainId: number | undefined,
|
||||
showEmptyGroups: boolean,
|
||||
): GroupUnitData[] {
|
||||
const units: GroupUnitData[] = []
|
||||
|
||||
for (const group of data.groups) {
|
||||
const services = group.services
|
||||
.filter((s) => serviceMatchesDomain(s, domainId))
|
||||
.filter((s) => filteredServiceIds.has(s.id))
|
||||
if (services.length === 0) continue
|
||||
const matchingDomain = group.services.filter((service) =>
|
||||
serviceMatchesDomain(service, domainId),
|
||||
)
|
||||
const services = matchingDomain.filter((service) =>
|
||||
filteredServiceIds.has(service.id),
|
||||
)
|
||||
|
||||
rows.push({
|
||||
kind: 'group',
|
||||
id: `group-${group.id}`,
|
||||
group,
|
||||
subRows: services.map((service) => ({
|
||||
kind: 'service' as const,
|
||||
id: `service-${service.id}`,
|
||||
service,
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
})),
|
||||
})
|
||||
if (services.length === 0) {
|
||||
if (showEmptyGroups && matchingDomain.length === 0) {
|
||||
units.push({ id: `group-${group.id}`, group, services: [] })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
units.push({ id: `group-${group.id}`, group, services })
|
||||
}
|
||||
|
||||
const ungrouped = data.ungrouped
|
||||
.filter((s) => serviceMatchesDomain(s, domainId))
|
||||
.filter((s) => filteredServiceIds.has(s.id))
|
||||
.filter((service) => serviceMatchesDomain(service, domainId))
|
||||
.filter((service) => filteredServiceIds.has(service.id))
|
||||
|
||||
if (ungrouped.length > 0) {
|
||||
rows.push({
|
||||
kind: 'group',
|
||||
units.push({
|
||||
id: 'group-ungrouped',
|
||||
group: null,
|
||||
subRows: ungrouped.map((service) => ({
|
||||
kind: 'service' as const,
|
||||
id: `service-${service.id}`,
|
||||
service,
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
})),
|
||||
services: ungrouped,
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
function defaultExpanded(rows: ServiceCatalogTreeRow[]): ExpandedState {
|
||||
return rows.reduce<Record<string, boolean>>((acc, row) => {
|
||||
acc[row.id] = true
|
||||
return acc
|
||||
}, {})
|
||||
return units
|
||||
}
|
||||
|
||||
export function ServicesAddMenu({
|
||||
@@ -194,11 +160,13 @@ interface ServicesGroupedCatalogProps {
|
||||
primaryAction?: ReactNode
|
||||
hideHeader?: boolean
|
||||
togglingId: number | null
|
||||
togglingIp: { serviceId: number; ip: string } | null
|
||||
activeTab?: string
|
||||
onTabChange?: (tabId: string) => void
|
||||
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
|
||||
@@ -213,24 +181,18 @@ export function ServicesGroupedCatalog({
|
||||
primaryAction,
|
||||
hideHeader = false,
|
||||
togglingId,
|
||||
activeTab: controlledTab,
|
||||
onTabChange,
|
||||
togglingIp,
|
||||
activeTab = 'all',
|
||||
onEditService,
|
||||
onDeleteService,
|
||||
onToggleService,
|
||||
onToggleServiceIp,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onAddServiceToGroup,
|
||||
emptyAction,
|
||||
}: ServicesGroupedCatalogProps) {
|
||||
const [internalTab, setInternalTab] = useState('all')
|
||||
const tab = controlledTab ?? internalTab
|
||||
const setTab = onTabChange ?? setInternalTab
|
||||
const [filters, setFilters] = useState<Filter[]>(() =>
|
||||
createDefaultServiceFilters(),
|
||||
)
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({})
|
||||
const filterFields = useServiceFilterFields()
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const flatRows = useMemo(() => {
|
||||
const rows: ServiceCatalogRow[] = []
|
||||
@@ -247,76 +209,33 @@ export function ServicesGroupedCatalog({
|
||||
return rows
|
||||
}, [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 afterTab = flatRows.filter((row) => catalogTabFilter(row, tab))
|
||||
const afterFilters = applyFiltersToData(afterTab, filters, (item, field) =>
|
||||
serviceFilterFieldValue(item, field),
|
||||
const afterTab = flatRows.filter((row) => serviceTabFilter(row, activeTab))
|
||||
const afterQuery = afterTab.filter((row) =>
|
||||
serviceMatchesQuery(row.service, query),
|
||||
)
|
||||
return new Set(afterFilters.map((r) => r.id))
|
||||
}, [flatRows, tab, filters])
|
||||
return new Set(afterQuery.map((r) => r.id))
|
||||
}, [flatRows, activeTab, query])
|
||||
|
||||
const treeData = useMemo(
|
||||
() => buildTreeRows(data, filteredIds, domainId),
|
||||
[data, filteredIds, domainId],
|
||||
const showEmptyGroups =
|
||||
activeTab === 'all' && domainId == null && query.trim().length === 0
|
||||
|
||||
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) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="mt-1 h-4 w-72" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-3 p-4">
|
||||
<Skeleton className="h-9 w-full max-w-md" />
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
<Skeleton className="h-8 w-full max-w-md" />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 w-full rounded-xl" />
|
||||
))}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
@@ -342,112 +261,66 @@ export function ServicesGroupedCatalog({
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredIds.size}
|
||||
emptyMessage="Нет записей по выбранным фильтрам."
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
{!hideHeader ? (
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle>Сервисы</FrameTitle>
|
||||
<FrameDescription>
|
||||
{domainLabel
|
||||
? `Каталог сервисов с привязками к ${domainLabel}`
|
||||
: 'Группы, FQDN и доступность сервисов'}
|
||||
</FrameDescription>
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
{!hideHeader ? (
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle>Сервисы</FrameTitle>
|
||||
<FrameDescription>
|
||||
{domainLabel
|
||||
? `Каталог сервисов с привязками к ${domainLabel}`
|
||||
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'}
|
||||
</FrameDescription>
|
||||
</div>
|
||||
{primaryAction ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{primaryAction}
|
||||
</div>
|
||||
{primaryAction ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{primaryAction}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
) : null}
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
) : null}
|
||||
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||
<CountedLineTabs
|
||||
tabs={ALL_TABS.map((t) => ({
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
count: tabCounts[t.id] ?? 0,
|
||||
}))}
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
/>
|
||||
</div>
|
||||
<FramePanel className="flex flex-col gap-4">
|
||||
<InputGroup className="max-w-md">
|
||||
<InputGroupAddon>
|
||||
<InputGroupText>
|
||||
<SearchIcon aria-hidden />
|
||||
</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Поиск по названию"
|
||||
aria-label="Поиск по названию"
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={setFilters}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden />
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTab('all')
|
||||
setFilters(createDefaultServiceFilters())
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
}
|
||||
{units.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Нет совпадений"
|
||||
description="Измените запрос поиска."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
{units.map((unit) => (
|
||||
<ServiceCatalogSection
|
||||
key={unit.id}
|
||||
group={unit.group}
|
||||
services={unit.services}
|
||||
togglingId={togglingId}
|
||||
togglingIp={togglingIp}
|
||||
onEditService={onEditService}
|
||||
onDeleteService={onDeleteService}
|
||||
onToggleService={onToggleService}
|
||||
onToggleServiceIp={onToggleServiceIp}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
onAddServiceToGroup={onAddServiceToGroup}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
<Separator />
|
||||
<FrameFooter>
|
||||
<DataGridPagination
|
||||
sizes={[5, 10, 20, 50]}
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
info="{from} - {to} of {count}"
|
||||
previousPageLabel="Предыдущая"
|
||||
nextPageLabel="Следующая"
|
||||
/>
|
||||
</FrameFooter>
|
||||
</>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
className={cn(
|
||||
'flex w-full justify-start',
|
||||
'flex w-full min-w-0 justify-start',
|
||||
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_timeout_ms: z.number().default(3000),
|
||||
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(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
@@ -76,6 +79,9 @@ export const serviceDomainBindingSchema = z
|
||||
health_check_interval_sec: z.number().default(30),
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
})
|
||||
.transform((binding) => ({
|
||||
@@ -94,6 +100,30 @@ export const serviceDomainBindingSchema = z
|
||||
: (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({
|
||||
subdomain: z.string().default(''),
|
||||
enabled: z.coerce.boolean().default(false),
|
||||
@@ -101,6 +131,8 @@ export const serviceViewSchema = serviceSchema.extend({
|
||||
domains: z.array(serviceDomainBindingSchema).default([]),
|
||||
health_status: z.enum(['up', 'down', 'degraded', 'unknown']).default('unknown'),
|
||||
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({
|
||||
@@ -160,6 +192,9 @@ export const serviceBindingSchema = z
|
||||
health_check_interval_sec: z.number().default(30),
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
@@ -244,6 +279,9 @@ const healthCheckConfigFields = {
|
||||
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
|
||||
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
|
||||
health_check_verify_tls: z.boolean().optional(),
|
||||
health_check_provider: z.enum(['local', 'cloudflare', '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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import {
|
||||
healthProbeLogSchema,
|
||||
serviceBindingSchema,
|
||||
serviceGroupsResponseSchema,
|
||||
serviceViewSchema,
|
||||
@@ -83,3 +84,112 @@ export async function createServiceBinding(body: CreateServiceBindingBody) {
|
||||
export async function deleteServiceBinding(id: number) {
|
||||
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.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as AuthRouteImport } from './routes/_auth'
|
||||
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 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 AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
|
||||
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
|
||||
import { Route as AuthGroupsRouteImport } from './routes/_auth/groups'
|
||||
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 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 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 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'
|
||||
|
||||
const AuthRoute = AuthRouteImport.update({
|
||||
id: '/_auth',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthRoute = AuthRouteImport.update({
|
||||
id: '/_auth',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthIndexRoute = AuthIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthCertificatesRoute = AuthCertificatesRouteImport.update({
|
||||
id: '/certificates',
|
||||
path: '/certificates',
|
||||
getParentRoute: () => AuthRoute,
|
||||
const AuthCallbackRoute = AuthCallbackRouteImport.update({
|
||||
id: '/auth/callback',
|
||||
path: '/auth/callback',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthGroupsRoute = AuthGroupsRouteImport.update({
|
||||
id: '/groups',
|
||||
path: '/groups',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthServicesRoute = AuthServicesRouteImport.update({
|
||||
id: '/services',
|
||||
path: '/services',
|
||||
const AuthCertificatesRoute = AuthCertificatesRouteImport.update({
|
||||
id: '/certificates',
|
||||
path: '/certificates',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthSettingsRouteRoute = AuthSettingsRouteRouteImport.update({
|
||||
@@ -59,30 +66,20 @@ const AuthSettingsRouteRoute = AuthSettingsRouteRouteImport.update({
|
||||
path: '/settings',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} 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({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AuthSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
|
||||
id: '/appearance',
|
||||
path: '/appearance',
|
||||
getParentRoute: () => AuthSettingsRouteRoute,
|
||||
const AuthServicesIndexRoute = AuthServicesIndexRouteImport.update({
|
||||
id: '/services/',
|
||||
path: '/services/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthDomainsIndexRoute = AuthDomainsIndexRouteImport.update({
|
||||
id: '/domains/',
|
||||
path: '/domains/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthSettingsIntegrationsRoute =
|
||||
AuthSettingsIntegrationsRouteImport.update({
|
||||
@@ -90,12 +87,63 @@ const AuthSettingsIntegrationsRoute =
|
||||
path: '/integrations',
|
||||
getParentRoute: () => AuthSettingsRouteRoute,
|
||||
} 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 =
|
||||
AuthDomainsDomainIdIndexRouteImport.update({
|
||||
id: '/domains/$domainId/',
|
||||
path: '/domains/$domainId/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} 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({
|
||||
id: '/domains/$domainId/dns',
|
||||
path: '/domains/$domainId/dns',
|
||||
@@ -108,30 +156,43 @@ export interface FileRoutesByFullPath {
|
||||
'/settings': typeof AuthSettingsRouteRouteWithChildren
|
||||
'/certificates': typeof AuthCertificatesRoute
|
||||
'/groups': typeof AuthGroupsRouteWithChildren
|
||||
'/services': typeof AuthServicesRoute
|
||||
'/auth/callback': typeof AuthCallbackRoute
|
||||
'/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
|
||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||
'/settings/health': typeof AuthSettingsHealthRoute
|
||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||
'/domains/': typeof AuthDomainsIndexRoute
|
||||
'/services/': typeof AuthServicesIndexRoute
|
||||
'/settings/': typeof AuthSettingsIndexRoute
|
||||
'/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
|
||||
'/services/$serviceId/': typeof AuthServicesServiceIdIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
'/certificates': typeof AuthCertificatesRoute
|
||||
'/groups': typeof AuthGroupsRouteWithChildren
|
||||
'/services': typeof AuthServicesRoute
|
||||
'/auth/callback': typeof AuthCallbackRoute
|
||||
'/': typeof AuthIndexRoute
|
||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||
'/settings/health': typeof AuthSettingsHealthRoute
|
||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||
'/domains': typeof AuthDomainsIndexRoute
|
||||
'/services': typeof AuthServicesIndexRoute
|
||||
'/settings': typeof AuthSettingsIndexRoute
|
||||
'/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
|
||||
'/services/$serviceId': typeof AuthServicesServiceIdIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -140,16 +201,23 @@ export interface FileRoutesById {
|
||||
'/_auth/settings': typeof AuthSettingsRouteRouteWithChildren
|
||||
'/_auth/certificates': typeof AuthCertificatesRoute
|
||||
'/_auth/groups': typeof AuthGroupsRouteWithChildren
|
||||
'/_auth/services': typeof AuthServicesRoute
|
||||
'/auth/callback': typeof AuthCallbackRoute
|
||||
'/_auth/': typeof AuthIndexRoute
|
||||
'/_auth/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
|
||||
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||
'/_auth/settings/health': typeof AuthSettingsHealthRoute
|
||||
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||
'/_auth/domains/': typeof AuthDomainsIndexRoute
|
||||
'/_auth/services/': typeof AuthServicesIndexRoute
|
||||
'/_auth/settings/': typeof AuthSettingsIndexRoute
|
||||
'/_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/services/$serviceId/': typeof AuthServicesServiceIdIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -159,30 +227,43 @@ export interface FileRouteTypes {
|
||||
| '/settings'
|
||||
| '/certificates'
|
||||
| '/groups'
|
||||
| '/services'
|
||||
| '/auth/callback'
|
||||
| '/services/$serviceId'
|
||||
| '/groups/$groupId'
|
||||
| '/settings/appearance'
|
||||
| '/settings/health'
|
||||
| '/settings/integrations'
|
||||
| '/domains/'
|
||||
| '/services/'
|
||||
| '/settings/'
|
||||
| '/domains/$domainId/dns'
|
||||
| '/services/$serviceId/health'
|
||||
| '/services/$serviceId/nodes'
|
||||
| '/services/$serviceId/routing'
|
||||
| '/services/$serviceId/subdomains'
|
||||
| '/domains/$domainId/'
|
||||
| '/services/$serviceId/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/login'
|
||||
| '/certificates'
|
||||
| '/groups'
|
||||
| '/services'
|
||||
| '/auth/callback'
|
||||
| '/'
|
||||
| '/groups/$groupId'
|
||||
| '/settings/appearance'
|
||||
| '/settings/health'
|
||||
| '/settings/integrations'
|
||||
| '/domains'
|
||||
| '/services'
|
||||
| '/settings'
|
||||
| '/domains/$domainId/dns'
|
||||
| '/services/$serviceId/health'
|
||||
| '/services/$serviceId/nodes'
|
||||
| '/services/$serviceId/routing'
|
||||
| '/services/$serviceId/subdomains'
|
||||
| '/domains/$domainId'
|
||||
| '/services/$serviceId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_auth'
|
||||
@@ -190,16 +271,23 @@ export interface FileRouteTypes {
|
||||
| '/_auth/settings'
|
||||
| '/_auth/certificates'
|
||||
| '/_auth/groups'
|
||||
| '/_auth/services'
|
||||
| '/auth/callback'
|
||||
| '/_auth/'
|
||||
| '/_auth/services/$serviceId'
|
||||
| '/_auth/groups/$groupId'
|
||||
| '/_auth/settings/appearance'
|
||||
| '/_auth/settings/health'
|
||||
| '/_auth/settings/integrations'
|
||||
| '/_auth/domains/'
|
||||
| '/_auth/services/'
|
||||
| '/_auth/settings/'
|
||||
| '/_auth/domains/$domainId/dns'
|
||||
| '/_auth/services/$serviceId/health'
|
||||
| '/_auth/services/$serviceId/nodes'
|
||||
| '/_auth/services/$serviceId/routing'
|
||||
| '/_auth/services/$serviceId/subdomains'
|
||||
| '/_auth/domains/$domainId/'
|
||||
| '/_auth/services/$serviceId/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -210,13 +298,6 @@ export interface RootRouteChildren {
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/_auth': {
|
||||
id: '/_auth'
|
||||
path: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AuthRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
@@ -224,6 +305,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth': {
|
||||
id: '/_auth'
|
||||
path: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AuthRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth/': {
|
||||
id: '/_auth/'
|
||||
path: '/'
|
||||
@@ -231,12 +319,12 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthIndexRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/certificates': {
|
||||
id: '/_auth/certificates'
|
||||
path: '/certificates'
|
||||
fullPath: '/certificates'
|
||||
preLoaderRoute: typeof AuthCertificatesRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
'/auth/callback': {
|
||||
id: '/auth/callback'
|
||||
path: '/auth/callback'
|
||||
fullPath: '/auth/callback'
|
||||
preLoaderRoute: typeof AuthCallbackRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth/groups': {
|
||||
id: '/_auth/groups'
|
||||
@@ -245,11 +333,11 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthGroupsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/services': {
|
||||
id: '/_auth/services'
|
||||
path: '/services'
|
||||
fullPath: '/services'
|
||||
preLoaderRoute: typeof AuthServicesRouteImport
|
||||
'/_auth/certificates': {
|
||||
id: '/_auth/certificates'
|
||||
path: '/certificates'
|
||||
fullPath: '/certificates'
|
||||
preLoaderRoute: typeof AuthCertificatesRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/settings': {
|
||||
@@ -259,12 +347,19 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthSettingsRouteRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/auth/callback': {
|
||||
id: '/auth/callback'
|
||||
path: '/auth/callback'
|
||||
fullPath: '/auth/callback'
|
||||
preLoaderRoute: typeof AuthCallbackRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
'/_auth/settings/': {
|
||||
id: '/_auth/settings/'
|
||||
path: '/'
|
||||
fullPath: '/settings/'
|
||||
preLoaderRoute: typeof AuthSettingsIndexRouteImport
|
||||
parentRoute: typeof AuthSettingsRouteRoute
|
||||
}
|
||||
'/_auth/services/': {
|
||||
id: '/_auth/services/'
|
||||
path: '/services'
|
||||
fullPath: '/services/'
|
||||
preLoaderRoute: typeof AuthServicesIndexRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/domains/': {
|
||||
id: '/_auth/domains/'
|
||||
@@ -273,18 +368,18 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthDomainsIndexRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/groups/$groupId': {
|
||||
id: '/_auth/groups/$groupId'
|
||||
path: '/$groupId'
|
||||
fullPath: '/groups/$groupId'
|
||||
preLoaderRoute: typeof AuthGroupsGroupIdRouteImport
|
||||
parentRoute: typeof AuthGroupsRoute
|
||||
'/_auth/settings/integrations': {
|
||||
id: '/_auth/settings/integrations'
|
||||
path: '/integrations'
|
||||
fullPath: '/settings/integrations'
|
||||
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
|
||||
parentRoute: typeof AuthSettingsRouteRoute
|
||||
}
|
||||
'/_auth/settings/': {
|
||||
id: '/_auth/settings/'
|
||||
path: '/'
|
||||
fullPath: '/settings/'
|
||||
preLoaderRoute: typeof AuthSettingsIndexRouteImport
|
||||
'/_auth/settings/health': {
|
||||
id: '/_auth/settings/health'
|
||||
path: '/health'
|
||||
fullPath: '/settings/health'
|
||||
preLoaderRoute: typeof AuthSettingsHealthRouteImport
|
||||
parentRoute: typeof AuthSettingsRouteRoute
|
||||
}
|
||||
'/_auth/settings/appearance': {
|
||||
@@ -294,12 +389,26 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthSettingsAppearanceRouteImport
|
||||
parentRoute: typeof AuthSettingsRouteRoute
|
||||
}
|
||||
'/_auth/settings/integrations': {
|
||||
id: '/_auth/settings/integrations'
|
||||
path: '/integrations'
|
||||
fullPath: '/settings/integrations'
|
||||
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
|
||||
parentRoute: typeof AuthSettingsRouteRoute
|
||||
'/_auth/groups/$groupId': {
|
||||
id: '/_auth/groups/$groupId'
|
||||
path: '/$groupId'
|
||||
fullPath: '/groups/$groupId'
|
||||
preLoaderRoute: typeof AuthGroupsGroupIdRouteImport
|
||||
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/': {
|
||||
id: '/_auth/domains/$domainId/'
|
||||
@@ -308,6 +417,34 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthDomainsDomainIdIndexRouteImport
|
||||
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': {
|
||||
id: '/_auth/domains/$domainId/dns'
|
||||
path: '/domains/$domainId/dns'
|
||||
@@ -320,12 +457,14 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
interface AuthSettingsRouteRouteChildren {
|
||||
AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute
|
||||
AuthSettingsHealthRoute: typeof AuthSettingsHealthRoute
|
||||
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
|
||||
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
|
||||
}
|
||||
|
||||
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
|
||||
AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute,
|
||||
AuthSettingsHealthRoute: AuthSettingsHealthRoute,
|
||||
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
|
||||
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
|
||||
}
|
||||
@@ -345,13 +484,36 @@ const AuthGroupsRouteWithChildren = AuthGroupsRoute._addFileChildren(
|
||||
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 {
|
||||
AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren
|
||||
AuthCertificatesRoute: typeof AuthCertificatesRoute
|
||||
AuthGroupsRoute: typeof AuthGroupsRouteWithChildren
|
||||
AuthServicesRoute: typeof AuthServicesRoute
|
||||
AuthIndexRoute: typeof AuthIndexRoute
|
||||
AuthServicesServiceIdRouteRoute: typeof AuthServicesServiceIdRouteRouteWithChildren
|
||||
AuthDomainsIndexRoute: typeof AuthDomainsIndexRoute
|
||||
AuthServicesIndexRoute: typeof AuthServicesIndexRoute
|
||||
AuthDomainsDomainIdDnsRoute: typeof AuthDomainsDomainIdDnsRoute
|
||||
AuthDomainsDomainIdIndexRoute: typeof AuthDomainsDomainIdIndexRoute
|
||||
}
|
||||
@@ -360,9 +522,10 @@ const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthSettingsRouteRoute: AuthSettingsRouteRouteWithChildren,
|
||||
AuthCertificatesRoute: AuthCertificatesRoute,
|
||||
AuthGroupsRoute: AuthGroupsRouteWithChildren,
|
||||
AuthServicesRoute: AuthServicesRoute,
|
||||
AuthIndexRoute: AuthIndexRoute,
|
||||
AuthServicesServiceIdRouteRoute: AuthServicesServiceIdRouteRouteWithChildren,
|
||||
AuthDomainsIndexRoute: AuthDomainsIndexRoute,
|
||||
AuthServicesIndexRoute: AuthServicesIndexRoute,
|
||||
AuthDomainsDomainIdDnsRoute: AuthDomainsDomainIdDnsRoute,
|
||||
AuthDomainsDomainIdIndexRoute: AuthDomainsDomainIdIndexRoute,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
PlugIcon,
|
||||
HeartPulseIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
certSummaryQueryOptions,
|
||||
domainsListQueryOptions,
|
||||
groupsQueryOptions,
|
||||
opsSummaryQueryOptions,
|
||||
serviceGroupsQueryOptions,
|
||||
} from '@/queries'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
@@ -50,23 +52,18 @@ export const Route = createFileRoute('/_auth/')({
|
||||
queryClient.ensureQueryData(groupsQueryOptions()),
|
||||
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
|
||||
queryClient.ensureQueryData(certificatesQueryOptions()),
|
||||
queryClient.ensureQueryData(opsSummaryQueryOptions()),
|
||||
]),
|
||||
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() {
|
||||
const { data: domains, isLoading: domainsLoading } = useQuery(domainsListQueryOptions())
|
||||
const { data: summary, isLoading: summaryLoading } = useQuery(certSummaryQueryOptions())
|
||||
const { data: certs } = useQuery(certificatesQueryOptions())
|
||||
const { data: groups } = useQuery(groupsQueryOptions())
|
||||
const { data: serviceData } = useQuery(serviceGroupsQueryOptions())
|
||||
const { data: ops } = useQuery(opsSummaryQueryOptions())
|
||||
const { data: appSettings } = useQuery({
|
||||
queryKey: ['app-settings'],
|
||||
queryFn: () => api.get<{ showQuickActions?: boolean }>('/api/v1/settings'),
|
||||
@@ -202,8 +199,7 @@ function DashboardPage() {
|
||||
|
||||
const attentionCount = attentionServices.length
|
||||
|
||||
const certWarnings = countByStatus(summary, ['warning', 'expired', 'error'])
|
||||
const certOk = countByStatus(summary, ['active', 'ok'])
|
||||
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
@@ -224,7 +220,6 @@ function DashboardPage() {
|
||||
)
|
||||
}, [isLoading, summary, statusChartData, groupChartData, domains, groups])
|
||||
|
||||
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
|
||||
const kpiCards: KpiStatCard[] = [
|
||||
{
|
||||
id: 'domains',
|
||||
@@ -239,19 +234,10 @@ function DashboardPage() {
|
||||
variant: ungroupedCount > 0 ? 'warning' : 'default',
|
||||
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',
|
||||
label: 'Сервисы',
|
||||
value: serviceCount,
|
||||
value: ops?.services ?? serviceCount,
|
||||
hint: attentionCount
|
||||
? `${attentionCount} требуют внимания`
|
||||
: ungroupedServiceCount
|
||||
@@ -264,17 +250,37 @@ function DashboardPage() {
|
||||
search: { domainId: undefined },
|
||||
},
|
||||
{
|
||||
id: 'certs',
|
||||
label: 'Сертификаты',
|
||||
value: certs?.length ?? 0,
|
||||
hint:
|
||||
certWarnings > 0
|
||||
? `${certOk} в норме · ${certWarnings} внимания`
|
||||
: `${certOk} в норме`,
|
||||
icon: <ShieldCheckIcon aria-hidden />,
|
||||
id: 'nodes',
|
||||
label: 'Ноды',
|
||||
value: ops?.nodes ?? 0,
|
||||
hint: `${ops?.healthy ?? 0} healthy`,
|
||||
icon: <HeartPulseIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
to: '/services',
|
||||
},
|
||||
{
|
||||
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',
|
||||
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 { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services')({
|
||||
export const Route = createFileRoute('/_auth/services/')({
|
||||
validateSearch: (
|
||||
search: Record<string, unknown>,
|
||||
): { 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 = [
|
||||
'bg-chart-1',
|
||||
'bg-chart-2',
|
||||
@@ -150,6 +173,10 @@ function ServicesPage() {
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [deletingGroupId, setDeletingGroupId] = 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 [activeTab, setActiveTab] = useState('all')
|
||||
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({
|
||||
mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`),
|
||||
onSuccess: async () => {
|
||||
@@ -398,6 +473,15 @@ function ServicesPage() {
|
||||
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) {
|
||||
setDefaultGroupId(groupId)
|
||||
setCreateSheetOpen(true)
|
||||
@@ -456,7 +540,6 @@ function ServicesPage() {
|
||||
board.columns.map((column, index) => ({
|
||||
id: column.id,
|
||||
title: column.title,
|
||||
description: column.domain ?? undefined,
|
||||
healthStatus: column.group?.health_status,
|
||||
healthLatencyMs: column.group?.health_latency_ms,
|
||||
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
|
||||
@@ -494,7 +577,7 @@ function ServicesPage() {
|
||||
|
||||
const pageDescription = filteredDomain
|
||||
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
|
||||
: 'Группы, FQDN и доступность сервисов'
|
||||
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'
|
||||
|
||||
const sheets = (
|
||||
<>
|
||||
@@ -603,9 +686,11 @@ function ServicesPage() {
|
||||
isLoading
|
||||
hideHeader
|
||||
togglingId={null}
|
||||
togglingIp={null}
|
||||
onEditService={() => {}}
|
||||
onDeleteService={() => {}}
|
||||
onToggleService={() => {}}
|
||||
onToggleServiceIp={() => {}}
|
||||
onEditGroup={() => {}}
|
||||
onDeleteGroup={() => {}}
|
||||
onAddServiceToGroup={() => {}}
|
||||
@@ -703,12 +788,14 @@ function ServicesPage() {
|
||||
domainId={domainId}
|
||||
domainLabel={filteredDomain?.zone_name}
|
||||
togglingId={togglingServiceId}
|
||||
togglingIp={togglingIp}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
hideHeader
|
||||
onEditService={setEditingService}
|
||||
onDeleteService={setDeletingService}
|
||||
onToggleService={handleServiceToggle}
|
||||
onToggleServiceIp={handleServiceIpToggle}
|
||||
onEditGroup={setEditingGroup}
|
||||
onDeleteGroup={setDeletingGroup}
|
||||
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/shared packages/shared
|
||||
COPY packages/db packages/db
|
||||
COPY workers/health-probe workers/health-probe
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm turbo build --filter=web --filter=@cfdm/api \
|
||||
&& 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 |
|
||||
|----------|-------------|
|
||||
| `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`) |
|
||||
| `JWT_SECRET` | JWT signing secret |
|
||||
| `ADMIN_USERNAME` | Admin username |
|
||||
| `ADMIN_PASSWORD_HASH` | Argon2 hash (empty = dev `admin`/`admin`) |
|
||||
| `LOG_LEVEL` | Уровень логов API (`info`, `debug`) |
|
||||
| `HEALTH_CHECK_CRON` | Cron для health-check (default `*/30 * * * * *`) |
|
||||
| `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`) |
|
||||
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`) |
|
||||
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`) |
|
||||
| `HEALTH_CHECK_CRON` | Cron для health-check (default `0 */2 * * * *`). Переопределяется в **Настройки → Health-check**. |
|
||||
| `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`). То же в UI. |
|
||||
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`). То же в UI. |
|
||||
| `HEALTH_SUCCESS_RECOVERIES` | Успехов подряд для recovery `CHECKING → HEALTHY` (default `2`). То же в UI. |
|
||||
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`). То же в UI. |
|
||||
|
||||
## Load balancing & health checks
|
||||
|
||||
@@ -37,11 +38,58 @@ health-check работают на двух уровнях:
|
||||
`(domain_id, service_id, hostname)`).
|
||||
- **Привязка сервиса с multi-A** — режим LB и health-check настраиваются в карточке
|
||||
сервиса для каждой привязки с несколькими 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`
|
||||
работает как `round_robin` (одна A на IP), веса хранятся в БД для будущих расширений
|
||||
и отображения в UI. Reconcile DNS запускается cron-задачей `health-check` при смене
|
||||
статуса IP (`up` / `degraded` / `down` / `unknown`); `down`-IP убирается из A-записей.
|
||||
работает как `round_robin` (одна A на IP). `unknown` **не** считается healthy и
|
||||
не попадает в пул, пока нет успешных проб; восстановление — `UNHEALTHY → CHECKING → HEALTHY`
|
||||
после `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
|
||||
|
||||
|
||||
+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).
|
||||
|
||||
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 one or more lines are too long
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';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user