diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index e120659..7623584 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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, diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 40e1ac3..215590b 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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, diff --git a/apps/api/src/errors.ts b/apps/api/src/errors.ts index 5e9b166..065753b 100644 --- a/apps/api/src/errors.ts +++ b/apps/api/src/errors.ts @@ -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); } diff --git a/apps/api/src/lib/cf-client.ts b/apps/api/src/lib/cf-client.ts index c5529fb..058cbb8 100644 --- a/apps/api/src/lib/cf-client.ts +++ b/apps/api/src/lib/cf-client.ts @@ -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 { - 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( - response: Response, - operation: string, - ): Promise { - 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; - 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 { - 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( - response, - "list_zones", - ); - if (batch.length === 0) break; - all.push(...batch); - if (batch.length < 50) break; - page += 1; - } - return all; - }); + listZones(): Promise { + return this.zones.listZones(); } - async getZone(zoneId: string): Promise { - 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 { + return this.zones.getZone(zoneId); } - async listDnsRecords(zoneId: string): Promise { - 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( - response, - "list_dns_records", - ); - if (batch.length === 0) break; - all.push(...batch); - page += 1; - } - return all; - }); + listDnsRecords(zoneId: string): Promise { + return this.dns.listDnsRecords(zoneId); } - async createDnsRecord( - zoneId: string, - payload: CreateDnsRecordPayload, - ): Promise { - 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 { + return this.dns.createDnsRecord(zoneId, payload); } - async updateDnsRecord( + updateDnsRecord( zoneId: string, recordId: string, payload: CreateDnsRecordPayload, ): Promise { - 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 { - 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 { + return this.dns.patchDnsRecord(zoneId, recordId, payload); + } + + deleteDnsRecord(zoneId: string, recordId: string): Promise { + return this.dns.deleteDnsRecord(zoneId, recordId); + } + + listHealthChecks(zoneId: string): Promise { + return this.healthchecks.listHealthChecks(zoneId); + } + + getHealthCheck(zoneId: string, id: string): Promise { + return this.healthchecks.getHealthCheck(zoneId, id); + } + + createHealthCheck(zoneId: string, payload: CfHealthCheckPayload): Promise { + return this.healthchecks.createHealthCheck(zoneId, payload); + } + + updateHealthCheck( + zoneId: string, + id: string, + payload: CfHealthCheckPayload, + ): Promise { + return this.healthchecks.updateHealthCheck(zoneId, id, payload); + } + + deleteHealthCheck(zoneId: string, id: string): Promise { + return this.healthchecks.deleteHealthCheck(zoneId, id); } } diff --git a/apps/api/src/lib/cloudflare/dns-service.ts b/apps/api/src/lib/cloudflare/dns-service.ts new file mode 100644 index 0000000..9dbd9c0 --- /dev/null +++ b/apps/api/src/lib/cloudflare/dns-service.ts @@ -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 { + 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( + response, + "list_dns_records", + ); + if (batch.length === 0) break; + all.push(...batch); + page += 1; + } + return all; + }); + }, + + async createDnsRecord( + zoneId: string, + payload: CreateDnsRecordPayload, + ): Promise { + 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 { + 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 { + 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 { + 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"); + }, + }; +} diff --git a/apps/api/src/lib/cloudflare/healthcheck-service.ts b/apps/api/src/lib/cloudflare/healthcheck-service.ts new file mode 100644 index 0000000..bd1d91d --- /dev/null +++ b/apps/api/src/lib/cloudflare/healthcheck-service.ts @@ -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; + 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 { + 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 { + 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 { + 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 { + 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 { + 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"); + }, + }; +} diff --git a/apps/api/src/lib/cloudflare/http.ts b/apps/api/src/lib/cloudflare/http.ts new file mode 100644 index 0000000..5e6709b --- /dev/null +++ b/apps/api/src/lib/cloudflare/http.ts @@ -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 { + 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( + response: Response, + operation: string, +): Promise { + 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; + 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( + token: string, + path: string, + operation: string, + init: RequestInit = {}, +): Promise { + 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(response, operation); +} + +export type DnsRecordResult = CfDnsRecord; diff --git a/apps/api/src/lib/cloudflare/zone-service.ts b/apps/api/src/lib/cloudflare/zone-service.ts new file mode 100644 index 0000000..8d91f70 --- /dev/null +++ b/apps/api/src/lib/cloudflare/zone-service.ts @@ -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 { + 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(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 { + const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(30_000), + }); + return handleCfResponse(response, "get_zone"); + }, + }; +} diff --git a/apps/api/src/lib/permissions.ts b/apps/api/src/lib/permissions.ts index bd7fa31..2cd8311 100644 --- a/apps/api/src/lib/permissions.ts +++ b/apps/api/src/lib/permissions.ts @@ -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", }, { diff --git a/apps/api/src/routes/health-check.ts b/apps/api/src/routes/health-check.ts index 5a2363f..0270aeb 100644 --- a/apps/api/src/routes/health-check.ts +++ b/apps/api/src/routes/health-check.ts @@ -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, diff --git a/apps/api/src/routes/origin-health-checks.ts b/apps/api/src/routes/origin-health-checks.ts new file mode 100644 index 0000000..afa54ae --- /dev/null +++ b/apps/api/src/routes/origin-health-checks.ts @@ -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), + ); + }); +} diff --git a/apps/api/src/routes/service-bindings.ts b/apps/api/src/routes/service-bindings.ts index 0a76426..4b9a203 100644 --- a/apps/api/src/routes/service-bindings.ts +++ b/apps/api/src/routes/service-bindings.ts @@ -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)); diff --git a/apps/api/src/routes/services.ts b/apps/api/src/routes/services.ts index 2f0bda7..c8f9888 100644 --- a/apps/api/src/routes/services.ts +++ b/apps/api/src/routes/services.ts @@ -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 }; diff --git a/apps/api/src/services/change-domain-service.ts b/apps/api/src/services/change-domain-service.ts new file mode 100644 index 0000000..35d8758 --- /dev/null +++ b/apps/api/src/services/change-domain-service.ts @@ -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 { + 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}. Старые записи зоны удалены.`, + }; +} diff --git a/apps/api/src/services/change-ip-service.ts b/apps/api/src/services/change-ip-service.ts new file mode 100644 index 0000000..639f6a1 --- /dev/null +++ b/apps/api/src/services/change-ip-service.ts @@ -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 { + 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 { + 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.`, + }; + }); +} diff --git a/apps/api/src/services/dns-service.ts b/apps/api/src/services/dns-service.ts index 09ccf3a..d202019 100644 --- a/apps/api/src/services/dns-service.ts +++ b/apps/api/src/services/dns-service.ts @@ -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 { + 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, diff --git a/apps/api/src/services/health-check-service.ts b/apps/api/src/services/health-check-service.ts index b8a876a..6be5e09 100644 --- a/apps/api/src/services/health-check-service.ts +++ b/apps/api/src/services/health-check-service.ts @@ -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, diff --git a/apps/api/src/services/health/cloudflare.ts b/apps/api/src/services/health/cloudflare.ts new file mode 100644 index 0000000..05c988f --- /dev/null +++ b/apps/api/src/services/health/cloudflare.ts @@ -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 { + 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 { + 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 { + 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 { + if (!check.cf_zone_id || !check.cf_healthcheck_id) return; + await this.cf.deleteHealthCheck(check.cf_zone_id, check.cf_healthcheck_id); + } +} diff --git a/apps/api/src/services/health/local.ts b/apps/api/src/services/health/local.ts new file mode 100644 index 0000000..cb9aaa6 --- /dev/null +++ b/apps/api/src/services/health/local.ts @@ -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 { + return probeTarget(target); + } +} diff --git a/apps/api/src/services/health/provider.ts b/apps/api/src/services/health/provider.ts new file mode 100644 index 0000000..bf8c7f1 --- /dev/null +++ b/apps/api/src/services/health/provider.ts @@ -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; +} diff --git a/apps/api/src/services/health/state-machine.ts b/apps/api/src/services/health/state-machine.ts new file mode 100644 index 0000000..663a833 --- /dev/null +++ b/apps/api/src/services/health/state-machine.ts @@ -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"; +} diff --git a/apps/api/src/services/node-service.ts b/apps/api/src/services/node-service.ts new file mode 100644 index 0000000..a57ba22 --- /dev/null +++ b/apps/api/src/services/node-service.ts @@ -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 { + 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(); + 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, + }; +} diff --git a/apps/api/src/services/origin-health-check-service.ts b/apps/api/src/services/origin-health-check-service.ts new file mode 100644 index 0000000..02217ae --- /dev/null +++ b/apps/api/src/services/origin-health-check-service.ts @@ -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 { + 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, +): Promise { + 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 { + 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 { + 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); +} diff --git a/apps/api/src/services/routing/binding-lock.ts b/apps/api/src/services/routing/binding-lock.ts new file mode 100644 index 0000000..6b735a5 --- /dev/null +++ b/apps/api/src/services/routing/binding-lock.ts @@ -0,0 +1,25 @@ +const locks = new Map>(); + +export async function withBindingLock( + bindingId: number, + fn: () => Promise, +): Promise { + const previous = locks.get(bindingId) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((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); + } + } +} diff --git a/apps/api/src/services/routing/failover.ts b/apps/api/src/services/routing/failover.ts new file mode 100644 index 0000000..efbdad3 --- /dev/null +++ b/apps/api/src/services/routing/failover.ts @@ -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]; +} diff --git a/apps/api/src/services/routing/health.ts b/apps/api/src/services/routing/health.ts new file mode 100644 index 0000000..554118f --- /dev/null +++ b/apps/api/src/services/routing/health.ts @@ -0,0 +1,5 @@ +import type { IpHealthState, NodeHealthState } from "@cfdm/shared"; + +export function isHealthy(state: IpHealthState | NodeHealthState | string): boolean { + return state === "up" || state === "healthy"; +} diff --git a/apps/api/src/services/routing/index.ts b/apps/api/src/services/routing/index.ts new file mode 100644 index 0000000..fa26c3e --- /dev/null +++ b/apps/api/src/services/routing/index.ts @@ -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"; +} diff --git a/apps/api/src/services/routing/round-robin.ts b/apps/api/src/services/routing/round-robin.ts new file mode 100644 index 0000000..345c70c --- /dev/null +++ b/apps/api/src/services/routing/round-robin.ts @@ -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); +} diff --git a/apps/api/src/services/routing/types.ts b/apps/api/src/services/routing/types.ts new file mode 100644 index 0000000..ab09a03 --- /dev/null +++ b/apps/api/src/services/routing/types.ts @@ -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; +} diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index 1522428..e01f55b 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -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 { + 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 { 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; } diff --git a/apps/api/test/cf-healthcheck.test.ts b/apps/api/test/cf-healthcheck.test.ts new file mode 100644 index 0000000..ad90979 --- /dev/null +++ b/apps/api/test/cf-healthcheck.test.ts @@ -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>) { + 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(); + }); +}); diff --git a/apps/api/test/change-domain.test.ts b/apps/api/test/change-domain.test.ts new file mode 100644 index 0000000..063df80 --- /dev/null +++ b/apps/api/test/change-domain.test.ts @@ -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(); + }); +}); diff --git a/apps/api/test/change-ip.test.ts b/apps/api/test/change-ip.test.ts new file mode 100644 index 0000000..6c63593 --- /dev/null +++ b/apps/api/test/change-ip.test.ts @@ -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]); + }); +}); diff --git a/apps/api/test/health-state-machine.test.ts b/apps/api/test/health-state-machine.test.ts new file mode 100644 index 0000000..71f6582 --- /dev/null +++ b/apps/api/test/health-state-machine.test.ts @@ -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"); + }); +}); diff --git a/apps/api/test/lb-reconcile.test.ts b/apps/api/test/lb-reconcile.test.ts index 2ec30d7..2a278e6 100644 --- a/apps/api/test/lb-reconcile.test.ts +++ b/apps/api/test/lb-reconcile.test.ts @@ -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", diff --git a/apps/api/test/nodes-api.test.ts b/apps/api/test/nodes-api.test.ts new file mode 100644 index 0000000..472e6db --- /dev/null +++ b/apps/api/test/nodes-api.test.ts @@ -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>) { + 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(); + }); +}); diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 356a1f0..154eb23 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -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() { + diff --git a/apps/web/src/components/change-domain-sheet.tsx b/apps/web/src/components/change-domain-sheet.tsx new file mode 100644 index 0000000..2ab175e --- /dev/null +++ b/apps/web/src/components/change-domain-sheet.tsx @@ -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({ + defaultValues: { + from_domain_id: fromDomainId ? String(fromDomainId) : '', + to_domain_id: '', + }, + }) + const [confirmOpen, setConfirmOpen] = useState(false) + const [preview, setPreview] = useState(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 ( + <> + { + setPreview( + fromZone && toZone + ? `${fromZone} → ${toZone}` + : 'Проверьте выбранные зоны', + ) + setConfirmOpen(true) + }} + footer={ + <> + + + + } + > + + + + + + + {fromZone && toZone ? ( + + Предпросмотр FQDN + + Привязки будут перенесены из {fromZone} в {toZone}. Старые DNS-записи + исходной зоны будут удалены. + + + ) : null} + + mutate.mutate()} + /> + + ) +} diff --git a/apps/web/src/components/change-ip-sheet.tsx b/apps/web/src/components/change-ip-sheet.tsx new file mode 100644 index 0000000..93ff071 --- /dev/null +++ b/apps/web/src/components/change-ip-sheet.tsx @@ -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({ + defaultValues: { from_ip: currentIp ?? '', to_ip: '', node_id: '' }, + }) + const [preview, setPreview] = useState(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 ( + mutate.mutate()} + footer={ + <> + + + Сменить IP + + + } + > + + + + {nodes.length > 0 ? ( + + + + ) : null} + + + + {previewText ? ( + + Предпросмотр + {preview ?? previewText} + + ) : null} + + ) +} diff --git a/apps/web/src/components/failover-timeline.tsx b/apps/web/src/components/failover-timeline.tsx new file mode 100644 index 0000000..21571d1 --- /dev/null +++ b/apps/web/src/components/failover-timeline.tsx @@ -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 ( +

+ Событий failover пока нет. +

+ ) + } + + return ( + + {events.map((event, index) => ( + + + + + {event.title} + + {event.detail} + + ))} + + ) +} diff --git a/apps/web/src/components/health-check-badge.tsx b/apps/web/src/components/health-check-badge.tsx index 3336ce7..41619ea 100644 --- a/apps/web/src/components/health-check-badge.tsx +++ b/apps/web/src/components/health-check-badge.tsx @@ -7,6 +7,20 @@ import { formatDate } from '@/lib/format' type BadgeVariant = NonNullable['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 = { up: 'success-light', degraded: 'warning-light', @@ -21,6 +35,13 @@ const healthLabels: Record = { unknown: '—', } +const extraLabels: Partial> = { + healthy: 'Healthy', + unhealthy: 'Unhealthy', + checking: 'Checking', + disabled: 'Disabled', +} + const dotColor: Record = { up: 'bg-success', degraded: 'bg-warning', @@ -29,7 +50,7 @@ const dotColor: Record = { } 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)} > {label} diff --git a/apps/web/src/components/health-check-config-fields.tsx b/apps/web/src/components/health-check-config-fields.tsx index 5e31047..f9696b4 100644 --- a/apps/web/src/components/health-check-config-fields.tsx +++ b/apps/web/src/components/health-check-config-fields.tsx @@ -32,6 +32,7 @@ export interface HealthCheckConfig { interval_sec: number timeout_ms: number verify_tls: boolean + provider?: 'local' | 'cloudflare' } export interface LbAndHealthConfig extends HealthCheckConfig { @@ -136,6 +137,29 @@ export function HealthCheckConfigFields({ ) : null} + + + + + + } + > + Обзор + onEditService(original.service)}> Изменить diff --git a/apps/web/src/queries/services.ts b/apps/web/src/queries/services.ts index 26f42f6..a5e3ca7 100644 --- a/apps/web/src/queries/services.ts +++ b/apps/web/src/queries/services.ts @@ -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) { + return api.post('/api/v1/health-checks', body) +} + +export async function listOriginHealthChecks() { + return api.get('/api/v1/health-checks') +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 0c8247c..0ddaab0 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,49 +9,55 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as AuthRouteImport } from './routes/_auth' import { Route as LoginRouteImport } from './routes/login' +import { Route as AuthRouteImport } from './routes/_auth' import { Route as AuthIndexRouteImport } from './routes/_auth/index' -import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates' -import { Route as AuthGroupsRouteImport } from './routes/_auth/groups' -import { Route as AuthServicesRouteImport } from './routes/_auth/services' -import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route' import { Route as AuthCallbackRouteImport } from './routes/auth.callback' -import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index' -import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId' +import { Route as AuthGroupsRouteImport } from './routes/_auth/groups' +import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates' +import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route' import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index' -import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance' +import { Route as AuthServicesIndexRouteImport } from './routes/_auth/services/index' +import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index' import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations' +import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance' +import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId' +import { Route as AuthServicesServiceIdRouteRouteImport } from './routes/_auth/services/$serviceId/route' +import { Route as AuthServicesServiceIdIndexRouteImport } from './routes/_auth/services/$serviceId/index' import { Route as AuthDomainsDomainIdIndexRouteImport } from './routes/_auth/domains/$domainId/index' +import { Route as AuthServicesServiceIdSubdomainsRouteImport } from './routes/_auth/services/$serviceId/subdomains' +import { Route as AuthServicesServiceIdRoutingRouteImport } from './routes/_auth/services/$serviceId/routing' +import { Route as AuthServicesServiceIdNodesRouteImport } from './routes/_auth/services/$serviceId/nodes' +import { Route as AuthServicesServiceIdHealthRouteImport } from './routes/_auth/services/$serviceId/health' import { Route as AuthDomainsDomainIdDnsRouteImport } from './routes/_auth/domains/$domainId/dns' -const AuthRoute = AuthRouteImport.update({ - id: '/_auth', - getParentRoute: () => rootRouteImport, -} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', getParentRoute: () => rootRouteImport, } as any) +const AuthRoute = AuthRouteImport.update({ + id: '/_auth', + getParentRoute: () => rootRouteImport, +} as any) const AuthIndexRoute = AuthIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => AuthRoute, } as any) -const AuthCertificatesRoute = AuthCertificatesRouteImport.update({ - id: '/certificates', - path: '/certificates', - getParentRoute: () => AuthRoute, +const AuthCallbackRoute = AuthCallbackRouteImport.update({ + id: '/auth/callback', + path: '/auth/callback', + getParentRoute: () => rootRouteImport, } as any) const AuthGroupsRoute = AuthGroupsRouteImport.update({ id: '/groups', path: '/groups', getParentRoute: () => AuthRoute, } as any) -const AuthServicesRoute = AuthServicesRouteImport.update({ - id: '/services', - path: '/services', +const AuthCertificatesRoute = AuthCertificatesRouteImport.update({ + id: '/certificates', + path: '/certificates', getParentRoute: () => AuthRoute, } as any) const AuthSettingsRouteRoute = AuthSettingsRouteRouteImport.update({ @@ -59,30 +65,20 @@ const AuthSettingsRouteRoute = AuthSettingsRouteRouteImport.update({ path: '/settings', getParentRoute: () => AuthRoute, } as any) -const AuthCallbackRoute = AuthCallbackRouteImport.update({ - id: '/auth/callback', - path: '/auth/callback', - getParentRoute: () => rootRouteImport, -} as any) -const AuthDomainsIndexRoute = AuthDomainsIndexRouteImport.update({ - id: '/domains/', - path: '/domains/', - getParentRoute: () => AuthRoute, -} as any) -const AuthGroupsGroupIdRoute = AuthGroupsGroupIdRouteImport.update({ - id: '/$groupId', - path: '/$groupId', - getParentRoute: () => AuthGroupsRoute, -} as any) const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => AuthSettingsRouteRoute, } as any) -const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({ - id: '/appearance', - path: '/appearance', - getParentRoute: () => AuthSettingsRouteRoute, +const AuthServicesIndexRoute = AuthServicesIndexRouteImport.update({ + id: '/services/', + path: '/services/', + getParentRoute: () => AuthRoute, +} as any) +const AuthDomainsIndexRoute = AuthDomainsIndexRouteImport.update({ + id: '/domains/', + path: '/domains/', + getParentRoute: () => AuthRoute, } as any) const AuthSettingsIntegrationsRoute = AuthSettingsIntegrationsRouteImport.update({ @@ -90,12 +86,58 @@ const AuthSettingsIntegrationsRoute = path: '/integrations', getParentRoute: () => AuthSettingsRouteRoute, } as any) +const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({ + id: '/appearance', + path: '/appearance', + getParentRoute: () => AuthSettingsRouteRoute, +} as any) +const AuthGroupsGroupIdRoute = AuthGroupsGroupIdRouteImport.update({ + id: '/$groupId', + path: '/$groupId', + getParentRoute: () => AuthGroupsRoute, +} as any) +const AuthServicesServiceIdRouteRoute = + AuthServicesServiceIdRouteRouteImport.update({ + id: '/services/$serviceId', + path: '/services/$serviceId', + getParentRoute: () => AuthRoute, + } as any) +const AuthServicesServiceIdIndexRoute = + AuthServicesServiceIdIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AuthServicesServiceIdRouteRoute, + } as any) const AuthDomainsDomainIdIndexRoute = AuthDomainsDomainIdIndexRouteImport.update({ id: '/domains/$domainId/', path: '/domains/$domainId/', getParentRoute: () => AuthRoute, } as any) +const AuthServicesServiceIdSubdomainsRoute = + AuthServicesServiceIdSubdomainsRouteImport.update({ + id: '/subdomains', + path: '/subdomains', + getParentRoute: () => AuthServicesServiceIdRouteRoute, + } as any) +const AuthServicesServiceIdRoutingRoute = + AuthServicesServiceIdRoutingRouteImport.update({ + id: '/routing', + path: '/routing', + getParentRoute: () => AuthServicesServiceIdRouteRoute, + } as any) +const AuthServicesServiceIdNodesRoute = + AuthServicesServiceIdNodesRouteImport.update({ + id: '/nodes', + path: '/nodes', + getParentRoute: () => AuthServicesServiceIdRouteRoute, + } as any) +const AuthServicesServiceIdHealthRoute = + AuthServicesServiceIdHealthRouteImport.update({ + id: '/health', + path: '/health', + getParentRoute: () => AuthServicesServiceIdRouteRoute, + } as any) const AuthDomainsDomainIdDnsRoute = AuthDomainsDomainIdDnsRouteImport.update({ id: '/domains/$domainId/dns', path: '/domains/$domainId/dns', @@ -108,30 +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 { @@ -210,13 +286,6 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/_auth': { - id: '/_auth' - path: '' - fullPath: '/' - preLoaderRoute: typeof AuthRouteImport - parentRoute: typeof rootRouteImport - } '/login': { id: '/login' path: '/login' @@ -224,6 +293,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } + '/_auth': { + id: '/_auth' + path: '' + fullPath: '/' + preLoaderRoute: typeof AuthRouteImport + parentRoute: typeof rootRouteImport + } '/_auth/': { id: '/_auth/' path: '/' @@ -231,12 +307,12 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthIndexRouteImport parentRoute: typeof AuthRoute } - '/_auth/certificates': { - id: '/_auth/certificates' - path: '/certificates' - fullPath: '/certificates' - preLoaderRoute: typeof AuthCertificatesRouteImport - parentRoute: typeof AuthRoute + '/auth/callback': { + id: '/auth/callback' + path: '/auth/callback' + fullPath: '/auth/callback' + preLoaderRoute: typeof AuthCallbackRouteImport + parentRoute: typeof rootRouteImport } '/_auth/groups': { id: '/_auth/groups' @@ -245,11 +321,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthGroupsRouteImport parentRoute: typeof AuthRoute } - '/_auth/services': { - id: '/_auth/services' - path: '/services' - fullPath: '/services' - preLoaderRoute: typeof AuthServicesRouteImport + '/_auth/certificates': { + id: '/_auth/certificates' + path: '/certificates' + fullPath: '/certificates' + preLoaderRoute: typeof AuthCertificatesRouteImport parentRoute: typeof AuthRoute } '/_auth/settings': { @@ -259,12 +335,19 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthSettingsRouteRouteImport parentRoute: typeof AuthRoute } - '/auth/callback': { - id: '/auth/callback' - path: '/auth/callback' - fullPath: '/auth/callback' - preLoaderRoute: typeof AuthCallbackRouteImport - parentRoute: typeof rootRouteImport + '/_auth/settings/': { + id: '/_auth/settings/' + path: '/' + fullPath: '/settings/' + preLoaderRoute: typeof AuthSettingsIndexRouteImport + parentRoute: typeof AuthSettingsRouteRoute + } + '/_auth/services/': { + id: '/_auth/services/' + path: '/services' + fullPath: '/services/' + preLoaderRoute: typeof AuthServicesIndexRouteImport + parentRoute: typeof AuthRoute } '/_auth/domains/': { id: '/_auth/domains/' @@ -273,18 +356,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthDomainsIndexRouteImport parentRoute: typeof AuthRoute } - '/_auth/groups/$groupId': { - id: '/_auth/groups/$groupId' - path: '/$groupId' - fullPath: '/groups/$groupId' - preLoaderRoute: typeof AuthGroupsGroupIdRouteImport - parentRoute: typeof AuthGroupsRoute - } - '/_auth/settings/': { - id: '/_auth/settings/' - path: '/' - fullPath: '/settings/' - preLoaderRoute: typeof AuthSettingsIndexRouteImport + '/_auth/settings/integrations': { + id: '/_auth/settings/integrations' + path: '/integrations' + fullPath: '/settings/integrations' + preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport parentRoute: typeof AuthSettingsRouteRoute } '/_auth/settings/appearance': { @@ -294,12 +370,26 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthSettingsAppearanceRouteImport parentRoute: typeof AuthSettingsRouteRoute } - '/_auth/settings/integrations': { - id: '/_auth/settings/integrations' - path: '/integrations' - fullPath: '/settings/integrations' - preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport - parentRoute: typeof AuthSettingsRouteRoute + '/_auth/groups/$groupId': { + id: '/_auth/groups/$groupId' + path: '/$groupId' + fullPath: '/groups/$groupId' + preLoaderRoute: typeof AuthGroupsGroupIdRouteImport + parentRoute: typeof AuthGroupsRoute + } + '/_auth/services/$serviceId': { + id: '/_auth/services/$serviceId' + path: '/services/$serviceId' + fullPath: '/services/$serviceId' + preLoaderRoute: typeof AuthServicesServiceIdRouteRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/services/$serviceId/': { + id: '/_auth/services/$serviceId/' + path: '/' + fullPath: '/services/$serviceId/' + preLoaderRoute: typeof AuthServicesServiceIdIndexRouteImport + parentRoute: typeof AuthServicesServiceIdRouteRoute } '/_auth/domains/$domainId/': { id: '/_auth/domains/$domainId/' @@ -308,6 +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, } diff --git a/apps/web/src/routes/_auth/index.tsx b/apps/web/src/routes/_auth/index.tsx index e38d2c0..289a211 100644 --- a/apps/web/src/routes/_auth/index.tsx +++ b/apps/web/src/routes/_auth/index.tsx @@ -7,6 +7,7 @@ import { FolderTreeIcon, GlobeIcon, PlugIcon, + HeartPulseIcon, ServerIcon, ShieldCheckIcon, } from 'lucide-react' @@ -15,6 +16,7 @@ import { certSummaryQueryOptions, domainsListQueryOptions, groupsQueryOptions, + opsSummaryQueryOptions, serviceGroupsQueryOptions, } from '@/queries' import { PageShell } from '@/components/page-shell' @@ -50,23 +52,18 @@ export const Route = createFileRoute('/_auth/')({ queryClient.ensureQueryData(groupsQueryOptions()), queryClient.ensureQueryData(serviceGroupsQueryOptions()), queryClient.ensureQueryData(certificatesQueryOptions()), + queryClient.ensureQueryData(opsSummaryQueryOptions()), ]), component: DashboardPage, }) -function countByStatus(summary: [string, number][] | undefined, statuses: string[]) { - if (!summary) return 0 - return summary - .filter(([status]) => statuses.includes(status)) - .reduce((sum, [, count]) => sum + count, 0) -} - function DashboardPage() { const { data: domains, isLoading: domainsLoading } = useQuery(domainsListQueryOptions()) const { data: summary, isLoading: summaryLoading } = useQuery(certSummaryQueryOptions()) const { data: certs } = useQuery(certificatesQueryOptions()) const { data: groups } = useQuery(groupsQueryOptions()) const { data: serviceData } = useQuery(serviceGroupsQueryOptions()) + const { data: ops } = useQuery(opsSummaryQueryOptions()) const { data: appSettings } = useQuery({ queryKey: ['app-settings'], queryFn: () => api.get<{ showQuickActions?: boolean }>('/api/v1/settings'), @@ -202,8 +199,7 @@ function DashboardPage() { const attentionCount = attentionServices.length - const certWarnings = countByStatus(summary, ['warning', 'expired', 'error']) - const certOk = countByStatus(summary, ['active', 'ok']) + const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0 useEffect(() => { if (isLoading) return @@ -224,7 +220,6 @@ function DashboardPage() { ) }, [isLoading, summary, statusChartData, groupChartData, domains, groups]) - const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0 const kpiCards: KpiStatCard[] = [ { id: 'domains', @@ -239,19 +234,10 @@ function DashboardPage() { variant: ungroupedCount > 0 ? 'warning' : 'default', to: '/domains', }, - { - id: 'groups', - label: 'Группы', - value: groups?.length ?? 0, - hint: `${groupChartData.filter((g) => g.count > 0).length} с зонами`, - icon: , - iconClassName: 'text-primary', - to: '/groups', - }, { id: 'services', label: 'Сервисы', - value: serviceCount, + value: ops?.services ?? serviceCount, hint: attentionCount ? `${attentionCount} требуют внимания` : ungroupedServiceCount @@ -264,17 +250,37 @@ function DashboardPage() { search: { domainId: undefined }, }, { - id: 'certs', - label: 'Сертификаты', - value: certs?.length ?? 0, - hint: - certWarnings > 0 - ? `${certOk} в норме · ${certWarnings} внимания` - : `${certOk} в норме`, - icon: , + id: 'nodes', + label: 'Ноды', + value: ops?.nodes ?? 0, + hint: `${ops?.healthy ?? 0} healthy`, + icon: , + iconClassName: 'text-info', + to: '/services', + }, + { + id: 'healthy', + label: 'Healthy', + value: ops?.healthy ?? 0, + icon: , + iconClassName: 'text-success', + }, + { + id: 'unhealthy', + label: 'Unhealthy', + value: ops?.unhealthy ?? 0, + variant: (ops?.unhealthy ?? 0) > 0 ? 'destructive' : 'default', + icon: , + iconClassName: 'text-destructive', + }, + { + id: 'failovers', + label: 'Failover', + value: ops?.active_failovers ?? 0, + hint: 'активные переключения', + variant: (ops?.active_failovers ?? 0) > 0 ? 'warning' : 'default', + icon: , iconClassName: 'text-warning', - variant: certWarnings > 0 ? 'warning' : 'default', - to: '/certificates', }, ] diff --git a/apps/web/src/routes/_auth/services/$serviceId/health.tsx b/apps/web/src/routes/_auth/services/$serviceId/health.tsx new file mode 100644 index 0000000..8d3517a --- /dev/null +++ b/apps/web/src/routes/_auth/services/$serviceId/health.tsx @@ -0,0 +1,158 @@ +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@cfdm/ui/components/select' +import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert' +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 ( + + setOpen(true)}> + Добавить проверку + + } + /> + {checks.length === 0 ? ( + + ) : ( +
+ {checks.map((check) => ( +
+
+ {check.name} + + {check.provider} · {check.protocol} + +
+
+ ))} +
+ )} + createMut.mutate(values)} + footer={ + + Сохранить + + } + > + + + + + + + {provider === 'cloudflare' ? ( + + Cloudflare Health Checks + + Если зона не поддерживает Health Checks, вернётся ошибка плана — останется + Local. Workers не используются. + + + ) : null} + + + + +
+ ) +} diff --git a/apps/web/src/routes/_auth/services/$serviceId/index.tsx b/apps/web/src/routes/_auth/services/$serviceId/index.tsx new file mode 100644 index 0000000..42fc9df --- /dev/null +++ b/apps/web/src/routes/_auth/services/$serviceId/index.tsx @@ -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 ( + + ) + } + + const nodes = overview.nodes ?? [] + const domains = overview.service.domains ?? [] + + return ( + + + } + /> + , + label: 'Поддомены', + description: + domains.length > 0 + ? domains.map((d) => d.fqdn).join(', ') + : 'Нет привязанных FQDN', + footer: {domains.length}, + }, + { + id: 'nodes', + icon: , + label: 'Ноды', + description: + nodes.length > 0 + ? nodes.map((n) => n.address).join(', ') + : 'Добавьте ноду, чтобы публиковать DNS', + footer: {nodes.length}, + }, + { + id: 'health', + icon: , + label: 'Пул', + description: + overview.active_addresses.length > 0 + ? 'Здоровые адреса участвуют в DNS' + : 'unknown не попадает в пул, пока не станет healthy', + }, + ]} + /> + {domains.length === 0 && nodes.length === 0 ? ( + + ) : null} + + ) +} diff --git a/apps/web/src/routes/_auth/services/$serviceId/nodes.tsx b/apps/web/src/routes/_auth/services/$serviceId/nodes.tsx new file mode 100644 index 0000000..8c416d9 --- /dev/null +++ b/apps/web/src/routes/_auth/services/$serviceId/nodes.tsx @@ -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 ( + + setOpen(true)}> + + Добавить ноду + + } + /> + {nodes.length === 0 ? ( + + ) : ( +
+ {nodes.map((node) => ( +
+
+ {node.address} + + {node.protocol} + {node.port ? `:${node.port}` : ''} · вес {node.weight} · приоритет{' '} + {node.priority} + +
+
+ + +
+
+ ))} +
+ )} + createMut.mutate(values)} + footer={ + + Добавить + + } + > + + + + + + + +
+ ) +} diff --git a/apps/web/src/routes/_auth/services/$serviceId/route.tsx b/apps/web/src/routes/_auth/services/$serviceId/route.tsx new file mode 100644 index 0000000..74d7fd5 --- /dev/null +++ b/apps/web/src/routes/_auth/services/$serviceId/route.tsx @@ -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 ( + + } + > + + К каталогу + + } + /> + + void overview.refetch()} + > + + + + ) +} diff --git a/apps/web/src/routes/_auth/services/$serviceId/routing.tsx b/apps/web/src/routes/_auth/services/$serviceId/routing.tsx new file mode 100644 index 0000000..0b77080 --- /dev/null +++ b/apps/web/src/routes/_auth/services/$serviceId/routing.tsx @@ -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 ( + + {overview?.routing_strategy ?? 'round_robin'}} + /> +

+ Активные адреса:{' '} + {overview?.active_addresses.join(', ') || 'нет (unknown не в пуле)'} +

+

+ Запись обновляется в Cloudflare. Распространение зависит от TTL. +

+ +
+ ) +} diff --git a/apps/web/src/routes/_auth/services/$serviceId/subdomains.tsx b/apps/web/src/routes/_auth/services/$serviceId/subdomains.tsx new file mode 100644 index 0000000..0231d82 --- /dev/null +++ b/apps/web/src/routes/_auth/services/$serviceId/subdomains.tsx @@ -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 ( + + setChangeDomain(true)} + disabled={rows.length === 0} + > + + Сменить домен + + } + /> + {rows.length === 0 ? ( + + ) : ( +
+ {rows.map((row) => ( +
+
+ {row.fqdn} + + {row.zone_name} · {row.target_ips.join(', ') || 'нет IP'} + +
+
+ {row.target_ips.length} IP + +
+
+ ))} +
+ )} + { + if (!open) setChangeIp(null) + }} + bindingId={changeIp?.bindingId ?? null} + serviceId={id} + currentIp={changeIp?.ip} + /> + +
+ ) +} diff --git a/apps/web/src/routes/_auth/services.tsx b/apps/web/src/routes/_auth/services/index.tsx similarity index 99% rename from apps/web/src/routes/_auth/services.tsx rename to apps/web/src/routes/_auth/services/index.tsx index e52bd53..ccc8a43 100644 --- a/apps/web/src/routes/_auth/services.tsx +++ b/apps/web/src/routes/_auth/services/index.tsx @@ -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, ): { domainId?: number; serviceId?: number; view?: 'board' } => ({ diff --git a/docs/Home.md b/docs/Home.md index 1d2b296..8df1f3b 100644 --- a/docs/Home.md +++ b/docs/Home.md @@ -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 diff --git a/packages/db/dist/index.d.ts b/packages/db/dist/index.d.ts index 9a0acb2..57b1a9d 100644 --- a/packages/db/dist/index.d.ts +++ b/packages/db/dist/index.d.ts @@ -1,7 +1,7 @@ import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core'; import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; -import { AuditSourceApp, AuditSeverity, AuditTargetType, AuditLogEntry, LbMode, HealthCheckType, IpHealthState, HealthCheckScope, ServiceBinding, Domain, Group, Service, ServiceGroup, Subdomain, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared'; +import { AuditSourceApp, AuditSeverity, AuditTargetType, AuditLogEntry, LbMode, HealthCheckType, IpHealthState, HealthCheckScope, ServiceBinding, Domain, Group, OriginHealthCheck, ServiceNode, Service, ServiceGroup, Subdomain, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared'; declare const groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: "groups"; @@ -1499,6 +1499,42 @@ declare const serviceBindings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ identity: undefined; generated: undefined; }, {}, {}>; + routing_strategy: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "routing_strategy"; + tableName: "service_bindings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + operation_version: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "operation_version"; + tableName: "service_bindings"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "service_bindings"; @@ -1540,6 +1576,688 @@ declare const serviceBindings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ }; dialect: "sqlite"; }>; +declare const healthChecks: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "health_checks"; + schema: undefined; + columns: { + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: "health_checks"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + provider: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "provider"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + cf_healthcheck_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "cf_healthcheck_id"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + cf_zone_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "cf_zone_id"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + name: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "name"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + protocol: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "protocol"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + path: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "path"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + method: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "method"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + timeout: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "timeout"; + tableName: "health_checks"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + interval_sec: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "interval_sec"; + tableName: "health_checks"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + retries: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "retries"; + tableName: "health_checks"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + expected_status: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "expected_status"; + tableName: "health_checks"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + consecutive_fails: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "consecutive_fails"; + tableName: "health_checks"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + consecutive_successes: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "consecutive_successes"; + tableName: "health_checks"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + suspended: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "suspended"; + tableName: "health_checks"; + dataType: "boolean"; + columnType: "SQLiteBoolean"; + data: boolean; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + updated_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; +}>; +declare const nodes: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "nodes"; + schema: undefined; + columns: { + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: "nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + service_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "service_id"; + tableName: "nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + address: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "address"; + tableName: "nodes"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + protocol: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "protocol"; + tableName: "nodes"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + port: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "port"; + tableName: "nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + enabled: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "enabled"; + tableName: "nodes"; + dataType: "boolean"; + columnType: "SQLiteBoolean"; + data: boolean; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + priority: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "priority"; + tableName: "nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + weight: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "weight"; + tableName: "nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_status: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_status"; + tableName: "nodes"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_id"; + tableName: "nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + consecutive_failures: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "consecutive_failures"; + tableName: "nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + consecutive_successes: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "consecutive_successes"; + tableName: "nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + last_check_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_check_at"; + tableName: "nodes"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + last_failure_reason: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_failure_reason"; + tableName: "nodes"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; + tableName: "nodes"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + updated_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; + tableName: "nodes"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; +}>; +declare const bindingNodes: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "binding_nodes"; + schema: undefined; + columns: { + binding_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "binding_id"; + tableName: "binding_nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + node_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "node_id"; + tableName: "binding_nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + weight: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "weight"; + tableName: "binding_nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + priority: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "priority"; + tableName: "binding_nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "sqlite"; +}>; declare const serviceIps: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: "service_ips"; schema: undefined; @@ -2200,6 +2918,23 @@ declare const ipHealthStatus: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ identity: undefined; generated: undefined; }, {}, {}>; + consecutive_successes: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "consecutive_successes"; + tableName: "ip_health_status"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; last_checked_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "last_checked_at"; tableName: "ip_health_status"; @@ -4795,6 +5530,42 @@ declare const schema: { identity: undefined; generated: undefined; }, {}, {}>; + routing_strategy: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "routing_strategy"; + tableName: "service_bindings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + operation_version: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "operation_version"; + tableName: "service_bindings"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "service_bindings"; @@ -4836,6 +5607,688 @@ declare const schema: { }; dialect: "sqlite"; }>; + healthChecks: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "health_checks"; + schema: undefined; + columns: { + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: "health_checks"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + provider: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "provider"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + cf_healthcheck_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "cf_healthcheck_id"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + cf_zone_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "cf_zone_id"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + name: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "name"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + protocol: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "protocol"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + path: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "path"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + method: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "method"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + timeout: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "timeout"; + tableName: "health_checks"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + interval_sec: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "interval_sec"; + tableName: "health_checks"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + retries: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "retries"; + tableName: "health_checks"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + expected_status: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "expected_status"; + tableName: "health_checks"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + consecutive_fails: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "consecutive_fails"; + tableName: "health_checks"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + consecutive_successes: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "consecutive_successes"; + tableName: "health_checks"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + suspended: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "suspended"; + tableName: "health_checks"; + dataType: "boolean"; + columnType: "SQLiteBoolean"; + data: boolean; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + updated_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; + tableName: "health_checks"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; + }>; + nodes: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "nodes"; + schema: undefined; + columns: { + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: "nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + service_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "service_id"; + tableName: "nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + address: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "address"; + tableName: "nodes"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + protocol: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "protocol"; + tableName: "nodes"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + port: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "port"; + tableName: "nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + enabled: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "enabled"; + tableName: "nodes"; + dataType: "boolean"; + columnType: "SQLiteBoolean"; + data: boolean; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + priority: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "priority"; + tableName: "nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + weight: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "weight"; + tableName: "nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_status: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_status"; + tableName: "nodes"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_id"; + tableName: "nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + consecutive_failures: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "consecutive_failures"; + tableName: "nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + consecutive_successes: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "consecutive_successes"; + tableName: "nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + last_check_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_check_at"; + tableName: "nodes"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + last_failure_reason: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_failure_reason"; + tableName: "nodes"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; + tableName: "nodes"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + updated_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; + tableName: "nodes"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; + }>; + bindingNodes: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "binding_nodes"; + schema: undefined; + columns: { + binding_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "binding_id"; + tableName: "binding_nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + node_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "node_id"; + tableName: "binding_nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + weight: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "weight"; + tableName: "binding_nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + priority: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "priority"; + tableName: "binding_nodes"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "sqlite"; + }>; serviceIps: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: "service_ips"; schema: undefined; @@ -5496,6 +6949,23 @@ declare const schema: { identity: undefined; generated: undefined; }, {}, {}>; + consecutive_successes: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "consecutive_successes"; + tableName: "ip_health_status"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; last_checked_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "last_checked_at"; tableName: "ip_health_status"; @@ -6755,6 +8225,81 @@ declare function setServiceGroupEnabled(db: Db, id: number, enabled: boolean): S declare function deleteServiceGroup(db: Db, id: number): void; declare function listServiceIps(db: Db, serviceId: number): string[]; declare function replaceServiceIps(db: Db, serviceId: number, ips: string[]): void; +declare function listNodes(db: Db, serviceId: number): ServiceNode[]; +declare function getNode(db: Db, id: number): ServiceNode; +declare function findNodeByAddress(db: Db, serviceId: number, address: string): ServiceNode | null; +declare function findNodeByIp(db: Db, address: string): ServiceNode | null; +declare function ensureNode(db: Db, serviceId: number, address: string, meta?: { + weight?: number; + priority?: number; + protocol?: string; + port?: number | null; +}): ServiceNode; +declare 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; +declare 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; +declare function deleteNode(db: Db, id: number): void; +declare function listHealthChecks(db: Db): OriginHealthCheck[]; +declare function getHealthCheck(db: Db, id: number): OriginHealthCheck; +declare function findHealthCheckByCfId(db: Db, cfId: string): OriginHealthCheck | null; +declare 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; +declare 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; +declare function deleteHealthCheck(db: Db, id: number): void; +declare function bumpBindingVersion(db: Db, bindingId: number, expected?: number): number; +declare function setBindingRoutingStrategy(db: Db, bindingId: number, strategy: LbMode): void; +declare function listAllNodes(db: Db): ServiceNode[]; +declare function updateBindingDomain(db: Db, bindingId: number, domainId: number, hostname: string): void; +declare function listBindingNodes(db: Db, bindingId: number): ServiceNode[]; interface BindingIpMeta { ip: string; weight: number; @@ -6821,7 +8366,7 @@ declare function aggregateGroupScopeHealthByIds(db: Db, groupIds: number[]): Map declare function aggregateIpHealthByServiceIds(db: Db, serviceIds: number[]): Map; declare function mergeHealthAggregates(parts: Array): HealthAggregate; declare function getIpHealthStatusRow(db: Db, scope: HealthCheckScope, refId: number, ip: string): IpHealthStatus | null; -declare function upsertIpHealthStatus(db: Db, scope: HealthCheckScope, refId: number, ip: string, status: string, latencyMs: number | null, consecutiveFailures: number, lastError: string | null): void; +declare function upsertIpHealthStatus(db: Db, scope: HealthCheckScope, refId: number, ip: string, status: string, latencyMs: number | null, consecutiveFailures: number, lastError: string | null, consecutiveSuccesses?: number): void; declare function deleteIpHealthStatusForRef(db: Db, scope: HealthCheckScope, refId: number): void; declare function deleteIpHealthStatusForIp(db: Db, scope: HealthCheckScope, refId: number, ip: string): void; /** Drop health rows whose IP is no longer a live probe target for that scope/ref. @@ -6909,10 +8454,13 @@ declare const repos_aggregateGroupScopeHealthByIds: typeof aggregateGroupScopeHe declare const repos_aggregateIpHealthByRefs: typeof aggregateIpHealthByRefs; declare const repos_aggregateIpHealthByServiceIds: typeof aggregateIpHealthByServiceIds; declare const repos_bindingsToRemove: typeof bindingsToRemove; +declare const repos_bumpBindingVersion: typeof bumpBindingVersion; declare const repos_countCertificatesByStatus: typeof countCertificatesByStatus; declare const repos_createDomain: typeof createDomain; declare const repos_createDomainMonitor: typeof createDomainMonitor; declare const repos_createGroup: typeof createGroup; +declare const repos_createHealthCheck: typeof createHealthCheck; +declare const repos_createNode: typeof createNode; declare const repos_createService: typeof createService; declare const repos_createServiceGroup: typeof createServiceGroup; declare const repos_createSubdomain: typeof createSubdomain; @@ -6924,14 +8472,20 @@ declare const repos_deleteDnsRecord: typeof deleteDnsRecord; declare const repos_deleteDomain: typeof deleteDomain; declare const repos_deleteDomainMonitor: typeof deleteDomainMonitor; declare const repos_deleteGroup: typeof deleteGroup; +declare const repos_deleteHealthCheck: typeof deleteHealthCheck; declare const repos_deleteIpHealthStatusForIp: typeof deleteIpHealthStatusForIp; declare const repos_deleteIpHealthStatusForRef: typeof deleteIpHealthStatusForRef; +declare const repos_deleteNode: typeof deleteNode; declare const repos_deleteService: typeof deleteService; declare const repos_deleteServiceGroup: typeof deleteServiceGroup; declare const repos_deleteSubdomain: typeof deleteSubdomain; +declare const repos_ensureNode: typeof ensureNode; declare const repos_findBinding: typeof findBinding; declare const repos_findDnsByCfId: typeof findDnsByCfId; declare const repos_findDomainByZoneName: typeof findDomainByZoneName; +declare const repos_findHealthCheckByCfId: typeof findHealthCheckByCfId; +declare const repos_findNodeByAddress: typeof findNodeByAddress; +declare const repos_findNodeByIp: typeof findNodeByIp; declare const repos_findSubdomainByDomainAndName: typeof findSubdomainByDomainAndName; declare const repos_finishSyncJob: typeof finishSyncJob; declare const repos_getBinding: typeof getBinding; @@ -6942,7 +8496,9 @@ declare const repos_getDomain: typeof getDomain; declare const repos_getDomainMonitor: typeof getDomainMonitor; declare const repos_getGroup: typeof getGroup; declare const repos_getGroupWithStats: typeof getGroupWithStats; +declare const repos_getHealthCheck: typeof getHealthCheck; declare const repos_getIpHealthStatusRow: typeof getIpHealthStatusRow; +declare const repos_getNode: typeof getNode; declare const repos_getService: typeof getService; declare const repos_getServiceGroup: typeof getServiceGroup; declare const repos_getSubdomain: typeof getSubdomain; @@ -6954,9 +8510,11 @@ declare const repos_linkBindingRecord: typeof linkBindingRecord; declare const repos_linkGroupDnsRecord: typeof linkGroupDnsRecord; declare const repos_listAllBindings: typeof listAllBindings; declare const repos_listAllDomains: typeof listAllDomains; +declare const repos_listAllNodes: typeof listAllNodes; declare const repos_listAllSubdomains: typeof listAllSubdomains; declare const repos_listBindingIps: typeof listBindingIps; declare const repos_listBindingIpsWithMeta: typeof listBindingIpsWithMeta; +declare const repos_listBindingNodes: typeof listBindingNodes; declare const repos_listBindingsByDomain: typeof listBindingsByDomain; declare const repos_listBindingsByService: typeof listBindingsByService; declare const repos_listCertificates: typeof listCertificates; @@ -6972,7 +8530,9 @@ declare const repos_listEnabledDomainMonitors: typeof listEnabledDomainMonitors; declare const repos_listGroupDnsRecords: typeof listGroupDnsRecords; declare const repos_listGroups: typeof listGroups; declare const repos_listHealthCheckTargets: typeof listHealthCheckTargets; +declare const repos_listHealthChecks: typeof listHealthChecks; declare const repos_listIpHealthStatus: typeof listIpHealthStatus; +declare const repos_listNodes: typeof listNodes; declare const repos_listNotificationLog: typeof listNotificationLog; declare const repos_listOriginIpsForFqdn: typeof listOriginIpsForFqdn; declare const repos_listRecordsForBinding: typeof listRecordsForBinding; @@ -6991,6 +8551,7 @@ declare const repos_replaceBindingIpsWithMeta: typeof replaceBindingIpsWithMeta; declare const repos_replaceServiceIps: typeof replaceServiceIps; declare const repos_setBindingCnameTarget: typeof setBindingCnameTarget; declare const repos_setBindingDnsRecordId: typeof setBindingDnsRecordId; +declare const repos_setBindingRoutingStrategy: typeof setBindingRoutingStrategy; declare const repos_setDnsSyncStatus: typeof setDnsSyncStatus; declare const repos_setDomainLastSynced: typeof setDomainLastSynced; declare const repos_setDomainTags: typeof setDomainTags; @@ -7000,12 +8561,15 @@ declare const repos_setServiceGroupEnabled: typeof setServiceGroupEnabled; declare const repos_setServiceLb: typeof setServiceLb; declare const repos_unlinkBindingRecord: typeof unlinkBindingRecord; declare const repos_unlinkGroupDnsRecord: typeof unlinkGroupDnsRecord; +declare const repos_updateBindingDomain: typeof updateBindingDomain; declare const repos_updateBindingFields: typeof updateBindingFields; declare const repos_updateBindingLbConfig: typeof updateBindingLbConfig; declare const repos_updateDnsFields: typeof updateDnsFields; declare const repos_updateDomain: typeof updateDomain; declare const repos_updateDomainMonitorResult: typeof updateDomainMonitorResult; declare const repos_updateGroup: typeof updateGroup; +declare const repos_updateHealthCheck: typeof updateHealthCheck; +declare const repos_updateNode: typeof updateNode; declare const repos_updateService: typeof updateService; declare const repos_updateServiceGroup: typeof updateServiceGroup; declare const repos_updateSubdomain: typeof updateSubdomain; @@ -7013,7 +8577,7 @@ declare const repos_upsertCertificateCheck: typeof upsertCertificateCheck; declare const repos_upsertIpHealthStatus: typeof upsertIpHealthStatus; declare const repos_upsertSubdomain: typeof upsertSubdomain; declare namespace repos { - export { type repos_BindingIpMeta as BindingIpMeta, type repos_BindingLbPatch as BindingLbPatch, type repos_DnsListFilter as DnsListFilter, type repos_DomainMonitorRow as DomainMonitorRow, type repos_HealthAggregate as HealthAggregate, type repos_ServiceGroupLbPatch as ServiceGroupLbPatch, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_addDomainTags as addDomainTags, repos_aggregateGroupScopeHealthByIds as aggregateGroupScopeHealthByIds, repos_aggregateIpHealthByRefs as aggregateIpHealthByRefs, repos_aggregateIpHealthByServiceIds as aggregateIpHealthByServiceIds, repos_bindingsToRemove as bindingsToRemove, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createDomainMonitor as createDomainMonitor, repos_createGroup as createGroup, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteCertificatesNotIn as deleteCertificatesNotIn, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteDomainMonitor as deleteDomainMonitor, repos_deleteGroup as deleteGroup, repos_deleteIpHealthStatusForIp as deleteIpHealthStatusForIp, repos_deleteIpHealthStatusForRef as deleteIpHealthStatusForRef, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_findSubdomainByDomainAndName as findSubdomainByDomainAndName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getDomainMonitor as getDomainMonitor, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getIpHealthStatusRow as getIpHealthStatusRow, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_insertNotificationLog as insertNotificationLog, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingIpsWithMeta as listBindingIpsWithMeta, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomainMonitorResults as listDomainMonitorResults, repos_listDomainMonitorResultsForDomain as listDomainMonitorResultsForDomain, repos_listDomainMonitors as listDomainMonitors, repos_listDomainTags as listDomainTags, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listEnabledDomainMonitors as listEnabledDomainMonitors, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listHealthCheckTargets as listHealthCheckTargets, repos_listIpHealthStatus as listIpHealthStatus, repos_listNotificationLog as listNotificationLog, repos_listOriginIpsForFqdn as listOriginIpsForFqdn, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroup as listServicesByGroup, repos_listSubdomainsByDomain as listSubdomainsByDomain, repos_listUngroupedServices as listUngroupedServices, repos_markDnsPendingDelete as markDnsPendingDelete, repos_mergeHealthAggregates as mergeHealthAggregates, repos_pruneStaleIpHealthStatus as pruneStaleIpHealthStatus, repos_reorderServices as reorderServices, repos_replaceBindingIps as replaceBindingIps, repos_replaceBindingIpsWithMeta as replaceBindingIpsWithMeta, repos_replaceServiceIps as replaceServiceIps, repos_setBindingCnameTarget as setBindingCnameTarget, repos_setBindingDnsRecordId as setBindingDnsRecordId, repos_setDnsSyncStatus as setDnsSyncStatus, repos_setDomainLastSynced as setDomainLastSynced, repos_setDomainTags as setDomainTags, repos_setServiceEnabled as setServiceEnabled, repos_setServiceGroup as setServiceGroup, repos_setServiceGroupEnabled as setServiceGroupEnabled, repos_setServiceLb as setServiceLb, repos_unlinkBindingRecord as unlinkBindingRecord, repos_unlinkGroupDnsRecord as unlinkGroupDnsRecord, repos_updateBindingFields as updateBindingFields, repos_updateBindingLbConfig as updateBindingLbConfig, repos_updateDnsFields as updateDnsFields, repos_updateDomain as updateDomain, repos_updateDomainMonitorResult as updateDomainMonitorResult, repos_updateGroup as updateGroup, repos_updateService as updateService, repos_updateServiceGroup as updateServiceGroup, repos_updateSubdomain as updateSubdomain, repos_upsertCertificateCheck as upsertCertificateCheck, repos_upsertIpHealthStatus as upsertIpHealthStatus, repos_upsertSubdomain as upsertSubdomain }; + export { type repos_BindingIpMeta as BindingIpMeta, type repos_BindingLbPatch as BindingLbPatch, type repos_DnsListFilter as DnsListFilter, type repos_DomainMonitorRow as DomainMonitorRow, type repos_HealthAggregate as HealthAggregate, type repos_ServiceGroupLbPatch as ServiceGroupLbPatch, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_addDomainTags as addDomainTags, repos_aggregateGroupScopeHealthByIds as aggregateGroupScopeHealthByIds, repos_aggregateIpHealthByRefs as aggregateIpHealthByRefs, repos_aggregateIpHealthByServiceIds as aggregateIpHealthByServiceIds, repos_bindingsToRemove as bindingsToRemove, repos_bumpBindingVersion as bumpBindingVersion, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createDomainMonitor as createDomainMonitor, repos_createGroup as createGroup, repos_createHealthCheck as createHealthCheck, repos_createNode as createNode, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteCertificatesNotIn as deleteCertificatesNotIn, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteDomainMonitor as deleteDomainMonitor, repos_deleteGroup as deleteGroup, repos_deleteHealthCheck as deleteHealthCheck, repos_deleteIpHealthStatusForIp as deleteIpHealthStatusForIp, repos_deleteIpHealthStatusForRef as deleteIpHealthStatusForRef, repos_deleteNode as deleteNode, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_ensureNode as ensureNode, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_findHealthCheckByCfId as findHealthCheckByCfId, repos_findNodeByAddress as findNodeByAddress, repos_findNodeByIp as findNodeByIp, repos_findSubdomainByDomainAndName as findSubdomainByDomainAndName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getDomainMonitor as getDomainMonitor, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getHealthCheck as getHealthCheck, repos_getIpHealthStatusRow as getIpHealthStatusRow, repos_getNode as getNode, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_insertNotificationLog as insertNotificationLog, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllNodes as listAllNodes, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingIpsWithMeta as listBindingIpsWithMeta, repos_listBindingNodes as listBindingNodes, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomainMonitorResults as listDomainMonitorResults, repos_listDomainMonitorResultsForDomain as listDomainMonitorResultsForDomain, repos_listDomainMonitors as listDomainMonitors, repos_listDomainTags as listDomainTags, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listEnabledDomainMonitors as listEnabledDomainMonitors, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listHealthCheckTargets as listHealthCheckTargets, repos_listHealthChecks as listHealthChecks, repos_listIpHealthStatus as listIpHealthStatus, repos_listNodes as listNodes, repos_listNotificationLog as listNotificationLog, repos_listOriginIpsForFqdn as listOriginIpsForFqdn, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroup as listServicesByGroup, repos_listSubdomainsByDomain as listSubdomainsByDomain, repos_listUngroupedServices as listUngroupedServices, repos_markDnsPendingDelete as markDnsPendingDelete, repos_mergeHealthAggregates as mergeHealthAggregates, repos_pruneStaleIpHealthStatus as pruneStaleIpHealthStatus, repos_reorderServices as reorderServices, repos_replaceBindingIps as replaceBindingIps, repos_replaceBindingIpsWithMeta as replaceBindingIpsWithMeta, repos_replaceServiceIps as replaceServiceIps, repos_setBindingCnameTarget as setBindingCnameTarget, repos_setBindingDnsRecordId as setBindingDnsRecordId, repos_setBindingRoutingStrategy as setBindingRoutingStrategy, repos_setDnsSyncStatus as setDnsSyncStatus, repos_setDomainLastSynced as setDomainLastSynced, repos_setDomainTags as setDomainTags, repos_setServiceEnabled as setServiceEnabled, repos_setServiceGroup as setServiceGroup, repos_setServiceGroupEnabled as setServiceGroupEnabled, repos_setServiceLb as setServiceLb, repos_unlinkBindingRecord as unlinkBindingRecord, repos_unlinkGroupDnsRecord as unlinkGroupDnsRecord, repos_updateBindingDomain as updateBindingDomain, repos_updateBindingFields as updateBindingFields, repos_updateBindingLbConfig as updateBindingLbConfig, repos_updateDnsFields as updateDnsFields, repos_updateDomain as updateDomain, repos_updateDomainMonitorResult as updateDomainMonitorResult, repos_updateGroup as updateGroup, repos_updateHealthCheck as updateHealthCheck, repos_updateNode as updateNode, repos_updateService as updateService, repos_updateServiceGroup as updateServiceGroup, repos_updateSubdomain as updateSubdomain, repos_upsertCertificateCheck as upsertCertificateCheck, repos_upsertIpHealthStatus as upsertIpHealthStatus, repos_upsertSubdomain as upsertSubdomain }; } -export { type AppSettingsDto, type AppSettingsPatch, type AppendAuditInput, ConflictError, type Db, type DnsListFilter, NotFoundError, type Sqlite, type UpdateSubdomainPatch, appSettings, appendAudit, auditLog, certificates, createDb, createMemoryDb, dnsRecords, domainMonitorResults, domainMonitors, domainTags, domains, getAppSettings, getAppSettingsSecrets, groups, healthCheck, ipHealthStatus, listAudit, notificationLog, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs, touchVpsTrackerSync, updateAppSettings }; +export { type AppSettingsDto, type AppSettingsPatch, type AppendAuditInput, ConflictError, type Db, type DnsListFilter, NotFoundError, type Sqlite, type UpdateSubdomainPatch, appSettings, appendAudit, auditLog, bindingNodes, certificates, createDb, createMemoryDb, dnsRecords, domainMonitorResults, domainMonitors, domainTags, domains, getAppSettings, getAppSettingsSecrets, groups, healthCheck, healthChecks, ipHealthStatus, listAudit, nodes, notificationLog, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs, touchVpsTrackerSync, updateAppSettings }; diff --git a/packages/db/dist/index.js b/packages/db/dist/index.js index 62df0e5..51b44c8 100644 --- a/packages/db/dist/index.js +++ b/packages/db/dist/index.js @@ -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, diff --git a/packages/db/migrations/018_nodes_healthchecks.sql b/packages/db/migrations/018_nodes_healthchecks.sql new file mode 100644 index 0000000..81b9595 --- /dev/null +++ b/packages/db/migrations/018_nodes_healthchecks.sql @@ -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'; diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index 9f8212b..882b84b 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -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 = { 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 = { 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 = { 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(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') diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index db89901..8ab2f1f 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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, diff --git a/packages/shared/dist/index.d.ts b/packages/shared/dist/index.d.ts index 2ef968c..2608810 100644 --- a/packages/shared/dist/index.d.ts +++ b/packages/shared/dist/index.d.ts @@ -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; consecutive_failures: z.ZodNumber; + consecutive_successes: z.ZodDefault>; last_checked_at: z.ZodNullable; last_error: z.ZodNullable; }, z.core.$strip>; @@ -1414,6 +1514,124 @@ type CreateServiceBindingInput = z.infer; type CreateDomainInput = z.infer; type LoginInput = z.infer; type CreateDnsRecordInput = z.infer; +declare const serviceNodeSchema: z.ZodObject<{ + id: z.ZodNumber; + service_id: z.ZodNumber; + address: z.ZodString; + protocol: z.ZodString; + port: z.ZodNullable; + enabled: z.ZodCoercedBoolean; + 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; + consecutive_failures: z.ZodNumber; + consecutive_successes: z.ZodNumber; + last_check_at: z.ZodNullable; + last_failure_reason: z.ZodNullable; + created_at: z.ZodString; + updated_at: z.ZodString; +}, z.core.$strip>; +declare const createServiceNodeSchema: z.ZodObject<{ + address: z.ZodString; + protocol: z.ZodDefault>>; + port: z.ZodOptional>; + enabled: z.ZodDefault>; + priority: z.ZodDefault>; + weight: z.ZodDefault>; + health_check_id: z.ZodOptional>; +}, z.core.$strip>; +declare const updateServiceNodeSchema: z.ZodObject<{ + address: z.ZodOptional; + protocol: z.ZodOptional>>>; + port: z.ZodOptional>>; + enabled: z.ZodOptional>>; + priority: z.ZodOptional>>; + weight: z.ZodOptional>>; + health_check_id: z.ZodOptional>>; +}, z.core.$strip>; +declare const originHealthCheckSchema: z.ZodObject<{ + id: z.ZodNumber; + provider: z.ZodEnum<{ + local: "local"; + cloudflare: "cloudflare"; + }>; + cf_healthcheck_id: z.ZodNullable; + cf_zone_id: z.ZodNullable; + name: z.ZodString; + protocol: z.ZodString; + path: z.ZodNullable; + method: z.ZodNullable; + timeout: z.ZodNumber; + interval_sec: z.ZodNumber; + retries: z.ZodNumber; + expected_status: z.ZodNullable; + consecutive_fails: z.ZodNumber; + consecutive_successes: z.ZodNumber; + suspended: z.ZodCoercedBoolean; + 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>; + protocol: z.ZodOptional>; + path: z.ZodOptional>; + method: z.ZodOptional>; + timeout: z.ZodOptional; + interval_sec: z.ZodOptional; + retries: z.ZodOptional; + expected_status: z.ZodOptional>; + consecutive_fails: z.ZodOptional; + consecutive_successes: z.ZodOptional; + suspended: z.ZodOptional; + node_id: z.ZodOptional; +}, z.core.$strip>; +declare const changeIpSchema: z.ZodObject<{ + from_ip: z.ZodOptional; + to_ip: z.ZodOptional; + node_id: z.ZodOptional; + dry_run: z.ZodDefault>; +}, z.core.$strip>; +declare const changeDomainSchema: z.ZodObject<{ + from_domain_id: z.ZodNumber; + to_domain_id: z.ZodNumber; + hostnames: z.ZodOptional>; + dry_run: z.ZodDefault>; +}, z.core.$strip>; +type ServiceNodeRecord = z.infer; +type CreateServiceNodeInput = z.infer; +type UpdateServiceNodeInput = z.infer; +type OriginHealthCheckRecord = z.infer; +type CreateOriginHealthCheckInput = z.infer; +type ChangeIpInput = z.infer; +type ChangeDomainInput = z.infer; declare const appSwitcherIconSchema: z.ZodEnum<{ server: "server"; @@ -1617,4 +1835,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{ }, z.core.$strip>; type IngestAuditEvent = z.infer; -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 }; diff --git a/packages/shared/dist/index.js b/packages/shared/dist/index.js index 1c8275c..5263f91 100644 --- a/packages/shared/dist/index.js +++ b/packages/shared/dist/index.js @@ -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 diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 329035a..adea368 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -21,7 +21,14 @@ export type { LbMode, HealthCheckType, IpHealthState, + NodeHealthState, + HealthCheckProvider, HealthCheckScope, IpHealthStatus, HealthCheckTarget, + ServiceNode, + OriginHealthCheck, + ServiceOverview, + PatchDnsRecordPayload, + CfHealthCheck, } from "./types.js"; diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index d1dfed1..ea2595f 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -19,6 +19,19 @@ export type DomainMonitorType = z.infer export const ipHealthStateSchema = z.enum(['up', 'down', 'degraded', 'unknown']) export type IpHealthState = z.infer +export const nodeHealthStateSchema = z.enum([ + 'unknown', + 'checking', + 'healthy', + 'degraded', + 'unhealthy', + 'disabled', +]) +export type NodeHealthState = z.infer + +export const healthCheckProviderSchema = z.enum(['local', 'cloudflare']) +export type HealthCheckProvider = z.infer + export const healthCheckScopeSchema = z.enum(['binding', 'group']) export type HealthCheckScope = z.infer @@ -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 export type LoginInput = z.infer export type CreateDnsRecordInput = z.infer + +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 +export type CreateServiceNodeInput = z.infer +export type UpdateServiceNodeInput = z.infer +export type OriginHealthCheckRecord = z.infer +export type CreateOriginHealthCheckInput = z.infer +export type ChangeIpInput = z.infer +export type ChangeDomainInput = z.infer diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index fe6a24a..635f10f 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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;