feat: Implement load balancing and health check features for service groups, including DNS-based load balancing modes and health check configurations, enhancing service reliability and performance monitoring
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m0s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m13s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Failing after 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

This commit is contained in:
Denozordec
2026-06-25 18:24:00 +07:00
parent e5dc483d43
commit 6a1498bf80
35 changed files with 5312 additions and 281 deletions
+27
View File
@@ -6,6 +6,8 @@ Self-hosted service for managing domains, DNS records, and SSL certificates via
- Domain and DNS record management (hybrid sync with Cloudflare)
- Domain groups (local / vpn / external) and service tags
- Service groups with common FQDN and DNS-based load balancing (Round Robin / Failover / Weighted)
- Health checks (TCP / HTTP) with automatic DNS reconciliation on status change
- SSL certificate expiry monitoring
- React UI with TanStack Router & Query
- Single Docker container deployment
@@ -52,3 +54,28 @@ shadcn CLI: `cd apps/web && pnpm dlx shadcn@latest add <component>`
## Documentation
See [docs/Home.md](docs/Home.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
## Load balancing & health checks
Группа сервисов может иметь общий домен (`service_groups.domain`). На общем домене и на
привязках сервиса с несколькими A-записями включается балансировка и health-check:
- **Режимы LB:** `round_robin` (по одной A на каждый up-IP), `failover` (A только для
up-IP с минимальным приоритетом; бэкапы подключаются при падении primary),
`weighted` (веса учитываются в БД; в Cloudflare free отображается как Round Robin, т.к.
CF API не допускает дубликаты `(name, type, content)` A-записей — для истинного weighted
нужен CF Load Balancer).
- **Health-check:** TCP connect или HTTP (настраиваемый порт, путь, ожидаемый статус,
интервал, таймаут). Результаты хранятся в `ip_health_status` (`up` / `degraded` / `down`
/ `unknown`) и опрашиваются UI с polling 10с.
- **Reconcile:** при смене статуса IP cron перезаписывает A-записи в Cloudflare, оставляя
только активные по политике LB. Реакция = TTL A-записи (`ttl=1` proxied — минимальный).
Env для health-check:
| Variable | Default | Description |
|----------|---------|-------------|
| `HEALTH_CHECK_CRON` | `*/30 * * * * *` | Cron выражение для запуска проверок (каждые 30с) |
| `HEALTH_DEGRADED_FAILURES` | `1` | Порог последовательных ошибок → статус `degraded` |
| `HEALTH_DOWN_FAILURES` | `2` | Порог последовательных ошибок → статус `down` (IP убирается из DNS) |
| `HEALTH_LATENCY_WARN_MS` | `1000` | Латентность выше порога → `degraded` даже при успешном connect |
+657 -74
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -23,7 +23,10 @@ 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 { healthCheckRoutes } from "./routes/health-check.js";
import * as certificateService from "./services/certificate-service.js";
import * as healthCheckService from "./services/health-check-service.js";
import * as serviceConfigService from "./services/service-config-service.js";
import { AsyncTask, CronJob } from "toad-scheduler";
export interface BuildAppOptions {
@@ -68,6 +71,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await protectedApi.register(subdomainRoutes);
await protectedApi.register(certificateRoutes);
await protectedApi.register(syncRoutes);
await protectedApi.register(healthCheckRoutes);
},
{ prefix: "/api/v1" },
);
@@ -105,6 +109,46 @@ export async function buildApp(opts: BuildAppOptions = {}) {
{ preventOverrun: true },
),
);
const healthTask = new AsyncTask(
"health-check",
async () => {
const n = await healthCheckService.runAllChecks(app.db, {
thresholds: {
degradedFailures: config.healthDegradedFailures,
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
},
onStatusChange: async (target, _prev, _next) => {
try {
await serviceConfigService.reconcileDnsForTarget(
app.db,
app.cf,
target.scope,
target.ref_id,
);
} catch (err) {
app.log.warn(
{ err, scope: target.scope, refId: target.ref_id },
"health-check reconcile failed",
);
}
},
});
app.log.info({ checked: n }, "health check completed");
},
(err) => {
app.log.warn({ err }, "health check failed");
},
);
app.scheduler.addCronJob(
new CronJob(
{ cronExpression: config.healthCheckCron },
healthTask,
{ preventOverrun: true },
),
);
}
return app;
+10
View File
@@ -10,6 +10,10 @@ export interface AppConfig {
serverPort: number;
staticDir: string | null;
certCheckCron: string;
healthCheckCron: string;
healthDegradedFailures: number;
healthDownFailures: number;
healthLatencyWarnMs: number;
logLevel: string;
}
@@ -27,6 +31,12 @@ export function loadConfig(): AppConfig {
? resolve(process.env.STATIC_DIR)
: null,
certCheckCron: process.env.CERT_CHECK_CRON ?? "0 0 */6 * * *",
healthCheckCron: process.env.HEALTH_CHECK_CRON ?? "*/30 * * * * *",
healthDegradedFailures:
Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1,
healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2,
healthLatencyWarnMs:
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
logLevel: process.env.LOG_LEVEL ?? "info",
};
}
+39
View File
@@ -0,0 +1,39 @@
import type { FastifyInstance } from "fastify";
import { healthStatusQuerySchema } from "@cfdm/shared";
import * as healthCheckService from "../services/health-check-service.js";
import * as serviceConfigService from "../services/service-config-service.js";
export async function healthCheckRoutes(app: FastifyInstance) {
app.get("/health-status", async (request) => {
const query = healthStatusQuerySchema.parse(request.query);
return healthCheckService.listStatus(
request.server.db,
query.scope,
query.ref_id,
);
});
app.post("/health-check/run", async (request) => {
const config = request.server.config;
const checked = await healthCheckService.runAllChecks(request.server.db, {
thresholds: {
degradedFailures: config.healthDegradedFailures,
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
},
onStatusChange: async (target, _prev, _next) => {
try {
await serviceConfigService.reconcileDnsForTarget(
request.server.db,
request.server.cf,
target.scope,
target.ref_id,
);
} catch {
// best-effort reconcile; ошибки логируются cron-задачей
}
},
});
return { checked };
});
}
+7 -5
View File
@@ -1,16 +1,18 @@
import type { FastifyInstance } from "fastify";
import { createServiceGroupSchema, toggleEnabledSchema } from "@cfdm/shared";
import {
createServiceGroupSchema,
toggleEnabledSchema,
updateServiceGroupSchema,
} from "@cfdm/shared";
import * as serviceConfig from "../services/service-config-service.js";
export async function serviceGroupRoutes(app: FastifyInstance) {
const bodySchema = createServiceGroupSchema;
app.get("/service-groups", async (request) => {
return serviceConfig.listGroupViews(request.server.db);
});
app.post("/service-groups", async (request) => {
const body = bodySchema.parse(request.body);
const body = createServiceGroupSchema.parse(request.body);
return serviceConfig.createGroup(
request.server.db,
request.server.cf,
@@ -20,7 +22,7 @@ export async function serviceGroupRoutes(app: FastifyInstance) {
app.patch("/service-groups/:id", async (request) => {
const { id } = request.params as { id: string };
const body = bodySchema.parse(request.body);
const body = updateServiceGroupSchema.parse(request.body);
return serviceConfig.updateGroup(
request.server.db,
request.server.cf,
+3 -2
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { reorderServicesSchema } from "@cfdm/shared";
import { reorderServicesSchema, updateServiceConfigSchema } from "@cfdm/shared";
import { repos } from "@cfdm/db";
import * as serviceConfig from "../services/service-config-service.js";
@@ -49,11 +49,12 @@ export async function serviceRoutes(app: FastifyInstance) {
app.patch("/services/:id", async (request) => {
const { id } = request.params as { id: string };
const body = updateServiceConfigSchema.parse(request.body);
return serviceConfig.updateConfig(
request.server.db,
request.server.cf,
Number(id),
request.body as serviceConfig.UpdateServiceConfigRequest,
body,
);
});
@@ -0,0 +1,203 @@
import { connect } from "node:net";
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
import { AppError } from "../errors.js";
export interface HealthCheckThresholds {
degradedFailures: number;
downFailures: number;
latencyWarnMs: number;
}
export interface ProbeResult {
ok: boolean;
latencyMs: number;
error: string | null;
}
function tcpProbe(
ip: string,
port: number,
timeoutMs: number,
): Promise<ProbeResult> {
return new Promise((resolve) => {
const started = Date.now();
const socket = connect({ host: ip, port, timeout: timeoutMs });
let settled = false;
const finish = (result: ProbeResult) => {
if (settled) return;
settled = true;
socket.destroy();
resolve(result);
};
socket.on("connect", () =>
finish({
ok: true,
latencyMs: Date.now() - started,
error: null,
}),
);
socket.on("timeout", () =>
finish({
ok: false,
latencyMs: Date.now() - started,
error: "connection timeout",
}),
);
socket.on("error", (err) =>
finish({
ok: false,
latencyMs: Date.now() - started,
error: err.message,
}),
);
});
}
async function httpProbe(
ip: string,
target: HealthCheckTarget,
timeoutMs: number,
): Promise<ProbeResult> {
const started = Date.now();
const path = target.path?.trim() || "/";
const url = `http://${ip}${path.startsWith("/") ? path : `/${path}`}`;
const hostHeader = target.hostname || ip;
try {
const response = await fetch(url, {
method: "GET",
headers: { Host: hostHeader },
signal: AbortSignal.timeout(timeoutMs),
redirect: "manual",
});
const latency = Date.now() - started;
if (target.expected_status != null) {
if (response.status !== target.expected_status) {
return {
ok: false,
latencyMs: latency,
error: `expected ${target.expected_status}, got ${response.status}`,
};
}
return { ok: true, latencyMs: latency, error: null };
}
if (response.status >= 200 && response.status < 400) {
return { ok: true, latencyMs: latency, error: null };
}
return {
ok: false,
latencyMs: latency,
error: `unexpected status ${response.status}`,
};
} catch (err) {
return {
ok: false,
latencyMs: Date.now() - started,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function probeTarget(
target: HealthCheckTarget,
): Promise<ProbeResult> {
const port = target.port ?? (target.type === "http" ? 80 : 80);
const timeoutMs = target.timeout_ms || 3000;
if (target.type === "http") {
return httpProbe(target.ip, target, timeoutMs);
}
return tcpProbe(target.ip, port, timeoutMs);
}
function deriveState(
ok: boolean,
latencyMs: number,
prev: { consecutive_failures: 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 };
}
export interface RunAllChecksOptions {
thresholds: HealthCheckThresholds;
onStatusChange?: (
target: HealthCheckTarget,
prevState: IpHealthState | null,
nextState: IpHealthState,
) => void;
}
export async function runAllChecks(
db: Db,
options: RunAllChecksOptions,
): Promise<number> {
const targets = repos.listHealthCheckTargets(db);
for (const target of targets) {
const prev = repos.getIpHealthStatusRow(
db,
target.scope,
target.ref_id,
target.ip,
);
const result = await probeTarget(target);
const { state, failures } = deriveState(
result.ok,
result.latencyMs,
prev
? {
consecutive_failures: prev.consecutive_failures,
status: prev.status,
}
: null,
options.thresholds,
);
const prevState: IpHealthState | null = prev
? (prev.status as IpHealthState)
: null;
repos.upsertIpHealthStatus(
db,
target.scope,
target.ref_id,
target.ip,
state,
result.latencyMs,
failures,
result.error,
);
if (prevState !== state) {
options.onStatusChange?.(target, prevState, state);
}
}
return targets.length;
}
export function listStatus(
db: Db,
scope: "binding" | "group",
refId: number,
) {
return repos.listIpHealthStatus(db, scope, refId);
}
export function requireValidScope(scope: string): "binding" | "group" {
if (scope !== "binding" && scope !== "group") {
throw AppError.validation(`invalid scope: ${scope}`);
}
return scope;
}
+317 -10
View File
@@ -2,6 +2,10 @@ import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type {
DnsRecord,
HealthCheckScope,
HealthCheckType,
IpHealthState,
LbMode,
Service,
ServiceGroup,
ServiceGroupsResponse,
@@ -25,6 +29,16 @@ export interface ServiceDomainInput {
target_ips?: string[];
target_ip?: string;
target_cname?: string;
target_ip_weights?: Record<string, number>;
target_ip_priorities?: Record<string, number>;
lb_mode?: LbMode;
health_check_enabled?: boolean;
health_check_type?: HealthCheckType;
health_check_port?: number | null;
health_check_path?: string | null;
health_check_expected_status?: number | null;
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
}
export interface ToggleRequest {
@@ -36,6 +50,29 @@ export interface ServiceGroupBody {
type?: string;
icon?: string | null;
domain?: string | null;
lb_mode?: LbMode;
health_check_enabled?: boolean;
health_check_type?: HealthCheckType;
health_check_port?: number | null;
health_check_path?: string | null;
health_check_expected_status?: number | null;
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
}
export interface UpdateServiceGroupBody {
name?: string;
type?: string;
icon?: string | null;
domain?: string | null;
lb_mode?: LbMode;
health_check_enabled?: boolean;
health_check_type?: HealthCheckType;
health_check_port?: number | null;
health_check_path?: string | null;
health_check_expected_status?: number | null;
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
}
export interface UpdateServiceConfigRequest {
@@ -43,6 +80,8 @@ export interface UpdateServiceConfigRequest {
slug?: string;
service_group_id?: number | null;
ips?: string[];
lb_weight?: number;
lb_priority?: number;
domains?: ServiceDomainInput[];
}
@@ -94,6 +133,133 @@ 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,
): { config: LbTargetConfig; rows: LbIpRow[] } {
const binding = repos.getBinding(db, bindingId);
const ipMetas = repos.listBindingIpsWithMeta(db, bindingId);
const rows: LbIpRow[] = ipMetas.map((entry) => {
const status = repos.getIpHealthStatusRow(db, "binding", bindingId, entry.ip);
return {
ip: entry.ip,
weight: entry.weight,
priority: entry.priority,
health: status ? (status.status as IpHealthState) : "unknown",
};
});
return {
config: {
lb_mode: binding.lb_mode,
health_check_enabled: binding.health_check_enabled,
},
rows,
};
}
function getGroupLbState(
db: Db,
groupId: number,
): { config: LbTargetConfig; rows: LbIpRow[] } {
const group = repos.getServiceGroup(db, groupId);
const services = repos.listServicesByGroup(db, groupId);
const seen = new Map<string, LbIpRow>();
for (const service of services) {
if (!service.enabled) continue;
const bindings = repos.listBindingsByService(db, service.id);
for (const binding of bindings) {
const ipMetas = repos.listBindingIpsWithMeta(db, binding.id);
for (const entry of ipMetas) {
const status = repos.getIpHealthStatusRow(db, "group", groupId, entry.ip);
const existing = seen.get(entry.ip);
const weight = entry.weight * service.lb_weight;
const priority = Math.min(entry.priority, service.lb_priority);
if (!existing) {
seen.set(entry.ip, {
ip: entry.ip,
weight,
priority,
health: status ? (status.status as IpHealthState) : "unknown",
});
} else {
existing.weight += weight;
existing.priority = Math.min(existing.priority, priority);
if (isHealthy(existing.health) && status && !isHealthy(status.status as IpHealthState)) {
existing.health = status.status as IpHealthState;
}
}
}
}
}
return {
config: {
lb_mode: group.lb_mode,
health_check_enabled: group.health_check_enabled,
},
rows: [...seen.values()],
};
}
function computeActiveIps(
db: Db,
scope: HealthCheckScope,
refId: number,
): string[] {
const state =
scope === "binding"
? getBindingLbState(db, refId)
: getGroupLbState(db, refId);
return selectActiveIpsByMode(state.config, state.rows);
}
async function collectKnownZones(
db: Db,
cf: CloudflareClient,
@@ -117,12 +283,25 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
const domainViews = bindings.map((binding) => {
const records = repos.listRecordsForBinding(db, binding.id);
const statuses = records.map((r) => r.sync_status);
const targetIps = repos.listBindingIps(db, binding.id);
const targetIpsWithMeta = repos.listBindingIpsWithMeta(db, binding.id);
const targetIps = targetIpsWithMeta.map((entry) => entry.ip);
const linkedCname = records.find(
(record) => record.record_type.toUpperCase() === "CNAME",
);
const targetCname =
binding.cname_target?.trim() || linkedCname?.content?.trim() || null;
const target_ip_weights: Record<string, number> = {};
const target_ip_priorities: Record<string, number> = {};
for (const entry of targetIpsWithMeta) {
target_ip_weights[entry.ip] = entry.weight;
target_ip_priorities[entry.ip] = entry.priority;
}
for (const ip of targetIps) {
if (target_ip_weights[ip] === undefined) target_ip_weights[ip] = 1;
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
}
return {
binding_id: binding.id,
domain_id: binding.domain_id,
@@ -131,7 +310,17 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
record_type: targetCname ? ("CNAME" as const) : ("A" as const),
target_ips: targetCname ? [] : targetIps,
target_ip_weights,
target_ip_priorities,
target_cname: targetCname,
lb_mode: binding.lb_mode,
health_check_enabled: binding.health_check_enabled,
health_check_type: binding.health_check_type,
health_check_port: binding.health_check_port,
health_check_path: binding.health_check_path,
health_check_expected_status: binding.health_check_expected_status,
health_check_interval_sec: binding.health_check_interval_sec,
health_check_timeout_ms: binding.health_check_timeout_ms,
sync_status: aggregateSyncStatus(statuses),
};
});
@@ -140,10 +329,12 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
id: service.id,
name: service.name,
slug: service.slug,
service_group_id: service.service_group_id,
subdomain: service.subdomain,
enabled: service.enabled,
service_group_id: service.service_group_id ?? null,
subdomain: service.subdomain ?? "",
enabled: Boolean(service.enabled),
computed_fqdn: null,
lb_weight: service.lb_weight,
lb_priority: service.lb_priority,
created_at: service.created_at,
updated_at: service.updated_at,
ips,
@@ -637,13 +828,21 @@ async function syncServiceBindingsToDns(
continue;
}
const targetIps = repos.listBindingIps(db, binding.id);
let targetIps = repos.listBindingIps(db, binding.id);
if (targetIps.length === 0) {
throw AppError.validation(
`укажите IP или CNAME для ${fqdnToDisplay(binding.hostname, binding.zone_name)}`,
);
}
validateTargetIpsInPool(targetIps, ips);
if (binding.health_check_enabled) {
const activeIps = computeActiveIps(db, "binding", binding.id);
if (activeIps.length > 0) {
targetIps = activeIps;
}
}
await syncBindingDns(
db,
cf,
@@ -777,7 +976,9 @@ async function syncGroupDomainDns(
const knownZones = await collectKnownZones(db, cf);
const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
const domainId = await resolveDomainId(db, cf, zoneName);
const desiredIps = await collectGroupDnsIps(db, groupId);
const desiredIps = group.health_check_enabled
? computeActiveIps(db, "group", groupId)
: await collectGroupDnsIps(db, groupId);
await syncGroupDomainDnsRecords(
db,
cf,
@@ -880,6 +1081,16 @@ export async function updateConfig(
repos.setServiceGroup(db, id, req.service_group_id);
}
if (req.lb_weight !== undefined || req.lb_priority !== undefined) {
const existing = repos.getService(db, id);
repos.setServiceLb(
db,
id,
req.lb_weight ?? existing.lb_weight,
req.lb_priority ?? existing.lb_priority,
);
}
const ipsUpdated = req.ips !== undefined;
const knownZones = await collectKnownZones(db, cf);
@@ -913,17 +1124,55 @@ export async function updateConfig(
repos.insertBinding(db, domainId, id, hostname, null);
keptBindingIds.push(binding.id);
repos.replaceBindingIps(db, binding.id, targetCname ? [] : targetIps);
const targetIpWeights = input.target_ip_weights ?? {};
const targetIpPriorities = input.target_ip_priorities ?? {};
const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({
ip,
weight: targetIpWeights[ip] ?? 1,
priority: targetIpPriorities[ip] ?? 1,
}));
repos.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries);
repos.setBindingCnameTarget(db, binding.id, targetCname);
if (
input.lb_mode !== undefined ||
input.health_check_enabled !== undefined ||
input.health_check_type !== undefined ||
input.health_check_port !== undefined ||
input.health_check_path !== undefined ||
input.health_check_expected_status !== undefined ||
input.health_check_interval_sec !== undefined ||
input.health_check_timeout_ms !== undefined
) {
repos.updateBindingLbConfig(db, binding.id, {
lb_mode: input.lb_mode,
health_check_enabled: input.health_check_enabled,
health_check_type: input.health_check_type,
health_check_port: input.health_check_port,
health_check_path: input.health_check_path,
health_check_expected_status: input.health_check_expected_status,
health_check_interval_sec: input.health_check_interval_sec,
health_check_timeout_ms: input.health_check_timeout_ms,
});
}
if (pushDns) {
let effectiveIps = targetIps;
const refreshedBinding = repos.getBinding(db, binding.id);
if (refreshedBinding.health_check_enabled) {
const activeIps = computeActiveIps(db, "binding", binding.id);
if (activeIps.length > 0) {
effectiveIps = activeIps;
}
}
await syncBindingDns(
db,
cf,
binding.id,
domainId,
hostname,
targetIps,
effectiveIps,
targetCname,
);
}
@@ -986,6 +1235,16 @@ export async function createGroup(
groupType,
body.icon ?? null,
domain,
{
lb_mode: body.lb_mode,
health_check_enabled: body.health_check_enabled,
health_check_type: body.health_check_type,
health_check_port: body.health_check_port,
health_check_path: body.health_check_path,
health_check_expected_status: body.health_check_expected_status,
health_check_interval_sec: body.health_check_interval_sec,
health_check_timeout_ms: body.health_check_timeout_ms,
},
);
}
@@ -993,10 +1252,11 @@ export async function updateGroup(
db: Db,
cf: CloudflareClient,
id: number,
body: ServiceGroupBody,
body: UpdateServiceGroupBody,
): Promise<ServiceGroup> {
const groupType = body.type?.trim() || "custom";
const previous = repos.getServiceGroup(db, id);
const name = body.name ?? previous.name;
const oldDomain = previous.domain?.trim();
if (oldDomain) {
await cleanupStaleGroupFqdnBindings(db, cf, id, oldDomain);
@@ -1006,10 +1266,20 @@ export async function updateGroup(
let group = repos.updateServiceGroup(
db,
id,
body.name,
name,
groupType,
body.icon ?? null,
domain,
{
lb_mode: body.lb_mode,
health_check_enabled: body.health_check_enabled,
health_check_type: body.health_check_type,
health_check_port: body.health_check_port,
health_check_path: body.health_check_path,
health_check_expected_status: body.health_check_expected_status,
health_check_interval_sec: body.health_check_interval_sec,
health_check_timeout_ms: body.health_check_timeout_ms,
},
);
if (!domain && group.enabled) {
repos.setServiceGroupEnabled(db, id, false);
@@ -1090,3 +1360,40 @@ export function reorderServices(
}
repos.reorderServices(db, groupId, serviceIds);
}
export async function reconcileDnsForTarget(
db: Db,
cf: CloudflareClient,
scope: HealthCheckScope,
refId: number,
): Promise<void> {
if (scope === "binding") {
const binding = repos.getBinding(db, refId);
if (!binding.health_check_enabled) return;
const service = repos.getService(db, binding.service_id);
if (!shouldPushDns(db, service)) return;
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) return;
const ips = repos.listServiceIps(db, service.id);
const targetIps = repos.listBindingIps(db, binding.id);
validateTargetIpsInPool(targetIps, ips);
const activeIps = computeActiveIps(db, "binding", refId);
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
await syncBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
desiredIps,
null,
);
return;
}
const group = repos.getServiceGroup(db, refId);
if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) {
return;
}
await syncGroupDomainDns(db, cf, refId);
}
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { createServer, type Server } from "node:net";
import * as healthCheckService from "../src/services/health-check-service.js";
import type { HealthCheckTarget } from "@cfdm/shared";
function startTcpServer(): Promise<{ server: Server; port: number }> {
return new Promise((resolve) => {
const server = createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port =
typeof address === "object" && address ? address.port : 0;
resolve({ server, port });
});
});
}
describe("health-check probeTarget", () => {
let server: Server;
let port: number;
beforeAll(async () => {
const started = await startTcpServer();
server = started.server;
port = started.port;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it("tcp probe succeeds for open port", async () => {
const target: HealthCheckTarget = {
scope: "binding",
ref_id: 1,
ip: "127.0.0.1",
hostname: "test.local",
type: "tcp",
port,
path: null,
expected_status: null,
timeout_ms: 1000,
};
const result = await healthCheckService.probeTarget(target);
expect(result.ok).toBe(true);
expect(result.error).toBeNull();
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
});
it("tcp probe fails for closed port", async () => {
const target: HealthCheckTarget = {
scope: "binding",
ref_id: 1,
ip: "127.0.0.1",
hostname: "test.local",
type: "tcp",
port: 1,
path: null,
expected_status: null,
timeout_ms: 500,
};
const result = await healthCheckService.probeTarget(target);
expect(result.ok).toBe(false);
expect(result.error).not.toBeNull();
});
});
describe("health-check state derivation via runAllChecks", () => {
it("marks ip down after threshold failures and up after recovery", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "example.com", "zone-id");
const service = repos.createService(db, "Svc", "svc");
const binding = repos.insertBinding(
db,
domain.id,
service.id,
"@",
null,
);
repos.updateBindingLbConfig(db, binding.id, {
health_check_enabled: true,
health_check_type: "tcp",
health_check_port: 1,
health_check_timeout_ms: 200,
});
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: "127.0.0.1", weight: 1, priority: 1 },
]);
// Запуск с closed port (port 1) — должен зафиксировать down после 2 проверок
await healthCheckService.runAllChecks(db, {
thresholds: {
degradedFailures: 1,
downFailures: 2,
latencyWarnMs: 1000,
},
});
let status = repos.getIpHealthStatusRow(
db,
"binding",
binding.id,
"127.0.0.1",
);
expect(status?.status).toBe("degraded");
await healthCheckService.runAllChecks(db, {
thresholds: {
degradedFailures: 1,
downFailures: 2,
latencyWarnMs: 1000,
},
});
status = repos.getIpHealthStatusRow(
db,
"binding",
binding.id,
"127.0.0.1",
);
expect(status?.status).toBe("down");
});
});
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
selectActiveIpsByMode,
type LbIpRow,
type LbTargetConfig,
} from "../src/services/service-config-service.js";
function row(
ip: string,
opts: Partial<Pick<LbIpRow, "weight" | "priority" | "health">> = {},
): LbIpRow {
return {
ip,
weight: opts.weight ?? 1,
priority: opts.priority ?? 1,
health: opts.health ?? "up",
};
}
describe("selectActiveIpsByMode", () => {
it("round_robin returns all healthy ips, falls back to all if none healthy", () => {
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: "down" }),
row("3.3.3.3", { health: "up" }),
];
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
"1.1.1.1",
"3.3.3.3",
]);
});
it("round_robin returns all ips when none checked (unknown)", () => {
const config: LbTargetConfig = {
lb_mode: "round_robin",
health_check_enabled: true,
};
const rows = [row("1.1.1.1", { health: "unknown" }), row("2.2.2.2")];
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
"1.1.1.1",
"2.2.2.2",
]);
});
it("failover returns only min-priority healthy ips", () => {
const config: LbTargetConfig = {
lb_mode: "failover",
health_check_enabled: true,
};
const rows = [
row("1.1.1.1", { priority: 1, health: "up" }),
row("2.2.2.2", { priority: 2, health: "up" }),
row("3.3.3.3", { priority: 1, health: "down" }),
];
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
});
it("failover falls back to min-priority ip among all when none healthy", () => {
const config: LbTargetConfig = {
lb_mode: "failover",
health_check_enabled: true,
};
const rows = [
row("1.1.1.1", { priority: 3, health: "down" }),
row("2.2.2.2", { priority: 1, health: "down" }),
row("3.3.3.3", { priority: 2, health: "down" }),
];
expect(selectActiveIpsByMode(config, rows)).toEqual(["2.2.2.2"]);
});
it("weighted returns all healthy ips (one A per ip; weights stored for display)", () => {
const config: LbTargetConfig = {
lb_mode: "weighted",
health_check_enabled: true,
};
const rows = [
row("1.1.1.1", { weight: 3, health: "up" }),
row("2.2.2.2", { weight: 1, health: "up" }),
row("3.3.3.3", { weight: 2, health: "down" }),
];
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
"1.1.1.1",
"2.2.2.2",
]);
});
it("returns empty array for no rows", () => {
const config: LbTargetConfig = {
lb_mode: "round_robin",
health_check_enabled: true,
};
expect(selectActiveIpsByMode(config, [])).toEqual([]);
});
});
+108 -28
View File
@@ -2,9 +2,15 @@ import { StatusBadge } from '@/components/status-badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { TableCard } from '@/components/table-card'
import { groupDnsRecords, type DnsRecordGroup } from '@/lib/dns-grouping'
import type { DnsRecord } from '@/lib/schemas'
import type { DnsRecord, IpHealthStatus } from '@/lib/schemas'
import { AppBadge } from '@/components/app-badge'
import { AppButton } from '@/components/app-button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import {
Table,
TableBody,
@@ -19,20 +25,68 @@ interface DnsRecordsTableProps {
records: DnsRecord[]
onDelete: (recordId: number) => void
isDeleting?: boolean
healthByIp?: Record<string, IpHealthStatus>
}
const healthDotClass: Record<IpHealthStatus['status'], string> = {
up: 'bg-emerald-500',
degraded: 'bg-amber-500',
down: 'bg-rose-500',
unknown: 'bg-muted-foreground/40',
}
const healthLabel: Record<IpHealthStatus['status'], string> = {
up: 'OK',
degraded: 'Деград.',
down: 'Down',
unknown: '—',
}
function IpHealthDot({ health }: { health: IpHealthStatus }) {
const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`]
if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`)
if (health.last_checked_at) tooltipParts.push(`Проверка: ${health.last_checked_at}`)
if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`)
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span
className={cn(
'inline-flex size-2 shrink-0 cursor-default rounded-full',
healthDotClass[health.status],
)}
aria-label={`Health: ${healthLabel[health.status]}`}
/>
}
/>
<TooltipContent>{tooltipParts.join('\n')}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
function DnsRecordCells({
record,
onDelete,
isDeleting,
healthByIp,
}: {
record: DnsRecord
onDelete: (recordId: number) => void
isDeleting?: boolean
healthByIp?: Record<string, IpHealthStatus>
}) {
const health = healthByIp?.[record.content]
return (
<>
<TableCell className="max-w-xs truncate font-mono text-sm">{record.content}</TableCell>
<TableCell className="max-w-xs truncate font-mono text-sm">
<div className="flex items-center gap-2">
{health ? <IpHealthDot health={health} /> : null}
<span className="truncate">{record.content}</span>
</div>
</TableCell>
<TableCell className="tabular-nums">{record.ttl}</TableCell>
<TableCell>
<StatusBadge status={record.sync_status} />
@@ -58,17 +112,24 @@ function SingleRecordRow({
onDelete,
isDeleting,
isFirstGroup,
healthByIp,
}: {
record: DnsRecord
onDelete: (recordId: number) => void
isDeleting?: boolean
isFirstGroup: boolean
healthByIp?: Record<string, IpHealthStatus>
}) {
return (
<TableRow className={cn(!isFirstGroup && 'border-t-4 border-muted')}>
<TableCell>{record.record_type}</TableCell>
<TableCell className="font-medium">{record.name}</TableCell>
<DnsRecordCells record={record} onDelete={onDelete} isDeleting={isDeleting} />
<DnsRecordCells
record={record}
onDelete={onDelete}
isDeleting={isDeleting}
healthByIp={healthByIp}
/>
</TableRow>
)
}
@@ -78,11 +139,13 @@ function MultiValueGroupRows({
onDelete,
isDeleting,
isFirstGroup,
healthByIp,
}: {
group: DnsRecordGroup
onDelete: (recordId: number) => void
isDeleting?: boolean
isFirstGroup: boolean
healthByIp?: Record<string, IpHealthStatus>
}) {
const [first, ...rest] = group.records
@@ -105,18 +168,33 @@ function MultiValueGroupRows({
</AppBadge>
</div>
</TableCell>
<DnsRecordCells record={first} onDelete={onDelete} isDeleting={isDeleting} />
<DnsRecordCells
record={first}
onDelete={onDelete}
isDeleting={isDeleting}
healthByIp={healthByIp}
/>
</TableRow>
{rest.map((record) => (
<TableRow key={record.id} className="bg-muted/25">
<DnsRecordCells record={record} onDelete={onDelete} isDeleting={isDeleting} />
<DnsRecordCells
record={record}
onDelete={onDelete}
isDeleting={isDeleting}
healthByIp={healthByIp}
/>
</TableRow>
))}
</>
)
}
export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTableProps) {
export function DnsRecordsTable({
records,
onDelete,
isDeleting,
healthByIp,
}: DnsRecordsTableProps) {
const groups = groupDnsRecords(records)
return (
@@ -133,28 +211,30 @@ export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTab
<TableHead className="w-28" />
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group, index) =>
group.isMultiValue ? (
<MultiValueGroupRows
key={group.key}
group={group}
onDelete={onDelete}
isDeleting={isDeleting}
isFirstGroup={index === 0}
/>
) : (
<SingleRecordRow
key={group.records[0].id}
record={group.records[0]}
onDelete={onDelete}
isDeleting={isDeleting}
isFirstGroup={index === 0}
/>
),
)}
</TableBody>
</Table>
<TableBody>
{groups.map((group, index) =>
group.isMultiValue ? (
<MultiValueGroupRows
key={group.key}
group={group}
onDelete={onDelete}
isDeleting={isDeleting}
isFirstGroup={index === 0}
healthByIp={healthByIp}
/>
) : (
<SingleRecordRow
key={group.records[0].id}
record={group.records[0]}
onDelete={onDelete}
isDeleting={isDeleting}
isFirstGroup={index === 0}
healthByIp={healthByIp}
/>
),
)}
</TableBody>
</Table>
</div>
</TableCard>
)
@@ -3,7 +3,8 @@ import { Link2Icon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import { StatusBadge } from '@/components/status-badge'
import { groupBindingsByHostname } from '@/lib/domain-ips'
import type { ServiceBinding } from '@/lib/schemas'
import { useHealthRows } from '@/lib/use-aggregated-health'
import type { IpHealthStatus, ServiceBinding } from '@/lib/schemas'
import { AppBadge } from '@/components/app-badge'
import { AppButton } from '@/components/app-button'
import {
@@ -25,13 +26,84 @@ import {
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
interface DomainBindingsCardProps {
bindings: ServiceBinding[]
}
const healthDotClass: Record<IpHealthStatus['status'], string> = {
up: 'bg-emerald-500',
degraded: 'bg-amber-500',
down: 'bg-rose-500',
unknown: 'bg-muted-foreground/40',
}
const healthLabel: Record<IpHealthStatus['status'], string> = {
up: 'OK',
degraded: 'Деград.',
down: 'Down',
unknown: '—',
}
function IpHealthDot({ health }: { health: IpHealthStatus }) {
const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`]
if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`)
if (health.last_checked_at) tooltipParts.push(`Проверка: ${health.last_checked_at}`)
if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`)
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span
className={cn(
'inline-flex size-2 shrink-0 cursor-default rounded-full',
healthDotClass[health.status],
)}
aria-label={`Health: ${healthLabel[health.status]}`}
/>
}
/>
<TooltipContent>{tooltipParts.join('\n')}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
function HostnameIpsHealth({
bindings,
ips,
}: {
bindings: ServiceBinding[]
ips: string[]
}) {
const binding = bindings.find((b) => b.health_check_enabled)
const { data: healthRows } = useHealthRows(
'binding',
binding?.id,
Boolean(binding),
)
if (!binding || !healthRows) return <span>{ips.join(', ')}</span>
const byIp = new Map(healthRows.map((r) => [r.ip, r] as const))
return (
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
{ips.map((ip) => {
const row = byIp.get(ip)
return (
<span key={ip} className="inline-flex items-center gap-1 tabular-nums">
{row ? <IpHealthDot health={row} /> : null}
{ip}
</span>
)
})}
</span>
)
}
function uniqueServices(bindings: ServiceBinding[]): string[] {
return [...new Set(bindings.map((b) => b.service_name))]
}
@@ -85,8 +157,11 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
<AppBadge key={name}>{name}</AppBadge>
))}
{uniqueIps.length > 0 && (
<span className="tabular-nums text-xs text-muted-foreground">
{uniqueIps.join(', ')}
<span className="text-xs text-muted-foreground">
<HostnameIpsHealth
bindings={hostnameBindings}
ips={uniqueIps}
/>
</span>
)}
{[
@@ -0,0 +1,66 @@
import { Badge, badgeVariants } from '@cfdm/ui/components/badge'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@cfdm/ui/components/tooltip'
import type { VariantProps } from 'class-variance-authority'
import { cn } from '@cfdm/ui/lib/utils'
import type { IpHealthStatus } from '@/lib/schemas'
type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>
const healthVariants: Record<IpHealthStatus['status'], BadgeVariant> = {
up: 'success',
degraded: 'secondary',
down: 'destructive',
unknown: 'outline',
}
const healthLabels: Record<IpHealthStatus['status'], string> = {
up: 'OK',
degraded: 'Деград.',
down: 'Down',
unknown: '—',
}
interface HealthCheckBadgeProps {
status: IpHealthStatus['status']
latencyMs?: number | null
lastCheckedAt?: string | null
lastError?: string | null
className?: string
}
export function HealthCheckBadge({
status,
latencyMs,
lastCheckedAt,
lastError,
className,
}: HealthCheckBadgeProps) {
const variant = healthVariants[status]
const label = healthLabels[status]
const tooltipParts: string[] = [`Статус: ${label}`]
if (latencyMs != null) tooltipParts.push(`Задержка: ${latencyMs} мс`)
if (lastCheckedAt) tooltipParts.push(`Проверка: ${lastCheckedAt}`)
if (lastError) tooltipParts.push(`Ошибка: ${lastError}`)
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span tabIndex={0} className="inline-flex cursor-default" />
}
>
<Badge variant={variant} className={cn('gap-1.5', className)}>
<span
className="size-1.5 rounded-full bg-current opacity-70"
aria-hidden
/>
{label}
</Badge>
</TooltipTrigger>
<TooltipContent>{tooltipParts.join('\n')}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
@@ -0,0 +1,193 @@
import { AppFieldGroup } from '@/components/app-field'
import { FormFieldSimple } from '@/components/form-field'
import { AppInput } from '@/components/app-input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Switch } from '@cfdm/ui/components/switch'
import { Label } from '@cfdm/ui/components/label'
export type LbMode = 'round_robin' | 'failover' | 'weighted'
export type HealthCheckType = 'tcp' | 'http'
export interface HealthCheckConfig {
enabled: boolean
type: HealthCheckType
port: number | null
path: string | null
expected_status: number | null
interval_sec: number
timeout_ms: number
}
export interface LbAndHealthConfig extends HealthCheckConfig {
lb_mode: LbMode
}
const defaultLbModeOptions = [
{ value: 'round_robin', label: 'Round Robin' },
{ value: 'failover', label: 'Failover (приоритет)' },
{ value: 'weighted', label: 'Weighted (веса)' },
]
const healthCheckTypes = [
{ value: 'tcp', label: 'TCP connect' },
{ value: 'http', label: 'HTTP' },
] as const
interface HealthCheckConfigFieldsProps {
value: LbAndHealthConfig
onChange: (next: LbAndHealthConfig) => void
lbModeLabel?: string
lbModeOptions?: { value: string; label: string }[]
idPrefix?: string
showLbMode?: boolean
}
export function HealthCheckConfigFields({
value,
onChange,
lbModeLabel = 'Режим балансировки',
lbModeOptions = defaultLbModeOptions,
idPrefix = 'health',
showLbMode = true,
}: HealthCheckConfigFieldsProps) {
function patch(next: Partial<LbAndHealthConfig>) {
onChange({ ...value, ...next })
}
return (
<AppFieldGroup>
{showLbMode && (
<FormFieldSimple label={lbModeLabel} htmlFor={`${idPrefix}-lb-mode`}>
<Select
value={value.lb_mode}
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
>
<SelectTrigger id={`${idPrefix}-lb-mode`} className="w-full">
<SelectValue placeholder="Выберите режим" />
</SelectTrigger>
<SelectContent>
{lbModeOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormFieldSimple>
)}
<FormFieldSimple label="Health-check" htmlFor={`${idPrefix}-enabled`}>
<div className="flex items-center gap-2">
<Switch
id={`${idPrefix}-enabled`}
checked={value.enabled}
onCheckedChange={(checked) => patch({ enabled: checked })}
/>
<Label htmlFor={`${idPrefix}-enabled`} className="text-muted-foreground">
{value.enabled ? 'Включён' : 'Выключен'}
</Label>
</div>
</FormFieldSimple>
<FormFieldSimple label="Тип проверки" htmlFor={`${idPrefix}-type`}>
<Select
value={value.type}
onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })}
>
<SelectTrigger id={`${idPrefix}-type`} className="w-full">
<SelectValue placeholder="Тип" />
</SelectTrigger>
<SelectContent>
{healthCheckTypes.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
<AppInput
id={`${idPrefix}-port`}
type="number"
inputMode="numeric"
placeholder="80"
value={value.port ?? ''}
onChange={(e) =>
patch({ port: e.target.value === '' ? null : Number(e.target.value) })
}
/>
</FormFieldSimple>
<FormFieldSimple label="HTTP path (для типа HTTP)" htmlFor={`${idPrefix}-path`}>
<AppInput
id={`${idPrefix}-path`}
placeholder="/health"
value={value.path ?? ''}
onChange={(e) =>
patch({ path: e.target.value === '' ? null : e.target.value })
}
/>
</FormFieldSimple>
<FormFieldSimple
label="Ожидаемый HTTP-статус"
htmlFor={`${idPrefix}-status`}
>
<AppInput
id={`${idPrefix}-status`}
type="number"
inputMode="numeric"
placeholder="200"
value={value.expected_status ?? ''}
onChange={(e) =>
patch({
expected_status:
e.target.value === '' ? null : Number(e.target.value),
})
}
/>
</FormFieldSimple>
<div className="grid grid-cols-2 gap-4">
<FormFieldSimple label="Интервал, сек" htmlFor={`${idPrefix}-interval`}>
<AppInput
id={`${idPrefix}-interval`}
type="number"
inputMode="numeric"
placeholder="30"
value={value.interval_sec}
onChange={(e) =>
patch({
interval_sec:
e.target.value === '' ? 30 : Number(e.target.value),
})
}
/>
</FormFieldSimple>
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
<AppInput
id={`${idPrefix}-timeout`}
type="number"
inputMode="numeric"
placeholder="3000"
value={value.timeout_ms}
onChange={(e) =>
patch({
timeout_ms:
e.target.value === '' ? 3000 : Number(e.target.value),
})
}
/>
</FormFieldSimple>
</div>
</AppFieldGroup>
)
}
@@ -1,5 +1,7 @@
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
import { AppButton } from '@/components/app-button'
import { AppInput } from '@/components/app-input'
import { Label } from '@cfdm/ui/components/label'
interface ServiceBindingIpInputProps {
id?: string
@@ -7,6 +9,13 @@ interface ServiceBindingIpInputProps {
pool: string[]
onChange: (value: string[]) => void
disabled?: boolean
showMeta?: boolean
weights?: Record<string, number>
priorities?: Record<string, number>
onMetaChange?: (
ip: string,
meta: { weight?: number; priority?: number },
) => void
}
export function ServiceBindingIpInput({
@@ -15,6 +24,10 @@ export function ServiceBindingIpInput({
pool,
onChange,
disabled,
showMeta = false,
weights,
priorities,
onMetaChange,
}: ServiceBindingIpInputProps) {
const available = pool.filter((ip) => !value.includes(ip))
const isPoolEmpty = pool.length === 0
@@ -47,6 +60,62 @@ export function ServiceBindingIpInput({
))}
</div>
) : null}
{showMeta && value.length > 0 ? (
<div className="flex flex-col gap-2">
{value.map((ip) => (
<div
key={ip}
className="grid grid-cols-[1fr_80px_80px] items-center gap-2"
>
<span className="truncate text-sm font-medium">{ip}</span>
<div className="flex flex-col gap-1">
<Label
htmlFor={`${id}-w-${ip}`}
className="text-xs text-muted-foreground"
>
Вес
</Label>
<AppInput
id={`${id}-w-${ip}`}
type="number"
inputMode="numeric"
min={1}
max={100}
className="h-8"
value={weights?.[ip] ?? 1}
onChange={(e) =>
onMetaChange?.(ip, {
weight: Math.max(1, Number(e.target.value) || 1),
})
}
/>
</div>
<div className="flex flex-col gap-1">
<Label
htmlFor={`${id}-p-${ip}`}
className="text-xs text-muted-foreground"
>
Приор.
</Label>
<AppInput
id={`${id}-p-${ip}`}
type="number"
inputMode="numeric"
min={1}
max={100}
className="h-8"
value={priorities?.[ip] ?? 1}
onChange={(e) =>
onMetaChange?.(ip, {
priority: Math.max(1, Number(e.target.value) || 1),
})
}
/>
</div>
</div>
))}
</div>
) : null}
</div>
)
}
+325 -74
View File
@@ -4,6 +4,12 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { EmptyState } from '@/components/empty-state'
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
import {
HealthCheckConfigFields,
type LbAndHealthConfig,
type LbMode,
type HealthCheckType,
} from '@/components/health-check-config-fields'
import type {
CreateServiceWithConfigInput,
DomainListItem,
@@ -41,6 +47,12 @@ import {
TabsList,
TabsTrigger,
} from '@cfdm/ui/components/tabs'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@cfdm/ui/components/accordion'
import {
Select,
SelectContent,
@@ -48,12 +60,37 @@ import {
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Separator } from '@cfdm/ui/components/separator'
interface BindingHealthConfig {
enabled: boolean
type: HealthCheckType
port: number | null
path: string | null
expected_status: number | null
interval_sec: number
timeout_ms: number
}
export interface ServiceBindingDraft {
fqdn: string
record_type: 'A' | 'CNAME'
target_ips: string[]
target_cname: string
lb_mode: LbMode
health: BindingHealthConfig
target_ip_weights: Record<string, number>
target_ip_priorities: Record<string, number>
}
const defaultHealth: BindingHealthConfig = {
enabled: false,
type: 'tcp',
port: null,
path: null,
expected_status: null,
interval_sec: 30,
timeout_ms: 3000,
}
interface ServiceEditSheetProps {
@@ -77,6 +114,18 @@ function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
record_type: binding.record_type ?? (binding.target_cname ? 'CNAME' : 'A'),
target_ips: binding.target_ips ?? [],
target_cname: binding.target_cname ?? '',
lb_mode: binding.lb_mode,
health: {
enabled: binding.health_check_enabled,
type: binding.health_check_type,
port: binding.health_check_port,
path: binding.health_check_path,
expected_status: binding.health_check_expected_status,
interval_sec: binding.health_check_interval_sec,
timeout_ms: binding.health_check_timeout_ms,
},
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
}))
}
@@ -96,6 +145,16 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
: {
fqdn: binding.fqdn.trim(),
target_ips: binding.target_ips,
target_ip_weights: binding.target_ip_weights,
target_ip_priorities: binding.target_ip_priorities,
lb_mode: binding.lb_mode,
health_check_enabled: binding.health.enabled,
health_check_type: binding.health.type,
health_check_port: binding.health.port,
health_check_path: binding.health.path,
health_check_expected_status: binding.health.expected_status,
health_check_interval_sec: binding.health.interval_sec,
health_check_timeout_ms: binding.health.timeout_ms,
},
)
}
@@ -119,6 +178,8 @@ export function ServiceEditSheet({
const [serviceGroupId, setServiceGroupId] = useState('none')
const [ips, setIps] = useState<string[]>([])
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
const [lbWeight, setLbWeight] = useState(1)
const [lbPriority, setLbPriority] = useState(1)
const groupItems = useMemo(
() => [
@@ -128,6 +189,13 @@ export function ServiceEditSheet({
[groups],
)
const selectedGroup = useMemo(() => {
if (serviceGroupId === 'none') return null
return groups.find((g) => String(g.id) === serviceGroupId) ?? null
}, [groups, serviceGroupId])
const groupHasDomain = Boolean(selectedGroup?.domain?.trim())
useEffect(() => {
if (!open) return
if (mode === 'edit' && service) {
@@ -138,6 +206,8 @@ export function ServiceEditSheet({
)
setIps(service.ips ?? [])
setBindings(toBindingDrafts(service))
setLbWeight(service.lb_weight ?? 1)
setLbPriority(service.lb_priority ?? 1)
return
}
if (mode === 'create') {
@@ -148,6 +218,8 @@ export function ServiceEditSheet({
)
setIps([])
setBindings([])
setLbWeight(1)
setLbPriority(1)
}
}, [open, mode, service, defaultGroupId])
@@ -159,7 +231,16 @@ export function ServiceEditSheet({
function handleAddBinding() {
setBindings((current) => [
...current,
{ fqdn: '', record_type: 'A', target_ips: [], target_cname: '' },
{
fqdn: '',
record_type: 'A',
target_ips: [],
target_cname: '',
lb_mode: 'round_robin',
health: { ...defaultHealth },
target_ip_weights: {},
target_ip_priorities: {},
},
])
}
@@ -197,7 +278,65 @@ export function ServiceEditSheet({
function handleIpsChange(index: number, targetIps: string[]) {
setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, target_ips: targetIps } : item)),
current.map((item, i) =>
i === index
? {
...item,
target_ips: targetIps,
target_ip_weights: Object.fromEntries(
targetIps.map((ip) => [ip, item.target_ip_weights[ip] ?? 1]),
),
target_ip_priorities: Object.fromEntries(
targetIps.map((ip) => [ip, item.target_ip_priorities[ip] ?? 1]),
),
}
: item,
),
)
}
function handleBindingLbModeChange(index: number, lbMode: LbMode) {
setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, lb_mode: lbMode } : item)),
)
}
function handleBindingMetaChange(
index: number,
ip: string,
meta: { weight?: number; priority?: number },
) {
setBindings((current) =>
current.map((item, i) => {
if (i !== index) return item
const weights = { ...item.target_ip_weights }
const priorities = { ...item.target_ip_priorities }
if (meta.weight !== undefined) weights[ip] = meta.weight
if (meta.priority !== undefined) priorities[ip] = meta.priority
return { ...item, target_ip_weights: weights, target_ip_priorities: priorities }
}),
)
}
function handleBindingHealthChange(index: number, next: LbAndHealthConfig) {
setBindings((current) =>
current.map((item, i) =>
i === index
? {
...item,
lb_mode: next.lb_mode,
health: {
enabled: next.enabled,
type: next.type,
port: next.port,
path: next.path,
expected_status: next.expected_status,
interval_sec: next.interval_sec,
timeout_ms: next.timeout_ms,
},
}
: item,
),
)
}
@@ -208,8 +347,12 @@ export function ServiceEditSheet({
function handleSubmit() {
const domains = buildDomainsPayload(bindings)
const groupId = resolveServiceGroupId()
const lbFields = groupHasDomain
? { lb_weight: lbWeight, lb_priority: lbPriority }
: {}
const configPayload = {
ips,
...lbFields,
...(domains.length > 0 ? { domains } : {}),
}
if (mode === 'create') {
@@ -218,6 +361,7 @@ export function ServiceEditSheet({
slug: slug.trim(),
service_group_id: groupId,
ips,
...lbFields,
domains,
})
return
@@ -320,6 +464,48 @@ export function ServiceEditSheet({
/>
</AppField>
</AppFieldGroup>
{groupHasDomain && (
<>
<Separator />
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Балансировка внутри группы «{selectedGroup?.name}»: вес и приоритет
сервиса для общего домена группы.
</p>
<div className="grid grid-cols-2 gap-4">
<AppField>
<AppFieldLabel htmlFor="service-lb-weight">Вес</AppFieldLabel>
<AppInput
id="service-lb-weight"
type="number"
inputMode="numeric"
min={1}
max={100}
value={lbWeight}
onChange={(e) =>
setLbWeight(Math.max(1, Number(e.target.value) || 1))
}
/>
</AppField>
<AppField>
<AppFieldLabel htmlFor="service-lb-priority">Приоритет</AppFieldLabel>
<AppInput
id="service-lb-priority"
type="number"
inputMode="numeric"
min={1}
max={100}
value={lbPriority}
onChange={(e) =>
setLbPriority(Math.max(1, Number(e.target.value) || 1))
}
/>
</AppField>
</div>
</div>
</>
)}
</TabsContent>
<TabsContent value="bindings" className="flex flex-col gap-4">
@@ -347,79 +533,144 @@ export function ServiceEditSheet({
/>
) : (
<AppItemGroup className="gap-2">
{bindings.map((binding, index) => (
<AppItem key={`binding-${index}`} variant="outline">
<AppItemContent className="flex flex-col gap-3">
<AppField>
<AppFieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</AppFieldLabel>
<TaggedInput
id={`binding-fqdn-${index}`}
value={binding.fqdn ? [binding.fqdn] : []}
onChange={(tags) => handleFqdnChange(index, tags)}
placeholder={
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'
}
maxItems={1}
/>
</AppField>
<AppField>
<AppFieldLabel htmlFor={`binding-type-${index}`}>Тип записи</AppFieldLabel>
<Select
items={[
{ label: 'A (IP)', value: 'A' },
{ label: 'CNAME', value: 'CNAME' },
]}
value={binding.record_type}
onValueChange={(value) =>
handleRecordTypeChange(index, (value ?? 'A') as 'A' | 'CNAME')
}
{bindings.map((binding, index) => {
const showLbBlock =
binding.record_type === 'A' && binding.target_ips.length > 1
const showMeta = binding.lb_mode !== 'round_robin'
return (
<AppItem key={`binding-${index}`} variant="outline">
<AppItemContent className="flex flex-col gap-3">
<AppField>
<AppFieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</AppFieldLabel>
<TaggedInput
id={`binding-fqdn-${index}`}
value={binding.fqdn ? [binding.fqdn] : []}
onChange={(tags) => handleFqdnChange(index, tags)}
placeholder={
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'
}
maxItems={1}
/>
</AppField>
<AppField>
<AppFieldLabel htmlFor={`binding-type-${index}`}>Тип записи</AppFieldLabel>
<Select
items={[
{ label: 'A (IP)', value: 'A' },
{ label: 'CNAME', value: 'CNAME' },
]}
value={binding.record_type}
onValueChange={(value) =>
handleRecordTypeChange(index, (value ?? 'A') as 'A' | 'CNAME')
}
>
<SelectTrigger id={`binding-type-${index}`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">A (IP)</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
</AppField>
{binding.record_type === 'CNAME' ? (
<AppField>
<AppFieldLabel htmlFor={`binding-cname-${index}`}>
CNAME-цель
</AppFieldLabel>
<AppInput
id={`binding-cname-${index}`}
value={binding.target_cname}
placeholder="mmsk.rkns.top"
onChange={(event) => handleCnameChange(index, event.target.value)}
/>
</AppField>
) : (
<AppField>
<AppFieldLabel htmlFor={`binding-ip-${index}`}>IP</AppFieldLabel>
<ServiceBindingIpInput
id={`binding-ip-${index}`}
value={binding.target_ips}
pool={ips}
onChange={(targetIps) => handleIpsChange(index, targetIps)}
showMeta={showLbBlock && showMeta}
weights={binding.target_ip_weights}
priorities={binding.target_ip_priorities}
onMetaChange={(ip, meta) =>
handleBindingMetaChange(index, ip, meta)
}
/>
</AppField>
)}
{showLbBlock && (
<Accordion>
<AccordionItem value={`lb-${index}`}>
<AccordionTrigger>
Балансировка и Health-check (multi-A)
</AccordionTrigger>
<AccordionContent>
<AppFieldGroup>
<AppField>
<AppFieldLabel htmlFor={`binding-lb-mode-${index}`}>
Режим балансировки
</AppFieldLabel>
<Select
value={binding.lb_mode}
onValueChange={(value) =>
handleBindingLbModeChange(
index,
(value ?? 'round_robin') as LbMode,
)
}
>
<SelectTrigger id={`binding-lb-mode-${index}`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="round_robin">Round Robin</SelectItem>
<SelectItem value="failover">Failover (приоритет)</SelectItem>
<SelectItem value="weighted">Weighted (веса)</SelectItem>
</SelectContent>
</Select>
</AppField>
<HealthCheckConfigFields
value={{
lb_mode: binding.lb_mode,
enabled: binding.health.enabled,
type: binding.health.type,
port: binding.health.port,
path: binding.health.path,
expected_status: binding.health.expected_status,
interval_sec: binding.health.interval_sec,
timeout_ms: binding.health.timeout_ms,
}}
onChange={(next) =>
handleBindingHealthChange(index, next)
}
showLbMode={false}
idPrefix={`binding-${index}-health`}
/>
</AppFieldGroup>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</AppItemContent>
<AppItemActions>
<AppButton
type="button"
variant="ghost"
size="icon-sm"
aria-label="Удалить привязку"
onClick={() => handleRemoveBinding(index)}
>
<SelectTrigger id={`binding-type-${index}`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">A (IP)</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
</AppField>
{binding.record_type === 'CNAME' ? (
<AppField>
<AppFieldLabel htmlFor={`binding-cname-${index}`}>
CNAME-цель
</AppFieldLabel>
<AppInput
id={`binding-cname-${index}`}
value={binding.target_cname}
placeholder="mmsk.rkns.top"
onChange={(event) => handleCnameChange(index, event.target.value)}
/>
</AppField>
) : (
<AppField>
<AppFieldLabel htmlFor={`binding-ip-${index}`}>IP</AppFieldLabel>
<ServiceBindingIpInput
id={`binding-ip-${index}`}
value={binding.target_ips}
pool={ips}
onChange={(targetIps) => handleIpsChange(index, targetIps)}
/>
</AppField>
)}
</AppItemContent>
<AppItemActions>
<AppButton
type="button"
variant="ghost"
size="icon-sm"
aria-label="Удалить привязку"
onClick={() => handleRemoveBinding(index)}
>
<Trash2Icon />
</AppButton>
</AppItemActions>
</AppItem>
))}
<Trash2Icon />
</AppButton>
</AppItemActions>
</AppItem>
)
})}
</AppItemGroup>
)}
</TabsContent>
@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import {
@@ -12,6 +12,10 @@ import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { AppFieldGroup } from '@/components/app-field'
import { AppInput } from '@/components/app-input'
import {
HealthCheckConfigFields,
type LbAndHealthConfig,
} from '@/components/health-check-config-fields'
import {
Select,
SelectContent,
@@ -19,6 +23,13 @@ import {
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@cfdm/ui/components/accordion'
import { Separator } from '@cfdm/ui/components/separator'
const groupTypes = [
{ value: 'vpn', label: 'VPN' },
@@ -40,6 +51,17 @@ interface ServiceGroupEditSheetProps {
onSave?: (id: number, body: CreateServiceGroupInput) => void
}
const defaultLbHealth: LbAndHealthConfig = {
lb_mode: 'round_robin',
enabled: false,
type: 'tcp',
port: null,
path: null,
expected_status: null,
interval_sec: 30,
timeout_ms: 3000,
}
export function ServiceGroupEditSheet({
mode,
group,
@@ -51,8 +73,13 @@ export function ServiceGroupEditSheet({
}: ServiceGroupEditSheetProps) {
const form = useForm<ServiceGroupFormValues>({
resolver: zodResolver(createServiceGroupSchema),
defaultValues: { name: '', type: 'custom', domain: null },
defaultValues: {
name: '',
type: 'custom',
domain: null,
},
})
const [lbHealth, setLbHealth] = useState<LbAndHealthConfig>(defaultLbHealth)
useEffect(() => {
if (!open) return
@@ -62,17 +89,39 @@ export function ServiceGroupEditSheet({
type: group.type,
domain: group.domain ?? null,
})
setLbHealth({
lb_mode: group.lb_mode,
enabled: group.health_check_enabled,
type: group.health_check_type,
port: group.health_check_port,
path: group.health_check_path,
expected_status: group.health_check_expected_status,
interval_sec: group.health_check_interval_sec,
timeout_ms: group.health_check_timeout_ms,
})
} else {
form.reset({ name: '', type: 'custom', domain: null })
setLbHealth(defaultLbHealth)
}
}, [open, mode, group, form])
const domainValue = form.watch('domain')
const hasDomain = Boolean(domainValue?.trim())
function handleSubmit(values: ServiceGroupFormValues) {
const body: CreateServiceGroupInput = {
name: values.name,
type: values.type ?? 'custom',
icon: values.icon,
domain: values.domain?.trim() || null,
lb_mode: lbHealth.lb_mode,
health_check_enabled: lbHealth.enabled,
health_check_type: lbHealth.type,
health_check_port: lbHealth.port,
health_check_path: lbHealth.path,
health_check_expected_status: lbHealth.expected_status,
health_check_interval_sec: lbHealth.interval_sec,
health_check_timeout_ms: lbHealth.timeout_ms,
}
if (mode === 'create') {
onCreate?.(body)
@@ -150,6 +199,25 @@ export function ServiceGroupEditSheet({
/>
</FormFieldSimple>
</AppFieldGroup>
{hasDomain && (
<>
<Separator />
<Accordion>
<AccordionItem value="lb-health">
<AccordionTrigger>Балансировка и Health-check</AccordionTrigger>
<AccordionContent>
<HealthCheckConfigFields
value={lbHealth}
onChange={setLbHealth}
lbModeLabel="Режим балансировки общего домена"
idPrefix="group-lb-health"
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</>
)}
</FormSheet>
)
}
@@ -2,6 +2,8 @@ import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { ServiceGroupIcon } from '@/components/service-group-icon'
import type { BoardColumn } from '@/components/services-board/types'
import type { ServiceGroupView } from '@/lib/schemas'
import { useAggregatedHealth } from '@/lib/use-aggregated-health'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { AppAccordionTrigger } from '@/components/app-accordion'
import { AppBadge } from '@/components/app-badge'
import { AppButton } from '@/components/app-button'
@@ -50,6 +52,16 @@ export function ServiceGroupHeader({
onEditGroup,
onDeleteGroup,
}: ServiceGroupHeaderProps) {
const groupId = column.groupId
const groupDomain = column.domain ?? null
const groupHealthEnabled = Boolean(
groupId !== null && groupDomain && column.group?.health_check_enabled,
)
const { data: groupHealth } = useAggregatedHealth(
'group',
groupId,
groupHealthEnabled,
)
return (
<div className="flex items-center gap-1 px-1">
{showCheckbox && !dragDisabled && column.items.length > 0 ? (
@@ -83,6 +95,14 @@ export function ServiceGroupHeader({
{column.domain ? (
<AppBadge variant="outline">{column.domain}</AppBadge>
) : null}
{groupHealthEnabled && groupHealth ? (
<HealthCheckBadge
status={groupHealth.status}
latencyMs={groupHealth.worstLatencyMs}
lastCheckedAt={groupHealth.lastCheckedAt}
lastError={groupHealth.lastError}
/>
) : null}
{!isOpen && isDragging && !dragDisabled ? (
<AppBadge variant="default" className="font-normal">
Отпустите для переноса
@@ -3,6 +3,8 @@ import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVerticalIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { StatusBadge } from '@/components/status-badge'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { useAggregatedHealth } from '@/lib/use-aggregated-health'
import { bindingToFqdn } from '@/lib/parse-fqdn'
import {
aggregateServiceSyncStatus,
@@ -70,6 +72,14 @@ export const ServiceRow = memo(function ServiceRow({
const syncStatus = aggregateServiceSyncStatus(service)
const fqdn = serviceDisplayFqdn(service)
const allFqdns = (service.domains ?? []).map((d) => bindingToFqdn(d))
const healthBinding = (service.domains ?? []).find(
(d) => d.record_type === 'A' && (d.target_ips?.length ?? 0) > 1 && d.health_check_enabled,
)
const { data: bindingHealth } = useAggregatedHealth(
'binding',
healthBinding?.binding_id,
Boolean(healthBinding),
)
const style = transform
? {
@@ -153,6 +163,14 @@ export const ServiceRow = memo(function ServiceRow({
</div>
<div className="flex shrink-0 items-center gap-2">
{healthBinding && bindingHealth ? (
<HealthCheckBadge
status={bindingHealth.status}
latencyMs={bindingHealth.worstLatencyMs}
lastCheckedAt={bindingHealth.lastCheckedAt}
lastError={bindingHealth.lastError}
/>
) : null}
{syncStatus ? <StatusBadge status={syncStatus} /> : null}
{isToggling ? (
<AppSpinner className="size-4" />
+84 -3
View File
@@ -27,6 +27,14 @@ export const serviceGroupSchema = z.object({
icon: z.string().nullable(),
domain: z.string().nullable(),
enabled: z.boolean(),
lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
health_check_enabled: z.boolean().default(false),
health_check_type: z.enum(['tcp', 'http']).catch('tcp'),
health_check_port: z.number().nullable(),
health_check_path: z.string().nullable(),
health_check_expected_status: z.number().nullable(),
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
created_at: z.string(),
updated_at: z.string(),
})
@@ -39,6 +47,8 @@ export const serviceSchema = z.object({
subdomain: z.string().optional(),
enabled: z.boolean().optional(),
computed_fqdn: z.string().nullable().optional(),
lb_weight: z.number().default(1),
lb_priority: z.number().default(1),
created_at: z.string(),
updated_at: z.string(),
})
@@ -53,7 +63,17 @@ export const serviceDomainBindingSchema = z
record_type: z.enum(['A', 'CNAME']).default('A'),
target_ips: z.array(z.string()).optional(),
target_ip: z.string().nullable().optional(),
target_ip_weights: z.record(z.string(), z.number()).optional(),
target_ip_priorities: z.record(z.string(), z.number()).optional(),
target_cname: z.string().nullable().optional(),
lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
health_check_enabled: z.boolean().default(false),
health_check_type: z.enum(['tcp', 'http']).catch('tcp'),
health_check_port: z.number().nullable(),
health_check_path: z.string().nullable(),
health_check_expected_status: z.number().nullable(),
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
sync_status: z.string().nullable(),
})
.transform((binding) => ({
@@ -64,6 +84,8 @@ export const serviceDomainBindingSchema = z
: binding.target_ip
? [binding.target_ip]
: [],
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
target_cname: binding.target_cname?.trim() || null,
record_type: binding.target_cname?.trim()
? ('CNAME' as const)
@@ -117,6 +139,16 @@ export const serviceBindingSchema = z
service_slug: z.string(),
target_ip: z.string().nullable(),
target_ips: z.array(z.string()).optional(),
target_ip_weights: z.record(z.string(), z.number()).optional(),
target_ip_priorities: z.record(z.string(), z.number()).optional(),
lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
health_check_enabled: z.boolean().default(false),
health_check_type: z.enum(['tcp', 'http']).catch('tcp'),
health_check_port: z.number().nullable(),
health_check_path: z.string().nullable(),
health_check_expected_status: z.number().nullable(),
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
sync_status: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
@@ -129,6 +161,8 @@ export const serviceBindingSchema = z
: binding.target_ip
? [binding.target_ip]
: [],
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
}))
export const dnsRecordSchema = z.object({
@@ -187,11 +221,28 @@ const ipv4Schema = z
'╨Э╨╡╨║╨╛╤А╤А╨╡╨║╤В╨╜╤Л╨╣ IPv4',
)
const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted'])
const healthCheckTypeSchema = z.enum(['tcp', 'http'])
const healthCheckConfigFields = {
health_check_enabled: z.boolean().optional(),
health_check_type: healthCheckTypeSchema.optional(),
health_check_port: z.number().int().min(1).max(65535).nullable().optional(),
health_check_path: z.string().nullable().optional(),
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
}
const serviceDomainInputSchema = z
.object({
fqdn: z.string().min(1, 'Укажите FQDN'),
target_ips: z.array(ipv4Schema).optional(),
target_cname: z.string().min(1, 'Укажите CNAME-цель').optional(),
target_ip_weights: z.record(ipv4Schema, z.number().int().min(1).max(100)).optional(),
target_ip_priorities: z.record(ipv4Schema, z.number().int().min(1).max(100)).optional(),
lb_mode: lbModeSchema.optional(),
...healthCheckConfigFields,
})
.superRefine((data, ctx) => {
const hasIps = (data.target_ips?.length ?? 0) > 0
@@ -220,6 +271,8 @@ export const createServiceSchema = z.object({
export const createServiceWithConfigSchema = createServiceSchema.extend({
service_group_id: z.number().nullable().optional(),
ips: z.array(ipv4Schema).default([]),
lb_weight: z.number().int().min(1).max(100).optional(),
lb_priority: z.number().int().min(1).max(100).optional(),
domains: z.array(serviceDomainInputSchema).default([]),
})
@@ -258,10 +311,12 @@ export type CreateServiceInput = z.infer<typeof createServiceSchema>
export type CreateServiceWithConfigInput = z.infer<typeof createServiceWithConfigSchema>
export const updateServiceConfigSchema = z.object({
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡').optional(),
slug: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ slug').optional(),
name: z.string().min(1, 'name').optional(),
slug: z.string().min(1, 'slug').optional(),
service_group_id: z.number().nullable().optional(),
ips: z.array(ipv4Schema).optional(),
lb_weight: z.number().int().min(1).max(100).optional(),
lb_priority: z.number().int().min(1).max(100).optional(),
domains: z
.array(serviceDomainInputSchema)
.optional(),
@@ -269,13 +324,39 @@ export const updateServiceConfigSchema = z.object({
export type UpdateServiceConfigInput = z.infer<typeof updateServiceConfigSchema>
export const updateServiceGroupSchema = z.object({
name: z.string().min(1, 'name').optional(),
type: serviceGroupTypeSchema.optional(),
icon: z.string().nullable().optional(),
domain: z.string().nullable().optional(),
lb_mode: lbModeSchema.optional(),
...healthCheckConfigFields,
})
export type UpdateServiceGroupInput = z.infer<typeof updateServiceGroupSchema>
export const createServiceGroupSchema = z.object({
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
name: z.string().min(1, 'name'),
type: serviceGroupTypeSchema.default('custom'),
icon: z.string().nullable().optional(),
domain: z.string().nullable().optional(),
lb_mode: lbModeSchema.optional(),
...healthCheckConfigFields,
})
export const ipHealthStatusSchema = z.object({
scope: z.enum(['binding', 'group']),
ref_id: z.number(),
ip: z.string(),
status: z.enum(['up', 'down', 'degraded', 'unknown']),
latency_ms: z.number().nullable(),
consecutive_failures: z.number(),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable(),
})
export type IpHealthStatus = z.infer<typeof ipHealthStatusSchema>
export const toggleEnabledSchema = z.object({
enabled: z.boolean(),
})
+106
View File
@@ -0,0 +1,106 @@
import { useQuery } from '@tanstack/react-query'
import { healthStatusQueryOptions } from '@/queries'
import type { IpHealthStatus } from '@/lib/schemas'
export type HealthScope = 'binding' | 'group'
export type AggregatedHealthStatus =
| 'up'
| 'degraded'
| 'down'
| 'unknown'
const statusRank: Record<AggregatedHealthStatus, number> = {
up: 0,
unknown: 1,
degraded: 2,
down: 3,
}
export interface AggregatedHealth {
status: AggregatedHealthStatus
upCount: number
downCount: number
degradedCount: number
unknownCount: number
total: number
worstLatencyMs: number | null
lastCheckedAt: string | null
lastError: string | null
}
function emptyAggregated(): AggregatedHealth {
return {
status: 'unknown',
upCount: 0,
downCount: 0,
degradedCount: 0,
unknownCount: 0,
total: 0,
worstLatencyMs: null,
lastCheckedAt: null,
lastError: null,
}
}
export function aggregateHealth(rows: IpHealthStatus[]): AggregatedHealth {
if (rows.length === 0) return emptyAggregated()
const counts = { up: 0, degraded: 0, down: 0, unknown: 0 }
let worstLatencyMs: number | null = null
let lastCheckedAt: string | null = null
let lastError: string | null = null
let worstStatus: AggregatedHealthStatus = 'up'
for (const row of rows) {
const status = row.status as AggregatedHealthStatus
counts[status] = (counts[status] ?? 0) + 1
if (statusRank[status] > statusRank[worstStatus]) worstStatus = status
if (row.latency_ms != null) {
if (worstLatencyMs == null || row.latency_ms > worstLatencyMs) {
worstLatencyMs = row.latency_ms
}
}
if (row.last_checked_at) {
if (!lastCheckedAt || row.last_checked_at > lastCheckedAt) {
lastCheckedAt = row.last_checked_at
}
}
if (row.last_error && !lastError) lastError = row.last_error
}
return {
status: worstStatus,
upCount: counts.up,
degradedCount: counts.degraded,
downCount: counts.down,
unknownCount: counts.unknown,
total: rows.length,
worstLatencyMs,
lastCheckedAt,
lastError,
}
}
export function useAggregatedHealth(
scope: HealthScope,
refId: number | null | undefined,
enabled: boolean,
) {
return useQuery({
...healthStatusQueryOptions(
scope,
refId ?? 0,
),
enabled: enabled && refId != null,
select: aggregateHealth,
})
}
export function useHealthRows(
scope: HealthScope,
refId: number | null | undefined,
enabled: boolean,
) {
return useQuery({
...healthStatusQueryOptions(scope, refId ?? 0),
enabled: enabled && refId != null,
})
}
+28
View File
@@ -7,6 +7,7 @@ import {
domainSchema,
groupSchema,
groupWithStatsSchema,
ipHealthStatusSchema,
serviceBindingSchema,
serviceGroupsResponseSchema,
serviceViewSchema,
@@ -208,3 +209,30 @@ export function invalidateDomainPage(
void queryClient.invalidateQueries({ queryKey: domainKeys.detail(domainId) })
void queryClient.invalidateQueries({ queryKey: dnsKeys.list(domainId) })
}
export const healthStatusKeys = {
all: ['health-status'] as const,
list: (scope: 'binding' | 'group', refId: number) =>
[...healthStatusKeys.all, scope, refId] as const,
}
export function healthStatusQueryOptions(
scope: 'binding' | 'group',
refId: number,
) {
return queryOptions({
queryKey: healthStatusKeys.list(scope, refId),
queryFn: async () => {
const data = await api.get<unknown[]>(
`/api/v1/health-status?scope=${scope}&ref_id=${refId}`,
)
return z.array(ipHealthStatusSchema).parse(data)
},
refetchInterval: 10_000,
staleTime: 5_000,
})
}
export async function runHealthCheck() {
return api.post<{ checked: number }>('/api/v1/health-check/run', {})
}
+19
View File
@@ -16,6 +16,25 @@ Manage Cloudflare zones, DNS records, domain groups, and TLS certificate expiry
| `ADMIN_USERNAME` | Admin username |
| `ADMIN_PASSWORD_HASH` | Argon2 hash (empty = dev `admin`/`admin`) |
| `LOG_LEVEL` | Уровень логов API (`info`, `debug`) |
| `HEALTH_CHECK_CRON` | Cron для health-check (default `*/30 * * * * *`) |
| `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`) |
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`) |
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`) |
## Load balancing & health checks
Группа сервисов может иметь общий домен (`service_groups.domain`). Балансировка и
health-check работают на двух уровнях:
- **Общий домен группы** — A-записи формируются из IP сервисов группы; режим LB
и параметры health-check настраиваются в карточке группы.
- **Привязка сервиса с multi-A** — режим LB и health-check настраиваются в карточке
сервиса для каждой привязки с несколькими IP; для IP задаются вес/приоритет.
Режимы 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-записей.
## Docker
+1177 -5
View File
File diff suppressed because it is too large Load Diff
+251 -26
View File
@@ -30,6 +30,8 @@ var services = sqliteTable("services", {
subdomain: text("subdomain"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
sort_order: integer("sort_order").notNull().default(0),
lb_weight: integer("lb_weight").notNull().default(1),
lb_priority: integer("lb_priority").notNull().default(1),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
});
@@ -40,6 +42,14 @@ var serviceGroups = sqliteTable("service_groups", {
icon: text("icon"),
domain: text("domain"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
lb_mode: text("lb_mode").notNull().default("round_robin"),
health_check_enabled: integer("health_check_enabled", { mode: "boolean" }).notNull().default(false),
health_check_type: text("health_check_type").notNull().default("tcp"),
health_check_port: integer("health_check_port"),
health_check_path: text("health_check_path"),
health_check_expected_status: integer("health_check_expected_status"),
health_check_interval_sec: integer("health_check_interval_sec").notNull().default(30),
health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
});
@@ -91,6 +101,14 @@ var serviceBindings = sqliteTable("service_bindings", {
dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, {
onDelete: "set null"
}),
lb_mode: text("lb_mode").notNull().default("round_robin"),
health_check_enabled: integer("health_check_enabled", { mode: "boolean" }).notNull().default(false),
health_check_type: text("health_check_type").notNull().default("tcp"),
health_check_port: integer("health_check_port"),
health_check_path: text("health_check_path"),
health_check_expected_status: integer("health_check_expected_status"),
health_check_interval_sec: integer("health_check_interval_sec").notNull().default(30),
health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
});
@@ -112,7 +130,9 @@ var serviceBindingIps = sqliteTable(
"service_binding_ips",
{
binding_id: integer("binding_id").notNull().references(() => serviceBindings.id, { onDelete: "cascade" }),
ip: text("ip").notNull()
ip: text("ip").notNull(),
weight: integer("weight").notNull().default(1),
priority: integer("priority").notNull().default(1)
},
(t) => [primaryKey({ columns: [t.binding_id, t.ip] })]
);
@@ -148,6 +168,22 @@ var syncJobs = sqliteTable("sync_jobs", {
created_at: text("created_at").notNull().default(sql`datetime('now')`),
finished_at: text("finished_at")
});
var ipHealthStatus = sqliteTable(
"ip_health_status",
{
scope: text("scope").notNull(),
ref_id: integer("ref_id").notNull(),
ip: text("ip").notNull(),
status: text("status").notNull().default("unknown"),
latency_ms: integer("latency_ms"),
consecutive_failures: integer("consecutive_failures").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')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
},
(t) => [primaryKey({ columns: [t.scope, t.ref_id, t.ip] })]
);
var schema = {
groups,
services,
@@ -161,7 +197,8 @@ var schema = {
serviceBindingIps,
serviceGroupDnsRecords,
certificates,
syncJobs
syncJobs,
ipHealthStatus
};
// src/client.ts
@@ -243,6 +280,8 @@ __export(repos_exports, {
deleteDnsRecord: () => deleteDnsRecord,
deleteDomain: () => deleteDomain,
deleteGroup: () => deleteGroup,
deleteIpHealthStatusForIp: () => deleteIpHealthStatusForIp,
deleteIpHealthStatusForRef: () => deleteIpHealthStatusForRef,
deleteService: () => deleteService,
deleteServiceGroup: () => deleteServiceGroup,
deleteSubdomain: () => deleteSubdomain,
@@ -258,6 +297,7 @@ __export(repos_exports, {
getDomain: () => getDomain,
getGroup: () => getGroup,
getGroupWithStats: () => getGroupWithStats,
getIpHealthStatusRow: () => getIpHealthStatusRow,
getService: () => getService,
getServiceGroup: () => getServiceGroup,
getSubdomain: () => getSubdomain,
@@ -270,6 +310,7 @@ __export(repos_exports, {
listAllDomains: () => listAllDomains,
listAllSubdomains: () => listAllSubdomains,
listBindingIps: () => listBindingIps,
listBindingIpsWithMeta: () => listBindingIpsWithMeta,
listBindingsByDomain: () => listBindingsByDomain,
listBindingsByService: () => listBindingsByService,
listCertificates: () => listCertificates,
@@ -279,6 +320,8 @@ __export(repos_exports, {
listDomainsEnriched: () => listDomainsEnriched,
listGroupDnsRecords: () => listGroupDnsRecords,
listGroups: () => listGroups,
listHealthCheckTargets: () => listHealthCheckTargets,
listIpHealthStatus: () => listIpHealthStatus,
listRecordsForBinding: () => listRecordsForBinding,
listServiceGroups: () => listServiceGroups,
listServiceIps: () => listServiceIps,
@@ -289,6 +332,7 @@ __export(repos_exports, {
markDnsPendingDelete: () => markDnsPendingDelete,
reorderServices: () => reorderServices,
replaceBindingIps: () => replaceBindingIps,
replaceBindingIpsWithMeta: () => replaceBindingIpsWithMeta,
replaceServiceIps: () => replaceServiceIps,
setBindingCnameTarget: () => setBindingCnameTarget,
setBindingDnsRecordId: () => setBindingDnsRecordId,
@@ -297,9 +341,11 @@ __export(repos_exports, {
setServiceEnabled: () => setServiceEnabled,
setServiceGroup: () => setServiceGroup,
setServiceGroupEnabled: () => setServiceGroupEnabled,
setServiceLb: () => setServiceLb,
unlinkBindingRecord: () => unlinkBindingRecord,
unlinkGroupDnsRecord: () => unlinkGroupDnsRecord,
updateBindingFields: () => updateBindingFields,
updateBindingLbConfig: () => updateBindingLbConfig,
updateDnsFields: () => updateDnsFields,
updateDomain: () => updateDomain,
updateGroup: () => updateGroup,
@@ -307,6 +353,7 @@ __export(repos_exports, {
updateServiceGroup: () => updateServiceGroup,
updateSubdomain: () => updateSubdomain,
upsertCertificateCheck: () => upsertCertificateCheck,
upsertIpHealthStatus: () => upsertIpHealthStatus,
upsertSubdomain: () => upsertSubdomain
});
import { dnsRecordNamesMatch } from "@cfdm/shared";
@@ -580,6 +627,13 @@ function setServiceEnabled(db, id, enabled) {
db.update(services).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq(services.id, id)).run();
return getService(db, id);
}
function setServiceLb(db, id, weight, priority) {
db.update(services).set({
lb_weight: weight,
lb_priority: priority,
updated_at: sql2`datetime('now')`
}).where(eq(services.id, id)).run();
}
function setServiceGroup(db, id, groupId) {
const sortOrder = maxSortOrderInGroup(db, groupId) + 1;
db.update(services).set({
@@ -619,6 +673,14 @@ function mapServiceGroup(row) {
icon: row.icon,
domain: row.domain,
enabled: row.enabled,
lb_mode: row.lb_mode,
health_check_enabled: row.health_check_enabled,
health_check_type: row.health_check_type,
health_check_port: row.health_check_port,
health_check_path: row.health_check_path,
health_check_expected_status: row.health_check_expected_status,
health_check_interval_sec: row.health_check_interval_sec,
health_check_timeout_ms: row.health_check_timeout_ms,
created_at: row.created_at,
updated_at: row.updated_at
};
@@ -631,18 +693,49 @@ function getServiceGroup(db, id) {
if (!row) throw new NotFoundError(`service group ${id}`);
return mapServiceGroup(row);
}
function createServiceGroup(db, name, groupType, icon, domain) {
const id = db.insert(serviceGroups).values({ name, type: groupType, icon, domain }).returning({ id: serviceGroups.id }).get().id;
function createServiceGroup(db, name, groupType, icon, domain, lbPatch) {
const id = db.insert(serviceGroups).values({
name,
type: groupType,
icon,
domain,
lb_mode: lbPatch?.lb_mode ?? "round_robin",
health_check_enabled: lbPatch?.health_check_enabled ?? false,
health_check_type: lbPatch?.health_check_type ?? "tcp",
health_check_port: lbPatch?.health_check_port ?? null,
health_check_path: lbPatch?.health_check_path ?? null,
health_check_expected_status: lbPatch?.health_check_expected_status ?? null,
health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3e3
}).returning({ id: serviceGroups.id }).get().id;
return getServiceGroup(db, id);
}
function updateServiceGroup(db, id, name, groupType, icon, domain) {
const result = db.update(serviceGroups).set({
function updateServiceGroup(db, id, name, groupType, icon, domain, lbPatch) {
const update = {
name,
type: groupType,
icon,
domain,
updated_at: sql2`datetime('now')`
}).where(eq(serviceGroups.id, id)).run();
};
if (lbPatch) {
if (lbPatch.lb_mode !== void 0) update.lb_mode = lbPatch.lb_mode;
if (lbPatch.health_check_enabled !== void 0)
update.health_check_enabled = lbPatch.health_check_enabled;
if (lbPatch.health_check_type !== void 0)
update.health_check_type = lbPatch.health_check_type;
if (lbPatch.health_check_port !== void 0)
update.health_check_port = lbPatch.health_check_port;
if (lbPatch.health_check_path !== void 0)
update.health_check_path = lbPatch.health_check_path;
if (lbPatch.health_check_expected_status !== void 0)
update.health_check_expected_status = lbPatch.health_check_expected_status;
if (lbPatch.health_check_interval_sec !== void 0)
update.health_check_interval_sec = lbPatch.health_check_interval_sec;
if (lbPatch.health_check_timeout_ms !== void 0)
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
}
const result = db.update(serviceGroups).set(update).where(eq(serviceGroups.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
return getServiceGroup(db, id);
}
@@ -667,12 +760,52 @@ function replaceServiceIps(db, serviceId, ips) {
function listBindingIps(db, bindingId) {
return db.select({ ip: serviceBindingIps.ip }).from(serviceBindingIps).where(eq(serviceBindingIps.binding_id, bindingId)).all().map((r) => r.ip);
}
function listBindingIpsWithMeta(db, bindingId) {
return db.select({
ip: serviceBindingIps.ip,
weight: serviceBindingIps.weight,
priority: serviceBindingIps.priority
}).from(serviceBindingIps).where(eq(serviceBindingIps.binding_id, bindingId)).all();
}
function replaceBindingIps(db, bindingId, ips) {
replaceBindingIpsWithMeta(
db,
bindingId,
ips.map((ip) => ({ ip, weight: 1, priority: 1 }))
);
}
function replaceBindingIpsWithMeta(db, bindingId, entries) {
db.delete(serviceBindingIps).where(eq(serviceBindingIps.binding_id, bindingId)).run();
for (const ip of ips) {
db.insert(serviceBindingIps).values({ binding_id: bindingId, ip }).run();
for (const entry of entries) {
db.insert(serviceBindingIps).values({
binding_id: bindingId,
ip: entry.ip,
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.health_check_enabled !== void 0)
update.health_check_enabled = patch.health_check_enabled;
if (patch.health_check_type !== void 0)
update.health_check_type = patch.health_check_type;
if (patch.health_check_port !== void 0)
update.health_check_port = patch.health_check_port;
if (patch.health_check_path !== void 0)
update.health_check_path = patch.health_check_path;
if (patch.health_check_expected_status !== void 0)
update.health_check_expected_status = patch.health_check_expected_status;
if (patch.health_check_interval_sec !== void 0)
update.health_check_interval_sec = patch.health_check_interval_sec;
if (patch.health_check_timeout_ms !== void 0)
update.health_check_timeout_ms = patch.health_check_timeout_ms;
db.update(serviceBindings).set(update).where(eq(serviceBindings.id, bindingId)).run();
}
function setBindingCnameTarget(db, bindingId, target) {
db.update(serviceBindings).set({
cname_target: target,
@@ -726,13 +859,22 @@ function unlinkGroupDnsRecord(db, groupId, dnsRecordId) {
function dnsRecordMatchesHostname(recordName, hostname, zoneName) {
return dnsRecordNamesMatch(recordName, hostname, zoneName);
}
var SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec,
sb.health_check_timeout_ms, sb.cname_target,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
sb.created_at, sb.updated_at`;
function enrichServiceBindingView(db, row) {
const configured = listBindingIps(db, row.id);
const configured = listBindingIpsWithMeta(db, row.id);
const configuredIps = configured.map((c) => c.ip);
const linkedRecords = listRecordsForBinding(db, row.id);
const linkedIps = linkedRecords.filter((record) => record.record_type.toUpperCase() === "A").map((record) => record.content);
const target_ips = [
.../* @__PURE__ */ new Set([
...configured,
...configuredIps,
...linkedIps,
...row.target_ip ? [row.target_ip] : []
])
@@ -749,21 +891,29 @@ function enrichServiceBindingView(db, row) {
}
target_ips.sort();
}
const target_ip_weights = {};
const target_ip_priorities = {};
for (const entry of configured) {
target_ip_weights[entry.ip] = entry.weight;
target_ip_priorities[entry.ip] = entry.priority;
}
for (const ip of target_ips) {
if (target_ip_weights[ip] === void 0) target_ip_weights[ip] = 1;
if (target_ip_priorities[ip] === void 0) target_ip_priorities[ip] = 1;
}
const sync_status = row.sync_status ?? linkedRecords.find((record) => record.sync_status)?.sync_status ?? null;
return {
...row,
target_ips,
target_ip: target_ips[0] ?? null,
target_ip_weights,
target_ip_priorities,
sync_status
};
}
function listAllBindings(db) {
return db.all(sql2`
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
sb.created_at, sb.updated_at
SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)}
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
LEFT JOIN groups g ON g.id = d.group_id
@@ -774,11 +924,7 @@ function listAllBindings(db) {
}
function listBindingsByDomain(db, domainId) {
return db.all(sql2`
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
sb.created_at, sb.updated_at
SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)}
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
LEFT JOIN groups g ON g.id = d.group_id
@@ -802,11 +948,7 @@ function getBinding(db, id) {
}
function getBindingView(db, id) {
const rows = db.all(sql2`
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
sb.created_at, sb.updated_at
SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)}
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
LEFT JOIN groups g ON g.id = d.group_id
@@ -932,6 +1074,88 @@ function finishSyncJob(db, id, status, message) {
finished_at: sql2`datetime('now')`
}).where(eq(syncJobs.id, id)).run();
}
function listIpHealthStatus(db, scope, refId) {
return db.all(sql2`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error
FROM ip_health_status
WHERE scope = ${scope} AND ref_id = ${refId}
`);
}
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
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) {
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)
VALUES (${scope}, ${refId}, ${ip}, ${status}, ${latencyMs}, ${consecutiveFailures},
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,
last_checked_at = excluded.last_checked_at,
last_error = excluded.last_error,
updated_at = datetime('now')
`);
}
function deleteIpHealthStatusForRef(db, scope, refId) {
db.delete(ipHealthStatus).where(
and(
eq(ipHealthStatus.scope, scope),
eq(ipHealthStatus.ref_id, refId)
)
).run();
}
function deleteIpHealthStatusForIp(db, scope, refId, ip) {
db.delete(ipHealthStatus).where(
and(
eq(ipHealthStatus.scope, scope),
eq(ipHealthStatus.ref_id, refId),
eq(ipHealthStatus.ip, ip)
)
).run();
}
function listHealthCheckTargets(db) {
const bindingTargets = db.all(sql2`
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
sb.hostname AS hostname,
sb.health_check_type AS type,
sb.health_check_port AS port,
sb.health_check_path AS path,
sb.health_check_expected_status AS expected_status,
sb.health_check_timeout_ms AS timeout_ms
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
WHERE sb.health_check_enabled = 1
`);
const groupTargets = db.all(sql2`
SELECT 'group' AS scope, sg.id AS ref_id, sip.ip,
sg.domain AS hostname,
sg.health_check_type AS type,
sg.health_check_port AS port,
sg.health_check_path AS path,
sg.health_check_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms
FROM services s
JOIN service_ips sip ON sip.service_id = s.id
JOIN service_groups sg ON sg.id = s.service_group_id
WHERE sg.health_check_enabled = 1
AND sg.domain IS NOT NULL
AND s.enabled = 1
AND (sg.enabled = 1)
`);
return [...bindingTargets, ...groupTargets];
}
export {
ConflictError,
NotFoundError,
@@ -942,6 +1166,7 @@ export {
domains,
groups,
healthCheck,
ipHealthStatus,
repos_exports as repos,
resolveDatabasePath,
runMigrations,
@@ -0,0 +1,39 @@
ALTER TABLE services ADD COLUMN lb_weight INTEGER NOT NULL DEFAULT 1;
ALTER TABLE services ADD COLUMN lb_priority INTEGER NOT NULL DEFAULT 1;
ALTER TABLE service_groups ADD COLUMN lb_mode TEXT NOT NULL DEFAULT 'round_robin';
ALTER TABLE service_groups ADD COLUMN health_check_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE service_groups ADD COLUMN health_check_type TEXT NOT NULL DEFAULT 'tcp';
ALTER TABLE service_groups ADD COLUMN health_check_port INTEGER;
ALTER TABLE service_groups ADD COLUMN health_check_path TEXT;
ALTER TABLE service_groups ADD COLUMN health_check_expected_status INTEGER;
ALTER TABLE service_groups ADD COLUMN health_check_interval_sec INTEGER NOT NULL DEFAULT 30;
ALTER TABLE service_groups ADD COLUMN health_check_timeout_ms INTEGER NOT NULL DEFAULT 3000;
ALTER TABLE service_bindings ADD COLUMN lb_mode TEXT NOT NULL DEFAULT 'round_robin';
ALTER TABLE service_bindings ADD COLUMN health_check_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE service_bindings ADD COLUMN health_check_type TEXT NOT NULL DEFAULT 'tcp';
ALTER TABLE service_bindings ADD COLUMN health_check_port INTEGER;
ALTER TABLE service_bindings ADD COLUMN health_check_path TEXT;
ALTER TABLE service_bindings ADD COLUMN health_check_expected_status INTEGER;
ALTER TABLE service_bindings ADD COLUMN health_check_interval_sec INTEGER NOT NULL DEFAULT 30;
ALTER TABLE service_bindings ADD COLUMN health_check_timeout_ms INTEGER NOT NULL DEFAULT 3000;
ALTER TABLE service_binding_ips ADD COLUMN weight INTEGER NOT NULL DEFAULT 1;
ALTER TABLE service_binding_ips ADD COLUMN priority INTEGER NOT NULL DEFAULT 1;
CREATE TABLE ip_health_status (
scope TEXT NOT NULL,
ref_id INTEGER NOT NULL,
ip TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'unknown',
latency_ms INTEGER,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
last_checked_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (scope, ref_id, ip)
);
CREATE INDEX idx_ip_health_status_scope_ref ON ip_health_status(scope, ref_id);
+324 -31
View File
@@ -5,6 +5,11 @@ import type {
DomainListItem,
Group,
GroupWithStats,
HealthCheckScope,
HealthCheckTarget,
HealthCheckType,
IpHealthStatus,
LbMode,
Service,
ServiceBinding,
ServiceBindingView,
@@ -21,6 +26,7 @@ import {
dnsRecords,
domains,
groups,
ipHealthStatus,
serviceBindingIps,
serviceBindingRecords,
serviceBindings,
@@ -578,6 +584,22 @@ export function setServiceEnabled(
return getService(db, id);
}
export function setServiceLb(
db: Db,
id: number,
weight: number,
priority: number,
): void {
db.update(services)
.set({
lb_weight: weight,
lb_priority: priority,
updated_at: sql`datetime('now')`,
})
.where(eq(services.id, id))
.run();
}
export function setServiceGroup(
db: Db,
id: number,
@@ -648,6 +670,14 @@ function mapServiceGroup(row: typeof serviceGroups.$inferSelect): ServiceGroup {
icon: row.icon,
domain: row.domain,
enabled: row.enabled,
lb_mode: row.lb_mode as LbMode,
health_check_enabled: row.health_check_enabled,
health_check_type: row.health_check_type as HealthCheckType,
health_check_port: row.health_check_port,
health_check_path: row.health_check_path,
health_check_expected_status: row.health_check_expected_status,
health_check_interval_sec: row.health_check_interval_sec,
health_check_timeout_ms: row.health_check_timeout_ms,
created_at: row.created_at,
updated_at: row.updated_at,
};
@@ -672,16 +702,41 @@ export function getServiceGroup(db: Db, id: number): ServiceGroup {
return mapServiceGroup(row);
}
export interface ServiceGroupLbPatch {
lb_mode?: LbMode;
health_check_enabled?: boolean;
health_check_type?: HealthCheckType;
health_check_port?: number | null;
health_check_path?: string | null;
health_check_expected_status?: number | null;
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
}
export function createServiceGroup(
db: Db,
name: string,
groupType: string,
icon: string | null,
domain: string | null,
lbPatch?: ServiceGroupLbPatch,
): ServiceGroup {
const id = db
.insert(serviceGroups)
.values({ name, type: groupType, icon, domain })
.values({
name,
type: groupType,
icon,
domain,
lb_mode: lbPatch?.lb_mode ?? "round_robin",
health_check_enabled: lbPatch?.health_check_enabled ?? false,
health_check_type: lbPatch?.health_check_type ?? "tcp",
health_check_port: lbPatch?.health_check_port ?? null,
health_check_path: lbPatch?.health_check_path ?? null,
health_check_expected_status: lbPatch?.health_check_expected_status ?? null,
health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3000,
})
.returning({ id: serviceGroups.id })
.get()!.id;
return getServiceGroup(db, id);
@@ -694,16 +749,35 @@ export function updateServiceGroup(
groupType: string,
icon: string | null,
domain: string | null,
lbPatch?: ServiceGroupLbPatch,
): ServiceGroup {
const update: Record<string, unknown> = {
name,
type: groupType,
icon,
domain,
updated_at: sql`datetime('now')`,
};
if (lbPatch) {
if (lbPatch.lb_mode !== undefined) update.lb_mode = lbPatch.lb_mode;
if (lbPatch.health_check_enabled !== undefined)
update.health_check_enabled = lbPatch.health_check_enabled;
if (lbPatch.health_check_type !== undefined)
update.health_check_type = lbPatch.health_check_type;
if (lbPatch.health_check_port !== undefined)
update.health_check_port = lbPatch.health_check_port;
if (lbPatch.health_check_path !== undefined)
update.health_check_path = lbPatch.health_check_path;
if (lbPatch.health_check_expected_status !== undefined)
update.health_check_expected_status = lbPatch.health_check_expected_status;
if (lbPatch.health_check_interval_sec !== undefined)
update.health_check_interval_sec = lbPatch.health_check_interval_sec;
if (lbPatch.health_check_timeout_ms !== undefined)
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
}
const result = db
.update(serviceGroups)
.set({
name,
type: groupType,
icon,
domain,
updated_at: sql`datetime('now')`,
})
.set(update)
.where(eq(serviceGroups.id, id))
.run();
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
@@ -753,6 +827,12 @@ export function replaceServiceIps(
// --- Service Binding IPs ---
export interface BindingIpMeta {
ip: string;
weight: number;
priority: number;
}
export function listBindingIps(db: Db, bindingId: number): string[] {
return db
.select({ ip: serviceBindingIps.ip })
@@ -762,19 +842,93 @@ export function listBindingIps(db: Db, bindingId: number): string[] {
.map((r) => r.ip);
}
export function listBindingIpsWithMeta(
db: Db,
bindingId: number,
): BindingIpMeta[] {
return db
.select({
ip: serviceBindingIps.ip,
weight: serviceBindingIps.weight,
priority: serviceBindingIps.priority,
})
.from(serviceBindingIps)
.where(eq(serviceBindingIps.binding_id, bindingId))
.all();
}
export function replaceBindingIps(
db: Db,
bindingId: number,
ips: string[],
): void {
replaceBindingIpsWithMeta(
db,
bindingId,
ips.map((ip) => ({ ip, weight: 1, priority: 1 })),
);
}
export function replaceBindingIpsWithMeta(
db: Db,
bindingId: number,
entries: BindingIpMeta[],
): void {
db.delete(serviceBindingIps)
.where(eq(serviceBindingIps.binding_id, bindingId))
.run();
for (const ip of ips) {
db.insert(serviceBindingIps).values({ binding_id: bindingId, ip }).run();
for (const entry of entries) {
db.insert(serviceBindingIps)
.values({
binding_id: bindingId,
ip: entry.ip,
weight: entry.weight,
priority: entry.priority,
})
.run();
}
}
export interface BindingLbPatch {
lb_mode?: LbMode;
health_check_enabled?: boolean;
health_check_type?: HealthCheckType;
health_check_port?: number | null;
health_check_path?: string | null;
health_check_expected_status?: number | null;
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
}
export function updateBindingLbConfig(
db: Db,
bindingId: number,
patch: BindingLbPatch,
): void {
const update: Record<string, unknown> = {
updated_at: sql`datetime('now')`,
};
if (patch.lb_mode !== undefined) update.lb_mode = patch.lb_mode;
if (patch.health_check_enabled !== undefined)
update.health_check_enabled = patch.health_check_enabled;
if (patch.health_check_type !== undefined)
update.health_check_type = patch.health_check_type;
if (patch.health_check_port !== undefined)
update.health_check_port = patch.health_check_port;
if (patch.health_check_path !== undefined)
update.health_check_path = patch.health_check_path;
if (patch.health_check_expected_status !== undefined)
update.health_check_expected_status = patch.health_check_expected_status;
if (patch.health_check_interval_sec !== undefined)
update.health_check_interval_sec = patch.health_check_interval_sec;
if (patch.health_check_timeout_ms !== undefined)
update.health_check_timeout_ms = patch.health_check_timeout_ms;
db.update(serviceBindings)
.set(update)
.where(eq(serviceBindings.id, bindingId))
.run();
}
export function setBindingCnameTarget(
db: Db,
bindingId: number,
@@ -873,11 +1027,23 @@ function dnsRecordMatchesHostname(
return dnsRecordNamesMatch(recordName, hostname, zoneName);
}
const SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec,
sb.health_check_timeout_ms, sb.cname_target,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
sb.created_at, sb.updated_at`;
function enrichServiceBindingView(
db: Db,
row: Omit<ServiceBindingView, "target_ips"> & { target_ips?: string[] },
row: Omit<ServiceBindingView, "target_ips" | "target_ip_weights" | "target_ip_priorities"> & {
target_ips?: string[];
},
): ServiceBindingView {
const configured = listBindingIps(db, row.id);
const configured = listBindingIpsWithMeta(db, row.id);
const configuredIps = configured.map((c) => c.ip);
const linkedRecords = listRecordsForBinding(db, row.id);
const linkedIps = linkedRecords
.filter((record) => record.record_type.toUpperCase() === "A")
@@ -885,7 +1051,7 @@ function enrichServiceBindingView(
const target_ips = [
...new Set([
...configured,
...configuredIps,
...linkedIps,
...(row.target_ip ? [row.target_ip] : []),
]),
@@ -904,6 +1070,17 @@ function enrichServiceBindingView(
target_ips.sort();
}
const target_ip_weights: Record<string, number> = {};
const target_ip_priorities: Record<string, number> = {};
for (const entry of configured) {
target_ip_weights[entry.ip] = entry.weight;
target_ip_priorities[entry.ip] = entry.priority;
}
for (const ip of target_ips) {
if (target_ip_weights[ip] === undefined) target_ip_weights[ip] = 1;
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
}
const sync_status =
row.sync_status ??
linkedRecords.find((record) => record.sync_status)?.sync_status ??
@@ -913,18 +1090,16 @@ function enrichServiceBindingView(
...row,
target_ips,
target_ip: target_ips[0] ?? null,
target_ip_weights,
target_ip_priorities,
sync_status,
};
}
export function listAllBindings(db: Db): ServiceBindingView[] {
return db
.all<Omit<ServiceBindingView, "target_ips">>(sql`
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
sb.created_at, sb.updated_at
.all<Omit<ServiceBindingView, "target_ips" | "target_ip_weights" | "target_ip_priorities">>(sql`
SELECT ${sql.raw(SERVICE_BINDING_SELECT_COLUMNS)}
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
LEFT JOIN groups g ON g.id = d.group_id
@@ -937,12 +1112,8 @@ export function listAllBindings(db: Db): ServiceBindingView[] {
export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingView[] {
return db
.all<Omit<ServiceBindingView, "target_ips">>(sql`
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
sb.created_at, sb.updated_at
.all<Omit<ServiceBindingView, "target_ips" | "target_ip_weights" | "target_ip_priorities">>(sql`
SELECT ${sql.raw(SERVICE_BINDING_SELECT_COLUMNS)}
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
LEFT JOIN groups g ON g.id = d.group_id
@@ -973,12 +1144,8 @@ export function getBinding(db: Db, id: number): ServiceBinding {
}
export function getBindingView(db: Db, id: number): ServiceBindingView {
const rows = db.all<Omit<ServiceBindingView, "target_ips">>(sql`
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
sb.created_at, sb.updated_at
const rows = db.all<Omit<ServiceBindingView, "target_ips" | "target_ip_weights" | "target_ip_priorities">>(sql`
SELECT ${sql.raw(SERVICE_BINDING_SELECT_COLUMNS)}
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
LEFT JOIN groups g ON g.id = d.group_id
@@ -1225,3 +1392,129 @@ export function finishSyncJob(
.where(eq(syncJobs.id, id))
.run();
}
// --- IP Health Status ---
export function listIpHealthStatus(
db: Db,
scope: HealthCheckScope,
refId: number,
): IpHealthStatus[] {
return db
.all<IpHealthStatus>(sql`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error
FROM ip_health_status
WHERE scope = ${scope} AND ref_id = ${refId}
`);
}
export function getIpHealthStatusRow(
db: Db,
scope: HealthCheckScope,
refId: number,
ip: string,
): IpHealthStatus | null {
const rows = db.all<IpHealthStatus>(sql`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
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;
}
export function upsertIpHealthStatus(
db: Db,
scope: HealthCheckScope,
refId: number,
ip: string,
status: string,
latencyMs: number | null,
consecutiveFailures: number,
lastError: string | null,
): 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)
VALUES (${scope}, ${refId}, ${ip}, ${status}, ${latencyMs}, ${consecutiveFailures},
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,
last_checked_at = excluded.last_checked_at,
last_error = excluded.last_error,
updated_at = datetime('now')
`);
}
export function deleteIpHealthStatusForRef(
db: Db,
scope: HealthCheckScope,
refId: number,
): void {
db.delete(ipHealthStatus)
.where(
and(
eq(ipHealthStatus.scope, scope),
eq(ipHealthStatus.ref_id, refId),
),
)
.run();
}
export function deleteIpHealthStatusForIp(
db: Db,
scope: HealthCheckScope,
refId: number,
ip: string,
): void {
db.delete(ipHealthStatus)
.where(
and(
eq(ipHealthStatus.scope, scope),
eq(ipHealthStatus.ref_id, refId),
eq(ipHealthStatus.ip, ip),
),
)
.run();
}
// --- Health Check Targets ---
export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
const bindingTargets = db.all<HealthCheckTarget>(sql`
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
sb.hostname AS hostname,
sb.health_check_type AS type,
sb.health_check_port AS port,
sb.health_check_path AS path,
sb.health_check_expected_status AS expected_status,
sb.health_check_timeout_ms AS timeout_ms
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
WHERE sb.health_check_enabled = 1
`);
const groupTargets = db.all<HealthCheckTarget>(sql`
SELECT 'group' AS scope, sg.id AS ref_id, sip.ip,
sg.domain AS hostname,
sg.health_check_type AS type,
sg.health_check_port AS port,
sg.health_check_path AS path,
sg.health_check_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms
FROM services s
JOIN service_ips sip ON sip.service_id = s.id
JOIN service_groups sg ON sg.id = s.service_group_id
WHERE sg.health_check_enabled = 1
AND sg.domain IS NOT NULL
AND s.enabled = 1
AND (sg.enabled = 1)
`);
return [...bindingTargets, ...groupTargets];
}
+56
View File
@@ -29,6 +29,8 @@ export const services = sqliteTable("services", {
subdomain: text("subdomain"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
sort_order: integer("sort_order").notNull().default(0),
lb_weight: integer("lb_weight").notNull().default(1),
lb_priority: integer("lb_priority").notNull().default(1),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
@@ -44,6 +46,20 @@ export const serviceGroups = sqliteTable("service_groups", {
icon: text("icon"),
domain: text("domain"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
lb_mode: text("lb_mode").notNull().default("round_robin"),
health_check_enabled: integer("health_check_enabled", { mode: "boolean" })
.notNull()
.default(false),
health_check_type: text("health_check_type").notNull().default("tcp"),
health_check_port: integer("health_check_port"),
health_check_path: text("health_check_path"),
health_check_expected_status: integer("health_check_expected_status"),
health_check_interval_sec: integer("health_check_interval_sec")
.notNull()
.default(30),
health_check_timeout_ms: integer("health_check_timeout_ms")
.notNull()
.default(3000),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
@@ -123,6 +139,20 @@ export const serviceBindings = sqliteTable("service_bindings", {
dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, {
onDelete: "set null",
}),
lb_mode: text("lb_mode").notNull().default("round_robin"),
health_check_enabled: integer("health_check_enabled", { mode: "boolean" })
.notNull()
.default(false),
health_check_type: text("health_check_type").notNull().default("tcp"),
health_check_port: integer("health_check_port"),
health_check_path: text("health_check_path"),
health_check_expected_status: integer("health_check_expected_status"),
health_check_interval_sec: integer("health_check_interval_sec")
.notNull()
.default(30),
health_check_timeout_ms: integer("health_check_timeout_ms")
.notNull()
.default(3000),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
@@ -162,6 +192,8 @@ export const serviceBindingIps = sqliteTable(
.notNull()
.references(() => serviceBindings.id, { onDelete: "cascade" }),
ip: text("ip").notNull(),
weight: integer("weight").notNull().default(1),
priority: integer("priority").notNull().default(1),
},
(t) => [primaryKey({ columns: [t.binding_id, t.ip] })],
);
@@ -213,6 +245,29 @@ export const syncJobs = sqliteTable("sync_jobs", {
finished_at: text("finished_at"),
});
export const ipHealthStatus = sqliteTable(
"ip_health_status",
{
scope: text("scope").notNull(),
ref_id: integer("ref_id").notNull(),
ip: text("ip").notNull(),
status: text("status").notNull().default("unknown"),
latency_ms: integer("latency_ms"),
consecutive_failures: integer("consecutive_failures")
.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')`),
updated_at: text("updated_at")
.notNull()
.default(sql`datetime('now')`),
},
(t) => [primaryKey({ columns: [t.scope, t.ref_id, t.ip] })],
);
export const schema = {
groups,
services,
@@ -227,4 +282,5 @@ export const schema = {
serviceGroupDnsRecords,
certificates,
syncJobs,
ipHealthStatus,
};
+482 -13
View File
@@ -22,17 +22,14 @@ interface ServiceGroup$1 {
icon: string | null;
domain: string | null;
enabled: boolean;
created_at: string;
updated_at: string;
}
interface Service$1 {
id: number;
name: string;
slug: string;
service_group_id: number | null;
subdomain: string;
enabled: boolean;
sort_order: number;
lb_mode: LbMode;
health_check_enabled: boolean;
health_check_type: HealthCheckType;
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
created_at: string;
updated_at: string;
}
@@ -53,6 +50,14 @@ interface ServiceBinding {
hostname: string;
cname_target: string | null;
dns_record_id: number | null;
lb_mode: LbMode;
health_check_enabled: boolean;
health_check_type: HealthCheckType;
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
created_at: string;
updated_at: string;
}
@@ -69,6 +74,16 @@ interface ServiceBindingView {
service_slug: string;
target_ip: string | null;
target_ips: string[];
target_ip_weights: Record<string, number>;
target_ip_priorities: Record<string, number>;
lb_mode: LbMode;
health_check_enabled: boolean;
health_check_type: HealthCheckType;
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
sync_status: string | null;
created_at: string;
updated_at: string;
@@ -81,7 +96,17 @@ interface ServiceDomainBindingView {
fqdn: string;
record_type: "A" | "CNAME";
target_ips: string[];
target_ip_weights: Record<string, number>;
target_ip_priorities: Record<string, number>;
target_cname: string | null;
lb_mode: LbMode;
health_check_enabled: boolean;
health_check_type: HealthCheckType;
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
sync_status: string | null;
}
interface SyncJob {
@@ -126,13 +151,41 @@ interface JwtClaims {
sub: string;
exp: number;
}
type LbMode = "round_robin" | "failover" | "weighted";
type HealthCheckType = "tcp" | "http";
type IpHealthState = "up" | "down" | "degraded" | "unknown";
type HealthCheckScope = "binding" | "group";
interface IpHealthStatus {
scope: HealthCheckScope;
ref_id: number;
ip: string;
status: IpHealthState;
latency_ms: number | null;
consecutive_failures: number;
last_checked_at: string | null;
last_error: string | null;
}
interface HealthCheckTarget {
scope: HealthCheckScope;
ref_id: number;
ip: string;
hostname: string;
type: HealthCheckType;
port: number | null;
path: string | null;
expected_status: number | null;
timeout_ms: number;
}
declare class ValidationError extends Error {
constructor(message: string);
}
declare function validateDnsRecord(recordType: string, name: string, content: string, ttl: number, proxied: boolean): void;
declare function certStatusFromExpiry(daysLeft: number): string;
declare function shouldMonitorService(service: Pick<Service$1, "enabled" | "service_group_id">, group?: Pick<ServiceGroup$1, "enabled"> | null): boolean;
declare function shouldMonitorService(service: {
enabled?: boolean;
service_group_id?: number | null;
}, group?: Pick<ServiceGroup$1, "enabled"> | null): boolean;
declare function isValidIpv4(ip: string): boolean;
declare function dnsNameToSubdomainLabel(recordName: string, zoneName: string): string | null;
@@ -160,6 +213,43 @@ declare const certMonitoringSchema: z.ZodEnum<{
skipped: "skipped";
}>;
type CertMonitoring = z.infer<typeof certMonitoringSchema>;
declare const lbModeSchema: z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>;
declare const healthCheckTypeSchema: z.ZodEnum<{
tcp: "tcp";
http: "http";
}>;
declare const ipHealthStateSchema: z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>;
declare const healthCheckScopeSchema: z.ZodEnum<{
binding: "binding";
group: "group";
}>;
declare const ipHealthStatusSchema: z.ZodObject<{
scope: z.ZodEnum<{
binding: "binding";
group: "group";
}>;
ref_id: z.ZodNumber;
ip: z.ZodString;
status: z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
consecutive_failures: z.ZodNumber;
last_checked_at: z.ZodNullable<z.ZodString>;
last_error: z.ZodNullable<z.ZodString>;
}, z.core.$strip>;
declare const groupSchema: z.ZodObject<{
id: z.ZodNumber;
name: z.ZodString;
@@ -195,6 +285,21 @@ declare const serviceGroupSchema: z.ZodObject<{
icon: z.ZodNullable<z.ZodString>;
domain: z.ZodNullable<z.ZodString>;
enabled: z.ZodBoolean;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
health_check_enabled: z.ZodDefault<z.ZodBoolean>;
health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp";
http: "http";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
health_check_expected_status: z.ZodNullable<z.ZodNumber>;
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
created_at: z.ZodString;
updated_at: z.ZodString;
}, z.core.$strip>;
@@ -206,6 +311,8 @@ declare const serviceSchema: z.ZodObject<{
subdomain: z.ZodOptional<z.ZodString>;
enabled: z.ZodOptional<z.ZodBoolean>;
computed_fqdn: z.ZodOptional<z.ZodNullable<z.ZodString>>;
lb_weight: z.ZodDefault<z.ZodNumber>;
lb_priority: z.ZodDefault<z.ZodNumber>;
created_at: z.ZodString;
updated_at: z.ZodString;
}, z.core.$strip>;
@@ -221,10 +328,29 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
}>>;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_ip: z.ZodOptional<z.ZodNullable<z.ZodString>>;
target_ip_weights: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
target_ip_priorities: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
target_cname: z.ZodOptional<z.ZodNullable<z.ZodString>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
health_check_enabled: z.ZodDefault<z.ZodBoolean>;
health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp";
http: "http";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
health_check_expected_status: z.ZodNullable<z.ZodNumber>;
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
sync_status: z.ZodNullable<z.ZodString>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
target_ip_weights: Record<string, number>;
target_ip_priorities: Record<string, number>;
target_cname: string | null;
record_type: "A" | "CNAME";
binding_id: number;
@@ -232,6 +358,14 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
zone_name: string;
hostname: string;
fqdn: string;
lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean;
health_check_type: "tcp" | "http";
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
sync_status: string | null;
target_ip?: string | null | undefined;
}, {
@@ -241,9 +375,19 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
hostname: string;
fqdn: string;
record_type: "A" | "CNAME";
lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean;
health_check_type: "tcp" | "http";
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
sync_status: string | null;
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined;
target_ip_priorities?: Record<string, number> | undefined;
target_cname?: string | null | undefined;
}>>;
declare const serviceViewSchema: z.ZodObject<{
@@ -252,6 +396,8 @@ declare const serviceViewSchema: z.ZodObject<{
slug: z.ZodString;
service_group_id: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
computed_fqdn: z.ZodOptional<z.ZodNullable<z.ZodString>>;
lb_weight: z.ZodDefault<z.ZodNumber>;
lb_priority: z.ZodDefault<z.ZodNumber>;
created_at: z.ZodString;
updated_at: z.ZodString;
subdomain: z.ZodDefault<z.ZodString>;
@@ -269,10 +415,29 @@ declare const serviceViewSchema: z.ZodObject<{
}>>;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_ip: z.ZodOptional<z.ZodNullable<z.ZodString>>;
target_ip_weights: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
target_ip_priorities: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
target_cname: z.ZodOptional<z.ZodNullable<z.ZodString>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
health_check_enabled: z.ZodDefault<z.ZodBoolean>;
health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp";
http: "http";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
health_check_expected_status: z.ZodNullable<z.ZodNumber>;
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
sync_status: z.ZodNullable<z.ZodString>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
target_ip_weights: Record<string, number>;
target_ip_priorities: Record<string, number>;
target_cname: string | null;
record_type: "A" | "CNAME";
binding_id: number;
@@ -280,6 +445,14 @@ declare const serviceViewSchema: z.ZodObject<{
zone_name: string;
hostname: string;
fqdn: string;
lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean;
health_check_type: "tcp" | "http";
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
sync_status: string | null;
target_ip?: string | null | undefined;
}, {
@@ -289,9 +462,19 @@ declare const serviceViewSchema: z.ZodObject<{
hostname: string;
fqdn: string;
record_type: "A" | "CNAME";
lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean;
health_check_type: "tcp" | "http";
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
sync_status: string | null;
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined;
target_ip_priorities?: Record<string, number> | undefined;
target_cname?: string | null | undefined;
}>>>>;
}, z.core.$strip>;
@@ -308,6 +491,21 @@ declare const serviceGroupViewSchema: z.ZodObject<{
icon: z.ZodNullable<z.ZodString>;
domain: z.ZodNullable<z.ZodString>;
enabled: z.ZodBoolean;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
health_check_enabled: z.ZodDefault<z.ZodBoolean>;
health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp";
http: "http";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
health_check_expected_status: z.ZodNullable<z.ZodNumber>;
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
created_at: z.ZodString;
updated_at: z.ZodString;
services: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -316,6 +514,8 @@ declare const serviceGroupViewSchema: z.ZodObject<{
slug: z.ZodString;
service_group_id: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
computed_fqdn: z.ZodOptional<z.ZodNullable<z.ZodString>>;
lb_weight: z.ZodDefault<z.ZodNumber>;
lb_priority: z.ZodDefault<z.ZodNumber>;
created_at: z.ZodString;
updated_at: z.ZodString;
subdomain: z.ZodDefault<z.ZodString>;
@@ -333,10 +533,29 @@ declare const serviceGroupViewSchema: z.ZodObject<{
}>>;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_ip: z.ZodOptional<z.ZodNullable<z.ZodString>>;
target_ip_weights: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
target_ip_priorities: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
target_cname: z.ZodOptional<z.ZodNullable<z.ZodString>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
health_check_enabled: z.ZodDefault<z.ZodBoolean>;
health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp";
http: "http";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
health_check_expected_status: z.ZodNullable<z.ZodNumber>;
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
sync_status: z.ZodNullable<z.ZodString>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
target_ip_weights: Record<string, number>;
target_ip_priorities: Record<string, number>;
target_cname: string | null;
record_type: "A" | "CNAME";
binding_id: number;
@@ -344,6 +563,14 @@ declare const serviceGroupViewSchema: z.ZodObject<{
zone_name: string;
hostname: string;
fqdn: string;
lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean;
health_check_type: "tcp" | "http";
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
sync_status: string | null;
target_ip?: string | null | undefined;
}, {
@@ -353,9 +580,19 @@ declare const serviceGroupViewSchema: z.ZodObject<{
hostname: string;
fqdn: string;
record_type: "A" | "CNAME";
lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean;
health_check_type: "tcp" | "http";
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
sync_status: string | null;
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined;
target_ip_priorities?: Record<string, number> | undefined;
target_cname?: string | null | undefined;
}>>>>;
}, z.core.$strip>>>;
@@ -374,6 +611,21 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
icon: z.ZodNullable<z.ZodString>;
domain: z.ZodNullable<z.ZodString>;
enabled: z.ZodBoolean;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
health_check_enabled: z.ZodDefault<z.ZodBoolean>;
health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp";
http: "http";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
health_check_expected_status: z.ZodNullable<z.ZodNumber>;
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
created_at: z.ZodString;
updated_at: z.ZodString;
services: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -382,6 +634,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
slug: z.ZodString;
service_group_id: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
computed_fqdn: z.ZodOptional<z.ZodNullable<z.ZodString>>;
lb_weight: z.ZodDefault<z.ZodNumber>;
lb_priority: z.ZodDefault<z.ZodNumber>;
created_at: z.ZodString;
updated_at: z.ZodString;
subdomain: z.ZodDefault<z.ZodString>;
@@ -399,10 +653,29 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
}>>;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_ip: z.ZodOptional<z.ZodNullable<z.ZodString>>;
target_ip_weights: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
target_ip_priorities: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
target_cname: z.ZodOptional<z.ZodNullable<z.ZodString>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
health_check_enabled: z.ZodDefault<z.ZodBoolean>;
health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp";
http: "http";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
health_check_expected_status: z.ZodNullable<z.ZodNumber>;
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
sync_status: z.ZodNullable<z.ZodString>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
target_ip_weights: Record<string, number>;
target_ip_priorities: Record<string, number>;
target_cname: string | null;
record_type: "A" | "CNAME";
binding_id: number;
@@ -410,6 +683,14 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
zone_name: string;
hostname: string;
fqdn: string;
lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean;
health_check_type: "tcp" | "http";
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
sync_status: string | null;
target_ip?: string | null | undefined;
}, {
@@ -419,9 +700,19 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
hostname: string;
fqdn: string;
record_type: "A" | "CNAME";
lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean;
health_check_type: "tcp" | "http";
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
sync_status: string | null;
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined;
target_ip_priorities?: Record<string, number> | undefined;
target_cname?: string | null | undefined;
}>>>>;
}, z.core.$strip>>>;
@@ -432,6 +723,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
slug: z.ZodString;
service_group_id: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
computed_fqdn: z.ZodOptional<z.ZodNullable<z.ZodString>>;
lb_weight: z.ZodDefault<z.ZodNumber>;
lb_priority: z.ZodDefault<z.ZodNumber>;
created_at: z.ZodString;
updated_at: z.ZodString;
subdomain: z.ZodDefault<z.ZodString>;
@@ -449,10 +742,29 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
}>>;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_ip: z.ZodOptional<z.ZodNullable<z.ZodString>>;
target_ip_weights: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
target_ip_priorities: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
target_cname: z.ZodOptional<z.ZodNullable<z.ZodString>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
health_check_enabled: z.ZodDefault<z.ZodBoolean>;
health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp";
http: "http";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
health_check_expected_status: z.ZodNullable<z.ZodNumber>;
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
sync_status: z.ZodNullable<z.ZodString>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
target_ip_weights: Record<string, number>;
target_ip_priorities: Record<string, number>;
target_cname: string | null;
record_type: "A" | "CNAME";
binding_id: number;
@@ -460,6 +772,14 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
zone_name: string;
hostname: string;
fqdn: string;
lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean;
health_check_type: "tcp" | "http";
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
sync_status: string | null;
target_ip?: string | null | undefined;
}, {
@@ -469,9 +789,19 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
hostname: string;
fqdn: string;
record_type: "A" | "CNAME";
lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean;
health_check_type: "tcp" | "http";
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
sync_status: string | null;
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined;
target_ip_priorities?: Record<string, number> | undefined;
target_cname?: string | null | undefined;
}>>>>;
}, z.core.$strip>>>;
@@ -521,11 +851,30 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
service_slug: z.ZodString;
target_ip: z.ZodNullable<z.ZodString>;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_ip_weights: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
target_ip_priorities: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
health_check_enabled: z.ZodDefault<z.ZodBoolean>;
health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp";
http: "http";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
health_check_expected_status: z.ZodNullable<z.ZodNumber>;
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
sync_status: z.ZodNullable<z.ZodString>;
created_at: z.ZodString;
updated_at: z.ZodString;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
target_ip_weights: Record<string, number>;
target_ip_priorities: Record<string, number>;
id: number;
domain_id: number;
service_id: number;
@@ -537,6 +886,14 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
service_name: string;
service_slug: string;
target_ip: string | null;
lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean;
health_check_type: "tcp" | "http";
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
sync_status: string | null;
created_at: string;
updated_at: string;
@@ -552,10 +909,20 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
service_name: string;
service_slug: string;
target_ip: string | null;
lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean;
health_check_type: "tcp" | "http";
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
sync_status: string | null;
created_at: string;
updated_at: string;
target_ips?: string[] | undefined;
target_ip_weights?: Record<string, number> | undefined;
target_ip_priorities?: Record<string, number> | undefined;
}>>;
declare const dnsRecordSchema: z.ZodObject<{
id: z.ZodNumber;
@@ -601,6 +968,19 @@ declare const createGroupSchema: z.ZodObject<{
name: z.ZodString;
slug: z.ZodString;
}, z.core.$strip>;
declare const healthCheckConfigSchema: z.ZodObject<{
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
}>>;
health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>;
type HealthCheckConfig = z.infer<typeof healthCheckConfigSchema>;
declare const createServiceSchema: z.ZodObject<{
name: z.ZodString;
slug: z.ZodString;
@@ -610,10 +990,29 @@ declare const createServiceWithConfigSchema: z.ZodObject<{
slug: z.ZodString;
service_group_id: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
lb_weight: z.ZodOptional<z.ZodNumber>;
lb_priority: z.ZodOptional<z.ZodNumber>;
domains: z.ZodDefault<z.ZodArray<z.ZodObject<{
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
}>>;
health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
fqdn: z.ZodString;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_cname: z.ZodOptional<z.ZodString>;
target_ip_weights: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
target_ip_priorities: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
lb_mode: z.ZodOptional<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
}, z.core.$strip>>>;
}, z.core.$strip>;
declare const createServiceBindingSchema: z.ZodObject<{
@@ -694,14 +1093,43 @@ declare const updateServiceConfigSchema: z.ZodObject<{
slug: z.ZodOptional<z.ZodString>;
service_group_id: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
lb_weight: z.ZodOptional<z.ZodNumber>;
lb_priority: z.ZodOptional<z.ZodNumber>;
domains: z.ZodOptional<z.ZodArray<z.ZodObject<{
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
}>>;
health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
fqdn: z.ZodString;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_cname: z.ZodOptional<z.ZodString>;
target_ip_weights: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
target_ip_priorities: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
lb_mode: z.ZodOptional<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
}, z.core.$strip>>>;
}, z.core.$strip>;
type UpdateServiceConfigInput = z.infer<typeof updateServiceConfigSchema>;
declare const createServiceGroupSchema: z.ZodObject<{
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
}>>;
health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
name: z.ZodString;
type: z.ZodDefault<z.ZodEnum<{
vpn: "vpn";
@@ -712,7 +1140,40 @@ declare const createServiceGroupSchema: z.ZodObject<{
}>>;
icon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
domain: z.ZodOptional<z.ZodNullable<z.ZodString>>;
lb_mode: z.ZodOptional<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
}, z.core.$strip>;
declare const updateServiceGroupSchema: z.ZodObject<{
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
}>>;
health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
name: z.ZodOptional<z.ZodString>;
type: z.ZodOptional<z.ZodEnum<{
vpn: "vpn";
network: "network";
internet: "internet";
bgp: "bgp";
custom: "custom";
}>>;
icon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
domain: z.ZodOptional<z.ZodNullable<z.ZodString>>;
lb_mode: z.ZodOptional<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
}, z.core.$strip>;
type UpdateServiceGroupInput = z.infer<typeof updateServiceGroupSchema>;
declare const toggleEnabledSchema: z.ZodObject<{
enabled: z.ZodBoolean;
}, z.core.$strip>;
@@ -720,6 +1181,14 @@ declare const reorderServicesSchema: z.ZodObject<{
group_id: z.ZodDefault<z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>>>;
service_ids: z.ZodArray<z.ZodNumber>;
}, z.core.$strip>;
declare const healthStatusQuerySchema: z.ZodObject<{
scope: z.ZodEnum<{
binding: "binding";
group: "group";
}>;
ref_id: z.ZodCoercedNumber<unknown>;
}, z.core.$strip>;
type HealthStatusQuery = z.infer<typeof healthStatusQuerySchema>;
type CreateServiceGroupInput = z.infer<typeof createServiceGroupSchema>;
type ToggleEnabledInput = z.infer<typeof toggleEnabledSchema>;
type ReorderServicesInput = z.infer<typeof reorderServicesSchema>;
@@ -728,4 +1197,4 @@ type CreateDomainInput = z.infer<typeof createDomainSchema>;
type LoginInput = z.infer<typeof loginSchema>;
type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema>;
export { 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 CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainListItem, type Group, type GroupWithStats, type JwtClaims, type LoginInput, type LoginRequest, type LoginResponse, 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 UpdateSubdomainInput, ValidationError, bindingToFqdn, certMonitoringSchema, certStatusFromExpiry, certificateSchema, createDnsRecordSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainListItemSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, isValidIpv4, loginSchema, normalizeDnsRecordName, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateSubdomainSchema, validateDnsRecord };
export { 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 CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainListItem, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, 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, bindingToFqdn, certMonitoringSchema, certStatusFromExpiry, certificateSchema, createDnsRecordSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainListItemSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ipHealthStateSchema, ipHealthStatusSchema, isValidIpv4, lbModeSchema, loginSchema, normalizeDnsRecordName, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord };
+91 -3
View File
@@ -160,6 +160,20 @@ function bindingToFqdn(binding) {
// src/schemas.ts
import { z } from "zod";
var certMonitoringSchema = z.enum(["auto", "required", "skipped"]);
var lbModeSchema = z.enum(["round_robin", "failover", "weighted"]);
var healthCheckTypeSchema = z.enum(["tcp", "http"]);
var ipHealthStateSchema = z.enum(["up", "down", "degraded", "unknown"]);
var healthCheckScopeSchema = z.enum(["binding", "group"]);
var ipHealthStatusSchema = z.object({
scope: healthCheckScopeSchema,
ref_id: z.number(),
ip: z.string(),
status: ipHealthStateSchema,
latency_ms: z.number().nullable(),
consecutive_failures: z.number(),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable()
});
var groupSchema = z.object({
id: z.number(),
name: z.string(),
@@ -184,6 +198,14 @@ var serviceGroupSchema = z.object({
icon: z.string().nullable(),
domain: z.string().nullable(),
enabled: z.boolean(),
lb_mode: lbModeSchema.catch("round_robin"),
health_check_enabled: z.boolean().default(false),
health_check_type: healthCheckTypeSchema.catch("tcp"),
health_check_port: z.number().nullable(),
health_check_path: z.string().nullable(),
health_check_expected_status: z.number().nullable(),
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3e3),
created_at: z.string(),
updated_at: z.string()
});
@@ -195,6 +217,8 @@ var serviceSchema = z.object({
subdomain: z.string().optional(),
enabled: z.boolean().optional(),
computed_fqdn: z.string().nullable().optional(),
lb_weight: z.number().default(1),
lb_priority: z.number().default(1),
created_at: z.string(),
updated_at: z.string()
});
@@ -207,11 +231,23 @@ var serviceDomainBindingSchema = z.object({
record_type: z.enum(["A", "CNAME"]).default("A"),
target_ips: z.array(z.string()).optional(),
target_ip: z.string().nullable().optional(),
target_ip_weights: z.record(z.string(), z.number()).optional(),
target_ip_priorities: z.record(z.string(), z.number()).optional(),
target_cname: z.string().nullable().optional(),
lb_mode: lbModeSchema.catch("round_robin"),
health_check_enabled: z.boolean().default(false),
health_check_type: healthCheckTypeSchema.catch("tcp"),
health_check_port: z.number().nullable(),
health_check_path: z.string().nullable(),
health_check_expected_status: z.number().nullable(),
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3e3),
sync_status: z.string().nullable()
}).transform((binding) => ({
...binding,
target_ips: binding.target_ips && binding.target_ips.length > 0 ? binding.target_ips : binding.target_ip ? [binding.target_ip] : [],
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
target_cname: binding.target_cname?.trim() || null,
record_type: binding.target_cname?.trim() ? "CNAME" : binding.record_type ?? "A"
}));
@@ -256,12 +292,24 @@ var serviceBindingSchema = z.object({
service_slug: z.string(),
target_ip: z.string().nullable(),
target_ips: z.array(z.string()).optional(),
target_ip_weights: z.record(z.string(), z.number()).optional(),
target_ip_priorities: z.record(z.string(), z.number()).optional(),
lb_mode: lbModeSchema.catch("round_robin"),
health_check_enabled: z.boolean().default(false),
health_check_type: healthCheckTypeSchema.catch("tcp"),
health_check_port: z.number().nullable(),
health_check_path: z.string().nullable(),
health_check_expected_status: z.number().nullable(),
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3e3),
sync_status: z.string().nullable(),
created_at: z.string(),
updated_at: z.string()
}).transform((binding) => ({
...binding,
target_ips: binding.target_ips && binding.target_ips.length > 0 ? binding.target_ips : binding.target_ip ? [binding.target_ip] : []
target_ips: binding.target_ips && binding.target_ips.length > 0 ? binding.target_ips : binding.target_ip ? [binding.target_ip] : [],
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {}
}));
var dnsRecordSchema = z.object({
id: z.number(),
@@ -299,10 +347,24 @@ 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 healthCheckConfigFields = {
health_check_enabled: z.boolean().optional(),
health_check_type: healthCheckTypeSchema.optional(),
health_check_port: z.number().int().min(1).max(65535).nullable().optional(),
health_check_path: z.string().nullable().optional(),
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
health_check_timeout_ms: z.number().int().min(100).max(3e4).optional()
};
var healthCheckConfigSchema = z.object(healthCheckConfigFields);
var serviceDomainInputSchema = z.object({
fqdn: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 FQDN"),
target_ips: z.array(ipv4Schema).optional(),
target_cname: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 CNAME-\u0446\u0435\u043B\u044C").optional()
target_cname: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 CNAME-\u0446\u0435\u043B\u044C").optional(),
target_ip_weights: z.record(ipv4Schema, z.number().int().min(1).max(100)).optional(),
target_ip_priorities: z.record(ipv4Schema, z.number().int().min(1).max(100)).optional(),
lb_mode: lbModeSchema.optional(),
...healthCheckConfigFields
}).superRefine((data, ctx) => {
const hasIps = (data.target_ips?.length ?? 0) > 0;
const hasCname = Boolean(data.target_cname?.trim());
@@ -328,6 +390,8 @@ var createServiceSchema = z.object({
var createServiceWithConfigSchema = createServiceSchema.extend({
service_group_id: z.number().nullable().optional(),
ips: z.array(ipv4Schema).default([]),
lb_weight: z.number().int().min(1).max(100).optional(),
lb_priority: z.number().int().min(1).max(100).optional(),
domains: z.array(serviceDomainInputSchema).default([])
});
var createServiceBindingSchema = z.object({
@@ -383,13 +447,25 @@ var updateServiceConfigSchema = z.object({
slug: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 slug").optional(),
service_group_id: z.number().nullable().optional(),
ips: z.array(ipv4Schema).optional(),
lb_weight: z.number().int().min(1).max(100).optional(),
lb_priority: z.number().int().min(1).max(100).optional(),
domains: z.array(serviceDomainInputSchema).optional()
});
var createServiceGroupSchema = z.object({
name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435"),
type: serviceGroupTypeSchema.default("custom"),
icon: z.string().nullable().optional(),
domain: z.string().nullable().optional()
domain: z.string().nullable().optional(),
lb_mode: lbModeSchema.optional(),
...healthCheckConfigFields
});
var updateServiceGroupSchema = z.object({
name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435").optional(),
type: serviceGroupTypeSchema.optional(),
icon: z.string().nullable().optional(),
domain: z.string().nullable().optional(),
lb_mode: lbModeSchema.optional(),
...healthCheckConfigFields
});
var toggleEnabledSchema = z.object({
enabled: z.boolean()
@@ -398,6 +474,10 @@ var reorderServicesSchema = z.object({
group_id: z.union([z.number(), z.null()]).optional().default(null),
service_ids: z.array(z.number().int().positive()).min(1)
});
var healthStatusQuerySchema = z.object({
scope: healthCheckScopeSchema,
ref_id: z.coerce.number().int().positive()
});
export {
CERT_ERROR,
CERT_EXPIRED,
@@ -434,7 +514,14 @@ export {
fqdnToDisplay,
groupSchema,
groupWithStatsSchema,
healthCheckConfigSchema,
healthCheckScopeSchema,
healthCheckTypeSchema,
healthStatusQuerySchema,
ipHealthStateSchema,
ipHealthStatusSchema,
isValidIpv4,
lbModeSchema,
loginSchema,
normalizeDnsRecordName,
parseFqdn,
@@ -454,6 +541,7 @@ export {
updateDomainGroupSchema,
updateDomainSchema,
updateServiceConfigSchema,
updateServiceGroupSchema,
updateSubdomainSchema,
validateDnsRecord
};
+6
View File
@@ -15,4 +15,10 @@ export type {
ServiceBindingView,
ServiceDomainBindingView,
Subdomain,
LbMode,
HealthCheckType,
IpHealthState,
HealthCheckScope,
IpHealthStatus,
HealthCheckTarget,
} from "./types.js";
+100
View File
@@ -4,6 +4,31 @@ export const certMonitoringSchema = z.enum(['auto', 'required', 'skipped'])
export type CertMonitoring = z.infer<typeof certMonitoringSchema>
export const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted'])
export type LbMode = z.infer<typeof lbModeSchema>
export const healthCheckTypeSchema = z.enum(['tcp', 'http'])
export type HealthCheckType = z.infer<typeof healthCheckTypeSchema>
export const ipHealthStateSchema = z.enum(['up', 'down', 'degraded', 'unknown'])
export type IpHealthState = z.infer<typeof ipHealthStateSchema>
export const healthCheckScopeSchema = z.enum(['binding', 'group'])
export type HealthCheckScope = z.infer<typeof healthCheckScopeSchema>
export const ipHealthStatusSchema = z.object({
scope: healthCheckScopeSchema,
ref_id: z.number(),
ip: z.string(),
status: ipHealthStateSchema,
latency_ms: z.number().nullable(),
consecutive_failures: z.number(),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable(),
})
export type IpHealthStatus = z.infer<typeof ipHealthStatusSchema>
export const groupSchema = z.object({
id: z.number(),
name: z.string(),
@@ -31,6 +56,14 @@ export const serviceGroupSchema = z.object({
icon: z.string().nullable(),
domain: z.string().nullable(),
enabled: z.boolean(),
lb_mode: lbModeSchema.catch('round_robin'),
health_check_enabled: z.boolean().default(false),
health_check_type: healthCheckTypeSchema.catch('tcp'),
health_check_port: z.number().nullable(),
health_check_path: z.string().nullable(),
health_check_expected_status: z.number().nullable(),
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
created_at: z.string(),
updated_at: z.string(),
})
@@ -43,6 +76,8 @@ export const serviceSchema = z.object({
subdomain: z.string().optional(),
enabled: z.boolean().optional(),
computed_fqdn: z.string().nullable().optional(),
lb_weight: z.number().default(1),
lb_priority: z.number().default(1),
created_at: z.string(),
updated_at: z.string(),
})
@@ -57,7 +92,17 @@ export const serviceDomainBindingSchema = z
record_type: z.enum(['A', 'CNAME']).default('A'),
target_ips: z.array(z.string()).optional(),
target_ip: z.string().nullable().optional(),
target_ip_weights: z.record(z.string(), z.number()).optional(),
target_ip_priorities: z.record(z.string(), z.number()).optional(),
target_cname: z.string().nullable().optional(),
lb_mode: lbModeSchema.catch('round_robin'),
health_check_enabled: z.boolean().default(false),
health_check_type: healthCheckTypeSchema.catch('tcp'),
health_check_port: z.number().nullable(),
health_check_path: z.string().nullable(),
health_check_expected_status: z.number().nullable(),
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
sync_status: z.string().nullable(),
})
.transform((binding) => ({
@@ -68,6 +113,8 @@ export const serviceDomainBindingSchema = z
: binding.target_ip
? [binding.target_ip]
: [],
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
target_cname: binding.target_cname?.trim() || null,
record_type: binding.target_cname?.trim()
? 'CNAME'
@@ -121,6 +168,16 @@ export const serviceBindingSchema = z
service_slug: z.string(),
target_ip: z.string().nullable(),
target_ips: z.array(z.string()).optional(),
target_ip_weights: z.record(z.string(), z.number()).optional(),
target_ip_priorities: z.record(z.string(), z.number()).optional(),
lb_mode: lbModeSchema.catch('round_robin'),
health_check_enabled: z.boolean().default(false),
health_check_type: healthCheckTypeSchema.catch('tcp'),
health_check_port: z.number().nullable(),
health_check_path: z.string().nullable(),
health_check_expected_status: z.number().nullable(),
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
sync_status: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
@@ -133,6 +190,8 @@ export const serviceBindingSchema = z
: binding.target_ip
? [binding.target_ip]
: [],
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
}))
export const dnsRecordSchema = z.object({
@@ -190,11 +249,28 @@ const ipv4Schema = z
'Некорректный IPv4',
)
const healthCheckConfigFields = {
health_check_enabled: z.boolean().optional(),
health_check_type: healthCheckTypeSchema.optional(),
health_check_port: z.number().int().min(1).max(65535).nullable().optional(),
health_check_path: z.string().nullable().optional(),
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
}
export const healthCheckConfigSchema = z.object(healthCheckConfigFields)
export type HealthCheckConfig = z.infer<typeof healthCheckConfigSchema>
const serviceDomainInputSchema = z
.object({
fqdn: z.string().min(1, 'Укажите FQDN'),
target_ips: z.array(ipv4Schema).optional(),
target_cname: z.string().min(1, 'Укажите CNAME-цель').optional(),
target_ip_weights: z.record(ipv4Schema, z.number().int().min(1).max(100)).optional(),
target_ip_priorities: z.record(ipv4Schema, z.number().int().min(1).max(100)).optional(),
lb_mode: lbModeSchema.optional(),
...healthCheckConfigFields,
})
.superRefine((data, ctx) => {
const hasIps = (data.target_ips?.length ?? 0) > 0
@@ -223,6 +299,8 @@ export const createServiceSchema = z.object({
export const createServiceWithConfigSchema = createServiceSchema.extend({
service_group_id: z.number().nullable().optional(),
ips: z.array(ipv4Schema).default([]),
lb_weight: z.number().int().min(1).max(100).optional(),
lb_priority: z.number().int().min(1).max(100).optional(),
domains: z.array(serviceDomainInputSchema).default([]),
})
@@ -298,6 +376,8 @@ export const updateServiceConfigSchema = z.object({
slug: z.string().min(1, 'Укажите slug').optional(),
service_group_id: z.number().nullable().optional(),
ips: z.array(ipv4Schema).optional(),
lb_weight: z.number().int().min(1).max(100).optional(),
lb_priority: z.number().int().min(1).max(100).optional(),
domains: z
.array(serviceDomainInputSchema)
.optional(),
@@ -310,8 +390,21 @@ export const createServiceGroupSchema = z.object({
type: serviceGroupTypeSchema.default('custom'),
icon: z.string().nullable().optional(),
domain: z.string().nullable().optional(),
lb_mode: lbModeSchema.optional(),
...healthCheckConfigFields,
})
export const updateServiceGroupSchema = z.object({
name: z.string().min(1, 'Укажите название').optional(),
type: serviceGroupTypeSchema.optional(),
icon: z.string().nullable().optional(),
domain: z.string().nullable().optional(),
lb_mode: lbModeSchema.optional(),
...healthCheckConfigFields,
})
export type UpdateServiceGroupInput = z.infer<typeof updateServiceGroupSchema>
export const toggleEnabledSchema = z.object({
enabled: z.boolean(),
})
@@ -321,6 +414,13 @@ export const reorderServicesSchema = z.object({
service_ids: z.array(z.number().int().positive()).min(1),
})
export const healthStatusQuerySchema = z.object({
scope: healthCheckScopeSchema,
ref_id: z.coerce.number().int().positive(),
})
export type HealthStatusQuery = z.infer<typeof healthStatusQuerySchema>
export type CreateServiceGroupInput = z.infer<typeof createServiceGroupSchema>
export type ToggleEnabledInput = z.infer<typeof toggleEnabledSchema>
export type ReorderServicesInput = z.infer<typeof reorderServicesSchema>
+71
View File
@@ -13,6 +13,14 @@ export interface ServiceGroup {
icon: string | null;
domain: string | null;
enabled: boolean;
lb_mode: LbMode;
health_check_enabled: boolean;
health_check_type: HealthCheckType;
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
created_at: string;
updated_at: string;
}
@@ -34,6 +42,8 @@ export interface Service {
subdomain: string;
enabled: boolean;
sort_order: number;
lb_weight: number;
lb_priority: number;
created_at: string;
updated_at: string;
}
@@ -98,6 +108,14 @@ export interface ServiceBinding {
hostname: string;
cname_target: string | null;
dns_record_id: number | null;
lb_mode: LbMode;
health_check_enabled: boolean;
health_check_type: HealthCheckType;
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
created_at: string;
updated_at: string;
}
@@ -115,6 +133,16 @@ export interface ServiceBindingView {
service_slug: string;
target_ip: string | null;
target_ips: string[];
target_ip_weights: Record<string, number>;
target_ip_priorities: Record<string, number>;
lb_mode: LbMode;
health_check_enabled: boolean;
health_check_type: HealthCheckType;
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
sync_status: string | null;
created_at: string;
updated_at: string;
@@ -128,7 +156,17 @@ export interface ServiceDomainBindingView {
fqdn: string;
record_type: "A" | "CNAME";
target_ips: string[];
target_ip_weights: Record<string, number>;
target_ip_priorities: Record<string, number>;
target_cname: string | null;
lb_mode: LbMode;
health_check_enabled: boolean;
health_check_type: HealthCheckType;
health_check_port: number | null;
health_check_path: string | null;
health_check_expected_status: number | null;
health_check_interval_sec: number;
health_check_timeout_ms: number;
sync_status: string | null;
}
@@ -140,6 +178,8 @@ export interface ServiceView {
subdomain: string;
enabled: boolean;
computed_fqdn: string | null;
lb_weight: number;
lb_priority: number;
created_at: string;
updated_at: string;
ips: string[];
@@ -203,3 +243,34 @@ export interface JwtClaims {
sub: string;
exp: number;
}
export type LbMode = "round_robin" | "failover" | "weighted";
export type HealthCheckType = "tcp" | "http";
export type IpHealthState = "up" | "down" | "degraded" | "unknown";
export type HealthCheckScope = "binding" | "group";
export interface IpHealthStatus {
scope: HealthCheckScope;
ref_id: number;
ip: string;
status: IpHealthState;
latency_ms: number | null;
consecutive_failures: number;
last_checked_at: string | null;
last_error: string | null;
}
export interface HealthCheckTarget {
scope: HealthCheckScope;
ref_id: number;
ip: string;
hostname: string;
type: HealthCheckType;
port: number | null;
path: string | null;
expected_status: number | null;
timeout_ms: number;
}
+2 -2
View File
@@ -3,7 +3,7 @@ import {
CERT_OK,
CERT_WARNING,
} from "./constants.js";
import type { Service, ServiceGroup } from "./types.js";
import type { ServiceGroup } from "./types.js";
const NAME_RE =
/^(@|\*|[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?(\.[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?)*)$/;
@@ -72,7 +72,7 @@ export function certStatusFromExpiry(daysLeft: number): string {
}
export function shouldMonitorService(
service: Pick<Service, "enabled" | "service_group_id">,
service: { enabled?: boolean; service_group_id?: number | null },
group?: Pick<ServiceGroup, "enabled"> | null,
): boolean {
if (!service.enabled) return false;