Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab4ccbd7a1 | ||
|
|
b2dbb4ad98 | ||
|
|
3f6f402872 | ||
|
|
9c00b268dc | ||
|
|
a185b3a2cb |
@@ -22,6 +22,7 @@ node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.turbo
|
||||
# Генерируется `tsr generate` в `web` build (tsc идёт до Vite-плагина).
|
||||
apps/web/src/routeTree.gen.ts
|
||||
apps/web/playwright-report
|
||||
apps/web/test-results
|
||||
|
||||
+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-репозиторий (`GITEA_TOKEN`).
|
||||
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`).
|
||||
|
||||
### Теги образов
|
||||
|
||||
|
||||
@@ -37,21 +37,40 @@ jobs:
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Clone Wiki repository
|
||||
if: steps.check_changes.outputs.changed == 'true'
|
||||
run: |
|
||||
WIKI_URL=$(echo "${{ github.server_url }}/${{ github.repository }}.wiki.git" | sed -e "s|://|://gitea-actions:${{ secrets.GITEA_TOKEN }}@|")
|
||||
git clone "${WIKI_URL}" cfdm.wiki
|
||||
- name: Update and push Wiki content
|
||||
if: steps.check_changes.outputs.changed == 'true'
|
||||
env:
|
||||
# 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 "ACTIONS_PAT / GITEA_TOKEN is empty — cannot push wiki"
|
||||
exit 1
|
||||
fi
|
||||
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"
|
||||
git config user.email "actions@gitea"
|
||||
git add Home.md
|
||||
git diff --staged --quiet || git commit -m "docs: Update Wiki from main repository"
|
||||
git push
|
||||
if git diff --staged --quiet; then
|
||||
echo "Wiki Home.md already up to date"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "docs: Update Wiki from main repository"
|
||||
GIT_TERMINAL_PROMPT=0 git -c "${AUTH_INSTEAD}" push origin HEAD
|
||||
|
||||
publish:
|
||||
needs: [quality]
|
||||
|
||||
@@ -24,6 +24,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,
|
||||
@@ -81,6 +82,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);
|
||||
@@ -130,6 +132,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
degradedFailures: config.healthDegradedFailures,
|
||||
downFailures: config.healthDownFailures,
|
||||
latencyWarnMs: config.healthLatencyWarnMs,
|
||||
successRecoveries: config.healthSuccessRecoveries,
|
||||
};
|
||||
const n = await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
|
||||
@@ -13,6 +13,7 @@ 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;
|
||||
@@ -55,6 +56,8 @@ 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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+57
-125
@@ -1,152 +1,84 @@
|
||||
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 { 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 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.zones = createZoneAdapter(token);
|
||||
this.dns = createDnsAdapter(token);
|
||||
this.healthchecks = createHealthCheckAdapter(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;
|
||||
});
|
||||
listZones(): Promise<CfZone[]> {
|
||||
return this.zones.listZones();
|
||||
}
|
||||
|
||||
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");
|
||||
getZone(zoneId: string): Promise<CfZone> {
|
||||
return this.zones.getZone(zoneId);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
|
||||
return this.dns.listDnsRecords(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");
|
||||
createDnsRecord(zoneId: string, payload: CreateDnsRecordPayload): Promise<CfDnsRecord> {
|
||||
return this.dns.createDnsRecord(zoneId, payload);
|
||||
}
|
||||
|
||||
async updateDnsRecord(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,91 @@
|
||||
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")) {
|
||||
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;
|
||||
}
|
||||
|
||||
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,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");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function healthCheckRoutes(app: FastifyInstance) {
|
||||
degradedFailures: config.healthDegradedFailures,
|
||||
downFailures: config.healthDownFailures,
|
||||
latencyWarnMs: config.healthLatencyWarnMs,
|
||||
successRecoveries: config.healthSuccessRecoveries,
|
||||
};
|
||||
const checked = await healthCheckService.runAllChecks(request.server.db, {
|
||||
thresholds,
|
||||
|
||||
@@ -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,16 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { reorderServicesSchema, updateServiceConfigSchema } from "@cfdm/shared";
|
||||
import {
|
||||
changeDomainSchema,
|
||||
createServiceNodeSchema,
|
||||
reorderServicesSchema,
|
||||
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 +64,88 @@ export async function serviceRoutes(app: FastifyInstance) {
|
||||
return serviceConfig.getView(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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,11 +5,13 @@ import type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
|
||||
import { AppError } from "../errors.js";
|
||||
import { nextHealthState } from "./health/state-machine.js";
|
||||
|
||||
export interface HealthCheckThresholds {
|
||||
degradedFailures: number;
|
||||
downFailures: number;
|
||||
latencyWarnMs: number;
|
||||
successRecoveries?: number;
|
||||
}
|
||||
|
||||
export interface ProbeResult {
|
||||
@@ -234,23 +236,25 @@ 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 {
|
||||
@@ -324,12 +328,13 @@ export async function runAllChecks(
|
||||
target.ref_id,
|
||||
target.ip,
|
||||
);
|
||||
const { state, failures } = deriveState(
|
||||
const { state, failures, successes, node } = deriveState(
|
||||
result.ok,
|
||||
result.latencyMs,
|
||||
prev
|
||||
? {
|
||||
consecutive_failures: prev.consecutive_failures,
|
||||
consecutive_successes: prev.consecutive_successes,
|
||||
status: prev.status,
|
||||
}
|
||||
: null,
|
||||
@@ -347,7 +352,18 @@ export async function runAllChecks(
|
||||
result.latencyMs,
|
||||
failures,
|
||||
result.error,
|
||||
successes,
|
||||
);
|
||||
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: result.error,
|
||||
});
|
||||
}
|
||||
if (prevState !== state) {
|
||||
options.onStatusChange?.(target, prevState, state);
|
||||
}
|
||||
@@ -402,6 +418,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,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,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,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;
|
||||
}
|
||||
@@ -24,6 +24,15 @@ 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 {
|
||||
selectActiveIpsByMode,
|
||||
withBindingLock,
|
||||
type LbIpRow,
|
||||
type LbTargetConfig,
|
||||
} from "./routing/index.js";
|
||||
|
||||
export type { LbIpRow, LbTargetConfig };
|
||||
export { selectActiveIpsByMode };
|
||||
|
||||
export interface ServiceDomainInput {
|
||||
fqdn: string;
|
||||
@@ -137,54 +146,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,
|
||||
@@ -1411,6 +1372,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 +1398,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "tsr generate && tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
@@ -42,6 +42,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tanstack/router-cli": "^1.167.31",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { InboxIcon, type LucideIcon } from 'lucide-react'
|
||||
import { IconStack } from '@/components/reui/icon-stack'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
@@ -46,20 +47,29 @@ export function EmptyState({
|
||||
!centered && className,
|
||||
)}
|
||||
>
|
||||
<EmptyHeader className="gap-5 text-center">
|
||||
<EmptyHeader className={cn('text-center', stackedIcon ? 'gap-5' : 'gap-3')}>
|
||||
<EmptyMedia className="mb-0">
|
||||
{stackedIcon ? (
|
||||
<IconStack aria-hidden="true" className="h-14 w-12">
|
||||
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
|
||||
</IconStack>
|
||||
) : (
|
||||
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
className="size-10.5 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon />
|
||||
</IconTile>
|
||||
)}
|
||||
</EmptyMedia>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<EmptyTitle className="text-base font-semibold tracking-tight">
|
||||
<EmptyTitle
|
||||
className={cn(
|
||||
'font-semibold tracking-tight',
|
||||
stackedIcon ? 'text-base' : 'text-sm',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</EmptyTitle>
|
||||
{description ? (
|
||||
|
||||
@@ -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,7 +50,7 @@ const dotColor: Record<IpHealthStatus['status'], string> = {
|
||||
}
|
||||
|
||||
interface HealthCheckBadgeProps {
|
||||
status: IpHealthStatus['status']
|
||||
status: HealthStatus
|
||||
latencyMs?: number | null
|
||||
lastCheckedAt?: string | null
|
||||
lastError?: string | null
|
||||
@@ -49,8 +70,9 @@ export function HealthCheckBadge({
|
||||
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)
|
||||
@@ -74,7 +96,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}
|
||||
|
||||
@@ -17,11 +17,15 @@ import {
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { ButtonGroup } from '@cfdm/ui/components/button-group'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export type LbMode = 'round_robin' | 'failover' | 'weighted'
|
||||
export type HealthCheckType = 'tcp' | 'http'
|
||||
export type HealthProvider = 'local' | 'cloudflare'
|
||||
|
||||
export interface HealthCheckConfig {
|
||||
enabled: boolean
|
||||
@@ -32,6 +36,11 @@ export interface HealthCheckConfig {
|
||||
interval_sec: number
|
||||
timeout_ms: number
|
||||
verify_tls: boolean
|
||||
provider: HealthProvider
|
||||
method?: string | null
|
||||
retries?: number
|
||||
consecutive_fails?: number
|
||||
consecutive_successes?: number
|
||||
}
|
||||
|
||||
export interface LbAndHealthConfig extends HealthCheckConfig {
|
||||
@@ -49,6 +58,47 @@ const healthCheckTypes = [
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
] as const
|
||||
|
||||
const cloudflareTypes = [
|
||||
{ value: 'tcp', label: 'TCP' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
] as const
|
||||
|
||||
export function HealthProviderToggle({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
}: {
|
||||
value: HealthProvider
|
||||
onChange: (next: HealthProvider) => void
|
||||
id?: string
|
||||
}) {
|
||||
const provider = value || 'local'
|
||||
return (
|
||||
<ButtonGroup className="w-full" id={id}>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
variant={provider === 'local' ? 'secondary' : 'outline'}
|
||||
aria-pressed={provider === 'local'}
|
||||
onClick={() => onChange('local')}
|
||||
>
|
||||
Local
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
variant={provider === 'cloudflare' ? 'secondary' : 'outline'}
|
||||
aria-pressed={provider === 'cloudflare'}
|
||||
onClick={() => onChange('cloudflare')}
|
||||
>
|
||||
Cloudflare
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)
|
||||
}
|
||||
|
||||
interface HealthCheckConfigFieldsProps {
|
||||
value: LbAndHealthConfig
|
||||
onChange: (next: LbAndHealthConfig) => void
|
||||
@@ -119,6 +169,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 +187,34 @@ export function HealthCheckConfigFields({
|
||||
</SettingRow>
|
||||
) : null}
|
||||
|
||||
<SettingRow
|
||||
title="Провайдер health-check"
|
||||
description="Local TCP/HTTP или Cloudflare Health Checks API"
|
||||
labelFor={`${idPrefix}-provider`}
|
||||
compact
|
||||
className={rowClass}
|
||||
>
|
||||
<HealthProviderToggle
|
||||
id={`${idPrefix}-provider`}
|
||||
value={value.provider ?? 'local'}
|
||||
onChange={(provider) =>
|
||||
patch({
|
||||
provider,
|
||||
enabled: provider === 'cloudflare' ? true : value.enabled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
{value.provider === 'cloudflare' ? (
|
||||
<Alert>
|
||||
<AlertTitle>Cloudflare Health Checks</AlertTitle>
|
||||
<AlertDescription>
|
||||
Поля соответствуют официальному API зоны. Если план не позволяет Health
|
||||
Checks, API вернёт ошибку — останется Local. Workers не используются.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<SettingRow
|
||||
title="Health-check"
|
||||
description="TCP/HTTP проверка цели DNS"
|
||||
@@ -164,6 +243,7 @@ export function HealthCheckConfigFields({
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
|
||||
<Select
|
||||
modal={false}
|
||||
value={value.type}
|
||||
onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })}
|
||||
>
|
||||
@@ -171,7 +251,10 @@ export function HealthCheckConfigFields({
|
||||
<SelectValue placeholder="Тип" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{healthCheckTypes.map((item) => (
|
||||
{(value.provider === 'cloudflare'
|
||||
? cloudflareTypes
|
||||
: healthCheckTypes
|
||||
).map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
@@ -265,6 +348,57 @@ export function HealthCheckConfigFields({
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</div>
|
||||
{value.provider === 'cloudflare' ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormFieldSimple
|
||||
label="Retries"
|
||||
htmlFor={`${idPrefix}-retries`}
|
||||
hint="Cloudflare retries"
|
||||
>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-retries`}
|
||||
value={value.retries ?? 2}
|
||||
min={0}
|
||||
max={10}
|
||||
placeholder="2"
|
||||
onValueChange={(retries) => patch({ retries: retries ?? 2 })}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Successes"
|
||||
htmlFor={`${idPrefix}-successes`}
|
||||
hint="consecutive_successes"
|
||||
>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-successes`}
|
||||
value={value.consecutive_successes ?? 2}
|
||||
min={1}
|
||||
max={20}
|
||||
placeholder="2"
|
||||
onValueChange={(consecutive_successes) =>
|
||||
patch({ consecutive_successes: consecutive_successes ?? 2 })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</div>
|
||||
) : null}
|
||||
{value.provider === 'cloudflare' && isHttp ? (
|
||||
<FormFieldSimple label="HTTP method" htmlFor={`${idPrefix}-method`}>
|
||||
<Select
|
||||
modal={false}
|
||||
value={value.method ?? 'GET'}
|
||||
onValueChange={(v) => patch({ method: v ?? 'GET' })}
|
||||
>
|
||||
<SelectTrigger id={`${idPrefix}-method`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="HEAD">HEAD</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormFieldSimple>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
@@ -78,7 +78,7 @@ export function ServiceKanbanCard({
|
||||
</ItemHeader>
|
||||
|
||||
<ItemContent className="min-w-0 gap-2">
|
||||
<ServiceFqdnList service={service} />
|
||||
<ServiceFqdnList copyable service={service} />
|
||||
</ItemContent>
|
||||
|
||||
<ItemFooter className="min-w-0 justify-between gap-2">
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
/**
|
||||
* Sibling Frame columns for dashboard attention queue.
|
||||
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
|
||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
|
||||
*/
|
||||
export interface AttentionQueueColumn {
|
||||
id: string
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
iconClassName?: string
|
||||
count: number
|
||||
countVariant?: 'destructive' | 'warning' | 'secondary' | 'destructive-light' | 'warning-light'
|
||||
emptyTitle: string
|
||||
emptyDescription: string
|
||||
emptyAction?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface AttentionQueueProps {
|
||||
columns: AttentionQueueColumn[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
|
||||
export function AttentionQueue({ columns, className }: AttentionQueueProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-w-0 items-start gap-2 @3xl:grid-cols-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const Icon = column.icon
|
||||
const isEmpty = column.count === 0
|
||||
return (
|
||||
<Frame key={column.id} dense spacing="sm" className="min-w-0 w-full">
|
||||
<FrameHeader>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="sm"
|
||||
className={cn(DEFAULT_ICON_CLASS, column.iconClassName)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon />
|
||||
</IconTile>
|
||||
<FrameTitle className="min-w-0 truncate">{column.title}</FrameTitle>
|
||||
{column.count > 0 ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant={column.countVariant ?? 'secondary'}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{column.count}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="min-w-0">
|
||||
{isEmpty ? (
|
||||
<div className="flex min-h-28 items-center justify-center py-4">
|
||||
<EmptyState
|
||||
icon={Icon}
|
||||
title={column.emptyTitle}
|
||||
description={column.emptyDescription}
|
||||
action={column.emptyAction}
|
||||
centered={false}
|
||||
stackedIcon={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
column.children
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
type OpsKpiCard,
|
||||
} from './kpi-stat-grid'
|
||||
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||
export { AttentionQueue, type AttentionQueueColumn } from './attention-queue'
|
||||
export { OpsDashboard } from './ops-dashboard'
|
||||
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
|
||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
|
||||
|
||||
@@ -17,10 +10,18 @@ interface OpsDashboardProps {
|
||||
afterKpi?: ReactNode
|
||||
charts: ReactNode
|
||||
queue: ReactNode
|
||||
queueTitle?: string
|
||||
queueDescription?: string
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
/** Denser stack for KPI / charts / queue. PageHeader lives outside via PageShell. */
|
||||
/**
|
||||
* Ops dashboard: KPI → optional Quick Actions → charts → queue.
|
||||
* Queue is a sibling Frame grid — never wrap Frames inside another Frame.
|
||||
* @see https://reui.io/preview/base/dashboard-1
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
const rootClassName =
|
||||
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
|
||||
|
||||
@@ -32,7 +33,11 @@ function OpsDashboardSkeleton() {
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
<div className="grid gap-2 @3xl:grid-cols-3">
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -42,6 +47,8 @@ export function OpsDashboard({
|
||||
afterKpi,
|
||||
charts,
|
||||
queue,
|
||||
queueTitle = 'Требуют внимания',
|
||||
queueDescription = 'Проблемы health-check, истекающие сертификаты и домены без группы',
|
||||
isLoading = false,
|
||||
}: OpsDashboardProps) {
|
||||
if (isLoading) {
|
||||
@@ -63,16 +70,16 @@ export function OpsDashboard({
|
||||
{charts}
|
||||
</section>
|
||||
|
||||
<section aria-label="Требуют внимания">
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Требуют внимания</FrameTitle>
|
||||
<FrameDescription>
|
||||
Проблемы health-check, истекающие сертификаты и домены без группы
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>{queue}</FramePanel>
|
||||
</Frame>
|
||||
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
||||
{queueDescription ? (
|
||||
<p className="text-muted-foreground max-w-prose text-sm">
|
||||
{queueDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{queue}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -57,6 +57,7 @@ interface BindingHealthConfig {
|
||||
interval_sec: number
|
||||
timeout_ms: number
|
||||
verify_tls: boolean
|
||||
provider: 'local' | 'cloudflare'
|
||||
}
|
||||
|
||||
export interface ServiceBindingDraft {
|
||||
@@ -79,6 +80,7 @@ const defaultHealth: BindingHealthConfig = {
|
||||
interval_sec: 30,
|
||||
timeout_ms: 3000,
|
||||
verify_tls: false,
|
||||
provider: 'local',
|
||||
}
|
||||
|
||||
interface ServiceEditSheetProps {
|
||||
@@ -112,6 +114,7 @@ function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||
interval_sec: binding.health_check_interval_sec,
|
||||
timeout_ms: binding.health_check_timeout_ms,
|
||||
verify_tls: binding.health_check_verify_tls ?? false,
|
||||
provider: 'local',
|
||||
},
|
||||
target_ip_weights: binding.target_ip_weights ?? {},
|
||||
target_ip_priorities: binding.target_ip_priorities ?? {},
|
||||
@@ -328,6 +331,7 @@ export function ServiceEditSheet({
|
||||
interval_sec: next.interval_sec,
|
||||
timeout_ms: next.timeout_ms,
|
||||
verify_tls: next.verify_tls,
|
||||
provider: next.provider ?? 'local',
|
||||
},
|
||||
}
|
||||
: item,
|
||||
@@ -650,6 +654,7 @@ export function ServiceEditSheet({
|
||||
interval_sec: binding.health.interval_sec,
|
||||
timeout_ms: binding.health.timeout_ms,
|
||||
verify_tls: binding.health.verify_tls,
|
||||
provider: binding.health.provider ?? 'local',
|
||||
}}
|
||||
onChange={(next) => handleBindingHealthChange(index, next)}
|
||||
lbModeLabel="Режим балансировки"
|
||||
|
||||
@@ -54,6 +54,7 @@ const defaultLbHealth: LbAndHealthConfig = {
|
||||
interval_sec: 30,
|
||||
timeout_ms: 3000,
|
||||
verify_tls: false,
|
||||
provider: 'local',
|
||||
}
|
||||
|
||||
export function ServiceGroupEditSheet({
|
||||
@@ -93,6 +94,7 @@ export function ServiceGroupEditSheet({
|
||||
interval_sec: group.health_check_interval_sec,
|
||||
timeout_ms: group.health_check_timeout_ms,
|
||||
verify_tls: group.health_check_verify_tls,
|
||||
provider: 'local',
|
||||
})
|
||||
} else {
|
||||
form.reset({ name: '', type: 'custom', domain: null })
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -10,16 +15,51 @@ 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
|
||||
}
|
||||
|
||||
export function ServiceFqdnList({
|
||||
service,
|
||||
className,
|
||||
emptyLabel = 'FQDN не задан',
|
||||
emptyLabel = 'Нет FQDN',
|
||||
copyable = false,
|
||||
}: ServiceFqdnListProps) {
|
||||
const fqdns = serviceDisplayFqdns(service)
|
||||
if (fqdns.length === 0) {
|
||||
@@ -32,6 +72,7 @@ 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)}>
|
||||
@@ -62,6 +103,7 @@ export function ServiceFqdnList({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
{copyable ? <CopyFqdnButton value={copyValue} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
FolderOpenIcon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
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, ServiceFqdnList } from '@/components/services/service-fqdn-list'
|
||||
import { ServiceGroupIcon } from '@/components/service-group-icon'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
import type { ServiceGroup, ServiceGroupView, ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@cfdm/ui/components/collapsible'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
const VISIBLE_SERVICE_LIMIT = 5
|
||||
|
||||
const GROUP_TYPE_LABELS: Record<ServiceGroup['type'], string> = {
|
||||
vpn: 'VPN',
|
||||
network: 'Сеть',
|
||||
internet: 'Интернет',
|
||||
bgp: 'BGP',
|
||||
custom: 'Другое',
|
||||
}
|
||||
|
||||
function servicesCountLabel(count: number): string {
|
||||
const mod10 = count % 10
|
||||
const mod100 = count % 100
|
||||
if (mod10 === 1 && mod100 !== 11) return `${count} сервис`
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
|
||||
return `${count} сервиса`
|
||||
}
|
||||
return `${count} сервисов`
|
||||
}
|
||||
|
||||
interface ServiceGroupUnitProps {
|
||||
group: ServiceGroupView | null
|
||||
services: ServiceView[]
|
||||
togglingId: number | null
|
||||
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
|
||||
}
|
||||
|
||||
export function ServiceGroupUnit({
|
||||
group,
|
||||
services,
|
||||
togglingId,
|
||||
onEditService,
|
||||
onDeleteService,
|
||||
onToggleService,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onAddServiceToGroup,
|
||||
}: ServiceGroupUnitProps) {
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const title = group?.name ?? 'Без группы'
|
||||
const typeLabel = group ? GROUP_TYPE_LABELS[group.type] : null
|
||||
const commonDomain = group?.domain?.trim() || null
|
||||
const visible = services.slice(0, VISIBLE_SERVICE_LIMIT)
|
||||
const rest = services.slice(VISIBLE_SERVICE_LIMIT)
|
||||
const groupId = group?.id ?? null
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<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-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FrameTitle className="truncate">{title}</FrameTitle>
|
||||
<Badge variant="outline" size="xs" className="shrink-0 tabular-nums">
|
||||
{services.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span>{servicesCountLabel(services.length)}</span>
|
||||
{typeLabel ? (
|
||||
<>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{typeLabel}</span>
|
||||
</>
|
||||
) : null}
|
||||
</FrameDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{group ? (
|
||||
<HealthCheckBadge
|
||||
status={group.health_status ?? 'unknown'}
|
||||
latencyMs={group.health_latency_ms}
|
||||
size="xs"
|
||||
/>
|
||||
) : null}
|
||||
{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>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Добавить сервис без группы"
|
||||
onClick={() => onAddServiceToGroup(null)}
|
||||
>
|
||||
<PlusIcon aria-hidden />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
{group ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-muted-foreground text-xs">Общий домен</span>
|
||||
{commonDomain ? (
|
||||
<TruncatedText className="min-w-0 font-mono text-sm">
|
||||
{commonDomain}
|
||||
</TruncatedText>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">Не задан</span>
|
||||
)}
|
||||
</div>
|
||||
{commonDomain ? (
|
||||
<CopyFqdnButton className="shrink-0" value={commonDomain} />
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
<p className="text-muted-foreground text-xs">Сервисы</p>
|
||||
{services.length === 0 ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<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="flex flex-col">
|
||||
{visible.map((service, index) => (
|
||||
<ServiceUnitRow
|
||||
key={service.id}
|
||||
service={service}
|
||||
togglingId={togglingId}
|
||||
showSeparator={index > 0}
|
||||
onEditService={onEditService}
|
||||
onDeleteService={onDeleteService}
|
||||
onToggleService={onToggleService}
|
||||
/>
|
||||
))}
|
||||
{rest.length > 0 ? (
|
||||
<Collapsible open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<CollapsibleContent>
|
||||
{rest.map((service) => (
|
||||
<ServiceUnitRow
|
||||
key={service.id}
|
||||
service={service}
|
||||
togglingId={togglingId}
|
||||
showSeparator
|
||||
onEditService={onEditService}
|
||||
onDeleteService={onDeleteService}
|
||||
onToggleService={onToggleService}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
<div className="flex justify-end pt-2">
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-expanded={moreOpen}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{moreOpen
|
||||
? 'Скрыть'
|
||||
: `Показать ещё ${rest.length}`}
|
||||
<ChevronDownIcon
|
||||
aria-hidden
|
||||
data-icon="inline-end"
|
||||
className={cn(
|
||||
'transition-transform duration-200',
|
||||
moreOpen && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceUnitRow({
|
||||
service,
|
||||
togglingId,
|
||||
showSeparator,
|
||||
onEditService,
|
||||
onDeleteService,
|
||||
onToggleService,
|
||||
}: {
|
||||
service: ServiceView
|
||||
togglingId: number | null
|
||||
showSeparator: boolean
|
||||
onEditService: (service: ServiceView) => void
|
||||
onDeleteService: (service: ServiceView) => void
|
||||
onToggleService: (serviceId: number, enabled: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{showSeparator ? <Separator /> : null}
|
||||
<Item size="sm" className="rounded-none px-0 py-2.5">
|
||||
<ItemContent className="min-w-0 gap-1 sm:hidden">
|
||||
<ItemTitle className="min-w-0">
|
||||
<Link
|
||||
to="/services/$serviceId"
|
||||
params={{ serviceId: String(service.id) }}
|
||||
className="truncate hover:underline"
|
||||
>
|
||||
{service.name}
|
||||
</Link>
|
||||
</ItemTitle>
|
||||
<ServiceFqdnList copyable service={service} />
|
||||
</ItemContent>
|
||||
<ItemContent className="hidden min-w-0 sm:flex sm:flex-row sm:items-center sm:gap-3">
|
||||
<ItemTitle className="min-w-0 shrink-0">
|
||||
<Link
|
||||
to="/services/$serviceId"
|
||||
params={{ serviceId: String(service.id) }}
|
||||
className="truncate hover:underline"
|
||||
>
|
||||
{service.name}
|
||||
</Link>
|
||||
</ItemTitle>
|
||||
<ServiceFqdnList
|
||||
copyable
|
||||
className="min-w-0 flex-1 justify-end"
|
||||
service={service}
|
||||
/>
|
||||
</ItemContent>
|
||||
<ItemActions className="shrink-0">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={service.enabled}
|
||||
disabled={togglingId === service.id}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleService(service.id, Boolean(checked))
|
||||
}
|
||||
aria-label={
|
||||
service.enabled ? 'Выключить сервис' : 'Включить сервис'
|
||||
}
|
||||
/>
|
||||
<HealthCheckBadge
|
||||
status={service.health_status ?? 'unknown'}
|
||||
latencyMs={service.health_latency_ms}
|
||||
size="xs"
|
||||
/>
|
||||
<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>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
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,
|
||||
@@ -14,24 +8,21 @@ import {
|
||||
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 {
|
||||
applyFiltersToData,
|
||||
getActiveFilters,
|
||||
} from '@/components/reui-kit/filter-utils'
|
||||
import { ServiceGroupUnit } from '@/components/services/service-group-unit'
|
||||
import {
|
||||
SERVICE_TABS,
|
||||
createDefaultServiceFilters,
|
||||
@@ -98,60 +89,51 @@ function catalogTabFilter(row: ServiceCatalogRow, tabId: string) {
|
||||
return serviceTabFilter(row, tabId)
|
||||
}
|
||||
|
||||
function buildTreeRows(
|
||||
interface GroupUnitData {
|
||||
id: string
|
||||
group: ServiceGroupView | null
|
||||
services: ServiceView[]
|
||||
}
|
||||
|
||||
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({
|
||||
@@ -229,7 +211,6 @@ export function ServicesGroupedCatalog({
|
||||
const [filters, setFilters] = useState<Filter[]>(() =>
|
||||
createDefaultServiceFilters(),
|
||||
)
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({})
|
||||
const filterFields = useServiceFilterFields()
|
||||
|
||||
const flatRows = useMemo(() => {
|
||||
@@ -263,60 +244,25 @@ export function ServicesGroupedCatalog({
|
||||
return new Set(afterFilters.map((r) => r.id))
|
||||
}, [flatRows, tab, filters])
|
||||
|
||||
const treeData = useMemo(
|
||||
() => buildTreeRows(data, filteredIds, domainId),
|
||||
[data, filteredIds, domainId],
|
||||
const showEmptyGroups =
|
||||
tab === 'all' && domainId == null && getActiveFilters(filters).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" />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 w-full rounded-xl" />
|
||||
))}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
@@ -342,112 +288,106 @@ 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>
|
||||
</div>
|
||||
{primaryAction ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{primaryAction}
|
||||
</div>
|
||||
) : 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}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
{primaryAction ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{primaryAction}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
) : null}
|
||||
|
||||
<Separator />
|
||||
<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>
|
||||
|
||||
<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 />
|
||||
Фильтры
|
||||
<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 />
|
||||
|
||||
{units.length === 0 ? (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
title="Нет совпадений"
|
||||
description="Измените фильтры или вкладку."
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTab('all')
|
||||
setFilters(createDefaultServiceFilters())
|
||||
}}
|
||||
>
|
||||
Сбросить
|
||||
</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>
|
||||
}
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
{units.map((unit) => (
|
||||
<ServiceGroupUnit
|
||||
key={unit.id}
|
||||
group={unit.group}
|
||||
services={unit.services}
|
||||
togglingId={togglingId}
|
||||
onEditService={onEditService}
|
||||
onDeleteService={onDeleteService}
|
||||
onToggleService={onToggleService}
|
||||
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,
|
||||
])
|
||||
}
|
||||
@@ -83,3 +83,92 @@ 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,
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
+163
-21
@@ -13,16 +13,22 @@ 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 AuthCallbackRouteImport } from './routes/auth.callback'
|
||||
import { Route as AuthServicesRouteImport } from './routes/_auth/services'
|
||||
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 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 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 LoginRoute = LoginRouteImport.update({
|
||||
@@ -44,11 +50,6 @@ const AuthCallbackRoute = AuthCallbackRouteImport.update({
|
||||
path: '/auth/callback',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthServicesRoute = AuthServicesRouteImport.update({
|
||||
id: '/services',
|
||||
path: '/services',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthGroupsRoute = AuthGroupsRouteImport.update({
|
||||
id: '/groups',
|
||||
path: '/groups',
|
||||
@@ -69,6 +70,11 @@ const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => AuthSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthServicesIndexRoute = AuthServicesIndexRouteImport.update({
|
||||
id: '/services/',
|
||||
path: '/services/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthDomainsIndexRoute = AuthDomainsIndexRouteImport.update({
|
||||
id: '/domains/',
|
||||
path: '/domains/',
|
||||
@@ -90,12 +96,48 @@ const AuthGroupsGroupIdRoute = AuthGroupsGroupIdRouteImport.update({
|
||||
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 +150,41 @@ 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/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/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 +193,22 @@ 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/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 +218,41 @@ export interface FileRouteTypes {
|
||||
| '/settings'
|
||||
| '/certificates'
|
||||
| '/groups'
|
||||
| '/services'
|
||||
| '/auth/callback'
|
||||
| '/services/$serviceId'
|
||||
| '/groups/$groupId'
|
||||
| '/settings/appearance'
|
||||
| '/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/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 +260,22 @@ 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/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 {
|
||||
@@ -238,13 +314,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthCallbackRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth/services': {
|
||||
id: '/_auth/services'
|
||||
path: '/services'
|
||||
fullPath: '/services'
|
||||
preLoaderRoute: typeof AuthServicesRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/groups': {
|
||||
id: '/_auth/groups'
|
||||
path: '/groups'
|
||||
@@ -273,6 +342,13 @@ declare module '@tanstack/react-router' {
|
||||
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/'
|
||||
path: '/domains'
|
||||
@@ -301,6 +377,20 @@ declare module '@tanstack/react-router' {
|
||||
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/'
|
||||
path: '/domains/$domainId'
|
||||
@@ -308,6 +398,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'
|
||||
@@ -345,13 +463,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 +501,10 @@ const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthSettingsRouteRoute: AuthSettingsRouteRouteWithChildren,
|
||||
AuthCertificatesRoute: AuthCertificatesRoute,
|
||||
AuthGroupsRoute: AuthGroupsRouteWithChildren,
|
||||
AuthServicesRoute: AuthServicesRoute,
|
||||
AuthIndexRoute: AuthIndexRoute,
|
||||
AuthServicesServiceIdRouteRoute: AuthServicesServiceIdRouteRouteWithChildren,
|
||||
AuthDomainsIndexRoute: AuthDomainsIndexRoute,
|
||||
AuthServicesIndexRoute: AuthServicesIndexRoute,
|
||||
AuthDomainsDomainIdDnsRoute: AuthDomainsDomainIdDnsRoute,
|
||||
AuthDomainsDomainIdIndexRoute: AuthDomainsDomainIdIndexRoute,
|
||||
}
|
||||
|
||||
+193
-164
@@ -7,6 +7,7 @@ import {
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
PlugIcon,
|
||||
HeartPulseIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
@@ -15,16 +16,17 @@ import {
|
||||
certSummaryQueryOptions,
|
||||
domainsListQueryOptions,
|
||||
groupsQueryOptions,
|
||||
opsSummaryQueryOptions,
|
||||
serviceGroupsQueryOptions,
|
||||
} from '@/queries'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import {
|
||||
CertStatusChart,
|
||||
GroupDomainsChart,
|
||||
OpsDashboard,
|
||||
QuickActionGrid,
|
||||
AttentionQueue,
|
||||
type KpiStatCard,
|
||||
type QuickActionItem,
|
||||
} from '@/components/reui-kit'
|
||||
@@ -32,10 +34,12 @@ import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { formatRelative } from '@/lib/format'
|
||||
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
|
||||
import { api } from '@/lib/api-client'
|
||||
@@ -48,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'),
|
||||
@@ -200,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
|
||||
@@ -222,7 +220,6 @@ function DashboardPage() {
|
||||
)
|
||||
}, [isLoading, summary, statusChartData, groupChartData, domains, groups])
|
||||
|
||||
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
|
||||
const kpiCards: KpiStatCard[] = [
|
||||
{
|
||||
id: 'domains',
|
||||
@@ -237,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
|
||||
@@ -262,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',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -300,143 +308,164 @@ function DashboardPage() {
|
||||
</>
|
||||
}
|
||||
queue={
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ActivityIcon
|
||||
className="text-destructive size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 className="text-sm font-semibold">Проблемы health-check</h3>
|
||||
{attentionCount > 0 ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
({attentionCount})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{attentionServices.length === 0 ? (
|
||||
<div className="flex min-h-24 items-center justify-center py-4">
|
||||
<EmptyState
|
||||
title="Нет проблем"
|
||||
description="Нет сервисов со статусом Down или Slow"
|
||||
centered={false}
|
||||
stackedIcon={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ItemGroup className="gap-2">
|
||||
{attentionServices.map((service) => (
|
||||
<Item key={service.id} variant="outline" size="sm">
|
||||
<ItemContent className="flex flex-row items-center justify-between gap-2">
|
||||
<ItemTitle className="truncate font-medium">
|
||||
<Link
|
||||
to="/services"
|
||||
className="hover:underline"
|
||||
>
|
||||
<AttentionQueue
|
||||
columns={[
|
||||
{
|
||||
id: 'health',
|
||||
title: 'Проблемы health-check',
|
||||
icon: ActivityIcon,
|
||||
iconClassName: 'text-destructive [&_svg]:text-current',
|
||||
count: attentionCount,
|
||||
countVariant: 'destructive-light',
|
||||
emptyTitle: 'Нет проблем',
|
||||
emptyDescription: 'Нет сервисов со статусом Down или Slow',
|
||||
emptyAction: (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link to="/services" />}
|
||||
>
|
||||
К сервисам
|
||||
</Button>
|
||||
),
|
||||
children: (
|
||||
<ItemGroup className="gap-2">
|
||||
{attentionServices.map((service) => (
|
||||
<Item key={service.id} variant="outline" size="sm">
|
||||
<ItemContent className="min-w-0 gap-1">
|
||||
<ItemTitle className="truncate font-medium">
|
||||
{service.name}
|
||||
</Link>
|
||||
</ItemTitle>
|
||||
<HealthCheckBadge
|
||||
status={
|
||||
(service.health_status as
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'degraded'
|
||||
| 'unknown') ?? 'unknown'
|
||||
}
|
||||
latencyMs={service.health_latency_ms}
|
||||
size="xs"
|
||||
/>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangleIcon
|
||||
className="text-warning size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 className="text-sm font-semibold">Истекающие сертификаты</h3>
|
||||
{expiringCerts.length > 0 ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
({expiringCerts.length})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{expiringCerts.length === 0 ? (
|
||||
<div className="flex min-h-24 items-center justify-center py-4">
|
||||
<EmptyState
|
||||
title="Нет истечений"
|
||||
description="В пределах 14 дней истечений нет"
|
||||
centered={false}
|
||||
stackedIcon={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ItemGroup className="gap-2">
|
||||
{expiringCerts.map(({ cert, ts }) => (
|
||||
<Item key={cert.id} variant="outline" size="sm">
|
||||
<ItemContent className="flex flex-row items-center justify-between gap-2">
|
||||
<ItemTitle className="truncate font-medium">{cert.hostname}</ItemTitle>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<StatusBadge status={cert.status} />
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<HealthCheckBadge
|
||||
status={
|
||||
(service.health_status as
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'degraded'
|
||||
| 'unknown') ?? 'unknown'
|
||||
}
|
||||
latencyMs={service.health_latency_ms}
|
||||
size="xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/services"
|
||||
search={{ serviceId: service.id }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'certs',
|
||||
title: 'Истекающие сертификаты',
|
||||
icon: AlertTriangleIcon,
|
||||
iconClassName: 'text-warning [&_svg]:text-current',
|
||||
count: expiringCerts.length,
|
||||
countVariant: 'warning-light',
|
||||
emptyTitle: 'Нет истечений',
|
||||
emptyDescription: 'В пределах 14 дней истечений нет',
|
||||
emptyAction: (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link to="/certificates" />}
|
||||
>
|
||||
К сертификатам
|
||||
</Button>
|
||||
),
|
||||
children: (
|
||||
<ItemGroup className="gap-2">
|
||||
{expiringCerts.map(({ cert, ts }) => (
|
||||
<Item key={cert.id} variant="outline" size="sm">
|
||||
<ItemContent className="min-w-0 gap-1">
|
||||
<ItemTitle className="truncate font-medium">
|
||||
{cert.hostname}
|
||||
</ItemTitle>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatRelative(new Date(ts).toISOString())}
|
||||
</span>
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderTreeIcon
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 className="text-sm font-semibold">Домены без группы</h3>
|
||||
{ungroupedCount > 0 ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
({ungroupedCount})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{ungroupedDomains.length === 0 ? (
|
||||
<div className="flex min-h-24 items-center justify-center py-4">
|
||||
<EmptyState
|
||||
title="Всё сгруппировано"
|
||||
description="Все домены в группах"
|
||||
centered={false}
|
||||
stackedIcon={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ItemGroup className="gap-2">
|
||||
{ungroupedDomains.map((domain) => (
|
||||
<Item key={domain.id} variant="outline" size="sm">
|
||||
<ItemContent className="flex flex-row items-center justify-between gap-2">
|
||||
<ItemTitle className="truncate font-medium">
|
||||
{domain.zone_name}
|
||||
</ItemTitle>
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(domain.id) }}
|
||||
className="text-primary text-xs font-medium hover:underline"
|
||||
>
|
||||
Открыть
|
||||
</Link>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<StatusBadge status={cert.status} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link to="/certificates" />}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ungrouped',
|
||||
title: 'Домены без группы',
|
||||
icon: FolderTreeIcon,
|
||||
iconClassName: 'text-info [&_svg]:text-current',
|
||||
count: ungroupedCount,
|
||||
countVariant: 'warning-light',
|
||||
emptyTitle: 'Всё сгруппировано',
|
||||
emptyDescription: 'Все домены в группах',
|
||||
emptyAction: (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link to="/domains" />}
|
||||
>
|
||||
К доменам
|
||||
</Button>
|
||||
),
|
||||
children: (
|
||||
<ItemGroup className="gap-2">
|
||||
{ungroupedDomains.map((domain) => (
|
||||
<Item key={domain.id} variant="outline" size="sm">
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="truncate font-medium">
|
||||
{domain.zone_name}
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(domain.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageShell>
|
||||
|
||||
@@ -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 { 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 { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { HealthProviderToggle } from '@/components/health-check-config-fields'
|
||||
import {
|
||||
createOriginHealthCheck,
|
||||
listOriginHealthChecks,
|
||||
serviceOverviewQueryOptions,
|
||||
} from '@/queries'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
|
||||
component: ServiceHealthPage,
|
||||
})
|
||||
|
||||
export function ServiceHealthPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const id = Number(serviceId)
|
||||
const queryClient = useQueryClient()
|
||||
const overview = useQuery(serviceOverviewQueryOptions(id))
|
||||
const checksQuery = useQuery({
|
||||
queryKey: ['health-checks'],
|
||||
queryFn: listOriginHealthChecks,
|
||||
})
|
||||
const [open, setOpen] = useState(false)
|
||||
const form = useForm<{
|
||||
name: string
|
||||
provider: 'local' | 'cloudflare'
|
||||
protocol: string
|
||||
}>({
|
||||
defaultValues: { name: '', provider: 'local', protocol: 'tcp' },
|
||||
})
|
||||
const provider = form.watch('provider')
|
||||
const checks = (checksQuery.data ?? []) as Array<{
|
||||
id: number
|
||||
name: string
|
||||
provider: string
|
||||
protocol: string
|
||||
}>
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (values: { name: string; provider: 'local' | 'cloudflare'; protocol: string }) =>
|
||||
createOriginHealthCheck({
|
||||
name: values.name,
|
||||
provider: values.provider,
|
||||
protocol: values.protocol,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
toast.success('Health check сохранён')
|
||||
await queryClient.invalidateQueries({ queryKey: ['health-checks'] })
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (e: unknown) =>
|
||||
toast.error(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: 'Cloudflare Health Checks недоступны для этой зоны',
|
||||
),
|
||||
})
|
||||
|
||||
void overview
|
||||
|
||||
return (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title="Health checks"
|
||||
description="Local TCP/HTTP или официальный Cloudflare Health Checks API."
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
Добавить проверку
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{checks.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Нет проверок"
|
||||
description="Локальные пробы уже работают на привязках. Cloudflare Health Checks — опционально."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{checks.map((check) => (
|
||||
<div
|
||||
key={check.id}
|
||||
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">{check.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{check.provider} · {check.protocol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Health check"
|
||||
description="Поля Cloudflare соответствуют официальному API (address, type, interval, timeout, retries)."
|
||||
form={form}
|
||||
onSubmit={(values) => createMut.mutate(values)}
|
||||
footer={
|
||||
<LoadingButton type="submit" isLoading={createMut.isPending}>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FormFieldSimple label="Имя" htmlFor="hc-name">
|
||||
<Input id="hc-name" {...form.register('name')} />
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Провайдер" htmlFor="hc-provider">
|
||||
<HealthProviderToggle
|
||||
id="hc-provider"
|
||||
value={provider}
|
||||
onChange={(next) => form.setValue('provider', next)}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
{provider === 'cloudflare' ? (
|
||||
<Alert>
|
||||
<AlertTitle>Cloudflare Health Checks</AlertTitle>
|
||||
<AlertDescription>
|
||||
Если зона не поддерживает Health Checks, вернётся ошибка плана — останется
|
||||
Local. Workers не используются.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<FormFieldSimple label="Протокол" htmlFor="hc-protocol">
|
||||
<Input id="hc-protocol" {...form.register('protocol')} placeholder="tcp" />
|
||||
</FormFieldSimple>
|
||||
</FormSheet>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -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' } => ({
|
||||
@@ -494,7 +494,7 @@ function ServicesPage() {
|
||||
|
||||
const pageDescription = filteredDomain
|
||||
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
|
||||
: 'Группы, FQDN и доступность сервисов'
|
||||
: 'Группы, общий домен и FQDN сервисов'
|
||||
|
||||
const sheets = (
|
||||
<>
|
||||
+9
-3
@@ -23,6 +23,7 @@ Manage Cloudflare zones, DNS records, domain groups, and TLS certificate expiry
|
||||
| `HEALTH_CHECK_CRON` | Cron для health-check (default `*/30 * * * * *`) |
|
||||
| `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`) |
|
||||
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`) |
|
||||
| `HEALTH_SUCCESS_RECOVERIES` | Успехов подряд для recovery `CHECKING → HEALTHY` (default `2`) |
|
||||
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`) |
|
||||
|
||||
## Load balancing & health checks
|
||||
@@ -37,11 +38,16 @@ 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). Reconcile DNS запускается cron-задачей
|
||||
`health-check`. Cloudflare Health Checks — официальный API зоны, Workers не используются.
|
||||
|
||||
## 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`** для clone/push `*.wiki.git`.
|
||||
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
+1568
-4
File diff suppressed because one or more lines are too long
Vendored
+308
-5
@@ -117,6 +117,8 @@ var serviceBindings = sqliteTable(
|
||||
health_check_verify_tls: integer("health_check_verify_tls", {
|
||||
mode: "boolean"
|
||||
}).notNull().default(false),
|
||||
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
|
||||
operation_version: integer("operation_version").notNull().default(0),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||
},
|
||||
@@ -128,6 +130,59 @@ var serviceBindings = sqliteTable(
|
||||
)
|
||||
]
|
||||
);
|
||||
var healthChecks = sqliteTable("health_checks", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
provider: text("provider").notNull().default("local"),
|
||||
cf_healthcheck_id: text("cf_healthcheck_id"),
|
||||
cf_zone_id: text("cf_zone_id"),
|
||||
name: text("name").notNull(),
|
||||
protocol: text("protocol").notNull().default("tcp"),
|
||||
path: text("path"),
|
||||
method: text("method"),
|
||||
timeout: integer("timeout").notNull().default(5),
|
||||
interval_sec: integer("interval_sec").notNull().default(30),
|
||||
retries: integer("retries").notNull().default(2),
|
||||
expected_status: integer("expected_status"),
|
||||
consecutive_fails: integer("consecutive_fails").notNull().default(2),
|
||||
consecutive_successes: integer("consecutive_successes").notNull().default(2),
|
||||
suspended: integer("suspended", { mode: "boolean" }).notNull().default(false),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||
});
|
||||
var nodes = sqliteTable(
|
||||
"nodes",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
|
||||
address: text("address").notNull(),
|
||||
protocol: text("protocol").notNull().default("tcp"),
|
||||
port: integer("port"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
priority: integer("priority").notNull().default(1),
|
||||
weight: integer("weight").notNull().default(1),
|
||||
health_status: text("health_status").notNull().default("unknown"),
|
||||
health_check_id: integer("health_check_id").references(() => healthChecks.id, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
consecutive_failures: integer("consecutive_failures").notNull().default(0),
|
||||
consecutive_successes: integer("consecutive_successes").notNull().default(0),
|
||||
last_check_at: text("last_check_at"),
|
||||
last_failure_reason: text("last_failure_reason"),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||
},
|
||||
(t) => [unique("nodes_service_address").on(t.service_id, t.address)]
|
||||
);
|
||||
var bindingNodes = sqliteTable(
|
||||
"binding_nodes",
|
||||
{
|
||||
binding_id: integer("binding_id").notNull().references(() => serviceBindings.id, { onDelete: "cascade" }),
|
||||
node_id: integer("node_id").notNull().references(() => nodes.id, { onDelete: "cascade" }),
|
||||
weight: integer("weight").notNull().default(1),
|
||||
priority: integer("priority").notNull().default(1)
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.binding_id, t.node_id] })]
|
||||
);
|
||||
var serviceIps = sqliteTable("service_ips", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
|
||||
@@ -193,6 +248,7 @@ var ipHealthStatus = sqliteTable(
|
||||
status: text("status").notNull().default("unknown"),
|
||||
latency_ms: integer("latency_ms"),
|
||||
consecutive_failures: integer("consecutive_failures").notNull().default(0),
|
||||
consecutive_successes: integer("consecutive_successes").notNull().default(0),
|
||||
last_checked_at: text("last_checked_at"),
|
||||
last_error: text("last_error"),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||
@@ -282,6 +338,9 @@ var schema = {
|
||||
subdomains,
|
||||
dnsRecords,
|
||||
serviceBindings,
|
||||
healthChecks,
|
||||
nodes,
|
||||
bindingNodes,
|
||||
serviceIps,
|
||||
serviceBindingRecords,
|
||||
serviceBindingIps,
|
||||
@@ -495,10 +554,13 @@ __export(repos_exports, {
|
||||
aggregateIpHealthByRefs: () => aggregateIpHealthByRefs,
|
||||
aggregateIpHealthByServiceIds: () => aggregateIpHealthByServiceIds,
|
||||
bindingsToRemove: () => bindingsToRemove,
|
||||
bumpBindingVersion: () => bumpBindingVersion,
|
||||
countCertificatesByStatus: () => countCertificatesByStatus,
|
||||
createDomain: () => createDomain,
|
||||
createDomainMonitor: () => createDomainMonitor,
|
||||
createGroup: () => createGroup,
|
||||
createHealthCheck: () => createHealthCheck,
|
||||
createNode: () => createNode,
|
||||
createService: () => createService,
|
||||
createServiceGroup: () => createServiceGroup,
|
||||
createSubdomain: () => createSubdomain,
|
||||
@@ -510,14 +572,20 @@ __export(repos_exports, {
|
||||
deleteDomain: () => deleteDomain,
|
||||
deleteDomainMonitor: () => deleteDomainMonitor,
|
||||
deleteGroup: () => deleteGroup,
|
||||
deleteHealthCheck: () => deleteHealthCheck,
|
||||
deleteIpHealthStatusForIp: () => deleteIpHealthStatusForIp,
|
||||
deleteIpHealthStatusForRef: () => deleteIpHealthStatusForRef,
|
||||
deleteNode: () => deleteNode,
|
||||
deleteService: () => deleteService,
|
||||
deleteServiceGroup: () => deleteServiceGroup,
|
||||
deleteSubdomain: () => deleteSubdomain,
|
||||
ensureNode: () => ensureNode,
|
||||
findBinding: () => findBinding,
|
||||
findDnsByCfId: () => findDnsByCfId,
|
||||
findDomainByZoneName: () => findDomainByZoneName,
|
||||
findHealthCheckByCfId: () => findHealthCheckByCfId,
|
||||
findNodeByAddress: () => findNodeByAddress,
|
||||
findNodeByIp: () => findNodeByIp,
|
||||
findSubdomainByDomainAndName: () => findSubdomainByDomainAndName,
|
||||
finishSyncJob: () => finishSyncJob,
|
||||
getBinding: () => getBinding,
|
||||
@@ -528,7 +596,9 @@ __export(repos_exports, {
|
||||
getDomainMonitor: () => getDomainMonitor,
|
||||
getGroup: () => getGroup,
|
||||
getGroupWithStats: () => getGroupWithStats,
|
||||
getHealthCheck: () => getHealthCheck,
|
||||
getIpHealthStatusRow: () => getIpHealthStatusRow,
|
||||
getNode: () => getNode,
|
||||
getService: () => getService,
|
||||
getServiceGroup: () => getServiceGroup,
|
||||
getSubdomain: () => getSubdomain,
|
||||
@@ -540,9 +610,11 @@ __export(repos_exports, {
|
||||
linkGroupDnsRecord: () => linkGroupDnsRecord,
|
||||
listAllBindings: () => listAllBindings,
|
||||
listAllDomains: () => listAllDomains,
|
||||
listAllNodes: () => listAllNodes,
|
||||
listAllSubdomains: () => listAllSubdomains,
|
||||
listBindingIps: () => listBindingIps,
|
||||
listBindingIpsWithMeta: () => listBindingIpsWithMeta,
|
||||
listBindingNodes: () => listBindingNodes,
|
||||
listBindingsByDomain: () => listBindingsByDomain,
|
||||
listBindingsByService: () => listBindingsByService,
|
||||
listCertificates: () => listCertificates,
|
||||
@@ -558,7 +630,9 @@ __export(repos_exports, {
|
||||
listGroupDnsRecords: () => listGroupDnsRecords,
|
||||
listGroups: () => listGroups,
|
||||
listHealthCheckTargets: () => listHealthCheckTargets,
|
||||
listHealthChecks: () => listHealthChecks,
|
||||
listIpHealthStatus: () => listIpHealthStatus,
|
||||
listNodes: () => listNodes,
|
||||
listNotificationLog: () => listNotificationLog,
|
||||
listOriginIpsForFqdn: () => listOriginIpsForFqdn,
|
||||
listRecordsForBinding: () => listRecordsForBinding,
|
||||
@@ -577,6 +651,7 @@ __export(repos_exports, {
|
||||
replaceServiceIps: () => replaceServiceIps,
|
||||
setBindingCnameTarget: () => setBindingCnameTarget,
|
||||
setBindingDnsRecordId: () => setBindingDnsRecordId,
|
||||
setBindingRoutingStrategy: () => setBindingRoutingStrategy,
|
||||
setDnsSyncStatus: () => setDnsSyncStatus,
|
||||
setDomainLastSynced: () => setDomainLastSynced,
|
||||
setDomainTags: () => setDomainTags,
|
||||
@@ -586,12 +661,15 @@ __export(repos_exports, {
|
||||
setServiceLb: () => setServiceLb,
|
||||
unlinkBindingRecord: () => unlinkBindingRecord,
|
||||
unlinkGroupDnsRecord: () => unlinkGroupDnsRecord,
|
||||
updateBindingDomain: () => updateBindingDomain,
|
||||
updateBindingFields: () => updateBindingFields,
|
||||
updateBindingLbConfig: () => updateBindingLbConfig,
|
||||
updateDnsFields: () => updateDnsFields,
|
||||
updateDomain: () => updateDomain,
|
||||
updateDomainMonitorResult: () => updateDomainMonitorResult,
|
||||
updateGroup: () => updateGroup,
|
||||
updateHealthCheck: () => updateHealthCheck,
|
||||
updateNode: () => updateNode,
|
||||
updateService: () => updateService,
|
||||
updateServiceGroup: () => updateServiceGroup,
|
||||
updateSubdomain: () => updateSubdomain,
|
||||
@@ -1073,6 +1151,12 @@ function deleteServiceGroup(db, id) {
|
||||
const result = db.delete(serviceGroups).where(eq3(serviceGroups.id, id)).run();
|
||||
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
|
||||
}
|
||||
function insertServiceIpIfMissing(db, serviceId, ip) {
|
||||
const existing = db.select({ ip: serviceIps.ip }).from(serviceIps).where(and2(eq3(serviceIps.service_id, serviceId), eq3(serviceIps.ip, ip))).get();
|
||||
if (!existing) {
|
||||
db.insert(serviceIps).values({ service_id: serviceId, ip }).run();
|
||||
}
|
||||
}
|
||||
function listServiceIps(db, serviceId) {
|
||||
return db.select({ ip: serviceIps.ip }).from(serviceIps).where(eq3(serviceIps.service_id, serviceId)).all().map((r) => r.ip);
|
||||
}
|
||||
@@ -1080,7 +1164,207 @@ function replaceServiceIps(db, serviceId, ips) {
|
||||
db.delete(serviceIps).where(eq3(serviceIps.service_id, serviceId)).run();
|
||||
for (const ip of ips) {
|
||||
db.insert(serviceIps).values({ service_id: serviceId, ip }).run();
|
||||
ensureNode(db, serviceId, ip);
|
||||
}
|
||||
const keep = new Set(ips);
|
||||
for (const node of listNodes(db, serviceId)) {
|
||||
if (keep.has(node.address)) continue;
|
||||
const bound = db.select({ node_id: bindingNodes.node_id }).from(bindingNodes).where(eq3(bindingNodes.node_id, node.id)).get();
|
||||
if (!bound) deleteNode(db, node.id);
|
||||
}
|
||||
}
|
||||
function mapNode(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
service_id: row.service_id,
|
||||
address: row.address,
|
||||
protocol: row.protocol,
|
||||
port: row.port,
|
||||
enabled: Boolean(row.enabled),
|
||||
priority: row.priority,
|
||||
weight: row.weight,
|
||||
health_status: row.health_status,
|
||||
health_check_id: row.health_check_id,
|
||||
consecutive_failures: row.consecutive_failures,
|
||||
consecutive_successes: row.consecutive_successes,
|
||||
last_check_at: row.last_check_at,
|
||||
last_failure_reason: row.last_failure_reason,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
function listNodes(db, serviceId) {
|
||||
return db.select().from(nodes).where(eq3(nodes.service_id, serviceId)).all().map(mapNode);
|
||||
}
|
||||
function getNode(db, id) {
|
||||
const row = db.select().from(nodes).where(eq3(nodes.id, id)).get();
|
||||
if (!row) throw new NotFoundError(`node ${id}`);
|
||||
return mapNode(row);
|
||||
}
|
||||
function findNodeByAddress(db, serviceId, address) {
|
||||
const row = db.select().from(nodes).where(and2(eq3(nodes.service_id, serviceId), eq3(nodes.address, address))).get();
|
||||
return row ? mapNode(row) : null;
|
||||
}
|
||||
function findNodeByIp(db, address) {
|
||||
const row = db.select().from(nodes).where(eq3(nodes.address, address)).get();
|
||||
return row ? mapNode(row) : null;
|
||||
}
|
||||
function ensureNode(db, serviceId, address, meta) {
|
||||
const existing = findNodeByAddress(db, serviceId, address);
|
||||
if (existing) return existing;
|
||||
const id = db.insert(nodes).values({
|
||||
service_id: serviceId,
|
||||
address,
|
||||
protocol: meta?.protocol ?? "tcp",
|
||||
port: meta?.port ?? null,
|
||||
weight: meta?.weight ?? 1,
|
||||
priority: meta?.priority ?? 1
|
||||
}).returning({ id: nodes.id }).get().id;
|
||||
return getNode(db, id);
|
||||
}
|
||||
function createNode(db, serviceId, input) {
|
||||
getService(db, serviceId);
|
||||
const existing = findNodeByAddress(db, serviceId, input.address);
|
||||
if (existing) {
|
||||
throw new ConflictError(`node ${input.address} already exists`);
|
||||
}
|
||||
const id = db.insert(nodes).values({
|
||||
service_id: serviceId,
|
||||
address: input.address,
|
||||
protocol: input.protocol ?? "tcp",
|
||||
port: input.port ?? null,
|
||||
enabled: input.enabled ?? true,
|
||||
priority: input.priority ?? 1,
|
||||
weight: input.weight ?? 1,
|
||||
health_check_id: input.health_check_id ?? null
|
||||
}).returning({ id: nodes.id }).get().id;
|
||||
insertServiceIpIfMissing(db, serviceId, input.address);
|
||||
return getNode(db, id);
|
||||
}
|
||||
function updateNode(db, id, patch) {
|
||||
const current = getNode(db, id);
|
||||
const update = { updated_at: sql2`datetime('now')` };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value !== void 0) update[key] = value;
|
||||
}
|
||||
db.update(nodes).set(update).where(eq3(nodes.id, id)).run();
|
||||
if (patch.address && patch.address !== current.address) {
|
||||
db.delete(serviceIps).where(
|
||||
and2(
|
||||
eq3(serviceIps.service_id, current.service_id),
|
||||
eq3(serviceIps.ip, current.address)
|
||||
)
|
||||
).run();
|
||||
insertServiceIpIfMissing(db, current.service_id, patch.address);
|
||||
}
|
||||
return getNode(db, id);
|
||||
}
|
||||
function deleteNode(db, id) {
|
||||
const current = getNode(db, id);
|
||||
db.delete(nodes).where(eq3(nodes.id, id)).run();
|
||||
db.delete(serviceIps).where(
|
||||
and2(
|
||||
eq3(serviceIps.service_id, current.service_id),
|
||||
eq3(serviceIps.ip, current.address)
|
||||
)
|
||||
).run();
|
||||
}
|
||||
function mapHealthCheck(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
cf_healthcheck_id: row.cf_healthcheck_id,
|
||||
cf_zone_id: row.cf_zone_id,
|
||||
name: row.name,
|
||||
protocol: row.protocol,
|
||||
path: row.path,
|
||||
method: row.method,
|
||||
timeout: row.timeout,
|
||||
interval_sec: row.interval_sec,
|
||||
retries: row.retries,
|
||||
expected_status: row.expected_status,
|
||||
consecutive_fails: row.consecutive_fails,
|
||||
consecutive_successes: row.consecutive_successes,
|
||||
suspended: Boolean(row.suspended),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
function listHealthChecks(db) {
|
||||
return db.select().from(healthChecks).all().map(mapHealthCheck);
|
||||
}
|
||||
function getHealthCheck(db, id) {
|
||||
const row = db.select().from(healthChecks).where(eq3(healthChecks.id, id)).get();
|
||||
if (!row) throw new NotFoundError(`health check ${id}`);
|
||||
return mapHealthCheck(row);
|
||||
}
|
||||
function findHealthCheckByCfId(db, cfId) {
|
||||
const row = db.select().from(healthChecks).where(eq3(healthChecks.cf_healthcheck_id, cfId)).get();
|
||||
return row ? mapHealthCheck(row) : null;
|
||||
}
|
||||
function createHealthCheck(db, input) {
|
||||
const id = db.insert(healthChecks).values({
|
||||
provider: input.provider,
|
||||
name: input.name,
|
||||
cf_healthcheck_id: input.cf_healthcheck_id ?? null,
|
||||
cf_zone_id: input.cf_zone_id ?? null,
|
||||
protocol: input.protocol ?? "tcp",
|
||||
path: input.path ?? null,
|
||||
method: input.method ?? null,
|
||||
timeout: input.timeout ?? 5,
|
||||
interval_sec: input.interval_sec ?? 30,
|
||||
retries: input.retries ?? 2,
|
||||
expected_status: input.expected_status ?? null,
|
||||
consecutive_fails: input.consecutive_fails ?? 2,
|
||||
consecutive_successes: input.consecutive_successes ?? 2,
|
||||
suspended: input.suspended ?? false
|
||||
}).returning({ id: healthChecks.id }).get().id;
|
||||
return getHealthCheck(db, id);
|
||||
}
|
||||
function updateHealthCheck(db, id, patch) {
|
||||
getHealthCheck(db, id);
|
||||
const update = { updated_at: sql2`datetime('now')` };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value !== void 0) update[key] = value;
|
||||
}
|
||||
db.update(healthChecks).set(update).where(eq3(healthChecks.id, id)).run();
|
||||
return getHealthCheck(db, id);
|
||||
}
|
||||
function deleteHealthCheck(db, id) {
|
||||
const result = db.delete(healthChecks).where(eq3(healthChecks.id, id)).run();
|
||||
if (result.changes === 0) throw new NotFoundError(`health check ${id}`);
|
||||
}
|
||||
function bumpBindingVersion(db, bindingId, expected) {
|
||||
const binding = getBinding(db, bindingId);
|
||||
if (expected != null && binding.operation_version !== expected) {
|
||||
throw new ConflictError(`binding ${bindingId} version conflict`);
|
||||
}
|
||||
const next = (binding.operation_version ?? 0) + 1;
|
||||
db.update(serviceBindings).set({
|
||||
operation_version: next,
|
||||
updated_at: sql2`datetime('now')`
|
||||
}).where(eq3(serviceBindings.id, bindingId)).run();
|
||||
return next;
|
||||
}
|
||||
function setBindingRoutingStrategy(db, bindingId, strategy) {
|
||||
db.update(serviceBindings).set({
|
||||
routing_strategy: strategy,
|
||||
lb_mode: strategy,
|
||||
updated_at: sql2`datetime('now')`
|
||||
}).where(eq3(serviceBindings.id, bindingId)).run();
|
||||
}
|
||||
function listAllNodes(db) {
|
||||
return db.select().from(nodes).all().map(mapNode);
|
||||
}
|
||||
function updateBindingDomain(db, bindingId, domainId, hostname) {
|
||||
db.update(serviceBindings).set({
|
||||
domain_id: domainId,
|
||||
hostname,
|
||||
updated_at: sql2`datetime('now')`
|
||||
}).where(eq3(serviceBindings.id, bindingId)).run();
|
||||
}
|
||||
function listBindingNodes(db, bindingId) {
|
||||
return db.select({ node: nodes }).from(bindingNodes).innerJoin(nodes, eq3(bindingNodes.node_id, nodes.id)).where(eq3(bindingNodes.binding_id, bindingId)).all().map((row) => mapNode(row.node));
|
||||
}
|
||||
function listBindingIps(db, bindingId) {
|
||||
return db.select({ ip: serviceBindingIps.ip }).from(serviceBindingIps).where(eq3(serviceBindingIps.binding_id, bindingId)).all().map((r) => r.ip);
|
||||
@@ -1100,7 +1384,9 @@ function replaceBindingIps(db, bindingId, ips) {
|
||||
);
|
||||
}
|
||||
function replaceBindingIpsWithMeta(db, bindingId, entries) {
|
||||
const binding = getBinding(db, bindingId);
|
||||
db.delete(serviceBindingIps).where(eq3(serviceBindingIps.binding_id, bindingId)).run();
|
||||
db.delete(bindingNodes).where(eq3(bindingNodes.binding_id, bindingId)).run();
|
||||
for (const entry of entries) {
|
||||
db.insert(serviceBindingIps).values({
|
||||
binding_id: bindingId,
|
||||
@@ -1108,13 +1394,26 @@ function replaceBindingIpsWithMeta(db, bindingId, entries) {
|
||||
weight: entry.weight,
|
||||
priority: entry.priority
|
||||
}).run();
|
||||
const node = ensureNode(db, binding.service_id, entry.ip, {
|
||||
weight: entry.weight,
|
||||
priority: entry.priority
|
||||
});
|
||||
db.insert(bindingNodes).values({
|
||||
binding_id: bindingId,
|
||||
node_id: node.id,
|
||||
weight: entry.weight,
|
||||
priority: entry.priority
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
function updateBindingLbConfig(db, bindingId, patch) {
|
||||
const update = {
|
||||
updated_at: sql2`datetime('now')`
|
||||
};
|
||||
if (patch.lb_mode !== void 0) update.lb_mode = patch.lb_mode;
|
||||
if (patch.lb_mode !== void 0) {
|
||||
update.lb_mode = patch.lb_mode;
|
||||
update.routing_strategy = patch.lb_mode;
|
||||
}
|
||||
if (patch.health_check_enabled !== void 0)
|
||||
update.health_check_enabled = patch.health_check_enabled;
|
||||
if (patch.health_check_type !== void 0)
|
||||
@@ -1552,24 +1851,25 @@ function mergeHealthAggregates(parts) {
|
||||
function getIpHealthStatusRow(db, scope, refId, ip) {
|
||||
const rows = db.all(sql2`
|
||||
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
|
||||
last_checked_at, last_error
|
||||
consecutive_successes, last_checked_at, last_error
|
||||
FROM ip_health_status
|
||||
WHERE scope = ${scope} AND ref_id = ${refId} AND ip = ${ip}
|
||||
LIMIT 1
|
||||
`);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
function upsertIpHealthStatus(db, scope, refId, ip, status, latencyMs, consecutiveFailures, lastError) {
|
||||
function upsertIpHealthStatus(db, scope, refId, ip, status, latencyMs, consecutiveFailures, lastError, consecutiveSuccesses = 0) {
|
||||
db.run(sql2`
|
||||
INSERT INTO ip_health_status
|
||||
(scope, ref_id, ip, status, latency_ms, consecutive_failures,
|
||||
last_checked_at, last_error, created_at, updated_at)
|
||||
consecutive_successes, last_checked_at, last_error, created_at, updated_at)
|
||||
VALUES (${scope}, ${refId}, ${ip}, ${status}, ${latencyMs}, ${consecutiveFailures},
|
||||
datetime('now'), ${lastError}, datetime('now'), datetime('now'))
|
||||
${consecutiveSuccesses}, datetime('now'), ${lastError}, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(scope, ref_id, ip) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
latency_ms = excluded.latency_ms,
|
||||
consecutive_failures = excluded.consecutive_failures,
|
||||
consecutive_successes = excluded.consecutive_successes,
|
||||
last_checked_at = excluded.last_checked_at,
|
||||
last_error = excluded.last_error,
|
||||
updated_at = datetime('now')
|
||||
@@ -1844,6 +2144,7 @@ export {
|
||||
appSettings,
|
||||
appendAudit,
|
||||
auditLog,
|
||||
bindingNodes,
|
||||
certificates,
|
||||
createDb,
|
||||
createMemoryDb,
|
||||
@@ -1856,8 +2157,10 @@ export {
|
||||
getAppSettingsSecrets,
|
||||
groups,
|
||||
healthCheck,
|
||||
healthChecks,
|
||||
ipHealthStatus,
|
||||
listAudit,
|
||||
nodes,
|
||||
notificationLog,
|
||||
repos_exports as repos,
|
||||
resolveDatabasePath,
|
||||
|
||||
@@ -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';
|
||||
+398
-5
@@ -11,17 +11,19 @@ import type {
|
||||
IpHealthState,
|
||||
IpHealthStatus,
|
||||
LbMode,
|
||||
OriginHealthCheck,
|
||||
Service,
|
||||
ServiceBinding,
|
||||
ServiceBindingView,
|
||||
ServiceGroup,
|
||||
ServiceNode,
|
||||
Subdomain,
|
||||
SyncJob,
|
||||
} from "@cfdm/shared";
|
||||
import { dnsRecordNamesMatch, isIpLiteral } from "@cfdm/shared";
|
||||
import { and, asc, count, eq, isNull, like, notInArray, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "./client.js";
|
||||
import { NotFoundError } from "./errors.js";
|
||||
import { ConflictError, NotFoundError } from "./errors.js";
|
||||
import {
|
||||
certificates,
|
||||
dnsRecords,
|
||||
@@ -30,7 +32,10 @@ import {
|
||||
domainTags,
|
||||
domains,
|
||||
groups,
|
||||
healthChecks,
|
||||
ipHealthStatus,
|
||||
nodes,
|
||||
bindingNodes,
|
||||
notificationLog,
|
||||
serviceBindingIps,
|
||||
serviceBindingRecords,
|
||||
@@ -911,6 +916,17 @@ export function deleteServiceGroup(db: Db, id: number): void {
|
||||
|
||||
// --- Service IPs ---
|
||||
|
||||
function insertServiceIpIfMissing(db: Db, serviceId: number, ip: string): void {
|
||||
const existing = db
|
||||
.select({ ip: serviceIps.ip })
|
||||
.from(serviceIps)
|
||||
.where(and(eq(serviceIps.service_id, serviceId), eq(serviceIps.ip, ip)))
|
||||
.get();
|
||||
if (!existing) {
|
||||
db.insert(serviceIps).values({ service_id: serviceId, ip }).run();
|
||||
}
|
||||
}
|
||||
|
||||
export function listServiceIps(db: Db, serviceId: number): string[] {
|
||||
return db
|
||||
.select({ ip: serviceIps.ip })
|
||||
@@ -928,7 +944,363 @@ export function replaceServiceIps(
|
||||
db.delete(serviceIps).where(eq(serviceIps.service_id, serviceId)).run();
|
||||
for (const ip of ips) {
|
||||
db.insert(serviceIps).values({ service_id: serviceId, ip }).run();
|
||||
ensureNode(db, serviceId, ip);
|
||||
}
|
||||
const keep = new Set(ips);
|
||||
for (const node of listNodes(db, serviceId)) {
|
||||
if (keep.has(node.address)) continue;
|
||||
const bound = db
|
||||
.select({ node_id: bindingNodes.node_id })
|
||||
.from(bindingNodes)
|
||||
.where(eq(bindingNodes.node_id, node.id))
|
||||
.get();
|
||||
if (!bound) deleteNode(db, node.id);
|
||||
}
|
||||
}
|
||||
|
||||
function mapNode(row: typeof nodes.$inferSelect): ServiceNode {
|
||||
return {
|
||||
id: row.id,
|
||||
service_id: row.service_id,
|
||||
address: row.address,
|
||||
protocol: row.protocol,
|
||||
port: row.port,
|
||||
enabled: Boolean(row.enabled),
|
||||
priority: row.priority,
|
||||
weight: row.weight,
|
||||
health_status: row.health_status as ServiceNode["health_status"],
|
||||
health_check_id: row.health_check_id,
|
||||
consecutive_failures: row.consecutive_failures,
|
||||
consecutive_successes: row.consecutive_successes,
|
||||
last_check_at: row.last_check_at,
|
||||
last_failure_reason: row.last_failure_reason,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function listNodes(db: Db, serviceId: number): ServiceNode[] {
|
||||
return db
|
||||
.select()
|
||||
.from(nodes)
|
||||
.where(eq(nodes.service_id, serviceId))
|
||||
.all()
|
||||
.map(mapNode);
|
||||
}
|
||||
|
||||
export function getNode(db: Db, id: number): ServiceNode {
|
||||
const row = db.select().from(nodes).where(eq(nodes.id, id)).get();
|
||||
if (!row) throw new NotFoundError(`node ${id}`);
|
||||
return mapNode(row);
|
||||
}
|
||||
|
||||
export function findNodeByAddress(
|
||||
db: Db,
|
||||
serviceId: number,
|
||||
address: string,
|
||||
): ServiceNode | null {
|
||||
const row = db
|
||||
.select()
|
||||
.from(nodes)
|
||||
.where(and(eq(nodes.service_id, serviceId), eq(nodes.address, address)))
|
||||
.get();
|
||||
return row ? mapNode(row) : null;
|
||||
}
|
||||
|
||||
export function findNodeByIp(db: Db, address: string): ServiceNode | null {
|
||||
const row = db.select().from(nodes).where(eq(nodes.address, address)).get();
|
||||
return row ? mapNode(row) : null;
|
||||
}
|
||||
|
||||
export function ensureNode(
|
||||
db: Db,
|
||||
serviceId: number,
|
||||
address: string,
|
||||
meta?: { weight?: number; priority?: number; protocol?: string; port?: number | null },
|
||||
): ServiceNode {
|
||||
const existing = findNodeByAddress(db, serviceId, address);
|
||||
if (existing) return existing;
|
||||
const id = db
|
||||
.insert(nodes)
|
||||
.values({
|
||||
service_id: serviceId,
|
||||
address,
|
||||
protocol: meta?.protocol ?? "tcp",
|
||||
port: meta?.port ?? null,
|
||||
weight: meta?.weight ?? 1,
|
||||
priority: meta?.priority ?? 1,
|
||||
})
|
||||
.returning({ id: nodes.id })
|
||||
.get()!.id;
|
||||
return getNode(db, id);
|
||||
}
|
||||
|
||||
export function createNode(
|
||||
db: Db,
|
||||
serviceId: number,
|
||||
input: {
|
||||
address: string;
|
||||
protocol?: string;
|
||||
port?: number | null;
|
||||
enabled?: boolean;
|
||||
priority?: number;
|
||||
weight?: number;
|
||||
health_check_id?: number | null;
|
||||
},
|
||||
): ServiceNode {
|
||||
getService(db, serviceId);
|
||||
const existing = findNodeByAddress(db, serviceId, input.address);
|
||||
if (existing) {
|
||||
throw new ConflictError(`node ${input.address} already exists`);
|
||||
}
|
||||
const id = db
|
||||
.insert(nodes)
|
||||
.values({
|
||||
service_id: serviceId,
|
||||
address: input.address,
|
||||
protocol: input.protocol ?? "tcp",
|
||||
port: input.port ?? null,
|
||||
enabled: input.enabled ?? true,
|
||||
priority: input.priority ?? 1,
|
||||
weight: input.weight ?? 1,
|
||||
health_check_id: input.health_check_id ?? null,
|
||||
})
|
||||
.returning({ id: nodes.id })
|
||||
.get()!.id;
|
||||
insertServiceIpIfMissing(db, serviceId, input.address);
|
||||
return getNode(db, id);
|
||||
}
|
||||
|
||||
export function updateNode(
|
||||
db: Db,
|
||||
id: number,
|
||||
patch: Partial<{
|
||||
address: string;
|
||||
protocol: string;
|
||||
port: number | null;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
weight: number;
|
||||
health_check_id: number | null;
|
||||
health_status: string;
|
||||
consecutive_failures: number;
|
||||
consecutive_successes: number;
|
||||
last_check_at: string | null;
|
||||
last_failure_reason: string | null;
|
||||
}>,
|
||||
): ServiceNode {
|
||||
const current = getNode(db, id);
|
||||
const update: Record<string, unknown> = { updated_at: sql`datetime('now')` };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value !== undefined) update[key] = value;
|
||||
}
|
||||
db.update(nodes).set(update).where(eq(nodes.id, id)).run();
|
||||
if (patch.address && patch.address !== current.address) {
|
||||
db.delete(serviceIps)
|
||||
.where(
|
||||
and(
|
||||
eq(serviceIps.service_id, current.service_id),
|
||||
eq(serviceIps.ip, current.address),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
insertServiceIpIfMissing(db, current.service_id, patch.address);
|
||||
}
|
||||
return getNode(db, id);
|
||||
}
|
||||
|
||||
export function deleteNode(db: Db, id: number): void {
|
||||
const current = getNode(db, id);
|
||||
db.delete(nodes).where(eq(nodes.id, id)).run();
|
||||
db.delete(serviceIps)
|
||||
.where(
|
||||
and(
|
||||
eq(serviceIps.service_id, current.service_id),
|
||||
eq(serviceIps.ip, current.address),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
function mapHealthCheck(row: typeof healthChecks.$inferSelect): OriginHealthCheck {
|
||||
return {
|
||||
id: row.id,
|
||||
provider: row.provider as OriginHealthCheck["provider"],
|
||||
cf_healthcheck_id: row.cf_healthcheck_id,
|
||||
cf_zone_id: row.cf_zone_id,
|
||||
name: row.name,
|
||||
protocol: row.protocol,
|
||||
path: row.path,
|
||||
method: row.method,
|
||||
timeout: row.timeout,
|
||||
interval_sec: row.interval_sec,
|
||||
retries: row.retries,
|
||||
expected_status: row.expected_status,
|
||||
consecutive_fails: row.consecutive_fails,
|
||||
consecutive_successes: row.consecutive_successes,
|
||||
suspended: Boolean(row.suspended),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function listHealthChecks(db: Db): OriginHealthCheck[] {
|
||||
return db.select().from(healthChecks).all().map(mapHealthCheck);
|
||||
}
|
||||
|
||||
export function getHealthCheck(db: Db, id: number): OriginHealthCheck {
|
||||
const row = db.select().from(healthChecks).where(eq(healthChecks.id, id)).get();
|
||||
if (!row) throw new NotFoundError(`health check ${id}`);
|
||||
return mapHealthCheck(row);
|
||||
}
|
||||
|
||||
export function findHealthCheckByCfId(
|
||||
db: Db,
|
||||
cfId: string,
|
||||
): OriginHealthCheck | null {
|
||||
const row = db
|
||||
.select()
|
||||
.from(healthChecks)
|
||||
.where(eq(healthChecks.cf_healthcheck_id, cfId))
|
||||
.get();
|
||||
return row ? mapHealthCheck(row) : null;
|
||||
}
|
||||
|
||||
export function createHealthCheck(
|
||||
db: Db,
|
||||
input: {
|
||||
provider: string;
|
||||
name: string;
|
||||
cf_healthcheck_id?: string | null;
|
||||
cf_zone_id?: string | null;
|
||||
protocol?: string;
|
||||
path?: string | null;
|
||||
method?: string | null;
|
||||
timeout?: number;
|
||||
interval_sec?: number;
|
||||
retries?: number;
|
||||
expected_status?: number | null;
|
||||
consecutive_fails?: number;
|
||||
consecutive_successes?: number;
|
||||
suspended?: boolean;
|
||||
},
|
||||
): OriginHealthCheck {
|
||||
const id = db
|
||||
.insert(healthChecks)
|
||||
.values({
|
||||
provider: input.provider,
|
||||
name: input.name,
|
||||
cf_healthcheck_id: input.cf_healthcheck_id ?? null,
|
||||
cf_zone_id: input.cf_zone_id ?? null,
|
||||
protocol: input.protocol ?? "tcp",
|
||||
path: input.path ?? null,
|
||||
method: input.method ?? null,
|
||||
timeout: input.timeout ?? 5,
|
||||
interval_sec: input.interval_sec ?? 30,
|
||||
retries: input.retries ?? 2,
|
||||
expected_status: input.expected_status ?? null,
|
||||
consecutive_fails: input.consecutive_fails ?? 2,
|
||||
consecutive_successes: input.consecutive_successes ?? 2,
|
||||
suspended: input.suspended ?? false,
|
||||
})
|
||||
.returning({ id: healthChecks.id })
|
||||
.get()!.id;
|
||||
return getHealthCheck(db, id);
|
||||
}
|
||||
|
||||
export function updateHealthCheck(
|
||||
db: Db,
|
||||
id: number,
|
||||
patch: Partial<{
|
||||
provider: string;
|
||||
name: string;
|
||||
cf_healthcheck_id: string | null;
|
||||
cf_zone_id: string | null;
|
||||
protocol: string;
|
||||
path: string | null;
|
||||
method: string | null;
|
||||
timeout: number;
|
||||
interval_sec: number;
|
||||
retries: number;
|
||||
expected_status: number | null;
|
||||
consecutive_fails: number;
|
||||
consecutive_successes: number;
|
||||
suspended: boolean;
|
||||
}>,
|
||||
): OriginHealthCheck {
|
||||
getHealthCheck(db, id);
|
||||
const update: Record<string, unknown> = { updated_at: sql`datetime('now')` };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value !== undefined) update[key] = value;
|
||||
}
|
||||
db.update(healthChecks).set(update).where(eq(healthChecks.id, id)).run();
|
||||
return getHealthCheck(db, id);
|
||||
}
|
||||
|
||||
export function deleteHealthCheck(db: Db, id: number): void {
|
||||
const result = db.delete(healthChecks).where(eq(healthChecks.id, id)).run();
|
||||
if (result.changes === 0) throw new NotFoundError(`health check ${id}`);
|
||||
}
|
||||
|
||||
export function bumpBindingVersion(db: Db, bindingId: number, expected?: number): number {
|
||||
const binding = getBinding(db, bindingId);
|
||||
if (expected != null && binding.operation_version !== expected) {
|
||||
throw new ConflictError(`binding ${bindingId} version conflict`);
|
||||
}
|
||||
const next = (binding.operation_version ?? 0) + 1;
|
||||
db.update(serviceBindings)
|
||||
.set({
|
||||
operation_version: next,
|
||||
updated_at: sql`datetime('now')`,
|
||||
})
|
||||
.where(eq(serviceBindings.id, bindingId))
|
||||
.run();
|
||||
return next;
|
||||
}
|
||||
|
||||
export function setBindingRoutingStrategy(
|
||||
db: Db,
|
||||
bindingId: number,
|
||||
strategy: LbMode,
|
||||
): void {
|
||||
db.update(serviceBindings)
|
||||
.set({
|
||||
routing_strategy: strategy,
|
||||
lb_mode: strategy,
|
||||
updated_at: sql`datetime('now')`,
|
||||
})
|
||||
.where(eq(serviceBindings.id, bindingId))
|
||||
.run();
|
||||
}
|
||||
|
||||
export function listAllNodes(db: Db): ServiceNode[] {
|
||||
return db.select().from(nodes).all().map(mapNode);
|
||||
}
|
||||
|
||||
export function updateBindingDomain(
|
||||
db: Db,
|
||||
bindingId: number,
|
||||
domainId: number,
|
||||
hostname: string,
|
||||
): void {
|
||||
db.update(serviceBindings)
|
||||
.set({
|
||||
domain_id: domainId,
|
||||
hostname,
|
||||
updated_at: sql`datetime('now')`,
|
||||
})
|
||||
.where(eq(serviceBindings.id, bindingId))
|
||||
.run();
|
||||
}
|
||||
|
||||
export function listBindingNodes(db: Db, bindingId: number): ServiceNode[] {
|
||||
return db
|
||||
.select({ node: nodes })
|
||||
.from(bindingNodes)
|
||||
.innerJoin(nodes, eq(bindingNodes.node_id, nodes.id))
|
||||
.where(eq(bindingNodes.binding_id, bindingId))
|
||||
.all()
|
||||
.map((row) => mapNode(row.node));
|
||||
}
|
||||
|
||||
// --- Service Binding IPs ---
|
||||
@@ -980,9 +1352,13 @@ export function replaceBindingIpsWithMeta(
|
||||
bindingId: number,
|
||||
entries: BindingIpMeta[],
|
||||
): void {
|
||||
const binding = getBinding(db, bindingId);
|
||||
db.delete(serviceBindingIps)
|
||||
.where(eq(serviceBindingIps.binding_id, bindingId))
|
||||
.run();
|
||||
db.delete(bindingNodes)
|
||||
.where(eq(bindingNodes.binding_id, bindingId))
|
||||
.run();
|
||||
for (const entry of entries) {
|
||||
db.insert(serviceBindingIps)
|
||||
.values({
|
||||
@@ -992,6 +1368,18 @@ export function replaceBindingIpsWithMeta(
|
||||
priority: entry.priority,
|
||||
})
|
||||
.run();
|
||||
const node = ensureNode(db, binding.service_id, entry.ip, {
|
||||
weight: entry.weight,
|
||||
priority: entry.priority,
|
||||
});
|
||||
db.insert(bindingNodes)
|
||||
.values({
|
||||
binding_id: bindingId,
|
||||
node_id: node.id,
|
||||
weight: entry.weight,
|
||||
priority: entry.priority,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1015,7 +1403,10 @@ export function updateBindingLbConfig(
|
||||
const update: Record<string, unknown> = {
|
||||
updated_at: sql`datetime('now')`,
|
||||
};
|
||||
if (patch.lb_mode !== undefined) update.lb_mode = patch.lb_mode;
|
||||
if (patch.lb_mode !== undefined) {
|
||||
update.lb_mode = patch.lb_mode;
|
||||
update.routing_strategy = patch.lb_mode;
|
||||
}
|
||||
if (patch.health_check_enabled !== undefined)
|
||||
update.health_check_enabled = patch.health_check_enabled;
|
||||
if (patch.health_check_type !== undefined)
|
||||
@@ -1721,7 +2112,7 @@ export function getIpHealthStatusRow(
|
||||
): IpHealthStatus | null {
|
||||
const rows = db.all<IpHealthStatus>(sql`
|
||||
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
|
||||
last_checked_at, last_error
|
||||
consecutive_successes, last_checked_at, last_error
|
||||
FROM ip_health_status
|
||||
WHERE scope = ${scope} AND ref_id = ${refId} AND ip = ${ip}
|
||||
LIMIT 1
|
||||
@@ -1738,17 +2129,19 @@ export function upsertIpHealthStatus(
|
||||
latencyMs: number | null,
|
||||
consecutiveFailures: number,
|
||||
lastError: string | null,
|
||||
consecutiveSuccesses = 0,
|
||||
): void {
|
||||
db.run(sql`
|
||||
INSERT INTO ip_health_status
|
||||
(scope, ref_id, ip, status, latency_ms, consecutive_failures,
|
||||
last_checked_at, last_error, created_at, updated_at)
|
||||
consecutive_successes, last_checked_at, last_error, created_at, updated_at)
|
||||
VALUES (${scope}, ${refId}, ${ip}, ${status}, ${latencyMs}, ${consecutiveFailures},
|
||||
datetime('now'), ${lastError}, datetime('now'), datetime('now'))
|
||||
${consecutiveSuccesses}, datetime('now'), ${lastError}, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(scope, ref_id, ip) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
latency_ms = excluded.latency_ms,
|
||||
consecutive_failures = excluded.consecutive_failures,
|
||||
consecutive_successes = excluded.consecutive_successes,
|
||||
last_checked_at = excluded.last_checked_at,
|
||||
last_error = excluded.last_error,
|
||||
updated_at = datetime('now')
|
||||
|
||||
@@ -165,6 +165,8 @@ export const serviceBindings = sqliteTable(
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
|
||||
operation_version: integer("operation_version").notNull().default(0),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
@@ -181,6 +183,76 @@ export const serviceBindings = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const healthChecks = sqliteTable("health_checks", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
provider: text("provider").notNull().default("local"),
|
||||
cf_healthcheck_id: text("cf_healthcheck_id"),
|
||||
cf_zone_id: text("cf_zone_id"),
|
||||
name: text("name").notNull(),
|
||||
protocol: text("protocol").notNull().default("tcp"),
|
||||
path: text("path"),
|
||||
method: text("method"),
|
||||
timeout: integer("timeout").notNull().default(5),
|
||||
interval_sec: integer("interval_sec").notNull().default(30),
|
||||
retries: integer("retries").notNull().default(2),
|
||||
expected_status: integer("expected_status"),
|
||||
consecutive_fails: integer("consecutive_fails").notNull().default(2),
|
||||
consecutive_successes: integer("consecutive_successes").notNull().default(2),
|
||||
suspended: integer("suspended", { mode: "boolean" }).notNull().default(false),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
});
|
||||
|
||||
export const nodes = sqliteTable(
|
||||
"nodes",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
service_id: integer("service_id")
|
||||
.notNull()
|
||||
.references(() => services.id, { onDelete: "cascade" }),
|
||||
address: text("address").notNull(),
|
||||
protocol: text("protocol").notNull().default("tcp"),
|
||||
port: integer("port"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
priority: integer("priority").notNull().default(1),
|
||||
weight: integer("weight").notNull().default(1),
|
||||
health_status: text("health_status").notNull().default("unknown"),
|
||||
health_check_id: integer("health_check_id").references(() => healthChecks.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
consecutive_failures: integer("consecutive_failures").notNull().default(0),
|
||||
consecutive_successes: integer("consecutive_successes").notNull().default(0),
|
||||
last_check_at: text("last_check_at"),
|
||||
last_failure_reason: text("last_failure_reason"),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
},
|
||||
(t) => [unique("nodes_service_address").on(t.service_id, t.address)],
|
||||
);
|
||||
|
||||
export const bindingNodes = sqliteTable(
|
||||
"binding_nodes",
|
||||
{
|
||||
binding_id: integer("binding_id")
|
||||
.notNull()
|
||||
.references(() => serviceBindings.id, { onDelete: "cascade" }),
|
||||
node_id: integer("node_id")
|
||||
.notNull()
|
||||
.references(() => nodes.id, { onDelete: "cascade" }),
|
||||
weight: integer("weight").notNull().default(1),
|
||||
priority: integer("priority").notNull().default(1),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.binding_id, t.node_id] })],
|
||||
);
|
||||
|
||||
export const serviceIps = sqliteTable("service_ips", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
service_id: integer("service_id")
|
||||
@@ -276,6 +348,9 @@ export const ipHealthStatus = sqliteTable(
|
||||
consecutive_failures: integer("consecutive_failures")
|
||||
.notNull()
|
||||
.default(0),
|
||||
consecutive_successes: integer("consecutive_successes")
|
||||
.notNull()
|
||||
.default(0),
|
||||
last_checked_at: text("last_checked_at"),
|
||||
last_error: text("last_error"),
|
||||
created_at: text("created_at")
|
||||
@@ -399,6 +474,9 @@ export const schema = {
|
||||
subdomains,
|
||||
dnsRecords,
|
||||
serviceBindings,
|
||||
healthChecks,
|
||||
nodes,
|
||||
bindingNodes,
|
||||
serviceIps,
|
||||
serviceBindingRecords,
|
||||
serviceBindingIps,
|
||||
|
||||
Vendored
+219
-1
@@ -60,6 +60,8 @@ interface ServiceBinding {
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
routing_strategy: LbMode;
|
||||
operation_version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -114,6 +116,23 @@ interface ServiceDomainBindingView {
|
||||
health_check_verify_tls: boolean;
|
||||
sync_status: string | null;
|
||||
}
|
||||
interface ServiceView$1 {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
service_group_id: number | null;
|
||||
subdomain: string;
|
||||
enabled: boolean;
|
||||
computed_fqdn: string | null;
|
||||
lb_weight: number;
|
||||
lb_priority: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
ips: string[];
|
||||
domains: ServiceDomainBindingView[];
|
||||
health_status: IpHealthState;
|
||||
health_latency_ms: number | null;
|
||||
}
|
||||
interface SyncJob {
|
||||
id: string;
|
||||
status: string;
|
||||
@@ -159,6 +178,8 @@ interface JwtClaims {
|
||||
type LbMode = "round_robin" | "failover" | "weighted";
|
||||
type HealthCheckType = "tcp" | "http" | "ping" | "dns";
|
||||
type IpHealthState = "up" | "down" | "degraded" | "unknown";
|
||||
type NodeHealthState = "unknown" | "checking" | "healthy" | "degraded" | "unhealthy" | "disabled";
|
||||
type HealthCheckProvider = "local" | "cloudflare";
|
||||
type HealthCheckScope = "binding" | "group";
|
||||
interface IpHealthStatus {
|
||||
scope: HealthCheckScope;
|
||||
@@ -167,9 +188,75 @@ interface IpHealthStatus {
|
||||
status: IpHealthState;
|
||||
latency_ms: number | null;
|
||||
consecutive_failures: number;
|
||||
consecutive_successes?: number;
|
||||
last_checked_at: string | null;
|
||||
last_error: string | null;
|
||||
}
|
||||
interface ServiceNode {
|
||||
id: number;
|
||||
service_id: number;
|
||||
address: string;
|
||||
protocol: string;
|
||||
port: number | null;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
weight: number;
|
||||
health_status: NodeHealthState;
|
||||
health_check_id: number | null;
|
||||
consecutive_failures: number;
|
||||
consecutive_successes: number;
|
||||
last_check_at: string | null;
|
||||
last_failure_reason: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
interface OriginHealthCheck {
|
||||
id: number;
|
||||
provider: HealthCheckProvider;
|
||||
cf_healthcheck_id: string | null;
|
||||
cf_zone_id: string | null;
|
||||
name: string;
|
||||
protocol: string;
|
||||
path: string | null;
|
||||
method: string | null;
|
||||
timeout: number;
|
||||
interval_sec: number;
|
||||
retries: number;
|
||||
expected_status: number | null;
|
||||
consecutive_fails: number;
|
||||
consecutive_successes: number;
|
||||
suspended: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
interface ServiceOverview {
|
||||
service: ServiceView$1;
|
||||
nodes: ServiceNode[];
|
||||
health_check: OriginHealthCheck | null;
|
||||
routing_strategy: LbMode;
|
||||
active_addresses: string[];
|
||||
}
|
||||
interface PatchDnsRecordPayload {
|
||||
type?: string;
|
||||
name?: string;
|
||||
content?: string;
|
||||
ttl?: number;
|
||||
proxied?: boolean;
|
||||
priority?: number;
|
||||
}
|
||||
interface CfHealthCheck {
|
||||
id: string;
|
||||
address: string;
|
||||
name: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
interval?: number;
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
consecutive_fails?: number;
|
||||
consecutive_successes?: number;
|
||||
suspended?: boolean;
|
||||
}
|
||||
interface HealthCheckTarget {
|
||||
scope: HealthCheckScope;
|
||||
ref_id: number;
|
||||
@@ -250,6 +337,18 @@ declare const ipHealthStateSchema: z.ZodEnum<{
|
||||
down: "down";
|
||||
degraded: "degraded";
|
||||
}>;
|
||||
declare const nodeHealthStateSchema: z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
degraded: "degraded";
|
||||
checking: "checking";
|
||||
healthy: "healthy";
|
||||
unhealthy: "unhealthy";
|
||||
disabled: "disabled";
|
||||
}>;
|
||||
declare const healthCheckProviderSchema: z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
}>;
|
||||
declare const healthCheckScopeSchema: z.ZodEnum<{
|
||||
binding: "binding";
|
||||
group: "group";
|
||||
@@ -269,6 +368,7 @@ declare const ipHealthStatusSchema: z.ZodObject<{
|
||||
}>;
|
||||
latency_ms: z.ZodNullable<z.ZodNumber>;
|
||||
consecutive_failures: z.ZodNumber;
|
||||
consecutive_successes: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
||||
last_checked_at: z.ZodNullable<z.ZodString>;
|
||||
last_error: z.ZodNullable<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
@@ -1414,6 +1514,124 @@ type CreateServiceBindingInput = z.infer<typeof createServiceBindingSchema>;
|
||||
type CreateDomainInput = z.infer<typeof createDomainSchema>;
|
||||
type LoginInput = z.infer<typeof loginSchema>;
|
||||
type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema>;
|
||||
declare const serviceNodeSchema: z.ZodObject<{
|
||||
id: z.ZodNumber;
|
||||
service_id: z.ZodNumber;
|
||||
address: z.ZodString;
|
||||
protocol: z.ZodString;
|
||||
port: z.ZodNullable<z.ZodNumber>;
|
||||
enabled: z.ZodCoercedBoolean<unknown>;
|
||||
priority: z.ZodNumber;
|
||||
weight: z.ZodNumber;
|
||||
health_status: z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
degraded: "degraded";
|
||||
checking: "checking";
|
||||
healthy: "healthy";
|
||||
unhealthy: "unhealthy";
|
||||
disabled: "disabled";
|
||||
}>;
|
||||
health_check_id: z.ZodNullable<z.ZodNumber>;
|
||||
consecutive_failures: z.ZodNumber;
|
||||
consecutive_successes: z.ZodNumber;
|
||||
last_check_at: z.ZodNullable<z.ZodString>;
|
||||
last_failure_reason: z.ZodNullable<z.ZodString>;
|
||||
created_at: z.ZodString;
|
||||
updated_at: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
declare const createServiceNodeSchema: z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
protocol: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
|
||||
tcp: "tcp";
|
||||
http: "http";
|
||||
https: "https";
|
||||
}>>>;
|
||||
port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||
enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
||||
priority: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
||||
weight: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
||||
health_check_id: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||
}, z.core.$strip>;
|
||||
declare const updateServiceNodeSchema: z.ZodObject<{
|
||||
address: z.ZodOptional<z.ZodString>;
|
||||
protocol: z.ZodOptional<z.ZodDefault<z.ZodOptional<z.ZodEnum<{
|
||||
tcp: "tcp";
|
||||
http: "http";
|
||||
https: "https";
|
||||
}>>>>;
|
||||
port: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
|
||||
enabled: z.ZodOptional<z.ZodDefault<z.ZodOptional<z.ZodBoolean>>>;
|
||||
priority: z.ZodOptional<z.ZodDefault<z.ZodOptional<z.ZodNumber>>>;
|
||||
weight: z.ZodOptional<z.ZodDefault<z.ZodOptional<z.ZodNumber>>>;
|
||||
health_check_id: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
|
||||
}, z.core.$strip>;
|
||||
declare const originHealthCheckSchema: z.ZodObject<{
|
||||
id: z.ZodNumber;
|
||||
provider: z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
}>;
|
||||
cf_healthcheck_id: z.ZodNullable<z.ZodString>;
|
||||
cf_zone_id: z.ZodNullable<z.ZodString>;
|
||||
name: z.ZodString;
|
||||
protocol: z.ZodString;
|
||||
path: z.ZodNullable<z.ZodString>;
|
||||
method: z.ZodNullable<z.ZodString>;
|
||||
timeout: z.ZodNumber;
|
||||
interval_sec: z.ZodNumber;
|
||||
retries: z.ZodNumber;
|
||||
expected_status: z.ZodNullable<z.ZodNumber>;
|
||||
consecutive_fails: z.ZodNumber;
|
||||
consecutive_successes: z.ZodNumber;
|
||||
suspended: z.ZodCoercedBoolean<unknown>;
|
||||
created_at: z.ZodString;
|
||||
updated_at: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
declare const createOriginHealthCheckSchema: z.ZodObject<{
|
||||
provider: z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
}>;
|
||||
name: z.ZodString;
|
||||
cf_zone_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
protocol: z.ZodOptional<z.ZodEnum<{
|
||||
tcp: "tcp";
|
||||
http: "http";
|
||||
https: "https";
|
||||
HTTP: "HTTP";
|
||||
HTTPS: "HTTPS";
|
||||
TCP: "TCP";
|
||||
}>>;
|
||||
path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
method: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
timeout: z.ZodOptional<z.ZodNumber>;
|
||||
interval_sec: z.ZodOptional<z.ZodNumber>;
|
||||
retries: z.ZodOptional<z.ZodNumber>;
|
||||
expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||
consecutive_fails: z.ZodOptional<z.ZodNumber>;
|
||||
consecutive_successes: z.ZodOptional<z.ZodNumber>;
|
||||
suspended: z.ZodOptional<z.ZodBoolean>;
|
||||
node_id: z.ZodOptional<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
declare const changeIpSchema: z.ZodObject<{
|
||||
from_ip: z.ZodOptional<z.ZodString>;
|
||||
to_ip: z.ZodOptional<z.ZodString>;
|
||||
node_id: z.ZodOptional<z.ZodNumber>;
|
||||
dry_run: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
||||
}, z.core.$strip>;
|
||||
declare const changeDomainSchema: z.ZodObject<{
|
||||
from_domain_id: z.ZodNumber;
|
||||
to_domain_id: z.ZodNumber;
|
||||
hostnames: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
dry_run: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
||||
}, z.core.$strip>;
|
||||
type ServiceNodeRecord = z.infer<typeof serviceNodeSchema>;
|
||||
type CreateServiceNodeInput = z.infer<typeof createServiceNodeSchema>;
|
||||
type UpdateServiceNodeInput = z.infer<typeof updateServiceNodeSchema>;
|
||||
type OriginHealthCheckRecord = z.infer<typeof originHealthCheckSchema>;
|
||||
type CreateOriginHealthCheckInput = z.infer<typeof createOriginHealthCheckSchema>;
|
||||
type ChangeIpInput = z.infer<typeof changeIpSchema>;
|
||||
type ChangeDomainInput = z.infer<typeof changeDomainSchema>;
|
||||
|
||||
declare const appSwitcherIconSchema: z.ZodEnum<{
|
||||
server: "server";
|
||||
@@ -1617,4 +1835,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{
|
||||
}, z.core.$strip>;
|
||||
type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>;
|
||||
|
||||
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfZone, type CfdmBindingSyncItem, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NotificationLog, type ParsedFqdn, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, normalizeDnsRecordName, notificationLogSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
|
||||
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, notificationLogSchema, originHealthCheckSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
|
||||
|
||||
Vendored
+95
@@ -171,6 +171,15 @@ var healthCheckTypeSchema = z.enum(["tcp", "http", "ping", "dns"]);
|
||||
var domainEnvironmentSchema = z.enum(["prod", "staging", "dev"]);
|
||||
var domainMonitorTypeSchema = z.enum(["http", "ping", "dns"]);
|
||||
var ipHealthStateSchema = z.enum(["up", "down", "degraded", "unknown"]);
|
||||
var nodeHealthStateSchema = z.enum([
|
||||
"unknown",
|
||||
"checking",
|
||||
"healthy",
|
||||
"degraded",
|
||||
"unhealthy",
|
||||
"disabled"
|
||||
]);
|
||||
var healthCheckProviderSchema = z.enum(["local", "cloudflare"]);
|
||||
var healthCheckScopeSchema = z.enum(["binding", "group"]);
|
||||
var ipHealthStatusSchema = z.object({
|
||||
scope: healthCheckScopeSchema,
|
||||
@@ -179,6 +188,7 @@ var ipHealthStatusSchema = z.object({
|
||||
status: ipHealthStateSchema,
|
||||
latency_ms: z.number().nullable(),
|
||||
consecutive_failures: z.number(),
|
||||
consecutive_successes: z.number().optional().default(0),
|
||||
last_checked_at: z.string().nullable(),
|
||||
last_error: z.string().nullable()
|
||||
});
|
||||
@@ -366,6 +376,7 @@ var ipv4Schema = z.string().regex(
|
||||
/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/,
|
||||
"\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IPv4"
|
||||
);
|
||||
var nodeAddressSchema = z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0438\u043B\u0438 hostname").max(255);
|
||||
var healthCheckConfigFields = {
|
||||
health_check_enabled: z.boolean().optional(),
|
||||
health_check_type: healthCheckTypeSchema.optional(),
|
||||
@@ -549,6 +560,81 @@ var healthStatusQuerySchema = z.object({
|
||||
scope: healthCheckScopeSchema,
|
||||
ref_id: z.coerce.number().int().positive()
|
||||
});
|
||||
var serviceNodeSchema = z.object({
|
||||
id: z.number(),
|
||||
service_id: z.number(),
|
||||
address: z.string(),
|
||||
protocol: z.string(),
|
||||
port: z.number().nullable(),
|
||||
enabled: z.coerce.boolean(),
|
||||
priority: z.number(),
|
||||
weight: z.number(),
|
||||
health_status: nodeHealthStateSchema,
|
||||
health_check_id: z.number().nullable(),
|
||||
consecutive_failures: z.number(),
|
||||
consecutive_successes: z.number(),
|
||||
last_check_at: z.string().nullable(),
|
||||
last_failure_reason: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string()
|
||||
});
|
||||
var createServiceNodeSchema = z.object({
|
||||
address: nodeAddressSchema,
|
||||
protocol: z.enum(["tcp", "http", "https"]).optional().default("tcp"),
|
||||
port: z.number().int().min(1).max(65535).nullable().optional(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
priority: z.number().int().min(1).max(100).optional().default(1),
|
||||
weight: z.number().int().min(1).max(100).optional().default(1),
|
||||
health_check_id: z.number().int().positive().nullable().optional()
|
||||
});
|
||||
var updateServiceNodeSchema = createServiceNodeSchema.partial();
|
||||
var originHealthCheckSchema = z.object({
|
||||
id: z.number(),
|
||||
provider: healthCheckProviderSchema,
|
||||
cf_healthcheck_id: z.string().nullable(),
|
||||
cf_zone_id: z.string().nullable(),
|
||||
name: z.string(),
|
||||
protocol: z.string(),
|
||||
path: z.string().nullable(),
|
||||
method: z.string().nullable(),
|
||||
timeout: z.number(),
|
||||
interval_sec: z.number(),
|
||||
retries: z.number(),
|
||||
expected_status: z.number().nullable(),
|
||||
consecutive_fails: z.number(),
|
||||
consecutive_successes: z.number(),
|
||||
suspended: z.coerce.boolean(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string()
|
||||
});
|
||||
var createOriginHealthCheckSchema = z.object({
|
||||
provider: healthCheckProviderSchema,
|
||||
name: z.string().min(1).max(64),
|
||||
cf_zone_id: z.string().nullable().optional(),
|
||||
protocol: z.enum(["HTTP", "HTTPS", "TCP", "tcp", "http", "https"]).optional(),
|
||||
path: z.string().nullable().optional(),
|
||||
method: z.string().nullable().optional(),
|
||||
timeout: z.number().int().min(1).max(60).optional(),
|
||||
interval_sec: z.number().int().min(5).max(3600).optional(),
|
||||
retries: z.number().int().min(0).max(10).optional(),
|
||||
expected_status: z.number().int().min(100).max(599).nullable().optional(),
|
||||
consecutive_fails: z.number().int().min(1).max(20).optional(),
|
||||
consecutive_successes: z.number().int().min(1).max(20).optional(),
|
||||
suspended: z.boolean().optional(),
|
||||
node_id: z.number().int().positive().optional()
|
||||
});
|
||||
var changeIpSchema = z.object({
|
||||
from_ip: z.string().optional(),
|
||||
to_ip: z.string().min(1).optional(),
|
||||
node_id: z.number().int().positive().optional(),
|
||||
dry_run: z.boolean().optional().default(false)
|
||||
});
|
||||
var changeDomainSchema = z.object({
|
||||
from_domain_id: z.number().int().positive(),
|
||||
to_domain_id: z.number().int().positive(),
|
||||
hostnames: z.array(z.string().min(1)).optional(),
|
||||
dry_run: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
// src/app-switcher.ts
|
||||
import { z as z2 } from "zod";
|
||||
@@ -708,12 +794,16 @@ export {
|
||||
certificateSchema,
|
||||
cfdmBindingSyncItemSchema,
|
||||
cfdmSyncBindingsBodySchema,
|
||||
changeDomainSchema,
|
||||
changeIpSchema,
|
||||
createDnsRecordSchema,
|
||||
createDomainMonitorSchema,
|
||||
createDomainSchema,
|
||||
createGroupSchema,
|
||||
createOriginHealthCheckSchema,
|
||||
createServiceBindingSchema,
|
||||
createServiceGroupSchema,
|
||||
createServiceNodeSchema,
|
||||
createServiceSchema,
|
||||
createServiceWithConfigSchema,
|
||||
createSubdomainSchema,
|
||||
@@ -730,6 +820,7 @@ export {
|
||||
groupSchema,
|
||||
groupWithStatsSchema,
|
||||
healthCheckConfigSchema,
|
||||
healthCheckProviderSchema,
|
||||
healthCheckScopeSchema,
|
||||
healthCheckTypeSchema,
|
||||
healthStatusQuerySchema,
|
||||
@@ -740,8 +831,10 @@ export {
|
||||
isValidIpv4,
|
||||
lbModeSchema,
|
||||
loginSchema,
|
||||
nodeHealthStateSchema,
|
||||
normalizeDnsRecordName,
|
||||
notificationLogSchema,
|
||||
originHealthCheckSchema,
|
||||
parseFqdn,
|
||||
reorderServicesSchema,
|
||||
serviceBindingSchema,
|
||||
@@ -750,6 +843,7 @@ export {
|
||||
serviceGroupTypeSchema,
|
||||
serviceGroupViewSchema,
|
||||
serviceGroupsResponseSchema,
|
||||
serviceNodeSchema,
|
||||
serviceSchema,
|
||||
serviceViewSchema,
|
||||
shouldMonitorService,
|
||||
@@ -760,6 +854,7 @@ export {
|
||||
updateDomainSchema,
|
||||
updateServiceConfigSchema,
|
||||
updateServiceGroupSchema,
|
||||
updateServiceNodeSchema,
|
||||
updateSubdomainSchema,
|
||||
validateDnsRecord,
|
||||
vpsTrackerEventSchema
|
||||
|
||||
@@ -21,7 +21,14 @@ export type {
|
||||
LbMode,
|
||||
HealthCheckType,
|
||||
IpHealthState,
|
||||
NodeHealthState,
|
||||
HealthCheckProvider,
|
||||
HealthCheckScope,
|
||||
IpHealthStatus,
|
||||
HealthCheckTarget,
|
||||
ServiceNode,
|
||||
OriginHealthCheck,
|
||||
ServiceOverview,
|
||||
PatchDnsRecordPayload,
|
||||
CfHealthCheck,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -19,6 +19,19 @@ export type DomainMonitorType = z.infer<typeof domainMonitorTypeSchema>
|
||||
export const ipHealthStateSchema = z.enum(['up', 'down', 'degraded', 'unknown'])
|
||||
export type IpHealthState = z.infer<typeof ipHealthStateSchema>
|
||||
|
||||
export const nodeHealthStateSchema = z.enum([
|
||||
'unknown',
|
||||
'checking',
|
||||
'healthy',
|
||||
'degraded',
|
||||
'unhealthy',
|
||||
'disabled',
|
||||
])
|
||||
export type NodeHealthState = z.infer<typeof nodeHealthStateSchema>
|
||||
|
||||
export const healthCheckProviderSchema = z.enum(['local', 'cloudflare'])
|
||||
export type HealthCheckProvider = z.infer<typeof healthCheckProviderSchema>
|
||||
|
||||
export const healthCheckScopeSchema = z.enum(['binding', 'group'])
|
||||
export type HealthCheckScope = z.infer<typeof healthCheckScopeSchema>
|
||||
|
||||
@@ -29,6 +42,7 @@ export const ipHealthStatusSchema = z.object({
|
||||
status: ipHealthStateSchema,
|
||||
latency_ms: z.number().nullable(),
|
||||
consecutive_failures: z.number(),
|
||||
consecutive_successes: z.number().optional().default(0),
|
||||
last_checked_at: z.string().nullable(),
|
||||
last_error: z.string().nullable(),
|
||||
})
|
||||
@@ -266,6 +280,11 @@ const ipv4Schema = z
|
||||
'Некорректный IPv4',
|
||||
)
|
||||
|
||||
const nodeAddressSchema = z
|
||||
.string()
|
||||
.min(1, 'Укажите IP или hostname')
|
||||
.max(255)
|
||||
|
||||
const healthCheckConfigFields = {
|
||||
health_check_enabled: z.boolean().optional(),
|
||||
health_check_type: healthCheckTypeSchema.optional(),
|
||||
@@ -512,3 +531,93 @@ export type CreateServiceBindingInput = z.infer<typeof createServiceBindingSchem
|
||||
export type CreateDomainInput = z.infer<typeof createDomainSchema>
|
||||
export type LoginInput = z.infer<typeof loginSchema>
|
||||
export type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema>
|
||||
|
||||
export const serviceNodeSchema = z.object({
|
||||
id: z.number(),
|
||||
service_id: z.number(),
|
||||
address: z.string(),
|
||||
protocol: z.string(),
|
||||
port: z.number().nullable(),
|
||||
enabled: z.coerce.boolean(),
|
||||
priority: z.number(),
|
||||
weight: z.number(),
|
||||
health_status: nodeHealthStateSchema,
|
||||
health_check_id: z.number().nullable(),
|
||||
consecutive_failures: z.number(),
|
||||
consecutive_successes: z.number(),
|
||||
last_check_at: z.string().nullable(),
|
||||
last_failure_reason: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const createServiceNodeSchema = z.object({
|
||||
address: nodeAddressSchema,
|
||||
protocol: z.enum(['tcp', 'http', 'https']).optional().default('tcp'),
|
||||
port: z.number().int().min(1).max(65535).nullable().optional(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
priority: z.number().int().min(1).max(100).optional().default(1),
|
||||
weight: z.number().int().min(1).max(100).optional().default(1),
|
||||
health_check_id: z.number().int().positive().nullable().optional(),
|
||||
})
|
||||
|
||||
export const updateServiceNodeSchema = createServiceNodeSchema.partial()
|
||||
|
||||
export const originHealthCheckSchema = z.object({
|
||||
id: z.number(),
|
||||
provider: healthCheckProviderSchema,
|
||||
cf_healthcheck_id: z.string().nullable(),
|
||||
cf_zone_id: z.string().nullable(),
|
||||
name: z.string(),
|
||||
protocol: z.string(),
|
||||
path: z.string().nullable(),
|
||||
method: z.string().nullable(),
|
||||
timeout: z.number(),
|
||||
interval_sec: z.number(),
|
||||
retries: z.number(),
|
||||
expected_status: z.number().nullable(),
|
||||
consecutive_fails: z.number(),
|
||||
consecutive_successes: z.number(),
|
||||
suspended: z.coerce.boolean(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const createOriginHealthCheckSchema = z.object({
|
||||
provider: healthCheckProviderSchema,
|
||||
name: z.string().min(1).max(64),
|
||||
cf_zone_id: z.string().nullable().optional(),
|
||||
protocol: z.enum(['HTTP', 'HTTPS', 'TCP', 'tcp', 'http', 'https']).optional(),
|
||||
path: z.string().nullable().optional(),
|
||||
method: z.string().nullable().optional(),
|
||||
timeout: z.number().int().min(1).max(60).optional(),
|
||||
interval_sec: z.number().int().min(5).max(3600).optional(),
|
||||
retries: z.number().int().min(0).max(10).optional(),
|
||||
expected_status: z.number().int().min(100).max(599).nullable().optional(),
|
||||
consecutive_fails: z.number().int().min(1).max(20).optional(),
|
||||
consecutive_successes: z.number().int().min(1).max(20).optional(),
|
||||
suspended: z.boolean().optional(),
|
||||
node_id: z.number().int().positive().optional(),
|
||||
})
|
||||
|
||||
export const changeIpSchema = z.object({
|
||||
from_ip: z.string().optional(),
|
||||
to_ip: z.string().min(1).optional(),
|
||||
node_id: z.number().int().positive().optional(),
|
||||
dry_run: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
export const changeDomainSchema = z.object({
|
||||
from_domain_id: z.number().int().positive(),
|
||||
to_domain_id: z.number().int().positive(),
|
||||
hostnames: z.array(z.string().min(1)).optional(),
|
||||
dry_run: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
export type ServiceNodeRecord = z.infer<typeof serviceNodeSchema>
|
||||
export type CreateServiceNodeInput = z.infer<typeof createServiceNodeSchema>
|
||||
export type UpdateServiceNodeInput = z.infer<typeof updateServiceNodeSchema>
|
||||
export type OriginHealthCheckRecord = z.infer<typeof originHealthCheckSchema>
|
||||
export type CreateOriginHealthCheckInput = z.infer<typeof createOriginHealthCheckSchema>
|
||||
export type ChangeIpInput = z.infer<typeof changeIpSchema>
|
||||
export type ChangeDomainInput = z.infer<typeof changeDomainSchema>
|
||||
|
||||
@@ -121,6 +121,8 @@ export interface ServiceBinding {
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
routing_strategy: LbMode;
|
||||
operation_version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -267,6 +269,16 @@ export type DomainMonitorType = "http" | "ping" | "dns";
|
||||
|
||||
export type IpHealthState = "up" | "down" | "degraded" | "unknown";
|
||||
|
||||
export type NodeHealthState =
|
||||
| "unknown"
|
||||
| "checking"
|
||||
| "healthy"
|
||||
| "degraded"
|
||||
| "unhealthy"
|
||||
| "disabled";
|
||||
|
||||
export type HealthCheckProvider = "local" | "cloudflare";
|
||||
|
||||
export type HealthCheckScope = "binding" | "group";
|
||||
|
||||
export interface IpHealthStatus {
|
||||
@@ -276,10 +288,81 @@ export interface IpHealthStatus {
|
||||
status: IpHealthState;
|
||||
latency_ms: number | null;
|
||||
consecutive_failures: number;
|
||||
consecutive_successes?: number;
|
||||
last_checked_at: string | null;
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
export interface ServiceNode {
|
||||
id: number;
|
||||
service_id: number;
|
||||
address: string;
|
||||
protocol: string;
|
||||
port: number | null;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
weight: number;
|
||||
health_status: NodeHealthState;
|
||||
health_check_id: number | null;
|
||||
consecutive_failures: number;
|
||||
consecutive_successes: number;
|
||||
last_check_at: string | null;
|
||||
last_failure_reason: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OriginHealthCheck {
|
||||
id: number;
|
||||
provider: HealthCheckProvider;
|
||||
cf_healthcheck_id: string | null;
|
||||
cf_zone_id: string | null;
|
||||
name: string;
|
||||
protocol: string;
|
||||
path: string | null;
|
||||
method: string | null;
|
||||
timeout: number;
|
||||
interval_sec: number;
|
||||
retries: number;
|
||||
expected_status: number | null;
|
||||
consecutive_fails: number;
|
||||
consecutive_successes: number;
|
||||
suspended: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ServiceOverview {
|
||||
service: ServiceView;
|
||||
nodes: ServiceNode[];
|
||||
health_check: OriginHealthCheck | null;
|
||||
routing_strategy: LbMode;
|
||||
active_addresses: string[];
|
||||
}
|
||||
|
||||
export interface PatchDnsRecordPayload {
|
||||
type?: string;
|
||||
name?: string;
|
||||
content?: string;
|
||||
ttl?: number;
|
||||
proxied?: boolean;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface CfHealthCheck {
|
||||
id: string;
|
||||
address: string;
|
||||
name: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
interval?: number;
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
consecutive_fails?: number;
|
||||
consecutive_successes?: number;
|
||||
suspended?: boolean;
|
||||
}
|
||||
|
||||
export interface HealthCheckTarget {
|
||||
scope: HealthCheckScope;
|
||||
ref_id: number;
|
||||
|
||||
Generated
+68
-2
@@ -206,6 +206,9 @@ importers:
|
||||
'@eslint/js':
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1(eslint@10.5.0(jiti@2.7.0))
|
||||
'@tanstack/router-cli':
|
||||
specifier: ^1.167.31
|
||||
version: 1.167.31
|
||||
'@types/node':
|
||||
specifier: ^24.12.3
|
||||
version: 24.13.2
|
||||
@@ -596,11 +599,11 @@ packages:
|
||||
|
||||
'@esbuild-kit/core-utils@3.3.2':
|
||||
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
|
||||
deprecated: 'Merged into tsx: https://tsx.is'
|
||||
deprecated: 'Merged into tsx: https://tsx.hirok.io'
|
||||
|
||||
'@esbuild-kit/esm-loader@2.6.5':
|
||||
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
|
||||
deprecated: 'Merged into tsx: https://tsx.is'
|
||||
deprecated: 'Merged into tsx: https://tsx.hirok.io'
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||
@@ -2284,6 +2287,10 @@ packages:
|
||||
resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
||||
'@tanstack/history@1.162.1':
|
||||
resolution: {integrity: sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
||||
'@tanstack/query-core@5.101.0':
|
||||
resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==}
|
||||
|
||||
@@ -2318,14 +2325,27 @@ packages:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/router-cli@1.167.31':
|
||||
resolution: {integrity: sha512-/jjZzcwOdBpcLIXRYsmHzFlchbd3esBD6m9bmeoY8l9mVkv/NZOhfx1h/mEn6nETkpxBT4y3pTJ0Ave2EoYXnQ==}
|
||||
engines: {node: '>=20.19'}
|
||||
hasBin: true
|
||||
|
||||
'@tanstack/router-core@1.171.13':
|
||||
resolution: {integrity: sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
||||
'@tanstack/router-core@1.171.25':
|
||||
resolution: {integrity: sha512-w74Al2ar8UKx3Xp4ICTxIZD2Vmd6N9Yn4xH8ILq40gI5UqlxzjPPYoR5l2+5cmv9XCWZehxw7saIofmZpf1G4w==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
||||
'@tanstack/router-generator@1.167.17':
|
||||
resolution: {integrity: sha512-xtB9tB2Ws0tWR6Pi7nc3Qk9IYgoh1mQCKWjHqIl9tf6BNUpKoqniJoPAQ4+LGrK8FeZYU0o0p/qlZEyj9FAulA==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
||||
'@tanstack/router-generator@1.167.31':
|
||||
resolution: {integrity: sha512-xgk+8BCKzAoIONVJPeyyzLRKhb9W0PNWJepPIjx5gRU36+FDZUMNeZ/gjyOBZ4EzQgrUOCPs4/Vkr8JcnuRLLA==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
||||
'@tanstack/router-plugin@1.168.18':
|
||||
resolution: {integrity: sha512-MofS28/axfnfnhOD2RSgJEaU882aX5RsAzhGz5Vc4XhAmvCjy919u9JrNs4QsTWFbTD1P7IJ8WFlFVsrg0pStg==}
|
||||
engines: {node: '>=20.19'}
|
||||
@@ -4875,10 +4895,20 @@ packages:
|
||||
peerDependencies:
|
||||
seroval: ^1.0
|
||||
|
||||
seroval-plugins@1.6.2:
|
||||
resolution: {integrity: sha512-TfxuUjlbBESzUOWdTkTKqvSmav0ABym+itetDXLK6mDz8SmrpdI30aF8RTXE8Bvq+tH/1yIDkvy3W0lfQb1ipQ==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
seroval: ^1.0
|
||||
|
||||
seroval@1.5.4:
|
||||
resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
seroval@1.6.2:
|
||||
resolution: {integrity: sha512-mPT+SD2TrlB6wvte1KkYOYUkubaTbd6pZ/6Kk3C9nxzrHmCZyhxOO7XGAeL7f+yLKZglzGtM9odUVvg/EhO+vQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
@@ -7288,6 +7318,8 @@ snapshots:
|
||||
|
||||
'@tanstack/history@1.162.0': {}
|
||||
|
||||
'@tanstack/history@1.162.1': {}
|
||||
|
||||
'@tanstack/query-core@5.101.0': {}
|
||||
|
||||
'@tanstack/react-query@5.101.0(react@19.2.7)':
|
||||
@@ -7323,6 +7355,14 @@ snapshots:
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
'@tanstack/router-cli@1.167.31':
|
||||
dependencies:
|
||||
'@tanstack/router-generator': 1.167.31
|
||||
chokidar: 5.0.0
|
||||
yargs: 17.7.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@tanstack/router-core@1.171.13':
|
||||
dependencies:
|
||||
'@tanstack/history': 1.162.0
|
||||
@@ -7330,6 +7370,13 @@ snapshots:
|
||||
seroval: 1.5.4
|
||||
seroval-plugins: 1.5.4(seroval@1.5.4)
|
||||
|
||||
'@tanstack/router-core@1.171.25':
|
||||
dependencies:
|
||||
'@tanstack/history': 1.162.1
|
||||
cookie-es: 3.1.1
|
||||
seroval: 1.6.2
|
||||
seroval-plugins: 1.6.2(seroval@1.6.2)
|
||||
|
||||
'@tanstack/router-generator@1.167.17':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
@@ -7343,6 +7390,19 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@tanstack/router-generator@1.167.31':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
'@tanstack/router-core': 1.171.25
|
||||
'@tanstack/router-utils': 1.162.2
|
||||
'@tanstack/virtual-file-routes': 1.162.0
|
||||
jiti: 2.7.0
|
||||
magic-string: 0.30.21
|
||||
prettier: 3.8.4
|
||||
zod: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@tanstack/router-plugin@1.168.18(@tanstack/react-router@1.170.15(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
@@ -9829,8 +9889,14 @@ snapshots:
|
||||
dependencies:
|
||||
seroval: 1.5.4
|
||||
|
||||
seroval-plugins@1.6.2(seroval@1.6.2):
|
||||
dependencies:
|
||||
seroval: 1.6.2
|
||||
|
||||
seroval@1.5.4: {}
|
||||
|
||||
seroval@1.6.2: {}
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
Reference in New Issue
Block a user