Compare commits

...
35 Commits
Author SHA1 Message Date
DenozordecandCursor b078fa0a03 fix(health): не показывать Down при выключенном health-check
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 1m58s
CD / publish (push) Successful in 1m25s
Маскируем устаревший down, если HC выкл (binding/группа), IP или сервис отключены.
Бейдж Disabled — нейтральный, не красный.

Co-authored-by: Cursor <[email protected]>
2026-09-03 20:46:37 +07:00
DenozordecandCursor 75575a3243 fix(dns): искать записи в Cloudflare по имени и содержимому
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 7s
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / api (push) Successful in 48s
CD / quality (push) Successful in 58s
CD / publish (push) Successful in 1m35s
cf_record_id только как кэш-подсказка: при удалении/обновлении
сначала list + match type/name/content, иначе create или no-op.

Co-authored-by: Cursor <[email protected]>
2026-09-03 20:19:16 +07:00
DenozordecandCursor 5e2c301442 fix(dns): выравнивать доп. FQDN при конфликте с локальной/CF записью
quality / changes (push) Successful in 9s
quality / web (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / api (push) Successful in 48s
CD / quality (push) Successful in 1m2s
CD / publish (push) Successful in 1m26s
Больше не авто-подхватывать чужой CNAME в A-режиме (это стирало IP). Перед публикацией A удаляем конфликтующий CNAME и устаревшие A на том же имени.

Co-authored-by: Cursor <[email protected]>
2026-09-03 20:05:35 +07:00
DenozordecandCursor 56cddedefe fix(services): восстанавливать урезанные общие FQDN без пересоздания сервиса
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m16s
quality / api (push) Successful in 58s
CD / quality (push) Successful in 2m26s
CD / publish (push) Successful in 1m40s
Legacy toggle оставлял multi-IP привязки с неполным пулом в невидимом preserved — UI терял домены и падал на дубликатах. Чиним hydrate и чиним БД при buildView.

Co-authored-by: Cursor <[email protected]>
2026-09-03 19:26:46 +07:00
Denozordec df5d5ef4ab feat(dns): add functions to handle missing DNS records and mark records as synced
CD / quality (push) Successful in 1m16s
quality / changes (push) Successful in 9s
quality / web (push) Skipped
quality / api (push) Successful in 1m3s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
CD / publish (push) Successful in 1m24s
- Introduced `isMissingCfDnsRecord` to identify missing Cloudflare DNS records based on error messages.
- Added `markSynced` function to update DNS record fields in the database and return the updated record.
- Refactored `pushRecord` to utilize `markSynced` for better code organization and clarity.
- Enhanced error handling for cases where DNS records need to be recreated after manual edits.
2026-09-03 18:52:39 +07:00
DenozordecandCursor de3dbe8521 fix(sync): не считать сервис с одним IP резервированием
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m20s
quality / api (push) Successful in 1m4s
CD / quality (push) Successful in 2m38s
CD / publish (push) Successful in 56s
В payload для VPS Tracker lbMode не отдаём без пула уникальных IP; в UI показываем без резервирования вместо Round robin.

Co-authored-by: Cursor <[email protected]>
2026-09-01 15:09:19 +07:00
DenozordecandCursor 7eb195c5d7 feat(sync): передавать lbMode в payload для VPS Tracker
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 53s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 1m54s
CD / publish (push) Successful in 1m29s
Тип HA уходит в bindings, чтобы схема могла показать резервирование.

Co-authored-by: Cursor <[email protected]>
2026-09-01 11:30:41 +07:00
DenozordecandCursor 7f06466058 fix(dns): разрешить вложенный wildcard в имени DNS-записи
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 12s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m9s
quality / api (push) Successful in 1m3s
CD / quality (push) Successful in 2m29s
CD / publish (push) Successful in 1m49s
Co-authored-by: Cursor <[email protected]>
2026-08-31 16:02:20 +07:00
DenozordecandCursor 0d567379fa fix(services): разрешить несколько доп. FQDN на IP включая wildcard
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 10s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m16s
quality / api (push) Successful in 1m14s
CD / quality (push) Successful in 2m44s
CD / publish (push) Successful in 1m50s
У IP был один extra FQDN; wildcard вида *.mdns.shnt.top не матчился с именем из Cloudflare.

Co-authored-by: Cursor <[email protected]>
2026-08-31 12:40:27 +07:00
DenozordecandCursor a228febc27 fix(services): сохранить доп. FQDN у IP при пуле из одного адреса
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m10s
CD / quality (push) Successful in 1m23s
CD / publish (push) Successful in 1m41s
При одном IP общий и доп. FQDN неотличимы по target_ips; после сохранения гидратация относила оба к общим доменам.

Co-authored-by: Cursor <[email protected]>
2026-08-31 12:19:53 +07:00
DenozordecandCursor 5cf39880f8 fix(services): сразу возвращать IP в DNS после первой успешной пробы
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 8s
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / api (push) Successful in 1m20s
CD / quality (push) Successful in 1m31s
CD / publish (push) Successful in 2m4s
Co-authored-by: Cursor <[email protected]>
2026-08-20 19:51:23 +07:00
DenozordecandCursor f1443f1db5 fix(services): не балансировать сервис с одним IP
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
quality / api (push) Successful in 52s
CD / quality (push) Successful in 2m4s
CD / publish (push) Successful in 1m51s
KPI и карточка показывают живой OK, а не гистерезис unknown; DNS по-прежнему ждёт повторные успехи.

Co-authored-by: Cursor <[email protected]>
2026-08-20 18:13:06 +07:00
DenozordecandCursor a9a84fabac fix(services): передать режим балансировки в панель пула
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 10s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m23s
quality / api (push) Successful in 59s
CD / quality (push) Successful in 2m36s
CD / publish (push) Successful in 1m58s
Co-authored-by: Cursor <[email protected]>
2026-08-20 17:52:21 +07:00
DenozordecandCursor 2b150fa3a6 fix(services): снимать Down только с общего FQDN
Персональные A не крутить и не трогать при падении. Панель называется по режиму балансировки.

Co-authored-by: Cursor <[email protected]>
2026-08-20 17:51:49 +07:00
DenozordecandCursor 3d0ea33baf feat(services): крутить weighted как доли времени на одном IP
quality / changes (push) Successful in 9s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / web (push) Successful in 55s
quality / api (push) Successful in 45s
CD / quality (push) Successful in 1m57s
CD / publish (push) Successful in 1m36s
Веса задают долю слотов на общем FQDN (1 к 3 = ¼ и ¾ цикла), TTL 60.
В селекте режима показывать текстовое имя, не ID.

Co-authored-by: Cursor <[email protected]>
2026-08-20 17:35:13 +07:00
DenozordecandCursor 8a13888db7 fix(services): вернуть вес и приоритет в режиме балансировки
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
CD / quality (push) Successful in 1m5s
CD / publish (push) Successful in 1m43s
После объединения адресов в форме пропали per-IP поля. Weighted снова задаёт вес, failover — основной и запасной.

Co-authored-by: Cursor <[email protected]>
2026-08-20 17:06:29 +07:00
DenozordecandCursor f5d97e463a fix(services): показывать живой health и историю выхода из пула
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m14s
CD / publish (push) Successful in 2m3s
Таблица IP брала hysteresis unknown вместо последней пробы. Failover теперь показывает выход и возврат в пул, а не только DNS add.

Co-authored-by: Cursor <[email protected]>
2026-08-20 16:43:22 +07:00
DenozordecandCursor be8f94143f fix(health): не затирать график uptime пробой down в том же бакете
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
CD / quality (push) Successful in 1m7s
CD / publish (push) Successful in 1m38s
Если в минутном бакете есть живой IP, линия строится по его пингу, а не по null от Down.

Co-authored-by: Cursor <[email protected]>
2026-08-20 16:07:24 +07:00
DenozordecandCursor fa7d2b6df3 feat(services): считать сервис живым при одном OK и писать историю failover
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 1m1s
CD / quality (push) Successful in 2m30s
CD / publish (push) Successful in 2m4s
Сервис up, если жив хотя бы один IP. Failover показывает Down по FQDN и журнал add/remove A-записей.

Co-authored-by: Cursor <[email protected]>
2026-08-20 15:49:17 +07:00
DenozordecandCursor 7938d2f707 fix(services): считать failover по IP Health а не по строке ноды
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / web (push) Successful in 52s
quality / api (push) Successful in 42s
CD / quality (push) Successful in 1m50s
CD / publish (push) Successful in 1m43s
Панель брала service_nodes, которую затирал group apply.
Теперь тот же binding ip_health, что таблица активов; group не пишет в ноду.

Co-authored-by: Cursor <[email protected]>
2026-08-20 15:08:10 +07:00
DenozordecandCursor 994e79e118 fix(services): считать failover по DNS-пулу а не по статусу ноды
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
CD / quality (push) Canceled after 34s
CD / publish (push) Canceled after 0s
quality / web (push) Canceled after 22s
Красный инцидент только если адреса нет в active_addresses, даже при unhealthy.

Co-authored-by: Cursor <[email protected]>
2026-08-20 14:53:44 +07:00
DenozordecandCursor 87b0f1a894 fix(services): оформить failover timeline в стиле страницы сервиса
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 53s
CD / quality (push) Successful in 1m6s
CD / publish (push) Successful in 1m39s
Frame stacked как у мониторинга, Alert и semantic timeline вместо плоского степпера.

Co-authored-by: Cursor <[email protected]>
2026-08-20 14:31:30 +07:00
DenozordecandCursor ba4e04a224 feat(services): заменить один общий FQDN списком и убрать Другие FQDN
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m6s
CD / quality (push) Successful in 1m18s
CD / publish (push) Successful in 2m1s
Каждый общий домен смотрит на весь пул IP; CNAME без UI сохраняются.

Co-authored-by: Cursor <[email protected]>
2026-08-20 14:21:15 +07:00
DenozordecandCursor 4c59780ff0 fix(health): импортировать isHealthy при нескольких FQDN на одни IP
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / api (push) Successful in 42s
CD / quality (push) Successful in 54s
CD / publish (push) Successful in 1m36s
Схлопывание дубликатов в группе вызывало ReferenceError при сохранении сервиса.

Co-authored-by: Cursor <[email protected]>
2026-08-20 14:01:43 +07:00
DenozordecandCursor 1457389ae7 feat(services): объединить адреса сервиса в один блок формы
CD / update-wiki (push) Successful in 7s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
CD / quality (push) Successful in 1m6s
CD / publish (push) Successful in 1m36s
Общий FQDN, пул IP и доп. домен на строке IP — в одном Frame, без смены API.

Co-authored-by: Cursor <[email protected]>
2026-08-20 13:33:02 +07:00
DenozordecandCursor 153be28799 fix(health): показывать пинг при наведении на график uptime
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m11s
CD / quality (push) Successful in 1m23s
CD / publish (push) Successful in 1m57s
Co-authored-by: Cursor <[email protected]>
2026-08-20 12:44:17 +07:00
DenozordecandCursor 39fac7834f fix(ui): не дублировать крошку Настройки при смене раздела
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m15s
CD / publish (push) Successful in 1m58s
Co-authored-by: Cursor <[email protected]>
2026-08-20 12:28:06 +07:00
DenozordecandCursor d267e40157 fix(health): выровнять график источников и KPI на карточке сервиса
Co-authored-by: Cursor <[email protected]>
2026-08-20 12:27:21 +07:00
DenozordecandCursor 634a9dc362 fix(health): заменить плитки источников на компактный мультивыбор
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
quality / api (push) Successful in 43s
CD / quality (push) Successful in 1m48s
CD / publish (push) Successful in 1m39s
Крупные KPI-кнопки и вкладка «Все» заменены на ToggleGroup: фильтр занимает меньше места и не сбрасывает последний источник.

Co-authored-by: Cursor <[email protected]>
2026-08-20 02:13:57 +07:00
DenozordecandCursor 1bf6cfa0d0 fix(health): разворачивать CNAME до IP в статусе таблицы
Пробы CNAME больше не ключуются hostname: цель разворачивается до origin/пула, а уже сохранённый статус по CNAME копируется на IP сервиса.

Co-authored-by: Cursor <[email protected]>
2026-08-20 02:13:09 +07:00
DenozordecandCursor 3da6de9311 fix(health): сузить тип переключателя источников для tsc -b
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 52s
CD / quality (push) Successful in 1m3s
CD / publish (push) Successful in 1m53s
ALL_TAB как литерал 'all', чтобы SourceTab не попадал в HealthCheckProvider[].

Co-authored-by: Cursor <[email protected]>
2026-08-20 01:49:42 +07:00
DenozordecandCursor 7a8bacade9 feat(health): объединить источники, график и таймлайн в один блок мониторинга
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Failing after 49s
CD / quality (push) Failing after 1m1s
CD / publish (push) Skipped
Переключатель серий в шапке Frame меняет график uptime и смены статуса без отдельного списка плиток.

Co-authored-by: Cursor <[email protected]>
2026-08-20 01:44:16 +07:00
DenozordecandCursor 5d84c7bf6c fix(services): сохранять источники health-check и агрегацию в форме сервиса
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 9s
quality / changes (push) Successful in 12s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m14s
quality / api (push) Successful in 53s
CD / quality (push) Successful in 2m23s
CD / publish (push) Successful in 2m5s
GET отдавал sqlite-типы (0/1 и JSON-строка), Zod отклонял PATCH; форма сбрасывалась на refetch.

Co-authored-by: Cursor <[email protected]>
2026-08-20 01:03:00 +07:00
Denozordec 78811bc9b1 feat(uptime-chart): enhance tooltip functionality and refactor delta display
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
CD / quality (push) Successful in 1m6s
CD / publish (push) Successful in 1m53s
- Added useEffect to set tooltip portal for improved rendering.
- Introduced UptimeDelta component to streamline delta display logic.
- Updated ChartTooltip to utilize ChartTooltipContent for better formatting of tooltip data.
- Refactored chart margin and overflow properties for improved layout consistency.
2026-08-20 00:49:27 +07:00
Denozordec a458465153 feat(certificates): enhance service certificate management and monitoring
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 10s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m8s
quality / api (push) Successful in 1m9s
CD / quality (push) Successful in 2m31s
CD / publish (push) Successful in 1m35s
- Added new endpoints for listing and checking service certificates, improving visibility into SSL status.
- Integrated certificate monitoring options into service binding updates, allowing for flexible SSL management.
- Updated the service detail grid to include SSL monitoring controls, enhancing user interaction with certificate settings.
- Refactored related components and schemas to support the new certificate features, ensuring consistency across the application.
- Improved test coverage for certificate functionalities, validating the new features and ensuring reliability.
2026-08-20 00:32:43 +07:00
77 changed files with 7720 additions and 1394 deletions
+5
View File
@@ -38,6 +38,10 @@ import {
healthEngineFallbacksFromConfig,
scheduleHealthCheckJob,
} from "./services/health-check-scheduler.js";
import {
createWeightedDnsTask,
scheduleWeightedDnsJob,
} from "./services/weighted-dns-scheduler.js";
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
import { AsyncTask, CronJob } from "toad-scheduler";
@@ -133,6 +137,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
app.decorate("reloadHealthCheckJob", () => {
scheduleHealthCheckJob(app, config, healthTask);
});
scheduleWeightedDnsJob(app, createWeightedDnsTask(app));
if (config.cloudflareApiToken) {
fireEnsureHealthWorker(
app.db,
+2 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { changeIpSchema } from "@cfdm/shared";
import { certMonitoringSchema, changeIpSchema } from "@cfdm/shared";
import * as bindingService from "../services/binding-service.js";
import * as changeIp from "../services/change-ip-service.js";
import { recordAudit } from "../lib/audit.js";
@@ -17,6 +17,7 @@ export async function serviceBindingRoutes(app: FastifyInstance) {
service_id: z.number().optional(),
hostname: z.string().optional(),
target_ip: z.string().optional(),
cert_monitoring: certMonitoringSchema.optional(),
});
app.get("/service-bindings", async (request) => {
+30
View File
@@ -12,6 +12,7 @@ import { repos } from "@cfdm/db";
import * as serviceConfig from "../services/service-config-service.js";
import * as nodeService from "../services/node-service.js";
import * as changeDomain from "../services/change-domain-service.js";
import * as certificateService from "../services/certificate-service.js";
import { recordAudit } from "../lib/audit.js";
export async function serviceRoutes(app: FastifyInstance) {
@@ -77,6 +78,35 @@ export async function serviceRoutes(app: FastifyInstance) {
};
});
app.get("/services/:id/failover-log", async (request) => {
const { id } = request.params as { id: string };
repos.getService(request.server.db, Number(id));
return {
items: repos.listFailoverLogForService(
request.server.db,
Number(id),
200,
),
};
});
app.get("/services/:id/certificates", async (request) => {
const { id } = request.params as { id: string };
return certificateService.listServiceCertificates(
request.server.db,
Number(id),
);
});
app.post("/services/:id/certificates/check", async (request) => {
const { id } = request.params as { id: string };
const checked = await certificateService.runServiceChecks(
request.server.db,
Number(id),
);
return { checked };
});
app.get("/services/:id/overview", async (request) => {
const { id } = request.params as { id: string };
return nodeService.getOverview(request.server.db, Number(id));
+15
View File
@@ -15,6 +15,7 @@ export interface UpdateBindingRequest {
service_id?: number;
hostname?: string;
target_ip?: string;
cert_monitoring?: string;
}
function normalizeHostname(hostname?: string): string {
@@ -92,6 +93,20 @@ export async function update(
req: UpdateBindingRequest,
): Promise<ServiceBindingView> {
const existing = repos.getBinding(db, id);
if (req.cert_monitoring !== undefined) {
repos.updateBindingLbConfig(db, id, {
cert_monitoring: req.cert_monitoring,
});
}
const hasIdentityPatch =
req.service_id !== undefined ||
req.hostname !== undefined ||
req.target_ip !== undefined;
if (!hasIdentityPatch) {
return repos.getBindingView(db, id);
}
const serviceId = req.service_id ?? existing.service_id;
if (req.service_id) repos.getService(db, req.service_id);
const hostname = req.hostname
+81 -101
View File
@@ -2,7 +2,7 @@ import { connect } from "node:net";
import { connect as tlsConnect } from "node:tls";
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { Certificate, Domain, Subdomain } from "@cfdm/shared";
import type { Certificate, ServiceCertificateRow, Subdomain } from "@cfdm/shared";
import {
CERT_ERROR,
CERT_MONITOR_AUTO,
@@ -11,13 +11,13 @@ import {
CERT_UNKNOWN,
certStatusFromExpiry,
fqdnToDisplay,
parseFqdn,
shouldMonitorService,
} from "@cfdm/shared";
export interface CertificateTarget {
domainId: number;
subdomainId: number | null;
serviceId: number;
hostname: string;
}
@@ -41,6 +41,34 @@ export function getCertificate(db: Db, id: number): Certificate {
return repos.getCertificate(db, id);
}
export function listServiceCertificates(
db: Db,
serviceId: number,
): ServiceCertificateRow[] {
repos.getService(db, serviceId);
const certsByHost = new Map(
repos.listCertificates(db).map((cert) => [cert.hostname, cert]),
);
return repos.listBindingsByService(db, serviceId).map((binding) => {
const hostname = fqdnToDisplay(binding.hostname, binding.zone_name);
const cert = certsByHost.get(hostname);
return {
binding_id: binding.id,
domain_id: binding.domain_id,
service_id: binding.service_id,
hostname,
cert_monitoring:
(binding.cert_monitoring as ServiceCertificateRow["cert_monitoring"]) ??
"auto",
id: cert?.id ?? null,
status: cert?.status ?? "unknown",
expires_at: cert?.expires_at ?? null,
last_checked_at: cert?.last_checked_at ?? null,
last_error: cert?.last_error ?? null,
};
});
}
export async function checkHostname(
hostname: string,
): Promise<{ expiresAt: Date | null; error: string | null }> {
@@ -78,6 +106,7 @@ export async function checkAndStore(
domainId: number,
subdomainId: number | null,
hostname: string,
serviceId: number | null = null,
): Promise<Certificate> {
const { expiresAt, error } = await checkHostname(hostname);
@@ -90,6 +119,7 @@ export async function checkAndStore(
null,
CERT_ERROR,
error,
serviceId,
);
}
@@ -105,6 +135,7 @@ export async function checkAndStore(
expiresAt.toISOString(),
certStatusFromExpiry(days),
null,
serviceId,
);
}
@@ -116,23 +147,10 @@ export async function checkAndStore(
null,
CERT_UNKNOWN,
"unknown expiry",
serviceId,
);
}
function resolveMonitoringMode(
domain: Domain,
subdomain: Subdomain | null,
fqdn: string,
): string {
if (subdomain) {
return subdomain.cert_monitoring;
}
if (fqdn === domain.zone_name) {
return domain.cert_monitoring;
}
return CERT_MONITOR_AUTO;
}
function bindingSubdomain(
db: Db,
domainId: number,
@@ -165,10 +183,9 @@ function hasSslHealthGate(
return false;
}
export function buildServiceCertificateFqdns(
db: Db,
): Map<string, CertificateTarget> {
const result = new Map<string, CertificateTarget>();
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
const targets: CertificateTarget[] = [];
const seen = new Set<string>();
for (const binding of repos.listAllBindings(db)) {
const service = repos.getService(db, binding.service_id);
@@ -176,98 +193,40 @@ export function buildServiceCertificateFqdns(
? repos.getServiceGroup(db, service.service_group_id)
: null;
if (!shouldMonitorService(service, group)) continue;
if (
!hasSslHealthGate(
{
health_check_enabled: binding.health_check_enabled,
health_check_verify_tls: binding.health_check_verify_tls,
},
group,
)
) {
continue;
}
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
if (subdomain && !subdomain.enabled) continue;
const mode = binding.cert_monitoring ?? CERT_MONITOR_AUTO;
if (mode === CERT_MONITOR_SKIPPED) continue;
if (mode === CERT_MONITOR_AUTO) {
if (
!hasSslHealthGate(
{
health_check_enabled: binding.health_check_enabled,
health_check_verify_tls: binding.health_check_verify_tls,
},
group,
)
) {
continue;
}
} else if (mode !== CERT_MONITOR_REQUIRED) {
continue;
}
const fqdn = fqdnToDisplay(binding.hostname, binding.zone_name);
result.set(fqdn, {
if (seen.has(fqdn)) continue;
seen.add(fqdn);
targets.push({
domainId: binding.domain_id,
subdomainId: subdomain?.id ?? null,
serviceId: binding.service_id,
hostname: fqdn,
});
}
const knownZones = repos.listAllDomains(db).map((d) => d.zone_name);
for (const group of repos.listServiceGroups(db)) {
if (!group.enabled || !group.domain?.trim()) continue;
if (!group.health_check_enabled || !group.health_check_verify_tls) continue;
const parsed = parseFqdn(group.domain, knownZones);
if (!parsed) continue;
const domain = repos.findDomainByZoneName(db, parsed.zoneName);
if (!domain) continue;
const subdomain =
parsed.hostname === "@"
? null
: bindingSubdomain(db, domain.id, parsed.hostname);
if (subdomain && !subdomain.enabled) continue;
result.set(parsed.fqdn, {
domainId: domain.id,
subdomainId: subdomain?.id ?? null,
hostname: parsed.fqdn,
});
}
return result;
}
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
const serviceFqdns = buildServiceCertificateFqdns(db);
const targets = new Map<string, CertificateTarget>();
for (const domain of repos.listAllDomains(db)) {
if (domain.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
if (domain.cert_monitoring === CERT_MONITOR_REQUIRED) {
targets.set(domain.zone_name, {
domainId: domain.id,
subdomainId: null,
hostname: domain.zone_name,
});
}
}
for (const sub of repos.listAllSubdomains(db)) {
if (sub.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
if (sub.cert_monitoring === CERT_MONITOR_REQUIRED) {
targets.set(sub.fqdn, {
domainId: sub.domain_id,
subdomainId: sub.id,
hostname: sub.fqdn,
});
}
}
for (const [fqdn, meta] of serviceFqdns) {
const domain = repos.getDomain(db, meta.domainId);
const subdomain = meta.subdomainId
? repos.getSubdomain(db, meta.subdomainId)
: null;
const monitoring = resolveMonitoringMode(domain, subdomain, fqdn);
if (monitoring === CERT_MONITOR_SKIPPED) continue;
if (
monitoring === CERT_MONITOR_AUTO ||
monitoring === CERT_MONITOR_REQUIRED
) {
targets.set(fqdn, meta);
}
}
return [...targets.values()];
return targets;
}
export async function runAllChecks(db: Db): Promise<number> {
@@ -278,6 +237,7 @@ export async function runAllChecks(db: Db): Promise<number> {
target.domainId,
target.subdomainId,
target.hostname,
target.serviceId,
);
}
repos.deleteCertificatesNotIn(
@@ -287,6 +247,26 @@ export async function runAllChecks(db: Db): Promise<number> {
return targets.length;
}
export async function runServiceChecks(
db: Db,
serviceId: number,
): Promise<number> {
repos.getService(db, serviceId);
const targets = resolveCertificateTargets(db).filter(
(target) => target.serviceId === serviceId,
);
for (const target of targets) {
await checkAndStore(
db,
target.domainId,
target.subdomainId,
target.hostname,
target.serviceId,
);
}
return targets.length;
}
export function statusSummary(db: Db): Array<[string, number]> {
pruneStaleCertificates(db);
return repos.countCertificatesByStatus(db);
+189 -54
View File
@@ -1,11 +1,17 @@
import type { Db } from "@cfdm/db";
import { repos, type DnsListFilter } from "@cfdm/db";
import type { CreateDnsRecordPayload, DnsRecord, PatchDnsRecordPayload } from "@cfdm/shared";
import type {
CfDnsRecord,
CreateDnsRecordPayload,
DnsRecord,
PatchDnsRecordPayload,
} from "@cfdm/shared";
import {
SYNC_CONFLICT,
SYNC_ERROR,
SYNC_PENDING_PUSH,
SYNC_SYNCED,
dnsRecordNamesMatch,
normalizeDnsRecordName,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
@@ -64,6 +70,86 @@ function toCfPayload(
};
}
function isMissingCfDnsRecord(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /record does not exist|81044/i.test(message);
}
function dnsContentMatches(
recordType: string,
left: string,
right: string,
): boolean {
if (recordType.toUpperCase() === "CNAME") {
return (
left.trim().replace(/\.+$/, "").toLowerCase() ===
right.trim().replace(/\.+$/, "").toLowerCase()
);
}
return left === right;
}
/** Resolve live Cloudflare record by name + type + content (IP / CNAME target). */
function findRemoteByIdentity(
remote: readonly CfDnsRecord[],
zoneName: string,
recordType: string,
name: string,
content: string,
): CfDnsRecord | undefined {
const type = recordType.toUpperCase();
return remote.find(
(record) =>
Boolean(record.id) &&
(record.type ?? "").toUpperCase() === type &&
dnsRecordNamesMatch(record.name, name, zoneName) &&
dnsContentMatches(type, record.content, content),
);
}
function findRemoteByCfId(
remote: readonly CfDnsRecord[],
cfRecordId: string | null | undefined,
): CfDnsRecord | undefined {
if (!cfRecordId) return undefined;
return remote.find((record) => record.id === cfRecordId);
}
async function markSynced(
db: Db,
domainId: number,
record: DnsRecord,
cfRec: {
id?: string | null;
type?: string;
name: string;
content: string;
ttl: number;
proxied?: boolean | null;
priority?: number | null;
},
): Promise<DnsRecord> {
repos.updateDnsFields(
db,
record.id,
cfRec.type ?? record.record_type,
cfRec.name,
cfRec.content,
cfRec.ttl,
cfRec.proxied ?? false,
cfRec.priority ?? null,
SYNC_SYNCED,
cfRec.id ?? null,
null,
);
return repos.getDnsRecord(db, domainId, record.id);
}
/**
* Push local desired state to Cloudflare.
* Identity is name + type + content; cf_record_id is only a cache hint
* (records may be deleted/recreated outside CFDM).
*/
async function pushRecord(
db: Db,
cf: CloudflareClient,
@@ -71,6 +157,7 @@ async function pushRecord(
cfZoneId: string,
record: DnsRecord,
): Promise<DnsRecord> {
const domain = repos.getDomain(db, domainId);
const payload = toCfPayload(
record.record_type,
record.name,
@@ -81,25 +168,38 @@ async function pushRecord(
);
try {
const cfRec = record.cf_record_id
? await cf.updateDnsRecord(cfZoneId, record.cf_record_id, payload)
: await cf.createDnsRecord(cfZoneId, payload);
repos.updateDnsFields(
db,
record.id,
cfRec.type ?? record.record_type,
cfRec.name,
cfRec.content,
cfRec.ttl,
cfRec.proxied ?? false,
cfRec.priority ?? null,
SYNC_SYNCED,
cfRec.id ?? null,
null,
const remote = await cf.listDnsRecords(cfZoneId);
const byIdentity = findRemoteByIdentity(
remote,
domain.zone_name,
record.record_type,
record.name,
record.content,
);
return repos.getDnsRecord(db, domainId, record.id);
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
const targetId = byIdentity?.id ?? byCachedId?.id ?? null;
const cfRec = targetId
? await cf.updateDnsRecord(cfZoneId, targetId, payload)
: await cf.createDnsRecord(cfZoneId, payload);
return markSynced(db, domainId, record, cfRec);
} catch (e) {
// Race: cached id vanished mid-flight — recreate by identity.
if (isMissingCfDnsRecord(e)) {
try {
const created = await cf.createDnsRecord(cfZoneId, payload);
return markSynced(db, domainId, record, created);
} catch (createErr) {
repos.setDnsSyncStatus(
db,
record.id,
SYNC_ERROR,
null,
createErr instanceof Error ? createErr.message : String(createErr),
);
throw createErr;
}
}
repos.setDnsSyncStatus(
db,
record.id,
@@ -188,15 +288,23 @@ export async function patchContent(
): Promise<DnsRecord> {
const domain = repos.getDomain(db, domainId);
const existing = repos.getDnsRecord(db, domainId, recordId);
if (!existing.cf_record_id) {
throw AppError.dnsUpdateFailed("у DNS-записи нет идентификатора Cloudflare");
}
try {
const cfRec = await cf.patchDnsRecord(
domain.cf_zone_id,
existing.cf_record_id,
payload,
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const byIdentity = findRemoteByIdentity(
remote,
domain.zone_name,
existing.record_type,
existing.name,
existing.content,
);
const byCachedId = findRemoteByCfId(remote, existing.cf_record_id);
const targetId = byIdentity?.id ?? byCachedId?.id ?? null;
if (!targetId) {
throw AppError.dnsUpdateFailed(
"DNS-запись не найдена в Cloudflare по имени и содержимому",
);
}
const cfRec = await cf.patchDnsRecord(domain.cf_zone_id, targetId, payload);
repos.updateDnsFields(
db,
existing.id,
@@ -207,7 +315,7 @@ export async function patchContent(
cfRec.proxied ?? existing.proxied,
cfRec.priority ?? existing.priority,
SYNC_SYNCED,
cfRec.id ?? existing.cf_record_id,
cfRec.id ?? targetId,
null,
);
return repos.getDnsRecord(db, domainId, existing.id);
@@ -235,20 +343,41 @@ export async function deleteRecord(
const record = repos.getDnsRecord(db, domainId, recordId);
repos.markDnsPendingDelete(db, recordId);
if (record.cf_record_id) {
let targetId: string | null = null;
try {
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const byIdentity = findRemoteByIdentity(
remote,
domain.zone_name,
record.record_type,
record.name,
record.content,
);
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
targetId = byIdentity?.id ?? byCachedId?.id ?? null;
} catch {
// Zone list failed — fall back to cached id only.
targetId = record.cf_record_id;
}
if (targetId) {
try {
await cf.deleteDnsRecord(domain.cf_zone_id, record.cf_record_id);
await cf.deleteDnsRecord(domain.cf_zone_id, targetId);
} catch (e) {
repos.setDnsSyncStatus(
db,
recordId,
SYNC_ERROR,
record.cf_record_id,
e instanceof Error ? e.message : String(e),
);
throw e;
// Already gone in Cloudflare (manual delete) — drop local row.
if (!isMissingCfDnsRecord(e)) {
repos.setDnsSyncStatus(
db,
recordId,
SYNC_ERROR,
targetId,
e instanceof Error ? e.message : String(e),
);
throw e;
}
}
}
repos.deleteDnsRecord(db, recordId);
}
@@ -326,24 +455,30 @@ export async function resolveConflict(
}
if (req.source === "cloudflare") {
if (record.cf_record_id) {
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const r = remote.find((x) => x.id === record.cf_record_id);
if (r) {
repos.updateDnsFields(
db,
recordId,
r.type,
r.name,
r.content,
r.ttl,
r.proxied ?? false,
r.priority ?? null,
SYNC_SYNCED,
r.id ?? null,
null,
);
}
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const byIdentity = findRemoteByIdentity(
remote,
domain.zone_name,
record.record_type,
record.name,
record.content,
);
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
const r = byIdentity ?? byCachedId;
if (r) {
repos.updateDnsFields(
db,
recordId,
r.type,
r.name,
r.content,
r.ttl,
r.proxied ?? false,
r.priority ?? null,
SYNC_SYNCED,
r.id ?? null,
null,
);
}
return repos.getDnsRecord(db, domainId, recordId);
}
@@ -290,7 +290,7 @@ export interface RunAllChecksOptions {
target: HealthCheckTarget,
prevState: IpHealthState | null,
nextState: IpHealthState,
) => void;
) => void | Promise<void>;
}
function sleep(ms: number): Promise<void> {
@@ -326,7 +326,7 @@ function logSourceResult(
});
}
function applyAggregatedStatus(
async function applyAggregatedStatus(
db: Db,
target: HealthCheckTarget,
sources: Array<{ provider: HealthCheckProvider; result: ProbeResult }>,
@@ -386,7 +386,8 @@ function applyAggregatedStatus(
{ colo, provider: statusProvider },
);
const matchedNode = repos.findNodeByIp(db, target.ip);
if (matchedNode && matchedNode.enabled) {
// Binding-scope only: group apply must not clobber node with its own fetch failed.
if (matchedNode && matchedNode.enabled && target.scope === "binding") {
repos.updateNode(db, matchedNode.id, {
health_status: node,
consecutive_failures: failures,
@@ -396,7 +397,7 @@ function applyAggregatedStatus(
});
}
if (prevState !== state) {
options.onStatusChange?.(target, prevState, state);
await options.onStatusChange?.(target, prevState, state);
}
}
@@ -512,7 +513,7 @@ export async function runAllChecks(
for (const source of sources) {
logSourceResult(db, target, source.provider, source.result);
}
applyAggregatedStatus(db, target, sources, options);
await applyAggregatedStatus(db, target, sources, options);
}
}
+19 -6
View File
@@ -9,7 +9,10 @@ import type {
import { AppError } from "../errors.js";
import { isValidIpv4 } from "../lib/validators.js";
import { getView } from "./service-config-service.js";
import { selectActiveIpsByMode } from "./routing/index.js";
import {
isSharedPool,
resolveDesiredAIps,
} from "./routing/index.js";
function assertAddress(address: string): void {
if (!isValidIpv4(address)) {
@@ -89,8 +92,12 @@ export async function getOverview(
: null;
const active = new Set<string>();
const serviceIps = service.ips.filter(
(ip) => service.ip_enabled[ip] !== false,
);
for (const binding of bindings) {
const metas = repos.listBindingIpsWithMeta(db, binding.id);
const targetIps = metas.map((entry) => entry.ip);
const rows = metas.map((entry) => {
const status = repos.getIpHealthStatusRow(db, "binding", binding.id, entry.ip);
return {
@@ -100,12 +107,15 @@ export async function getOverview(
health: status ? status.status : ("unknown" as const),
};
});
for (const ip of selectActiveIpsByMode(
for (const ip of resolveDesiredAIps(
{
lb_mode: binding.lb_mode,
health_check_enabled: binding.health_check_enabled,
},
rows,
targetIps,
Date.now(),
serviceIps,
)) {
active.add(ip);
}
@@ -134,10 +144,13 @@ export function opsSummary(db: Db) {
if (binding.lb_mode !== "failover" || !binding.health_check_enabled) {
return false;
}
return binding.target_ips.some((ip) => {
const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip);
return row?.status === "down";
});
return (
isSharedPool(binding.target_ips) &&
binding.target_ips.some((ip) => {
const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip);
return row?.status === "down";
})
);
}).length;
return {
domains: domains.length,
+4 -4
View File
@@ -1,16 +1,16 @@
import type { LbIpRow } from "./types.js";
import { isHealthy } from "./health.js";
import { isPoolMember } from "./health.js";
export function failoverDesired(rows: LbIpRow[]): string[] {
if (rows.length === 0) return [];
const healthy = rows.filter((r) => isHealthy(r.health));
const pool = healthy.length > 0 ? healthy : rows;
const live = rows.filter((r) => isPoolMember(r.health));
const pool = live.length > 0 ? live : rows;
const sorted = [...pool].sort(
(a, b) => a.priority - b.priority || a.weight - b.weight,
);
const minPriority = sorted[0]!.priority;
const primaries = sorted.filter((r) => r.priority === minPriority);
if (healthy.length > 0) {
if (live.length > 0) {
return primaries.map((r) => r.ip);
}
return [sorted[0]!.ip];
+9
View File
@@ -3,3 +3,12 @@ import type { IpHealthState, NodeHealthState } from "@cfdm/shared";
export function isHealthy(state: IpHealthState | NodeHealthState | string): boolean {
return state === "up" || state === "healthy";
}
export function isDown(state: IpHealthState | NodeHealthState | string): boolean {
return state === "down" || state === "unhealthy";
}
/** A-pool membership: only Down is drained. Recovering (unknown/checking) and Slow return immediately. */
export function isPoolMember(state: IpHealthState | NodeHealthState | string): boolean {
return !isDown(state);
}
+32 -3
View File
@@ -1,26 +1,55 @@
import type { LbMode } from "@cfdm/shared";
import { failoverDesired } from "./failover.js";
import { canApplyLb, isSharedPool } from "./pool.js";
import { roundRobinDesired } from "./round-robin.js";
import type { LbIpRow, LbTargetConfig } from "./types.js";
import { weightedDesired } from "./weighted.js";
export type { LbIpRow, LbTargetConfig } from "./types.js";
export { isHealthy } from "./health.js";
export { isDown, isHealthy, isPoolMember } from "./health.js";
export { withBindingLock } from "./binding-lock.js";
export {
canApplyLb,
isSharedPool,
shouldRecordFailoverDnsDiff,
uniqueIpCount,
} from "./pool.js";
export { WEIGHTED_DNS_TTL, WEIGHTED_SLOT_MS, weightedDesired } from "./weighted.js";
export function selectActiveIpsByMode(
config: LbTargetConfig,
rows: LbIpRow[],
nowMs = Date.now(),
): string[] {
if (rows.length === 0) return [];
if (config.lb_mode === "failover") {
return failoverDesired(rows);
}
// weighted = round_robin on DNS (one A per IP)
if (config.lb_mode === "weighted") {
return weightedDesired(rows, nowMs);
}
return roundRobinDesired(rows);
}
export function resolveDesiredAIps(
config: LbTargetConfig,
rows: LbIpRow[],
fallbackIps: readonly string[],
nowMs = Date.now(),
serviceIps: readonly string[] = fallbackIps,
): string[] {
const fallback = [...fallbackIps];
if (!canApplyLb(serviceIps, fallback)) return fallback;
if (!isSharedPool(rows.map((row) => row.ip))) return fallback;
if (config.lb_mode === "weighted" || config.health_check_enabled) {
const activeIps = selectActiveIpsByMode(config, rows, nowMs);
if (activeIps.length > 0) return activeIps;
}
return fallback;
}
export function strategyLabel(mode: LbMode): string {
if (mode === "failover") return "Failover";
if (mode === "weighted") return "Round Robin (weighted alias)";
if (mode === "weighted") return "Weighted";
return "Round Robin";
}
+30
View File
@@ -0,0 +1,30 @@
import type { LbMode } from "@cfdm/shared";
export function uniqueIpCount(ips: readonly string[]): number {
return new Set(ips.filter(Boolean)).size;
}
/** Shared pool — two or more unique IPs. One IP (even duplicated) is not a pool. */
export function isSharedPool(ips: readonly string[]): boolean {
return uniqueIpCount(ips) >= 2;
}
/** LB / drain only when the service itself has a pool AND this FQDN is shared. */
export function canApplyLb(
serviceIps: readonly string[],
bindingIps: readonly string[],
): boolean {
return isSharedPool(serviceIps) && isSharedPool(bindingIps);
}
export function shouldRecordFailoverDnsDiff(input: {
configuredIps: readonly string[];
lbMode: LbMode;
added: readonly string[];
removed: readonly string[];
downIps: ReadonlySet<string>;
}): boolean {
if (!isSharedPool(input.configuredIps)) return false;
if (input.lbMode !== "weighted") return true;
return [...input.added, ...input.removed].some((ip) => input.downIps.has(ip));
}
+3 -3
View File
@@ -1,8 +1,8 @@
import type { LbIpRow } from "./types.js";
import { isHealthy } from "./health.js";
import { isPoolMember } from "./health.js";
export function roundRobinDesired(rows: LbIpRow[]): string[] {
const healthy = rows.filter((r) => isHealthy(r.health));
const pool = healthy.length > 0 ? healthy : rows;
const live = rows.filter((r) => isPoolMember(r.health));
const pool = live.length > 0 ? live : rows;
return pool.map((r) => r.ip);
}
+24
View File
@@ -0,0 +1,24 @@
import type { LbIpRow } from "./types.js";
import { isPoolMember } from "./health.js";
/** Slot length for time-sliced weighted DNS (one A at a time). */
export const WEIGHTED_SLOT_MS = 60_000;
/** Cloudflare DNS-only minimum TTL; Auto (1) is ~300s and would smear ratios. */
export const WEIGHTED_DNS_TTL = 60;
export function weightedDesired(rows: LbIpRow[], nowMs = Date.now()): string[] {
if (rows.length === 0) return [];
const live = rows.filter((r) => isPoolMember(r.health));
const pool = live.length > 0 ? live : rows;
if (pool.length === 1) return [pool[0]!.ip];
const sorted = [...pool].sort((a, b) => a.ip.localeCompare(b.ip));
const cycle: string[] = [];
for (const row of sorted) {
const weight = Math.max(1, Math.round(row.weight));
for (let i = 0; i < weight; i++) cycle.push(row.ip);
}
const slot = Math.floor(nowMs / WEIGHTED_SLOT_MS) % cycle.length;
return [cycle[slot]!];
}
+506 -89
View File
@@ -18,6 +18,7 @@ import {
SYNC_PENDING_PUSH,
SYNC_SYNCED,
dnsRecordNamesMatch,
isIpLiteral,
normalizeDnsRecordName,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
@@ -28,14 +29,91 @@ import * as domainService from "./domain-service.js";
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
import {
canApplyLb,
isPoolMember,
isSharedPool,
resolveDesiredAIps,
selectActiveIpsByMode,
shouldRecordFailoverDnsDiff,
withBindingLock,
WEIGHTED_DNS_TTL,
type LbIpRow,
type LbTargetConfig,
} from "./routing/index.js";
export type { LbIpRow, LbTargetConfig };
export { selectActiveIpsByMode };
export {
canApplyLb,
resolveDesiredAIps,
selectActiveIpsByMode,
shouldRecordFailoverDnsDiff,
};
const AUTO_DNS_TTL = 1;
function ttlForBinding(mode: LbMode, ips: readonly string[]): number {
return mode === "weighted" && isSharedPool(ips) ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL;
}
function enabledServiceIps(db: Db, serviceId: number): string[] {
return repos
.listServiceIpRows(db, serviceId)
.filter((row) => row.enabled)
.map((row) => row.ip);
}
export function failoverARecordDiff(
existingA: readonly string[],
desiredIps: readonly string[],
): { added: string[]; removed: string[] } {
const before = new Set(existingA);
const after = new Set(desiredIps);
return {
added: desiredIps.filter((ip) => !before.has(ip)),
removed: existingA.filter((ip) => !after.has(ip)),
};
}
function recordFailoverDnsDiff(
db: Db,
bindingId: number,
hostname: string,
zoneName: string,
existingRecords: DnsRecord[],
desiredIps: string[],
): void {
const existingA = existingRecords
.filter((record) => record.record_type.toUpperCase() === "A")
.map((record) => record.content);
const { added, removed } = failoverARecordDiff(existingA, desiredIps);
if (added.length === 0 && removed.length === 0) return;
const binding = repos.getBinding(db, bindingId);
const configuredIps = repos.listBindingIps(db, bindingId);
const { config, rows } = getBindingLbState(db, bindingId);
const downIps = new Set(
rows.filter((row) => row.health === "down").map((row) => row.ip),
);
if (
!shouldRecordFailoverDnsDiff({
configuredIps,
lbMode: config.lb_mode,
added,
removed,
downIps,
})
) {
return;
}
repos.insertFailoverLog(db, {
serviceId: binding.service_id,
bindingId,
fqdn: fqdnToDisplay(hostname, zoneName),
entries: [
...added.map((ip) => ({ ip, action: "added" as const })),
...removed.map((ip) => ({ ip, action: "removed" as const })),
],
});
}
export interface ServiceDomainInput {
fqdn: string;
@@ -209,7 +287,7 @@ function getGroupLbState(
} else {
existing.weight += weight;
existing.priority = Math.min(existing.priority, priority);
if (isHealthy(existing.health) && status && !isHealthy(status.status as IpHealthState)) {
if (isPoolMember(existing.health) && status && !isPoolMember(status.status as IpHealthState)) {
existing.health = status.status as IpHealthState;
}
}
@@ -225,16 +303,31 @@ function getGroupLbState(
};
}
function computeActiveIps(
function desiredAIps(
db: Db,
scope: HealthCheckScope,
refId: number,
fallbackIps: string[],
): string[] {
const state =
scope === "binding"
? getBindingLbState(db, refId)
: getGroupLbState(db, refId);
return selectActiveIpsByMode(state.config, state.rows);
// Configured binding/group IPs stay intact; DNS publishes only enabled ones.
const enabledIps =
scope === "binding"
? enabledServiceIps(db, repos.getBinding(db, refId).service_id)
: fallbackIps;
const enabledSet = new Set(enabledIps);
const activeFallback = fallbackIps.filter((ip) => enabledSet.has(ip));
const activeRows = state.rows.filter((row) => enabledSet.has(row.ip));
return resolveDesiredAIps(
state.config,
activeRows,
activeFallback,
Date.now(),
enabledIps,
);
}
async function collectKnownZones(
@@ -252,7 +345,38 @@ async function collectKnownZones(
return zones;
}
/** Restore common A-bindings whose IPs were shrunk by legacy IP toggles. */
function repairPoolSubsetBindings(db: Db, serviceId: number): void {
const pool = repos.listServiceIps(db, serviceId);
if (pool.length < 2) return;
const poolSet = new Set(pool);
for (const binding of repos.listBindingsByService(db, serviceId)) {
if (binding.cname_target?.trim()) continue;
const current = repos.listBindingIpsWithMeta(db, binding.id);
if (current.length <= 1) continue;
if (!current.every((entry) => poolSet.has(entry.ip))) continue;
const currentSet = new Set(current.map((entry) => entry.ip));
if (currentSet.size === pool.length && pool.every((ip) => currentSet.has(ip))) {
continue;
}
const byIp = new Map(current.map((entry) => [entry.ip, entry]));
repos.replaceBindingIpsWithMeta(
db,
binding.id,
pool.map((ip) => ({
ip,
weight: byIp.get(ip)?.weight ?? 1,
priority: byIp.get(ip)?.priority ?? 1,
})),
);
}
}
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
repairPoolSubsetBindings(db, serviceId);
const service = repos.getService(db, serviceId);
const ipRows = repos.listServiceIpRows(db, serviceId);
const ips = ipRows.map((row) => row.ip);
@@ -283,6 +407,11 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
}
const { config, rows } = getBindingLbState(db, binding.id);
const bindingActiveIps = targetCname
? []
: resolveDesiredAIps(config, rows, targetIps, Date.now(), ips);
return {
binding_id: binding.id,
domain_id: binding.domain_id,
@@ -308,14 +437,15 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
binding.health_check_provider ?? "local",
],
health_check_aggregate: binding.health_check_aggregate ?? "majority",
cert_monitoring: binding.cert_monitoring ?? "auto",
sync_status: aggregateSyncStatus(statuses),
active_ips: bindingActiveIps,
};
});
const activeIps = new Set<string>();
for (const binding of bindings) {
const { config, rows } = getBindingLbState(db, binding.id);
for (const ip of selectActiveIpsByMode(config, rows)) {
for (const domain of domainViews) {
for (const ip of domain.active_ips) {
activeIps.add(ip);
}
}
@@ -343,6 +473,107 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
};
}
const HEALTH_RANK: Record<string, number> = {
down: 3,
degraded: 2,
unknown: 1,
up: 0,
};
function cnameLookupKeys(value: string, zoneName?: string | null): string[] {
const trimmed = value.trim();
if (!trimmed) return [];
const noDot = trimmed.replace(/\.+$/, "");
const lower = noDot.toLowerCase();
const keys = new Set([trimmed, noDot, lower]);
if (zoneName && !lower.includes(".")) {
keys.add(`${lower}.${zoneName.trim().toLowerCase().replace(/\.+$/, "")}`);
}
return [...keys];
}
type ServiceHealthRow = {
ip: string;
status: IpHealthState;
latency_ms: number | null;
last_checked_at: string | null;
last_error: string | null;
provider: ServiceView["ip_health"][number]["provider"];
colo: string | null;
};
/** Health rows keyed by CNAME hostname (legacy probes) applied to service IPs. */
function fallbackCnameHealth(
rows: ServiceHealthRow[],
view: ServiceView,
): ServiceHealthRow | undefined {
const cnameKeys = new Set<string>();
for (const domain of view.domains ?? []) {
const cname = domain.target_cname?.trim();
if (!cname) continue;
for (const key of cnameLookupKeys(cname, domain.zone_name)) {
cnameKeys.add(key);
}
}
const hostnameRows = rows.filter((row) => !isIpLiteral(row.ip));
if (hostnameRows.length === 0) return undefined;
const matched =
cnameKeys.size === 0
? hostnameRows
: hostnameRows.filter((row) =>
cnameLookupKeys(row.ip).some((key) => cnameKeys.has(key)),
);
const candidates = matched.length > 0 ? matched : hostnameRows;
return candidates.reduce((worst, row) =>
(HEALTH_RANK[row.status] ?? 0) > (HEALTH_RANK[worst.status] ?? 0)
? row
: worst,
);
}
function overlayLiveHealth(
stored: IpHealthState | undefined,
live: IpHealthState | undefined,
): IpHealthState {
if (live && live !== "unknown") return live;
return stored ?? live ?? "unknown";
}
function bestAliveDisplayStatus(statuses: readonly string[]): IpHealthState {
if (statuses.some((status) => status === "up")) return "up";
if (statuses.some((status) => status === "degraded")) return "degraded";
if (statuses.some((status) => status === "down")) return "down";
return "unknown";
}
/** Health badge applies only when the service, IP and HC (binding or group) are active. */
function isServiceHealthCheckActive(db: Db, view: ServiceView): boolean {
if ((view.domains ?? []).some((domain) => domain.health_check_enabled)) {
return true;
}
if (!view.service_group_id) return false;
const group = repos.getServiceGroup(db, view.service_group_id);
return Boolean(group.health_check_enabled);
}
function isIpHealthMonitored(db: Db, view: ServiceView, ip: string): boolean {
if (!view.enabled) return false;
if (view.ip_enabled[ip] === false) return false;
return isServiceHealthCheckActive(db, view);
}
function inactiveIpHealthRow(ip: string): ServiceHealthRow {
return {
ip,
status: "unknown",
latency_ms: null,
last_checked_at: null,
last_error: null,
provider: "local",
colo: null,
};
}
function attachServiceHealth(
db: Db,
views: ServiceView[],
@@ -350,27 +581,60 @@ function attachServiceHealth(
const ids = views.map((v) => v.id);
const healthByService = repos.aggregateIpHealthByServiceIds(db, ids);
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
const liveByService = repos.listLatestLiveHealthByServiceIds(db, ids);
return views.map((view) => {
const health = healthByService.get(view.id);
const byIp = new Map(
(ipHealthByService.get(view.id) ?? []).map((row) => [row.ip, row]),
const rows = ipHealthByService.get(view.id) ?? [];
const liveRows = liveByService.get(view.id) ?? [];
const byIp = new Map(rows.map((row) => [row.ip, row]));
const liveByIp = new Map(liveRows.map((row) => [row.ip, row]));
const cnameFallback = fallbackCnameHealth(rows, view);
const aRecordIps = new Set(
(view.domains ?? []).flatMap((domain) =>
domain.target_cname?.trim() ? [] : (domain.target_ips ?? []),
),
);
const ip_health = (view.ips ?? []).map((ip) => {
const row = byIp.get(ip);
if (!isIpHealthMonitored(db, view, ip)) {
return inactiveIpHealthRow(ip);
}
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
const live = liveByIp.get(ip);
const status = overlayLiveHealth(row?.status, live?.status);
const extras = live && live.status !== "unknown" ? live : row;
return {
ip,
status: row?.status ?? ("unknown" as const),
latency_ms: row?.latency_ms ?? null,
last_checked_at: row?.last_checked_at ?? null,
last_error: row?.last_error ?? null,
provider: row?.provider ?? "local",
colo: row?.colo ?? null,
status,
latency_ms: extras?.latency_ms ?? null,
last_checked_at: extras?.last_checked_at ?? null,
last_error:
live && live.status !== "unknown"
? live.last_error
: (row?.last_error ?? null),
provider: extras?.provider ?? "local",
colo: extras?.colo ?? null,
};
});
const monitoredStatuses = ip_health
.filter((row) => isIpHealthMonitored(db, view, row.ip))
.map((row) => row.status);
const displayStatus =
monitoredStatuses.length > 0
? bestAliveDisplayStatus(monitoredStatuses)
: ("unknown" as const);
const latencyRow =
ip_health.find((row) => row.status === displayStatus && row.latency_ms != null) ??
ip_health.find((row) => row.latency_ms != null);
return {
...view,
health_status: health?.health_status ?? "unknown",
health_latency_ms: health?.health_latency_ms ?? null,
health_status:
monitoredStatuses.length > 0
? overlayLiveHealth(health?.health_status, displayStatus)
: "unknown",
health_latency_ms:
monitoredStatuses.length > 0 && displayStatus !== "unknown"
? (latencyRow?.latency_ms ?? null)
: null,
ip_health,
};
});
@@ -473,26 +737,11 @@ async function syncBindingDns(
desiredIps: string[],
cnameTarget: string | null,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
let effectiveCname = cnameTarget?.trim() || null;
if (!effectiveCname) {
const existingCname = await findOrImportDnsRecord(
db,
cf,
domainId,
zoneName,
hostname,
"CNAME",
);
if (existingCname) {
effectiveCname = existingCname.content;
repos.setBindingCnameTarget(db, bindingId, effectiveCname);
repos.replaceBindingIps(db, bindingId, []);
}
}
const effectiveCname = cnameTarget?.trim() || null;
// Desired config wins. Do NOT auto-adopt leftover CNAME from local/CF when the
// binding is A-mode — that wiped IPs and blocked extra FQDN publishes.
// Docs: https://developers.cloudflare.com/dns/manage-dns-records/troubleshooting/records-with-same-name/
if (effectiveCname) {
await syncBindingCnameDns(
db,
@@ -505,7 +754,83 @@ async function syncBindingDns(
return;
}
await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps);
const binding = repos.getBinding(db, bindingId);
const configuredIps = repos.listBindingIps(db, bindingId);
await syncBindingADns(
db,
cf,
bindingId,
domainId,
hostname,
desiredIps,
ttlForBinding(binding.lb_mode, configuredIps),
);
}
/** Delete every local+CF record for hostname that must not remain in A mode. */
async function reconcileHostnameForA(
db: Db,
cf: CloudflareClient,
domainId: number,
zoneName: string,
hostname: string,
desiredIps: readonly string[],
): Promise<void> {
const desired = new Set(desiredIps);
// CNAME on the same name blocks creating A records in Cloudflare.
for (;;) {
const cname = await findOrImportDnsRecord(
db,
cf,
domainId,
zoneName,
hostname,
"CNAME",
);
if (!cname) break;
await dnsService.deleteRecord(db, cf, domainId, cname.id);
}
const domain = repos.getDomain(db, domainId);
const staleLocal = repos
.listDnsByDomain(db, domainId)
.filter(
(record) =>
record.record_type.toUpperCase() === "A" &&
dnsRecordNamesMatch(record.name, hostname, zoneName) &&
!desired.has(record.content),
);
for (const record of staleLocal) {
await dnsService.deleteRecord(db, cf, domainId, record.id);
}
const remote = await cf.listDnsRecords(domain.cf_zone_id);
for (const cfRec of remote) {
if ((cfRec.type ?? "").toUpperCase() !== "A" || !cfRec.id) continue;
if (!dnsRecordNamesMatch(cfRec.name, hostname, zoneName)) continue;
if (desired.has(cfRec.content)) continue;
const existing = repos.findDnsByCfId(db, domainId, cfRec.id);
if (existing) {
await dnsService.deleteRecord(db, cf, domainId, existing.id);
continue;
}
const imported = repos.insertDnsRecord(
db,
domainId,
cfRec.type,
cfRec.name,
cfRec.content,
cfRec.ttl,
cfRec.proxied ?? false,
cfRec.priority ?? null,
SYNC_SYNCED,
"cloudflare",
cfRec.id,
);
await dnsService.deleteRecord(db, cf, domainId, imported.id);
}
}
async function syncBindingCnameDns(
@@ -519,6 +844,21 @@ async function syncBindingCnameDns(
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
const normalized = normalizeCnameTarget(cnameTarget, zoneName);
// A/AAAA on the same name blocks CNAME create in Cloudflare.
for (;;) {
const conflictingA = await findOrImportDnsRecord(
db,
cf,
domainId,
zoneName,
hostname,
"A",
);
if (!conflictingA) break;
await dnsService.deleteRecord(db, cf, domainId, conflictingA.id);
}
const existingRecords = repos.listRecordsForBinding(db, bindingId);
for (const record of existingRecords) {
@@ -592,9 +932,14 @@ async function syncBindingADns(
domainId: number,
hostname: string,
desiredIps: string[],
ttl: number,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
// Align zone/local leftovers with desired A set (CNAME conflicts, stale A IPs).
await reconcileHostnameForA(db, cf, domainId, zoneName, hostname, desiredIps);
const existingRecords = repos.listRecordsForBinding(db, bindingId);
for (const record of existingRecords) {
@@ -611,6 +956,14 @@ async function syncBindingADns(
if (desiredIps.length === 0) {
repos.setBindingDnsRecordId(db, bindingId, null);
recordFailoverDnsDiff(
db,
bindingId,
hostname,
zoneName,
existingRecords,
desiredIps,
);
return;
}
@@ -619,13 +972,15 @@ async function syncBindingADns(
for (const ip of desiredIps) {
const existing = refreshed.find((r) => r.content === ip);
const recordName = dnsNameForBinding(hostname, zoneName);
let recordId: number;
if (existing) {
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) {
await dnsService.update(db, cf, domainId, existing.id, {
record_type: "A",
name: dnsNameForBinding(hostname, zoneName),
name: recordName,
content: ip,
ttl,
proxied: false,
});
}
@@ -643,12 +998,24 @@ async function syncBindingADns(
if (adopted) {
repos.linkBindingRecord(db, bindingId, adopted.id);
recordId = adopted.id;
if (
!dnsRecordNamesMatch(adopted.name, hostname, zoneName) ||
adopted.ttl !== ttl
) {
await dnsService.update(db, cf, domainId, adopted.id, {
record_type: "A",
name: recordName,
content: ip,
ttl,
proxied: false,
});
}
} else {
const record = await dnsService.create(db, cf, domainId, {
record_type: "A",
name: dnsNameForBinding(hostname, zoneName),
name: recordName,
content: ip,
ttl: 1,
ttl,
proxied: false,
});
repos.linkBindingRecord(db, bindingId, record.id);
@@ -659,6 +1026,14 @@ async function syncBindingADns(
}
repos.setBindingDnsRecordId(db, bindingId, primaryId);
recordFailoverDnsDiff(
db,
bindingId,
hostname,
zoneName,
existingRecords,
desiredIps,
);
}
async function cleanupBindingDns(
@@ -877,12 +1252,7 @@ async function syncServiceBindingsToDns(
}
validateTargetIpsInPool(targetIps, ips);
if (binding.health_check_enabled) {
const activeIps = computeActiveIps(db, "binding", binding.id);
if (activeIps.length > 0) {
targetIps = activeIps;
}
}
const desiredIps = desiredAIps(db, "binding", binding.id, targetIps);
await syncBindingDns(
db,
@@ -890,7 +1260,7 @@ async function syncServiceBindingsToDns(
binding.id,
binding.domain_id,
binding.hostname,
targetIps,
desiredIps,
null,
);
}
@@ -904,10 +1274,12 @@ async function collectGroupDnsIps(
const ips: string[] = [];
for (const service of services) {
if (!service.enabled) continue;
const enabled = new Set(enabledServiceIps(db, service.id));
const bindings = repos.listBindingsByService(db, service.id);
for (const binding of bindings) {
for (const ip of repos.listBindingIps(db, binding.id)) {
if (!ips.includes(ip)) ips.push(ip);
if (!enabled.has(ip) || ips.includes(ip)) continue;
ips.push(ip);
}
}
}
@@ -922,6 +1294,7 @@ async function syncGroupDomainDnsRecords(
domainId: number,
hostname: string,
desiredIps: string[],
ttl: number = AUTO_DNS_TTL,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
@@ -937,14 +1310,16 @@ async function syncGroupDomainDnsRecords(
if (desiredIps.length === 0) return;
const refreshed = repos.listGroupDnsRecords(db, groupId);
const recordName = dnsNameForBinding(hostname, zoneName);
for (const ip of desiredIps) {
const existing = refreshed.find((r) => r.content === ip);
if (existing) {
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) {
await dnsService.update(db, cf, domainId, existing.id, {
record_type: "A",
name: dnsNameForBinding(hostname, zoneName),
name: recordName,
content: ip,
ttl,
proxied: false,
});
}
@@ -960,13 +1335,25 @@ async function syncGroupDomainDnsRecords(
);
if (adopted) {
repos.linkGroupDnsRecord(db, groupId, adopted.id);
if (
!dnsRecordNamesMatch(adopted.name, hostname, zoneName) ||
adopted.ttl !== ttl
) {
await dnsService.update(db, cf, domainId, adopted.id, {
record_type: "A",
name: recordName,
content: ip,
ttl,
proxied: false,
});
}
continue;
}
const record = await dnsService.create(db, cf, domainId, {
record_type: "A",
name: dnsNameForBinding(hostname, zoneName),
name: recordName,
content: ip,
ttl: 1,
ttl,
proxied: false,
});
repos.linkGroupDnsRecord(db, groupId, record.id);
@@ -1017,9 +1404,8 @@ async function syncGroupDomainDns(
const knownZones = await collectKnownZones(db, cf);
const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
const domainId = await resolveDomainId(db, cf, zoneName);
const desiredIps = group.health_check_enabled
? computeActiveIps(db, "group", groupId)
: await collectGroupDnsIps(db, groupId);
const fallbackIps = await collectGroupDnsIps(db, groupId);
const desiredIps = desiredAIps(db, "group", groupId, fallbackIps);
await syncGroupDomainDnsRecords(
db,
cf,
@@ -1027,6 +1413,7 @@ async function syncGroupDomainDns(
domainId,
hostname,
desiredIps,
ttlForBinding(group.lb_mode, fallbackIps),
);
}
@@ -1207,14 +1594,7 @@ export async function updateConfig(
}
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;
}
}
const effectiveIps = desiredAIps(db, "binding", binding.id, targetIps);
await syncBindingDns(
db,
cf,
@@ -1420,26 +1800,8 @@ export async function toggleServiceIp(
repos.updateNode(db, node.id, { enabled });
}
const bindings = repos.listBindingsByService(db, serviceId);
for (const binding of bindings) {
if (binding.cname_target?.trim()) continue;
const current = repos.listBindingIpsWithMeta(db, binding.id);
const hasIp = current.some((entry) => entry.ip === ip);
if (enabled && !hasIp) {
repos.replaceBindingIpsWithMeta(db, binding.id, [
...current,
{ ip, weight: 1, priority: 1 },
]);
continue;
}
if (!enabled && hasIp) {
repos.replaceBindingIpsWithMeta(
db,
binding.id,
current.filter((entry) => entry.ip !== ip),
);
}
}
// Keep binding IP membership stable (common FQDN = full pool). DNS sync
// filters by enabledServiceIps via desiredAIps — do not reshuffle bindings.
const service = repos.getService(db, serviceId);
if (shouldPushDns(db, service)) {
@@ -1520,16 +1882,18 @@ export async function reconcileDnsForTarget(
if (scope === "binding") {
await withBindingLock(refId, async () => {
const binding = repos.getBinding(db, refId);
if (!binding.health_check_enabled) return;
if (!binding.health_check_enabled && binding.lb_mode !== "weighted") 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 poolIps = enabledServiceIps(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;
const desiredIps = canApplyLb(poolIps, targetIps)
? desiredAIps(db, "binding", refId, targetIps)
: targetIps;
await syncBindingDns(
db,
cf,
@@ -1544,8 +1908,61 @@ export async function reconcileDnsForTarget(
}
const group = repos.getServiceGroup(db, refId);
if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) {
if (!group.enabled || !group.domain?.trim()) {
return;
}
if (!group.health_check_enabled && group.lb_mode !== "weighted") {
return;
}
await syncGroupDomainDns(db, cf, refId);
}
export async function reconcileWeightedDns(
db: Db,
cf: CloudflareClient,
): Promise<number> {
let n = 0;
for (const binding of repos.listAllBindings(db)) {
if (binding.lb_mode !== "weighted") continue;
if (binding.cname_target?.trim()) continue;
if (!isSharedPool(binding.target_ips ?? [])) continue;
try {
await withBindingLock(binding.id, async () => {
const latest = repos.getBinding(db, binding.id);
if (latest.lb_mode !== "weighted") return;
if (latest.cname_target?.trim()) return;
const service = repos.getService(db, latest.service_id);
if (!shouldPushDns(db, service)) return;
const targetIps = repos.listBindingIps(db, latest.id);
const poolIps = enabledServiceIps(db, service.id);
if (!canApplyLb(poolIps, targetIps)) return;
const ips = repos.listServiceIps(db, service.id);
validateTargetIpsInPool(targetIps, ips);
const desiredIps = desiredAIps(db, "binding", latest.id, targetIps);
await syncBindingDns(
db,
cf,
latest.id,
latest.domain_id,
latest.hostname,
desiredIps,
null,
);
n += 1;
});
} catch {
continue;
}
}
for (const group of repos.listServiceGroups(db)) {
if (group.lb_mode !== "weighted") continue;
if (!group.enabled || !group.domain?.trim()) continue;
try {
await syncGroupDomainDns(db, cf, group.id);
n += 1;
} catch {
continue;
}
}
return n;
}
+54 -1
View File
@@ -1,8 +1,53 @@
import { resolve4 } from "node:dns/promises";
import type { CfdmBindingSyncItem, ServiceBindingView } from "@cfdm/shared";
import type { CfdmBindingSyncItem, LbMode, ServiceBindingView } from "@cfdm/shared";
import { isIpLiteral } from "@cfdm/shared";
import type { Db } from "@cfdm/db";
import { repos, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db";
import { isSharedPool } from "./routing/pool.js";
export function isLbMode(value: unknown): value is LbMode {
return value === "round_robin" || value === "failover" || value === "weighted";
}
/** lb_mode binding, иначе service group. */
export function resolveLbModeForSync(
bindingLbMode: string | undefined | null,
groupLbMode?: string | null,
): LbMode | undefined {
if (isLbMode(bindingLbMode)) return bindingLbMode;
if (isLbMode(groupLbMode)) return groupLbMode;
return undefined;
}
/** Effective HA only when the service has two or more unique origin IPs. */
export function effectiveLbModeForSync(
bindingLbMode: string | undefined | null,
groupLbMode: string | undefined | null,
serviceIps: readonly string[],
): LbMode | undefined {
if (!isSharedPool(serviceIps)) return undefined;
return resolveLbModeForSync(bindingLbMode, groupLbMode);
}
function groupLbModeForService(
db: Db,
serviceId: number,
cache: Map<number, LbMode | undefined>,
): LbMode | undefined {
if (cache.has(serviceId)) return cache.get(serviceId);
let mode: LbMode | undefined;
try {
const service = repos.getService(db, serviceId);
if (service.service_group_id != null) {
const group = repos.getServiceGroup(db, service.service_group_id);
mode = resolveLbModeForSync(undefined, group.lb_mode);
}
} catch {
mode = undefined;
}
cache.set(serviceId, mode);
return mode;
}
function fqdnToDisplay(hostname: string, zoneName: string): string {
if (hostname === "@" || !hostname.trim()) return zoneName;
@@ -126,10 +171,13 @@ export async function buildServiceSyncBindingsAsync(
const allBindings = repos.listAllBindings(db);
const index = buildBindingIndex(allBindings);
const bindings = allBindings.filter((row) => row.service_id === serviceId);
const groupLbCache = new Map<number, LbMode | undefined>();
const groupLb = groupLbModeForService(db, serviceId, groupLbCache);
const items: CfdmBindingSyncItem[] = [];
for (const binding of bindings) {
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps);
items.push({
bindingId: binding.id,
serviceId: service.id,
@@ -140,6 +188,7 @@ export async function buildServiceSyncBindingsAsync(
hostname: binding.hostname,
ips,
cnameTarget: cnameTargetForSync(binding),
...(lbMode ? { lbMode } : {}),
});
}
@@ -166,6 +215,7 @@ export async function buildAllSyncBindings(
const bindings = repos.listAllBindings(db);
const index = buildBindingIndex(bindings);
const serviceIpCache = new Map<number, string[]>();
const groupLbCache = new Map<number, LbMode | undefined>();
const items: CfdmBindingSyncItem[] = [];
for (const binding of bindings) {
@@ -175,6 +225,8 @@ export async function buildAllSyncBindings(
serviceIpCache.set(binding.service_id, serviceIps);
}
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
const groupLb = groupLbModeForService(db, binding.service_id, groupLbCache);
const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps);
items.push({
bindingId: binding.id,
serviceId: binding.service_id,
@@ -185,6 +237,7 @@ export async function buildAllSyncBindings(
hostname: binding.hostname,
ips,
cnameTarget: cnameTargetForSync(binding),
...(lbMode ? { lbMode } : {}),
});
}
return items;
@@ -0,0 +1,39 @@
import type { FastifyInstance } from "fastify";
import { AsyncTask, SimpleIntervalJob } from "toad-scheduler";
import * as serviceConfigService from "./service-config-service.js";
import { WEIGHTED_SLOT_MS } from "./routing/weighted.js";
export const WEIGHTED_DNS_JOB_ID = "weighted-dns";
export function createWeightedDnsTask(app: FastifyInstance): AsyncTask {
return new AsyncTask(
WEIGHTED_DNS_JOB_ID,
async () => {
const n = await serviceConfigService.reconcileWeightedDns(app.db, app.cf);
if (n > 0) {
app.log.info({ reconciled: n }, "weighted dns rotated");
}
},
(err) => {
app.log.warn({ err }, "weighted dns rotate failed");
},
);
}
export function scheduleWeightedDnsJob(
app: FastifyInstance,
task: AsyncTask,
): void {
const scheduler = app.scheduler;
if (!scheduler) return;
if (scheduler.existsById(WEIGHTED_DNS_JOB_ID)) {
scheduler.removeById(WEIGHTED_DNS_JOB_ID);
}
scheduler.addSimpleIntervalJob(
new SimpleIntervalJob(
{ seconds: WEIGHTED_SLOT_MS / 1000, runImmediately: true },
task,
{ id: WEIGHTED_DNS_JOB_ID, preventOverrun: true },
),
);
}
+186 -20
View File
@@ -208,7 +208,7 @@ describe("certificates", () => {
await testApp.close();
});
it("required apex is monitored without bindings", async () => {
it("required binding is monitored without TLS health gate", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
@@ -221,10 +221,18 @@ describe("certificates", () => {
"required.example.com",
"cf-zone-req",
);
repos.updateDomain(testApp.db, domain.id, {
group_id: null,
status: "active",
const service = repos.createService(testApp.db, "Req", "req");
repos.setServiceEnabled(testApp.db, service.id, true);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"@",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED,
health_check_enabled: false,
});
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
@@ -247,7 +255,7 @@ describe("certificates", () => {
await testApp.close();
});
it("skipped apex removes stale certificate on check", async () => {
it("skipped binding removes stale certificate on check", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
@@ -260,6 +268,20 @@ describe("certificates", () => {
"skipped.example.com",
"cf-zone-skip",
);
const service = repos.createService(testApp.db, "Skip", "skip");
repos.setServiceEnabled(testApp.db, service.id, true);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"@",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_SKIPPED,
health_check_enabled: true,
health_check_verify_tls: true,
});
repos.upsertCertificateCheck(
testApp.db,
domain.id,
@@ -268,12 +290,8 @@ describe("certificates", () => {
null,
CERT_ERROR,
"stale",
service.id,
);
repos.updateDomain(testApp.db, domain.id, {
group_id: null,
status: "active",
cert_monitoring: CERT_MONITOR_SKIPPED,
});
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: null,
@@ -305,9 +323,16 @@ describe("certificates", () => {
"broken.example.com",
"cf-zone-broken",
);
repos.updateDomain(testApp.db, domain.id, {
group_id: null,
status: "active",
const service = repos.createService(testApp.db, "Broken", "broken");
repos.setServiceEnabled(testApp.db, service.id, true);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"@",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED,
});
@@ -424,7 +449,7 @@ describe("certificates", () => {
});
const certs = repos.listCertificates(testApp.db);
expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(true);
expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(false);
expect(certs.some((c) => c.hostname === "edge.ok.example.com")).toBe(true);
await testApp.close();
@@ -443,12 +468,7 @@ describe("certificates", () => {
"force.example.com",
"cf-zone-force",
);
repos.updateDomain(testApp.db, domain.id, {
group_id: null,
status: "active",
cert_monitoring: CERT_MONITOR_REQUIRED,
});
repos.createServiceGroup(
const group = repos.createServiceGroup(
testApp.db,
"Proxy",
"vpn",
@@ -459,6 +479,19 @@ describe("certificates", () => {
health_check_verify_tls: false,
},
);
const service = repos.createService(testApp.db, "Force", "force");
repos.setServiceEnabled(testApp.db, service.id, true);
repos.setServiceGroup(testApp.db, service.id, group.id);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"@",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED,
});
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
@@ -540,4 +573,137 @@ describe("certificates", () => {
await testApp.close();
});
it("GET /services/:id/certificates lists binding FQDNs", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(testApp);
const domain = repos.createDomain(
testApp.db,
null,
"svc.example.com",
"cf-zone-svc",
);
const service = repos.createService(testApp.db, "Api", "api");
repos.setServiceEnabled(testApp.db, service.id, true);
repos.insertBinding(testApp.db, domain.id, service.id, "www", null);
const res = await testApp.inject({
method: "GET",
url: `/api/v1/services/${service.id}/certificates`,
headers,
});
expect(res.statusCode).toBe(200);
const rows = res.json() as Array<{
hostname: string;
cert_monitoring: string;
status: string;
}>;
expect(rows).toHaveLength(1);
expect(rows[0]?.hostname).toBe("www.svc.example.com");
expect(rows[0]?.cert_monitoring).toBe("auto");
expect(rows[0]?.status).toBe("unknown");
await testApp.close();
});
it("PATCH /service-bindings/:id updates cert_monitoring", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(testApp);
const domain = repos.createDomain(
testApp.db,
null,
"patch.example.com",
"cf-zone-patch",
);
const service = repos.createService(testApp.db, "Patch", "patch");
repos.setServiceEnabled(testApp.db, service.id, true);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"api",
null,
);
const res = await testApp.inject({
method: "PATCH",
url: `/api/v1/service-bindings/${binding.id}`,
headers,
payload: { cert_monitoring: CERT_MONITOR_REQUIRED },
});
expect(res.statusCode).toBe(200);
expect((res.json() as { cert_monitoring: string }).cert_monitoring).toBe(
CERT_MONITOR_REQUIRED,
);
expect(repos.getBinding(testApp.db, binding.id).cert_monitoring).toBe(
CERT_MONITOR_REQUIRED,
);
await testApp.close();
});
it("POST /services/:id/certificates/check only checks that service", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(testApp);
const domain = repos.createDomain(
testApp.db,
null,
"check.example.com",
"cf-zone-check",
);
const service = repos.createService(testApp.db, "One", "one");
const other = repos.createService(testApp.db, "Two", "two");
repos.setServiceEnabled(testApp.db, service.id, true);
repos.setServiceEnabled(testApp.db, other.id, true);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"one",
null,
);
const otherBinding = repos.insertBinding(
testApp.db,
domain.id,
other.id,
"two",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED,
});
repos.updateBindingLbConfig(testApp.db, otherBinding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED,
});
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
error: null,
});
const res = await testApp.inject({
method: "POST",
url: `/api/v1/services/${service.id}/certificates/check`,
headers,
});
expect(res.statusCode).toBe(200);
expect((res.json() as { checked: number }).checked).toBe(1);
const certs = repos.listCertificates(testApp.db);
expect(certs.some((c) => c.hostname === "one.check.example.com")).toBe(true);
expect(certs.some((c) => c.hostname === "two.check.example.com")).toBe(false);
await testApp.close();
});
});
+220
View File
@@ -0,0 +1,220 @@
import { describe, expect, it } from "vitest";
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
import { SYNC_SYNCED } from "@cfdm/shared";
import type { CloudflareClient } from "../src/lib/cf-client.js";
import * as dnsService from "../src/services/dns-service.js";
import { updateConfig } from "../src/services/service-config-service.js";
type CfRec = {
id: string;
type: string;
name: string;
content: string;
ttl: number;
proxied: boolean;
};
function setupDb() {
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
return db;
}
describe("DNS identity by name + content", () => {
it("deletes by name+IP when cached cf_record_id is stale/missing in CF", async () => {
const db = setupDb();
const remote: CfRec[] = [
{
id: "cf-live",
type: "A",
name: "nsgt.example.com",
content: "130.49.213.176",
ttl: 1,
proxied: false,
},
];
const deleted: string[] = [];
const cf = {
listDnsRecords: async () => [...remote],
createDnsRecord: async () => {
throw new Error("create should not run");
},
updateDnsRecord: async () => {
throw new Error("update should not run");
},
deleteDnsRecord: async (_zoneId: string, id: string) => {
deleted.push(id);
const idx = remote.findIndex((r) => r.id === id);
if (idx >= 0) remote.splice(idx, 1);
},
verifyToken: async () => true,
listZones: async () => [],
} as unknown as CloudflareClient;
const domain = repos.createDomain(db, null, "example.com", "zone-1");
const local = repos.insertDnsRecord(
db,
domain.id,
"A",
"nsgt.example.com",
"130.49.213.176",
1,
false,
null,
SYNC_SYNCED,
"local",
"cf-stale-gone", // not present in Cloudflare
);
await dnsService.deleteRecord(db, cf, domain.id, local.id);
expect(deleted).toEqual(["cf-live"]);
expect(repos.listDnsByDomain(db, domain.id)).toEqual([]);
expect(remote).toEqual([]);
});
it("delete is no-op success when record already removed outside CFDM", async () => {
const db = setupDb();
const cf = {
listDnsRecords: async () => [],
deleteDnsRecord: async () => {
throw new Error("Record does not exist");
},
verifyToken: async () => true,
listZones: async () => [],
} as unknown as CloudflareClient;
const domain = repos.createDomain(db, null, "example.com", "zone-1");
const local = repos.insertDnsRecord(
db,
domain.id,
"A",
"nsgt.example.com",
"130.49.213.176",
1,
false,
null,
SYNC_SYNCED,
"local",
"cf-already-gone",
);
await expect(
dnsService.deleteRecord(db, cf, domain.id, local.id),
).resolves.toBeUndefined();
expect(repos.listDnsByDomain(db, domain.id)).toEqual([]);
});
it("updateConfig can remove extra FQDN when CF id is stale", async () => {
const db = setupDb();
const remote: CfRec[] = [
{
id: "cf-gt",
type: "A",
name: "gt.example.com",
content: "130.49.213.176",
ttl: 1,
proxied: false,
},
{
id: "cf-nsgt-live",
type: "A",
name: "nsgt.example.com",
content: "130.49.213.176",
ttl: 1,
proxied: false,
},
];
const deleted: string[] = [];
const cf = {
listDnsRecords: async () => [...remote],
createDnsRecord: async (
_zoneId: string,
payload: { type: string; name: string; content: string },
) => {
const rec: CfRec = {
id: `cf-new-${remote.length}`,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
};
remote.push(rec);
return rec;
},
updateDnsRecord: async (
_zoneId: string,
id: string,
payload: { type: string; name: string; content: string },
) => {
const idx = remote.findIndex((r) => r.id === id);
if (idx < 0) throw new Error("Record does not exist");
const rec: CfRec = {
id,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
};
remote[idx] = rec;
return rec;
},
deleteDnsRecord: async (_zoneId: string, id: string) => {
const idx = remote.findIndex((r) => r.id === id);
if (idx < 0) throw new Error("Record does not exist");
deleted.push(id);
remote.splice(idx, 1);
},
verifyToken: async () => true,
listZones: async () => [
{ id: "zone-1", name: "example.com", status: "active" },
],
} as unknown as CloudflareClient;
repos.createDomain(db, null, "example.com", "zone-1");
const service = repos.createService(db, "Main TG", "tg-pr");
repos.setServiceEnabled(db, service.id, true);
await updateConfig(db, cf, service.id, {
ips: ["130.49.213.176"],
domains: [
{ fqdn: "gt.example.com", target_ips: ["130.49.213.176"] },
{ fqdn: "nsgt.example.com", target_ips: ["130.49.213.176"] },
],
});
// Poison cached id on extra binding's DNS row.
const nsgtBinding = repos
.listBindingsByService(db, service.id)
.find((b) => b.hostname === "nsgt")!;
const nsgtRecords = repos.listRecordsForBinding(db, nsgtBinding.id);
for (const row of nsgtRecords) {
repos.updateDnsFields(
db,
row.id,
row.record_type,
row.name,
row.content,
row.ttl,
row.proxied,
row.priority,
SYNC_SYNCED,
"cf-stale-nsgt",
null,
);
}
const view = await updateConfig(db, cf, service.id, {
ips: ["130.49.213.176"],
domains: [{ fqdn: "gt.example.com", target_ips: ["130.49.213.176"] }],
});
expect(view.domains.map((d) => d.fqdn)).toEqual(["gt.example.com"]);
expect(deleted).toContain("cf-nsgt-live");
expect(remote.some((r) => r.name.includes("nsgt"))).toBe(false);
});
});
+224
View File
@@ -0,0 +1,224 @@
import { describe, expect, it } from "vitest";
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
import { SYNC_SYNCED } from "@cfdm/shared";
import type { CloudflareClient } from "../src/lib/cf-client.js";
import { updateConfig } from "../src/services/service-config-service.js";
type CfRec = {
id: string;
type: string;
name: string;
content: string;
ttl: number;
proxied: boolean;
};
function setupDb() {
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
return db;
}
describe("DNS reconcile for extra FQDN", () => {
it("publishes extra A even when local/CF already have CNAME on same name", async () => {
const db = setupDb();
const remote: CfRec[] = [
{
id: "cf-cname-nsgt",
type: "CNAME",
name: "nsgt.example.com",
content: "legacy.example.com",
ttl: 1,
proxied: false,
},
];
const created: Array<{ type: string; name: string; content: string }> = [];
const deleted: string[] = [];
const cf = {
listDnsRecords: async () => remote,
createDnsRecord: async (
_zoneId: string,
payload: { type: string; name: string; content: string },
) => {
created.push(payload);
const rec: CfRec = {
id: `cf-new-${created.length}`,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
};
remote.push(rec);
return rec;
},
updateDnsRecord: async (
_zoneId: string,
id: string,
payload: { type: string; name: string; content: string },
) => {
const idx = remote.findIndex((r) => r.id === id);
const rec: CfRec = {
id,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
};
if (idx >= 0) remote[idx] = rec;
else remote.push(rec);
return rec;
},
deleteDnsRecord: async (_zoneId: string, id: string) => {
deleted.push(id);
const idx = remote.findIndex((r) => r.id === id);
if (idx >= 0) remote.splice(idx, 1);
},
verifyToken: async () => true,
listZones: async () => [
{ id: "zone-1", name: "example.com", status: "active" },
],
} as unknown as CloudflareClient;
const domain = repos.createDomain(db, null, "example.com", "zone-1");
// Leftover local CNAME (previous service / manual import).
repos.insertDnsRecord(
db,
domain.id,
"CNAME",
"nsgt.example.com",
"legacy.example.com",
1,
false,
null,
SYNC_SYNCED,
"cloudflare",
"cf-cname-nsgt",
);
const service = repos.createService(db, "MSK Hip", "tg-msk-hip");
repos.setServiceEnabled(db, service.id, true);
const view = await updateConfig(db, cf, service.id, {
ips: ["130.49.213.176"],
domains: [
{
fqdn: "gt.example.com",
target_ips: ["130.49.213.176"],
},
{
fqdn: "nsgt.example.com",
target_ips: ["130.49.213.176"],
},
],
});
const nsgt = view.domains.find((d) => d.fqdn === "nsgt.example.com");
expect(nsgt?.record_type).toBe("A");
expect(nsgt?.target_ips).toEqual(["130.49.213.176"]);
expect(nsgt?.target_cname).toBeFalsy();
const binding = repos
.listBindingsByService(db, service.id)
.find((b) => b.hostname === "nsgt")!;
expect(repos.listBindingIps(db, binding.id)).toEqual(["130.49.213.176"]);
expect(deleted).toContain("cf-cname-nsgt");
expect(
created.some(
(r) =>
r.type === "A" &&
(r.name === "nsgt" || r.name === "nsgt.example.com") &&
r.content === "130.49.213.176",
),
).toBe(true);
expect(remote.some((r) => r.type === "CNAME" && r.name.includes("nsgt"))).toBe(
false,
);
expect(
remote.some(
(r) =>
r.type === "A" &&
r.name.includes("nsgt") &&
r.content === "130.49.213.176",
),
).toBe(true);
});
it("replaces stale A content for extra FQDN on the same hostname", async () => {
const db = setupDb();
const remote: CfRec[] = [
{
id: "cf-stale-a",
type: "A",
name: "nsgt.example.com",
content: "1.1.1.1",
ttl: 1,
proxied: false,
},
];
const deleted: string[] = [];
const created: Array<{ type: string; name: string; content: string }> = [];
const cf = {
listDnsRecords: async () => [...remote],
createDnsRecord: async (
_zoneId: string,
payload: { type: string; name: string; content: string },
) => {
created.push(payload);
const rec: CfRec = {
id: `cf-new-${created.length}`,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
};
remote.push(rec);
return rec;
},
updateDnsRecord: async (
_zoneId: string,
id: string,
payload: { type: string; name: string; content: string },
) => ({
id,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
}),
deleteDnsRecord: async (_zoneId: string, id: string) => {
deleted.push(id);
const idx = remote.findIndex((r) => r.id === id);
if (idx >= 0) remote.splice(idx, 1);
},
verifyToken: async () => true,
listZones: async () => [
{ id: "zone-1", name: "example.com", status: "active" },
],
} as unknown as CloudflareClient;
repos.createDomain(db, null, "example.com", "zone-1");
const service = repos.createService(db, "MSK", "msk");
repos.setServiceEnabled(db, service.id, true);
await updateConfig(db, cf, service.id, {
ips: ["130.49.213.176"],
domains: [
{ fqdn: "nsgt.example.com", target_ips: ["130.49.213.176"] },
],
});
expect(deleted).toContain("cf-stale-a");
expect(
created.some(
(r) => r.type === "A" && r.content === "130.49.213.176",
),
).toBe(true);
expect(remote.map((r) => r.content)).toEqual(["130.49.213.176"]);
});
});
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { failoverARecordDiff } from "../src/services/service-config-service.js";
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
const config = loadConfig();
const res = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: config.adminUsername, password: "admin" },
});
expect(res.statusCode).toBe(200);
const { token } = res.json() as { token: string };
return { authorization: `Bearer ${token}` };
}
describe("failoverARecordDiff", () => {
it("diffs added and removed A contents", () => {
expect(
failoverARecordDiff(
["130.49.213.153", "93.115.203.183"],
["93.115.203.183"],
),
).toEqual({
added: [],
removed: ["130.49.213.153"],
});
expect(failoverARecordDiff(["10.0.0.1"], ["10.0.0.1", "10.0.0.2"])).toEqual({
added: ["10.0.0.2"],
removed: [],
});
});
});
describe("GET /services/:id/failover-log", () => {
it("returns add/remove rows for the service", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const domain = repos.createDomain(app.db, null, "rkns.top", "zone-1");
const service = repos.createService(app.db, "MSK Hip", "msk-hip");
const binding = repos.insertBinding(app.db, domain.id, service.id, "gt", null);
repos.insertFailoverLog(app.db, {
serviceId: service.id,
bindingId: binding.id,
fqdn: "gt.rkns.top",
entries: [
{ ip: "130.49.213.153", action: "removed" },
{ ip: "93.115.203.183", action: "added" },
],
});
const res = await app.inject({
method: "GET",
url: `/api/v1/services/${service.id}/failover-log`,
headers,
});
expect(res.statusCode).toBe(200);
const body = res.json() as {
items: Array<{ ip: string; fqdn: string; action: string }>;
};
expect(body.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
ip: "130.49.213.153",
fqdn: "gt.rkns.top",
action: "removed",
}),
expect.objectContaining({
ip: "93.115.203.183",
fqdn: "gt.rkns.top",
action: "added",
}),
]),
);
await app.close();
});
});
describe("failover_log table", () => {
it("lists newest first", () => {
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "example.com", "zone-1");
const service = repos.createService(db, "Panel", "panel");
const binding = repos.insertBinding(db, domain.id, service.id, "panel", null);
repos.insertFailoverLog(db, {
serviceId: service.id,
bindingId: binding.id,
fqdn: "panel.example.com",
entries: [{ ip: "1.1.1.1", action: "removed" }],
});
repos.insertFailoverLog(db, {
serviceId: service.id,
bindingId: binding.id,
fqdn: "panel.example.com",
entries: [{ ip: "1.1.1.1", action: "added" }],
});
const rows = repos.listFailoverLogForService(db, service.id);
expect(rows.map((row) => row.action)).toEqual(["added", "removed"]);
});
});
+316
View File
@@ -235,4 +235,320 @@ describe("health-check state derivation via runAllChecks", () => {
expect(targets).toHaveLength(1);
expect(targets[0]?.verify_tls).toBe(true);
});
it("unwraps CNAME target to origin A record IPs", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
repos.insertDnsRecord(
db,
domain.id,
"A",
"ihome",
"2.59.161.102",
1,
false,
null,
"synced",
"cf",
null,
);
const service = repos.createService(db, "RW Sub", "rw-sub");
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
repos.updateBindingLbConfig(db, binding.id, {
health_check_enabled: true,
health_check_type: "tcp",
health_check_port: 443,
});
const targets = repos.listHealthCheckTargets(db);
expect(targets).toHaveLength(1);
expect(targets[0]?.ip).toBe("2.59.161.102");
expect(targets[0]?.hostname).toBe("s.rkns.top");
});
it("unwraps CNAME target to service IP pool when origin DNS is empty", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
const service = repos.createService(db, "RW Sub", "rw-sub");
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
repos.updateBindingLbConfig(db, binding.id, {
health_check_enabled: true,
health_check_type: "tcp",
health_check_port: 443,
});
const targets = repos.listHealthCheckTargets(db);
expect(targets).toHaveLength(1);
expect(targets[0]?.ip).toBe("2.59.161.102");
expect(targets[0]?.hostname).toBe("s.rkns.top");
});
it("does not mark node unhealthy when binding majority is OK and group local fails", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const tcp = await startTcpServer();
try {
const domain = repos.createDomain(db, null, "example.com", "zone-id");
const group = repos.createServiceGroup(
db,
"VPN",
"vpn",
null,
"vpn.example.com",
{
health_check_enabled: true,
health_check_type: "http",
health_check_port: 1,
health_check_timeout_ms: 200,
health_check_path: "/",
},
);
const service = repos.createService(db, "Svc", "svc");
repos.setServiceGroup(db, service.id, group.id);
repos.setServiceEnabled(db, service.id, true);
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: tcp.port,
health_check_timeout_ms: 500,
});
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: "127.0.0.1", weight: 1, priority: 1 },
]);
const node = repos.findNodeByIp(db, "127.0.0.1");
expect(node).not.toBeNull();
await healthCheckService.runAllChecks(db, {
probeGapMs: 0,
thresholds: {
degradedFailures: 1,
downFailures: 1,
latencyWarnMs: 1000,
},
});
const bindingHealth = repos.getIpHealthStatusRow(
db,
"binding",
binding.id,
"127.0.0.1",
);
const groupHealth = repos.getIpHealthStatusRow(
db,
"group",
group.id,
"127.0.0.1",
);
const after = repos.getNode(db, node!.id);
expect(bindingHealth?.status).toBe("up");
expect(groupHealth?.status).toBe("down");
expect(after.health_status).toBe("healthy");
expect(after.consecutive_failures).toBe(0);
expect(after.last_failure_reason).toBeNull();
} finally {
await new Promise<void>((resolve) => tcp.server.close(() => resolve()));
}
});
});
describe("CNAME health mapped onto service IPs", () => {
it("getView copies CNAME-keyed health onto the service IP row", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { getView } = await import("../src/services/service-config-service.js");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
const service = repos.createService(db, "RW Sub", "rw-sub");
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
repos.upsertIpHealthStatus(
db,
"binding",
binding.id,
"ihome.rkns.top",
"up",
12,
0,
null,
);
const view = await getView(db, service.id);
expect(view.health_status).toBe("up");
expect(view.ip_health).toEqual([
expect.objectContaining({
ip: "2.59.161.102",
status: "up",
latency_ms: 12,
}),
]);
});
it("getView is up when any binding IP is up", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { getView } = await import("../src/services/service-config-service.js");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
const service = repos.createService(db, "MSK Hip", "msk-hip");
repos.replaceServiceIps(db, service.id, ["10.0.0.1", "10.0.0.2"]);
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: "10.0.0.1", weight: 1, priority: 1 },
{ ip: "10.0.0.2", weight: 1, priority: 1 },
]);
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
repos.upsertIpHealthStatus(
db,
"binding",
binding.id,
"10.0.0.1",
"up",
12,
0,
null,
);
repos.upsertIpHealthStatus(
db,
"binding",
binding.id,
"10.0.0.2",
"down",
null,
5,
"timeout",
);
const view = await getView(db, service.id);
expect(view.health_status).toBe("up");
});
it("getView shows live OK over hysteresis unknown", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { getView } = await import("../src/services/service-config-service.js");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
const service = repos.createService(db, "RW Panel", "rw-panel");
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
const binding = repos.insertBinding(db, domain.id, service.id, "c", null);
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: "2.59.161.102", weight: 1, priority: 1 },
]);
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
repos.upsertIpHealthStatus(
db,
"binding",
binding.id,
"2.59.161.102",
"unknown",
63,
0,
null,
1,
);
repos.insertHealthProbeLog(db, {
scope: "binding",
refId: binding.id,
ip: "2.59.161.102",
provider: "local",
status: "up",
ok: true,
latencyMs: 63,
colo: null,
error: null,
});
const view = await getView(db, service.id);
expect(view.health_status).toBe("up");
expect(view.ip_health[0]?.status).toBe("up");
expect(view.ip_health[0]?.latency_ms).toBe(63);
});
it("getView masks stale down when health-check is disabled", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { getView } = await import("../src/services/service-config-service.js");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
const service = repos.createService(db, "Main TG", "main-tg");
repos.setServiceEnabled(db, service.id, true);
repos.replaceServiceIps(db, service.id, ["130.49.213.176"]);
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: "130.49.213.176", weight: 1, priority: 1 },
]);
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: false });
repos.upsertIpHealthStatus(
db,
"binding",
binding.id,
"130.49.213.176",
"down",
null,
5,
"timeout",
);
const view = await getView(db, service.id);
expect(view.health_status).toBe("unknown");
expect(view.ip_health).toEqual([
expect.objectContaining({
ip: "130.49.213.176",
status: "unknown",
latency_ms: null,
last_error: null,
}),
]);
});
it("getView masks stale down when IP is disabled in pool", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { getView } = await import("../src/services/service-config-service.js");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
const service = repos.createService(db, "Main TG", "main-tg");
repos.setServiceEnabled(db, service.id, true);
repos.replaceServiceIps(db, service.id, ["130.49.213.176"]);
repos.setServiceIpEnabled(db, service.id, "130.49.213.176", false);
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: "130.49.213.176", weight: 1, priority: 1 },
]);
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
repos.upsertIpHealthStatus(
db,
"binding",
binding.id,
"130.49.213.176",
"down",
null,
5,
"timeout",
);
const view = await getView(db, service.id);
expect(view.health_status).toBe("unknown");
expect(view.ip_health[0]?.status).toBe("unknown");
});
});
+200 -12
View File
@@ -1,9 +1,12 @@
import { describe, expect, it } from "vitest";
import {
resolveDesiredAIps,
selectActiveIpsByMode,
shouldRecordFailoverDnsDiff,
type LbIpRow,
type LbTargetConfig,
} from "../src/services/service-config-service.js";
import { WEIGHTED_SLOT_MS } from "../src/services/routing/weighted.js";
function row(
ip: string,
@@ -17,6 +20,11 @@ function row(
};
}
const weightedConfig: LbTargetConfig = {
lb_mode: "weighted",
health_check_enabled: true,
};
describe("selectActiveIpsByMode", () => {
it("round_robin returns all healthy ips, falls back to all if none healthy", () => {
const config: LbTargetConfig = {
@@ -62,6 +70,18 @@ describe("selectActiveIpsByMode", () => {
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
});
it("failover returns recovering unknown primary immediately", () => {
const config: LbTargetConfig = {
lb_mode: "failover",
health_check_enabled: true,
};
const rows = [
row("1.1.1.1", { priority: 1, health: "unknown" }),
row("2.2.2.2", { priority: 2, health: "up" }),
];
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",
@@ -75,23 +95,61 @@ describe("selectActiveIpsByMode", () => {
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,
};
it("weighted 1:3 picks the lighter ip on slot 0 and the heavier on slot 1", () => {
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" }),
row("1.1.1.1", { weight: 1, health: "up" }),
row("2.2.2.2", { weight: 3, health: "up" }),
];
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
"1.1.1.1",
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
expect(selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS)).toEqual([
"2.2.2.2",
]);
});
it("round_robin excludes unknown when another ip is up", () => {
it("weighted excludes down ips from the cycle", () => {
const rows = [
row("1.1.1.1", { weight: 1, health: "up" }),
row("2.2.2.2", { weight: 3, health: "down" }),
];
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
expect(
selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS),
).toEqual(["1.1.1.1"]);
});
it("weighted includes recovering unknown in the cycle", () => {
const rows = [
row("1.1.1.1", { weight: 1, health: "up" }),
row("2.2.2.2", { weight: 3, health: "unknown" }),
];
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
expect(
selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS),
).toEqual(["2.2.2.2"]);
});
it("weighted with one ip always returns that ip", () => {
expect(
selectActiveIpsByMode(weightedConfig, [row("1.1.1.1", { weight: 5 })], 0),
).toEqual(["1.1.1.1"]);
});
it("weighted with all unknown rotates across every ip", () => {
const rows = [
row("1.1.1.1", { weight: 1, health: "unknown" }),
row("2.2.2.2", { weight: 3, health: "unknown" }),
];
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
expect(selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS)).toEqual([
"2.2.2.2",
]);
});
it("weighted returns empty array for no rows", () => {
expect(selectActiveIpsByMode(weightedConfig, [], 0)).toEqual([]);
});
it("round_robin puts recovering unknown back with live ips", () => {
const config: LbTargetConfig = {
lb_mode: "round_robin",
health_check_enabled: true,
@@ -100,7 +158,25 @@ describe("selectActiveIpsByMode", () => {
row("1.1.1.1", { health: "up" }),
row("2.2.2.2", { health: "unknown" }),
];
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
"1.1.1.1",
"2.2.2.2",
]);
});
it("round_robin keeps degraded in the pool with live ips", () => {
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: "degraded" }),
];
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
"1.1.1.1",
"2.2.2.2",
]);
});
it("returns empty array for no rows", () => {
@@ -111,3 +187,115 @@ describe("selectActiveIpsByMode", () => {
expect(selectActiveIpsByMode(config, [])).toEqual([]);
});
});
describe("resolveDesiredAIps", () => {
it("keeps a dedicated single IP even when down", () => {
expect(
resolveDesiredAIps(
weightedConfig,
[row("1.1.1.1", { health: "down" })],
["1.1.1.1"],
),
).toEqual(["1.1.1.1"]);
});
it("does not drain when the service has only one unique IP", () => {
expect(
resolveDesiredAIps(
weightedConfig,
[row("1.1.1.1", { health: "down" })],
["1.1.1.1", "1.1.1.1"],
0,
["1.1.1.1"],
),
).toEqual(["1.1.1.1", "1.1.1.1"]);
});
it("does not apply overlay when service pool is a single IP", () => {
const rows = [
row("1.1.1.1", { health: "up" }),
row("2.2.2.2", { health: "down" }),
];
expect(
resolveDesiredAIps(
weightedConfig,
rows,
["1.1.1.1", "2.2.2.2"],
0,
["1.1.1.1"],
),
).toEqual(["1.1.1.1", "2.2.2.2"]);
});
it("applies weighted overlay on a shared pool", () => {
const rows = [
row("1.1.1.1", { weight: 1, health: "up" }),
row("2.2.2.2", { weight: 3, health: "up" }),
];
expect(
resolveDesiredAIps(weightedConfig, rows, ["1.1.1.1", "2.2.2.2"], 0),
).toEqual(selectActiveIpsByMode(weightedConfig, rows, 0));
});
});
describe("shouldRecordFailoverDnsDiff", () => {
it("skips duplicate listings of the same IP", () => {
expect(
shouldRecordFailoverDnsDiff({
configuredIps: ["1.1.1.1", "1.1.1.1"],
lbMode: "failover",
added: [],
removed: ["1.1.1.1"],
downIps: new Set(["1.1.1.1"]),
}),
).toBe(false);
});
it("skips dedicated extra-FQDN", () => {
expect(
shouldRecordFailoverDnsDiff({
configuredIps: ["1.1.1.1"],
lbMode: "failover",
added: [],
removed: ["1.1.1.1"],
downIps: new Set(["1.1.1.1"]),
}),
).toBe(false);
});
it("skips weighted live-to-live slot swap", () => {
expect(
shouldRecordFailoverDnsDiff({
configuredIps: ["1.1.1.1", "2.2.2.2"],
lbMode: "weighted",
added: ["2.2.2.2"],
removed: ["1.1.1.1"],
downIps: new Set(),
}),
).toBe(false);
});
it("logs weighted swap when a down ip leaves the pool", () => {
expect(
shouldRecordFailoverDnsDiff({
configuredIps: ["1.1.1.1", "2.2.2.2"],
lbMode: "weighted",
added: ["2.2.2.2"],
removed: ["1.1.1.1"],
downIps: new Set(["1.1.1.1"]),
}),
).toBe(true);
});
it("logs failover diffs on a shared pool", () => {
expect(
shouldRecordFailoverDnsDiff({
configuredIps: ["1.1.1.1", "2.2.2.2"],
lbMode: "failover",
added: ["2.2.2.2"],
removed: ["1.1.1.1"],
downIps: new Set(["1.1.1.1"]),
}),
).toBe(true);
});
});
@@ -78,4 +78,53 @@ describe("service bindings prune", () => {
expect(bindings).toHaveLength(2);
expect(bindings.map((b) => b.hostname).sort()).toEqual(["api", "www"]);
});
it("updateConfig with extra FQDN per IP does not throw when group health-check is on", async () => {
const db = setupDb();
const cf = mockCf();
const health = {
health_check_enabled: true,
health_check_type: "tcp" as const,
health_check_port: 443,
health_check_providers: ["local", "cloudflare", "globalping"] as const,
health_check_aggregate: "majority" as const,
};
repos.createDomain(db, null, "example.com", "cf-zone-example");
const group = repos.createServiceGroup(
db,
"VPN",
"vpn",
null,
"vpn.example.com",
{ ...health },
);
const service = repos.createService(db, "GT", "gt");
repos.setServiceGroup(db, service.id, group.id);
repos.setServiceEnabled(db, service.id, true);
const view = await updateConfig(db, cf, service.id, {
ips: ["93.115.203.183", "130.49.213.153"],
domains: [
{
fqdn: "gt.example.com",
target_ips: ["93.115.203.183", "130.49.213.153"],
...health,
},
{
fqdn: "rutg.example.com",
target_ips: ["93.115.203.183"],
...health,
},
{
fqdn: "nsgt.example.com",
target_ips: ["130.49.213.153"],
...health,
},
],
});
expect(view.domains).toHaveLength(3);
expect(view.ips.sort()).toEqual(["130.49.213.153", "93.115.203.183"]);
});
});
+276 -29
View File
@@ -1,11 +1,12 @@
import { describe, expect, it } from "vitest";
import { serviceGroupsResponseSchema } from "@cfdm/shared";
import { serviceGroupsResponseSchema, updateServiceConfigSchema } from "@cfdm/shared";
import { repos } from "@cfdm/db";
import type { CloudflareClient } from "../src/lib/cf-client.js";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import {
listGroupViews,
toggleServiceIp,
updateConfig,
} from "../src/services/service-config-service.js";
@@ -51,6 +52,26 @@ async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
}
describe("create service then list groups", () => {
it("accepts sqlite-shaped health fields on service config PATCH", () => {
const parsed = updateServiceConfigSchema.parse({
domains: [
{
fqdn: "gw.example.com",
target_ips: ["1.2.3.4"],
health_check_enabled: 1,
health_check_verify_tls: 0,
health_check_providers: '["local","cloudflare"]',
health_check_aggregate: "majority",
},
],
});
expect(parsed.domains?.[0]?.health_check_enabled).toBe(true);
expect(parsed.domains?.[0]?.health_check_verify_tls).toBe(false);
expect(parsed.domains?.[0]?.health_check_providers).toEqual([
"local",
"cloudflare",
]);
});
it("create + updateConfig then listGroupViews parses with shared Zod schema", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
@@ -133,6 +154,69 @@ describe("create service then list groups", () => {
await app.close();
});
it("PATCH /services/:id persists health providers and aggregate", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const cf = mockCf();
repos.createDomain(app.db, null, "example.com", "zone-1");
const createRes = await app.inject({
method: "POST",
url: "/api/v1/services",
headers,
payload: { name: "GW", slug: "gw" },
});
expect(createRes.statusCode).toBe(200);
const created = createRes.json() as { id: number };
await updateConfig(app.db, cf, created.id, {
ips: ["1.2.3.4"],
domains: [
{
fqdn: "gw.example.com",
target_ips: ["1.2.3.4"],
health_check_enabled: true,
health_check_type: "tcp",
health_check_interval_sec: 30,
health_check_timeout_ms: 3000,
health_check_providers: ["local", "cloudflare"],
health_check_aggregate: "majority",
},
],
});
const stored = repos.listBindingsByService(app.db, created.id)[0]!;
expect(stored.health_check_enabled).toBe(true);
expect(Array.isArray(stored.health_check_providers)).toBe(true);
expect(stored.health_check_providers).toEqual(["local", "cloudflare"]);
expect(stored.health_check_aggregate).toBe("majority");
const getRes = await app.inject({
method: "GET",
url: `/api/v1/services/${created.id}`,
headers,
});
expect(getRes.statusCode).toBe(200);
const view = getRes.json() as {
domains: Array<{
health_check_enabled: boolean;
health_check_providers: string[];
health_check_aggregate: string;
}>;
};
expect(view.domains[0]?.health_check_enabled).toBe(true);
expect(view.domains[0]?.health_check_providers).toEqual([
"local",
"cloudflare",
]);
expect(view.domains[0]?.health_check_aggregate).toBe("majority");
await app.close();
});
it("POST /services returns resolved ServiceView with numeric id", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
@@ -167,7 +251,7 @@ describe("create service then list groups", () => {
await app.close();
});
it("PATCH /services/:id/ips/toggle keeps IP in pool and removes it from A-binding", async () => {
it("PATCH /services/:id/ips/toggle keeps IP in pool and in A-binding", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
@@ -197,6 +281,18 @@ describe("create service then list groups", () => {
expect(createRes.statusCode).toBe(200);
const created = createRes.json() as { id: number };
const domainPayload = {
lb_mode: "round_robin" as const,
health_check_enabled: false,
health_check_type: "tcp" as const,
health_check_port: 443,
health_check_path: null,
health_check_expected_status: null,
health_check_interval_sec: 30,
health_check_timeout_ms: 3000,
health_check_verify_tls: false,
};
await updateConfig(app.db, cf, created.id, {
ips: ["1.2.3.4", "5.6.7.8"],
service_group_id: group.id,
@@ -206,23 +302,81 @@ describe("create service then list groups", () => {
target_ips: ["1.2.3.4", "5.6.7.8"],
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1 },
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1 },
lb_mode: "round_robin",
health_check_enabled: false,
health_check_type: "tcp",
health_check_port: 443,
health_check_path: null,
health_check_expected_status: null,
health_check_interval_sec: 30,
health_check_timeout_ms: 3000,
health_check_verify_tls: false,
...domainPayload,
},
{
fqdn: "extra.example.com",
target_ips: ["1.2.3.4"],
target_ip_weights: { "1.2.3.4": 1 },
target_ip_priorities: { "1.2.3.4": 1 },
...domainPayload,
},
],
});
// HTTP toggle uses request.server.cf; disable DNS push so the test
// does not call the real Cloudflare client.
repos.setServiceEnabled(app.db, created.id, false);
const commonBinding = repos
.listBindingsByService(app.db, created.id)
.find((b) => b.hostname === "panel")!;
const extraBinding = repos
.listBindingsByService(app.db, created.id)
.find((b) => b.hostname === "extra")!;
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
);
expect(
repos
.listRecordsForBinding(app.db, commonBinding.id)
.map((r) => r.content)
.sort(),
).toEqual(["1.2.3.4", "5.6.7.8"]);
// Direct service call with mock CF — keep HTTP path free of real Cloudflare.
await toggleServiceIp(app.db, cf, created.id, "1.2.3.4", false);
expect(repos.listServiceIps(app.db, created.id)).toEqual(
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
);
expect(
repos.listServiceIpRows(app.db, created.id).find((r) => r.ip === "1.2.3.4")
?.enabled,
).toBe(false);
// Common + per-IP bindings keep configured IPs (UI hydrate stays stable).
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
);
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
// DNS for common FQDN drops the disabled IP only.
expect(
repos
.listRecordsForBinding(app.db, commonBinding.id)
.map((r) => r.content)
.sort(),
).toEqual(["5.6.7.8"]);
// Per-IP extra FQDN has no enabled targets → A records removed.
expect(repos.listRecordsForBinding(app.db, extraBinding.id)).toEqual([]);
await toggleServiceIp(app.db, cf, created.id, "1.2.3.4", true);
expect(
repos.listServiceIpRows(app.db, created.id).find((r) => r.ip === "1.2.3.4")
?.enabled,
).toBe(true);
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
);
expect(
repos
.listRecordsForBinding(app.db, commonBinding.id)
.map((r) => r.content)
.sort(),
).toEqual(["1.2.3.4", "5.6.7.8"]);
expect(
repos
.listRecordsForBinding(app.db, extraBinding.id)
.map((r) => r.content),
).toEqual(["1.2.3.4"]);
// HTTP toggle still updates ip_enabled without mutating bindings.
repos.setServiceEnabled(app.db, created.id, false);
const offRes = await app.inject({
method: "PATCH",
url: `/api/v1/services/${created.id}/ips/toggle`,
@@ -236,24 +390,117 @@ describe("create service then list groups", () => {
};
expect(offView.ips).toEqual(expect.arrayContaining(["1.2.3.4", "5.6.7.8"]));
expect(offView.ip_enabled["1.2.3.4"]).toBe(false);
expect(offView.ip_enabled["5.6.7.8"]).toBe(true);
const binding = repos.listBindingsByService(app.db, created.id)[0]!;
expect(repos.listBindingIps(app.db, binding.id)).toEqual(["5.6.7.8"]);
const onRes = await app.inject({
method: "PATCH",
url: `/api/v1/services/${created.id}/ips/toggle`,
headers,
payload: { ip: "1.2.3.4", enabled: true },
});
expect(onRes.statusCode).toBe(200);
const onView = onRes.json() as { ip_enabled: Record<string, boolean> };
expect(onView.ip_enabled["1.2.3.4"]).toBe(true);
expect(repos.listBindingIps(app.db, binding.id)).toEqual(
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
);
await app.close();
});
it("GET /services/:id repairs multi-IP bindings shrunk below the pool", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const cf = mockCf();
repos.createDomain(app.db, null, "example.com", "zone-1");
const group = repos.createServiceGroup(
app.db,
"VPN",
"vpn-repair",
null,
"vpn.example.com",
);
const createRes = await app.inject({
method: "POST",
url: "/api/v1/services",
headers,
payload: {
name: "Repair",
slug: "panel-ip-repair",
service_group_id: group.id,
},
});
expect(createRes.statusCode).toBe(200);
const created = createRes.json() as { id: number };
await updateConfig(app.db, cf, created.id, {
ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
service_group_id: group.id,
domains: [
{
fqdn: "gw.example.com",
target_ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 },
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 },
lb_mode: "round_robin",
health_check_enabled: false,
health_check_type: "tcp",
health_check_port: 443,
health_check_path: null,
health_check_expected_status: null,
health_check_interval_sec: 30,
health_check_timeout_ms: 3000,
health_check_verify_tls: false,
},
{
fqdn: "extra.example.com",
target_ips: ["1.2.3.4"],
target_ip_weights: { "1.2.3.4": 1 },
target_ip_priorities: { "1.2.3.4": 1 },
lb_mode: "round_robin",
health_check_enabled: false,
health_check_type: "tcp",
health_check_port: 443,
health_check_path: null,
health_check_expected_status: null,
health_check_interval_sec: 30,
health_check_timeout_ms: 3000,
health_check_verify_tls: false,
},
],
});
const commonBinding = repos
.listBindingsByService(app.db, created.id)
.find((b) => b.hostname === "gw")!;
const extraBinding = repos
.listBindingsByService(app.db, created.id)
.find((b) => b.hostname === "extra")!;
// Simulate legacy toggle damage: shrink common binding, leave extra alone.
repos.replaceBindingIpsWithMeta(app.db, commonBinding.id, [
{ ip: "1.2.3.4", weight: 1, priority: 1 },
{ ip: "5.6.7.8", weight: 1, priority: 1 },
]);
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual([
"1.2.3.4",
"5.6.7.8",
]);
const getRes = await app.inject({
method: "GET",
url: `/api/v1/services/${created.id}`,
headers,
});
expect(getRes.statusCode).toBe(200);
const view = getRes.json() as {
domains: Array<{ fqdn: string; target_ips: string[] }>;
};
const gw = view.domains.find((d) => d.fqdn === "gw.example.com");
const extra = view.domains.find((d) => d.fqdn === "extra.example.com");
expect(gw?.target_ips.sort()).toEqual(["1.2.3.4", "5.6.7.8", "9.9.9.9"]);
expect(extra?.target_ips).toEqual(["1.2.3.4"]);
expect(repos.listBindingIps(app.db, commonBinding.id).sort()).toEqual([
"1.2.3.4",
"5.6.7.8",
"9.9.9.9",
]);
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
await app.close();
});
});
+77 -2
View File
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import type { ServiceBindingView } from "@cfdm/shared";
import { resolveBindingIpsForSync } from "../src/services/vps-tracker-sync.js";
import {
cfdmBindingSyncItemSchema,
type ServiceBindingView,
} from "@cfdm/shared";
import {
resolveBindingIpsForSync,
resolveLbModeForSync,
effectiveLbModeForSync,
} from "../src/services/vps-tracker-sync.js";
function binding(
partial: Partial<ServiceBindingView> &
@@ -160,3 +167,71 @@ describe("resolveBindingIpsForSync", () => {
expect(ips).toEqual(["203.0.113.55"]);
});
});
describe("resolveLbModeForSync", () => {
it("prefers binding lb_mode", () => {
expect(resolveLbModeForSync("failover", "round_robin")).toBe("failover");
});
it("falls back to service group lb_mode", () => {
expect(resolveLbModeForSync("off", "weighted")).toBe("weighted");
expect(resolveLbModeForSync(undefined, "round_robin")).toBe("round_robin");
});
it("returns undefined when neither is a known mode", () => {
expect(resolveLbModeForSync("off", "off")).toBeUndefined();
expect(resolveLbModeForSync(null, undefined)).toBeUndefined();
});
});
describe("effectiveLbModeForSync", () => {
it("omits mode when unique origin IPs are below two", () => {
expect(
effectiveLbModeForSync("round_robin", "failover", ["203.0.113.10"]),
).toBeUndefined();
expect(
effectiveLbModeForSync("round_robin", "failover", [
"203.0.113.10",
"203.0.113.10",
]),
).toBeUndefined();
});
it("emits configured mode when the service has a pool", () => {
expect(
effectiveLbModeForSync("failover", "round_robin", [
"203.0.113.10",
"203.0.113.20",
]),
).toBe("failover");
expect(
effectiveLbModeForSync("off", "weighted", [
"203.0.113.10",
"198.51.100.1",
]),
).toBe("weighted");
});
});
describe("cfdmBindingSyncItemSchema lbMode", () => {
const base = {
bindingId: 1,
serviceId: 10,
serviceName: "VPN",
serviceSlug: "vpn",
fqdn: "vpn.example.com",
zoneName: "example.com",
hostname: "vpn",
ips: ["203.0.113.10"],
};
it("accepts optional lbMode on sync payload", () => {
const parsed = cfdmBindingSyncItemSchema.parse({ ...base, lbMode: "failover" });
expect(parsed.lbMode).toBe("failover");
});
it("accepts payload without lbMode (legacy)", () => {
const parsed = cfdmBindingSyncItemSchema.parse(base);
expect(parsed.lbMode).toBeUndefined();
});
});
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { GlobeIcon, SearchIcon } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { GlobeIcon, SearchIcon, ServerIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
@@ -9,6 +10,7 @@ import { StatusBadge } from '@/components/status-badge'
import { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils'
import type { Certificate } from '@/lib/schemas'
import { formatDate, formatRelative } from '@/lib/format'
import { Button } from '@cfdm/ui/components/button'
export const CERT_TABS = [
{ id: 'all', label: 'Все' },
@@ -41,6 +43,7 @@ export function certTabFilter(item: Certificate, tabId: string) {
export function createDefaultCertFilters() {
return [
createFilter('hostname', 'contains', ['']),
createFilter('service', 'contains', ['']),
createFilter('status', 'is', ['']),
]
}
@@ -56,6 +59,14 @@ export function useCertFilterFields() {
className: 'w-52',
placeholder: 'Поиск по хосту…',
},
{
key: 'service',
label: 'Сервис',
icon: <ServerIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'Поиск по сервису…',
},
{
key: 'status',
label: 'Статус',
@@ -75,6 +86,8 @@ export function certFilterFieldValue(item: Certificate, field: string) {
switch (field) {
case 'hostname':
return `${item.hostname} ${item.status}`.toLowerCase()
case 'service':
return (item.service_name ?? '').toLowerCase()
case 'status':
return item.status
default:
@@ -82,7 +95,7 @@ export function certFilterFieldValue(item: Certificate, field: string) {
}
}
function certRelativeBadge(status: string, expiresAt: string | null) {
export function certRelativeBadge(status: string, expiresAt: string | null) {
const relative = formatRelative(expiresAt)
if (!expiresAt) {
return <span className="text-muted-foreground tabular-nums"></span>
@@ -112,6 +125,39 @@ export function useCertificateColumns() {
<span className="truncate font-medium">{row.original.hostname}</span>
),
},
{
id: 'service',
accessorFn: (row) => row.service_name ?? '',
header: ({ column }) => (
<DataGridColumnHeader
column={column}
title="Сервис"
icon={<ServerIcon className="size-3.5" />}
/>
),
cell: ({ row }) => {
const serviceId = row.original.service_id
const name = row.original.service_name
if (serviceId == null || !name) {
return <span className="text-muted-foreground"></span>
}
return (
<Button
variant="link"
className="h-auto max-w-full truncate p-0 font-medium"
nativeButton={false}
render={
<Link
to="/services/$serviceId"
params={{ serviceId: String(serviceId) }}
/>
}
>
{name}
</Button>
)
},
},
{
id: 'status',
header: 'Статус',
+142 -21
View File
@@ -1,40 +1,161 @@
import { ShieldCheckIcon } from 'lucide-react'
import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { EmptyState } from '@/components/empty-state'
import { formatDate, formatRelative, sqliteUtcToIso } from '@/lib/format'
import {
failoverEventCopy,
type FailoverEvent,
type FailoverHistoryItem,
} from '@/lib/failover-events'
import { cn } from '@cfdm/ui/lib/utils'
import type { ComponentProps } from 'react'
export interface FailoverEvent {
id: string
title: string
detail: string
type HealthBadgeStatus = ComponentProps<typeof HealthCheckBadge>['status']
function failStreakLabel(count: number): string {
const mod10 = count % 10
const mod100 = count % 100
if (mod10 === 1 && mod100 !== 11) return `${count} ошибка подряд`
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
return `${count} ошибки подряд`
}
return `${count} ошибок подряд`
}
export function FailoverTimeline({ events }: { events: FailoverEvent[] }) {
if (events.length === 0) {
function indicatorClass(tone: 'down' | 'added' | 'removed'): string {
if (tone === 'added') {
return 'border-success bg-success/15 group-data-completed/timeline-item:border-success'
}
return 'border-destructive bg-destructive/15 group-data-completed/timeline-item:border-destructive'
}
function separatorClass(tone: 'down' | 'added' | 'removed'): string {
if (tone === 'added') return 'bg-success/25'
return 'bg-destructive/25'
}
/**
* Failover как sibling «Смены статуса»: ReUI Timeline + Badge.
* Preview: https://reui.io/preview/base/components/c-timeline-10
* Preview: https://reui.io/preview/base/empty-state-12
* Docs: https://reui.io/docs/components/base/timeline
* Docs: https://reui.io/docs/components/base/badge
*/
export function FailoverTimeline({
events,
history = [],
}: {
events: FailoverEvent[]
history?: readonly FailoverHistoryItem[]
}) {
if (events.length === 0 && history.length === 0) {
return (
<p className="text-muted-foreground text-sm">
Событий failover пока нет.
</p>
<EmptyState
icon={ShieldCheckIcon}
title="Нет инцидентов"
description="Нет Down и нет выходов из общего пула"
stackedIcon={false}
centered={false}
/>
)
}
return (
<Timeline defaultValue={events.length} className="w-full">
{events.map((event, index) => (
<TimelineItem key={event.id} step={index + 1}>
<TimelineSeparator />
<TimelineIndicator />
<TimelineHeader>
<TimelineTitle>{event.title}</TimelineTitle>
</TimelineHeader>
<TimelineContent>{event.detail}</TimelineContent>
</TimelineItem>
))}
</Timeline>
<div className="flex flex-col gap-4">
{events.length > 0 ? (
<Timeline defaultValue={0} className="gap-0">
{events.map((event, index) => {
const checkedIso = event.lastCheckAt
? (sqliteUtcToIso(event.lastCheckAt) ?? event.lastCheckAt)
: null
return (
<TimelineItem key={event.id} step={index + 1}>
<TimelineSeparator className={separatorClass('down')} />
<TimelineIndicator className={indicatorClass('down')} />
<TimelineHeader>
<TimelineTitle className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm">{event.address}</span>
<HealthCheckBadge
status={event.status as HealthBadgeStatus}
lastError={event.lastFailureReason}
lastCheckedAt={checkedIso}
size="xs"
/>
</TimelineTitle>
<TimelineDate>
{event.consecutiveFailures > 0
? failStreakLabel(event.consecutiveFailures)
: null}
{checkedIso
? `${event.consecutiveFailures > 0 ? ' · ' : ''}${formatRelative(checkedIso)} · ${formatDate(checkedIso)}`
: null}
</TimelineDate>
</TimelineHeader>
<TimelineContent className="flex flex-col gap-2">
<p className="text-foreground text-sm">
{failoverEventCopy(event)}
</p>
{event.lastFailureReason ? (
<code
className={cn(
'bg-muted block overflow-x-auto rounded-md px-2 py-1.5 font-mono text-xs',
)}
>
{event.lastFailureReason}
</code>
) : null}
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
) : null}
{history.length > 0 ? (
<Timeline defaultValue={0} className="gap-0">
{history.map((item, index) => {
const checkedIso = sqliteUtcToIso(item.created_at) ?? item.created_at
const tone = item.action === 'added' ? 'added' : 'removed'
return (
<TimelineItem key={item.id} step={index + 1}>
<TimelineSeparator className={separatorClass(tone)} />
<TimelineIndicator className={indicatorClass(tone)} />
<TimelineHeader>
<TimelineTitle className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm">{item.ip}</span>
<HealthCheckBadge
status={item.action === 'added' ? 'up' : 'down'}
size="xs"
/>
<span className="text-muted-foreground text-xs">
{item.fqdn}
</span>
</TimelineTitle>
<TimelineDate>
{formatRelative(checkedIso)} · {formatDate(checkedIso)}
</TimelineDate>
</TimelineHeader>
<TimelineContent>
<p className="text-foreground text-sm">{item.copy}</p>
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
) : null}
</div>
)
}
@@ -16,8 +16,8 @@ type HealthStatus =
function normalizeHealth(status: HealthStatus): IpHealthStatus['status'] {
if (status === 'healthy') return 'up'
if (status === 'unhealthy' || status === 'disabled') return 'down'
if (status === 'checking') return 'unknown'
if (status === 'unhealthy') return 'down'
if (status === 'disabled' || status === 'checking') return 'unknown'
return status
}
@@ -1,6 +1,7 @@
import { FormFieldSimple } from '@/components/form-field'
import { AppInput } from '@/components/app-input'
import { SettingRow } from '@/components/setting-row'
import { SelectField } from '@/components/select-field'
import { Badge } from '@/components/reui/badge'
import {
NumberField,
@@ -9,15 +10,8 @@ import {
NumberFieldIncrement,
NumberFieldInput,
} from '@/components/reui/number-field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Switch } from '@cfdm/ui/components/switch'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
import { Button } from '@cfdm/ui/components/button'
import { ButtonGroup } from '@cfdm/ui/components/button-group'
import { CableIcon, GlobeIcon } from 'lucide-react'
@@ -28,7 +22,7 @@ import {
type HealthAggregate,
type HealthProvider,
} from '@/components/reui-kit/health-source-tiles'
import { uniqueHealthProviders } from '@cfdm/shared'
import { parseHealthProviders } from '@cfdm/shared'
export type LbMode = 'round_robin' | 'failover' | 'weighted'
export type HealthCheckType = 'tcp' | 'http'
@@ -59,9 +53,14 @@ export interface LbAndHealthConfig extends HealthCheckConfig {
const defaultLbModeOptions = [
{ value: 'round_robin', label: 'Round Robin' },
{ value: 'failover', label: 'Failover (приоритет)' },
{ value: 'weighted', label: 'Weighted (веса)' },
{ value: 'weighted', label: 'Веса (подмена IP)' },
]
export interface LbPoolMetaChange {
weight?: number
priority?: number
}
function CompactNumberField({
id,
value,
@@ -95,6 +94,89 @@ function CompactNumberField({
)
}
function PoolLbMetaFields({
idPrefix,
mode,
ips,
weights,
priorities,
onMetaChange,
}: {
idPrefix: string
mode: Exclude<LbMode, 'round_robin'>
ips: readonly string[]
weights: Record<string, number>
priorities: Record<string, number>
onMetaChange?: (ip: string, meta: LbPoolMetaChange) => void
}) {
const isWeighted = mode === 'weighted'
const minPriority =
ips.length === 0
? 1
: Math.min(...ips.map((ip) => priorities[ip] ?? 1))
return (
<SettingRow
title={isWeighted ? 'Вес IP' : 'Приоритет IP'}
description={
isWeighted
? 'Доля времени на общем FQDN: 1 и 3 = ¼ и ¾ цикла (слот 60 с)'
: '1 — основной, больше — запасной'
}
compact
stacked
className="gap-3 px-0 py-3"
contentClassName="min-w-0"
>
{ips.length === 0 ? (
<p className="text-muted-foreground text-sm">Сначала добавьте IP выше</p>
) : (
<div className="flex w-full flex-col gap-2">
{ips.map((ip) => {
const isPrimary = (priorities[ip] ?? 1) === minPriority
const fieldId = isWeighted
? `${idPrefix}-weight-${ip}`
: `${idPrefix}-priority-${ip}`
return (
<div key={ip} className="flex items-center gap-3">
<span className="min-w-0 flex-1 truncate font-mono text-sm">{ip}</span>
{!isWeighted ? (
<Badge
variant={isPrimary ? 'success-light' : 'outline'}
size="xs"
radius="full"
>
{isPrimary ? 'Основной' : 'Запасной'}
</Badge>
) : null}
<Field className="w-28 gap-0">
<FieldLabel htmlFor={fieldId} className="sr-only">
{isWeighted ? `Вес ${ip}` : `Приоритет ${ip}`}
</FieldLabel>
<CompactNumberField
id={fieldId}
value={isWeighted ? (weights[ip] ?? 1) : (priorities[ip] ?? 1)}
min={1}
max={100}
onValueChange={(next) =>
onMetaChange?.(
ip,
isWeighted
? { weight: next ?? 1 }
: { priority: next ?? 1 },
)
}
/>
</Field>
</div>
)
})}
</div>
)}
</SettingRow>
)
}
export function HealthCheckConfigFields({
value,
onChange,
@@ -102,6 +184,10 @@ export function HealthCheckConfigFields({
lbModeOptions = defaultLbModeOptions,
idPrefix = 'health',
showLbMode = true,
ips = [],
weights = {},
priorities = {},
onMetaChange,
className,
}: {
value: LbAndHealthConfig
@@ -110,16 +196,20 @@ export function HealthCheckConfigFields({
lbModeOptions?: { value: string; label: string }[]
idPrefix?: string
showLbMode?: boolean
ips?: readonly string[]
weights?: Record<string, number>
priorities?: Record<string, number>
onMetaChange?: (ip: string, meta: LbPoolMetaChange) => void
className?: string
}) {
function patch(next: Partial<LbAndHealthConfig>) {
onChange({ ...value, ...next })
}
const providers =
value.providers?.length > 0
? uniqueHealthProviders(value.providers)
: uniqueHealthProviders([value.provider ?? 'local'])
const providers = parseHealthProviders(
value.providers,
value.provider ?? 'local',
)
const aggregate = value.aggregate ?? 'majority'
const isHttp = value.type === 'http'
const rowClass = 'gap-3 px-0 py-3'
@@ -134,25 +224,28 @@ export function HealthCheckConfigFields({
compact
className={rowClass}
>
<Select
<SelectField
modal={false}
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>
triggerId={`${idPrefix}-lb-mode`}
placeholder="Выберите режим"
options={lbModeOptions}
/>
</SettingRow>
) : null}
{showLbMode && value.lb_mode !== 'round_robin' ? (
<PoolLbMetaFields
idPrefix={idPrefix}
mode={value.lb_mode}
ips={ips}
weights={weights}
priorities={priorities}
onMetaChange={onMetaChange}
/>
) : null}
<SettingRow
title="Провайдер health-check"
description="Кто пробирует цель. Можно выбрать несколько источников."
+10 -77
View File
@@ -1,5 +1,5 @@
import { Fragment, useMemo } from 'react'
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
import { useMemo } from 'react'
import {
Breadcrumb,
BreadcrumbItem,
@@ -12,82 +12,12 @@ import { Separator } from '@cfdm/ui/components/separator'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
import { getBreadcrumbs } from '@/lib/breadcrumbs'
export interface RouteBreadcrumbLoaderData {
breadcrumb?: string
}
const routeTitles: Record<string, string> = {
'/': 'Панель управления',
'/domains': 'Домены',
'/groups': 'Группы доменов',
'/services': 'Сервисы',
'/certificates': 'Сертификаты',
'/settings/appearance': 'Внешний вид',
'/settings/health': 'Health-check',
'/settings/integrations': 'Интеграции',
}
function getBreadcrumbs(
pathname: string,
dynamicLabels: Record<string, string>,
) {
if (pathname === '/') {
return [{ label: 'Панель управления', href: '/' }]
}
if (pathname.match(/^\/services\/\d+$/)) {
return [
{ label: 'Сервисы', href: '/services' },
{ label: dynamicLabels[pathname] ?? 'Сервис', href: pathname },
]
}
if (pathname.match(/^\/groups\/\d+$/)) {
return [
{ label: 'Группы доменов', href: '/groups' },
{ label: dynamicLabels[pathname] ?? 'Группа', href: pathname },
]
}
if (pathname.match(/^\/domains\/\d+\/dns$/)) {
const domainId = pathname.split('/')[2]
const domainPath = `/domains/${domainId}`
return [
{ label: 'Домены', href: '/domains' },
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
{ label: 'DNS', href: pathname },
]
}
if (pathname.match(/^\/domains\/\d+$/)) {
return [
{ label: 'Домены', href: '/domains' },
{ label: dynamicLabels[pathname] ?? 'Обзор домена', href: pathname },
]
}
if (pathname.startsWith('/settings')) {
return [
{ label: 'Настройки', href: '/settings/appearance' },
...(pathname === '/settings/integrations'
? [{ label: 'Интеграции', href: pathname }]
: pathname === '/settings/health'
? [{ label: 'Health-check', href: pathname }]
: pathname === '/settings/appearance'
? [{ label: 'Внешний вид', href: pathname }]
: []),
]
}
const title = routeTitles[pathname]
if (title) {
return [{ label: title, href: pathname }]
}
return [{ label: 'Панель управления', href: '/' }]
}
function useDynamicBreadcrumbLabels() {
const matches = useMatches()
return useMemo(() => {
@@ -106,7 +36,10 @@ function useDynamicBreadcrumbLabels() {
export function SiteHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const dynamicLabels = useDynamicBreadcrumbLabels()
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
const crumbs = useMemo(
() => getBreadcrumbs(pathname, dynamicLabels),
[pathname, dynamicLabels],
)
return (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
@@ -117,10 +50,10 @@ export function SiteHeader() {
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
return (
<span key={crumb.href} className="contents">
{index > 0 && (
<Fragment key={`${index}-${crumb.href}`}>
{index > 0 ? (
<BreadcrumbSeparator className="hidden md:block" />
)}
) : null}
<BreadcrumbItem
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
>
@@ -132,7 +65,7 @@ export function SiteHeader() {
</BreadcrumbLink>
)}
</BreadcrumbItem>
</span>
</Fragment>
)
})}
</BreadcrumbList>
@@ -14,7 +14,11 @@ import {
} from '@cfdm/ui/components/item'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { cn } from '@cfdm/ui/lib/utils'
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
import {
ToggleGroup,
ToggleGroupItem,
} from '@cfdm/ui/components/toggle-group'
import { parseHealthProviders, type HealthCheckAggregate, type HealthCheckProvider } from '@cfdm/shared'
import type { HealthLogStatus } from '@/lib/health-log'
export type HealthProvider = HealthCheckProvider
@@ -189,7 +193,7 @@ export function HealthSourceTiles({
value: HealthProvider[]
onChange: (next: HealthProvider[]) => void
}) {
const selected = value.length > 0 ? value : (['local'] as HealthProvider[])
const selected = parseHealthProviders(value)
function toggle(id: HealthProvider) {
if (selected.includes(id)) {
@@ -261,6 +265,86 @@ export function HealthAggregateTiles({
)
}
function toggleProviders(
active: HealthProvider[],
id: HealthProvider,
): HealthProvider[] {
if (active.includes(id)) {
if (active.length === 1) return active
return active.filter((item) => item !== id)
}
return [...active, id]
}
/**
* Компактный мультивыбор типа пробы (toolbar в stacked Frame).
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/chart-17
* Docs: https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/badge
*/
export function HealthSourceFilterBar({
enabled,
selected,
statuses,
onChange,
}: {
enabled: HealthProvider[]
selected: HealthProvider[]
statuses: Partial<Record<HealthProvider, HealthLogStatus>>
onChange: (next: HealthProvider[]) => void
}) {
const visible = HEALTH_PROVIDER_ITEMS.filter((item) => enabled.includes(item.id))
if (visible.length === 0) return null
const active = selected.length > 0 ? selected : enabled
return (
<div className="@container min-w-0 w-full">
<ToggleGroup
multiple
variant="outline"
size="sm"
className="flex w-full min-w-0 flex-wrap justify-start"
value={active}
aria-label="Тип пробы"
onValueChange={(next) => {
const values = next.filter((value): value is HealthProvider =>
visible.some((item) => item.id === value),
)
if (values.length === 0) return
onChange(values)
}}
>
{visible.map((item) => (
<ToggleGroupItem
key={item.id}
value={item.id}
aria-label={item.title}
title={item.title}
className="max-w-full min-w-0 flex-none justify-start gap-1.5 @[16rem]:min-w-[8.5rem]"
>
<IconTile
variant="elevated"
size="xs"
className={item.iconClassName}
aria-hidden="true"
>
{item.icon}
</IconTile>
<span className="hidden min-w-0 truncate @[16rem]:inline">
{item.title}
</span>
<HealthCheckBadge
status={statuses[item.id] ?? 'unknown'}
provider={item.id}
size="xs"
/>
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
)
}
/**
* Read-only status tiles for enabled probe sources; click filters the monitor.
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/stats-12
@@ -282,15 +366,6 @@ export function HealthProviderStatusTiles({
const active = selected.length > 0 ? selected : enabled
function toggle(id: HealthProvider) {
if (active.includes(id)) {
if (active.length === 1) return
onChange(active.filter((item) => item !== id))
return
}
onChange([...active, id])
}
return (
<ChoiceFrame>
{visible.map((item) => (
@@ -309,7 +384,7 @@ export function HealthProviderStatusTiles({
size="xs"
/>
}
onActivate={() => toggle(item.id)}
onActivate={() => onChange(toggleProviders(active, item.id))}
/>
))}
</ChoiceFrame>
+4 -1
View File
@@ -1,5 +1,6 @@
export { UptimeChart, type UptimeProbe, type UptimePeriodKey } from './uptime-chart'
export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart'
export { ServiceHealthMonitor } from './service-health-monitor'
export { ServiceFailoverPanel } from './service-failover-panel'
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
@@ -24,6 +25,8 @@ export {
HealthSourceTiles,
HealthAggregateTiles,
HealthProviderStatusTiles,
HealthSourceFilterBar,
type HealthProvider,
type HealthAggregate,
} from './health-source-tiles'
export { ServiceAddressBlock } from './service-address-block'
@@ -67,7 +67,7 @@ function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === 'string') {
return (
<Badge variant="outline" size="sm">
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
{item.hint}
</Badge>
)
@@ -81,30 +81,39 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const valueVariant = item.variant ?? 'default'
return (
<div className="relative z-10 flex h-full items-start gap-3">
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
{item.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
className={cn('size-10.5 shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
{footer ? <div className="shrink-0">{footer}</div> : null}
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
{item.label}
</div>
{footer ? (
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
{footer}
</div>
) : null}
</div>
<div
className={cn(
'text-2xl leading-none font-bold tabular-nums',
'min-w-0 break-all text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{item.value}
</div>
{footer ? (
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
) : null}
</div>
</div>
)
@@ -116,7 +125,7 @@ function panelClassName(item: KpiStatItem, className?: string) {
const selected = isSelected(item)
return cn(
'relative isolate flex h-full flex-col',
'relative isolate flex h-full min-w-0 flex-col',
clickable &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
selected && 'ring-primary/30 bg-muted/30 ring-1',
@@ -0,0 +1,408 @@
import { useState, type KeyboardEvent, type ReactNode } from 'react'
import { ServerIcon, Trash2Icon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import { isValidIpv4 } from '@/components/tagged-input'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { parseFqdn } from '@/lib/parse-fqdn'
import {
addAddressNode,
addCommonFqdn,
addExtraFqdn,
addressHasFqdn,
removeAddressNode,
removeCommonFqdn,
removeExtraFqdn,
updateCommonFqdn,
updateExtraFqdn,
type AddressBlockState,
} from '@/lib/service-address'
import { Button } from '@cfdm/ui/components/button'
import { Field, FieldLabel } from '@cfdm/ui/components/field'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@cfdm/ui/components/input-group'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@cfdm/ui/components/item'
function ZoneAddon({
fqdn,
zoneHints,
trailing,
}: {
fqdn: string
zoneHints: string[]
trailing?: ReactNode
}) {
const parsed = parseFqdn(fqdn, zoneHints)
if (!parsed && !fqdn.trim() && !trailing) return null
return (
<InputGroupAddon align="inline-end">
{parsed ? (
<Badge variant="outline" size="xs" className="font-mono">
{parsed.zoneName}
</Badge>
) : fqdn.trim() ? (
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
) : null}
{trailing}
</InputGroupAddon>
)
}
/**
* Единый блок адресов сервиса: список общих FQDN на весь пул + IP с доп. доменами.
* Preview: https://reui.io/preview/base/settings-3
* Preview: https://reui.io/preview/base/list-9
* Preview: https://reui.io/preview/base/form-7
* Docs: https://reui.io/docs/components/base/frame
* Docs: https://reui.io/docs/components/base/icon-tile
* Docs: https://reui.io/docs/components/base/badge
*/
export function ServiceAddressBlock({
value,
onChange,
zoneHints,
}: {
value: AddressBlockState
onChange: (next: AddressBlockState) => void
zoneHints: string[]
}) {
const [pendingIp, setPendingIp] = useState('')
const [ipInvalid, setIpInvalid] = useState(false)
const [pendingFqdn, setPendingFqdn] = useState('')
const [fqdnInvalid, setFqdnInvalid] = useState(false)
const [pendingExtraByIp, setPendingExtraByIp] = useState<Record<string, string>>({})
const [extraInvalidByIp, setExtraInvalidByIp] = useState<Record<string, boolean>>({})
const pool = value.nodes.map((node) => node.ip)
const pendingIpTrimmed = pendingIp.trim()
const pendingFqdnTrimmed = pendingFqdn.trim()
const pendingIpInvalid =
ipInvalid && pendingIpTrimmed.length > 0 && !isValidIpv4(pendingIpTrimmed)
const pendingFqdnInvalid =
fqdnInvalid && pendingFqdnTrimmed.length > 0
function tryAddFqdn(raw: string) {
const trimmed = raw.trim()
if (!trimmed) {
setFqdnInvalid(false)
return
}
if (addressHasFqdn(value, trimmed)) {
setFqdnInvalid(true)
return
}
onChange(addCommonFqdn(value, trimmed))
setPendingFqdn('')
setFqdnInvalid(false)
}
function tryAddIp(raw: string) {
const trimmed = raw.trim()
if (!trimmed) {
setIpInvalid(false)
return
}
if (!isValidIpv4(trimmed) || pool.includes(trimmed)) {
setIpInvalid(true)
return
}
onChange(addAddressNode(value, trimmed))
setPendingIp('')
setIpInvalid(false)
}
function handleFqdnKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
event.preventDefault()
tryAddFqdn(pendingFqdn)
}
}
function handleIpKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
event.preventDefault()
tryAddIp(pendingIp)
}
}
function tryAddExtra(ip: string, raw: string) {
const trimmed = raw.trim()
if (!trimmed) {
setExtraInvalidByIp((current) => ({ ...current, [ip]: false }))
return
}
if (addressHasFqdn(value, trimmed)) {
setExtraInvalidByIp((current) => ({ ...current, [ip]: true }))
return
}
onChange(addExtraFqdn(value, ip, trimmed))
setPendingExtraByIp((current) => ({ ...current, [ip]: '' }))
setExtraInvalidByIp((current) => ({ ...current, [ip]: false }))
}
function handleExtraKeyDown(ip: string, event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
event.preventDefault()
tryAddExtra(ip, pendingExtraByIp[ip] ?? '')
}
}
return (
<Frame stacked dense spacing="sm" className="w-full min-w-0">
<FramePanel fit className="flex flex-col gap-3">
<FrameHeader className="px-0 pt-0">
<FrameTitle>Адреса</FrameTitle>
<FrameDescription>
Общие FQDN на весь пул · у IP свои доп. домены
</FrameDescription>
</FrameHeader>
<Field>
<FieldLabel htmlFor="service-common-fqdn-add">Общие домены (FQDN)</FieldLabel>
<div className="flex w-full flex-col gap-2">
{value.commonFqdns.map((fqdn, index) => (
<InputGroup key={`common-fqdn-${index}`}>
<InputGroupInput
id={`service-common-fqdn-${index}`}
className="font-mono"
value={fqdn}
placeholder={zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'}
onChange={(event) =>
onChange(updateCommonFqdn(value, index, event.target.value))
}
/>
<ZoneAddon
fqdn={fqdn}
zoneHints={zoneHints}
trailing={
<InputGroupButton
size="icon-xs"
aria-label={`Удалить ${fqdn || 'FQDN'}`}
onClick={() => onChange(removeCommonFqdn(value, index))}
>
<Trash2Icon />
</InputGroupButton>
}
/>
</InputGroup>
))}
<InputGroup>
<InputGroupInput
id="service-common-fqdn-add"
className="font-mono"
value={pendingFqdn}
placeholder={zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'}
aria-invalid={pendingFqdnInvalid || undefined}
onChange={(event) => {
setPendingFqdn(event.target.value)
setFqdnInvalid(false)
}}
onKeyDown={handleFqdnKeyDown}
onBlur={() => tryAddFqdn(pendingFqdn)}
/>
<ZoneAddon
fqdn={pendingFqdn}
zoneHints={zoneHints}
trailing={
<InputGroupButton size="sm" onClick={() => tryAddFqdn(pendingFqdn)}>
Добавить
</InputGroupButton>
}
/>
</InputGroup>
</div>
</Field>
</FramePanel>
<FramePanel fit className="flex flex-col gap-3">
<FrameHeader className="px-0 pt-0">
<FrameTitle>IP-адреса</FrameTitle>
</FrameHeader>
{value.nodes.length === 0 ? (
<EmptyState
icon={ServerIcon}
title="Добавьте IP пула"
description="IPv4 сервиса. Для каждого адреса можно указать несколько доп. FQDN, в том числе wildcard."
stackedIcon={false}
centered={false}
/>
) : (
<ItemGroup className="gap-2">
{value.nodes.map((node) => {
return (
<Item
key={node.ip}
variant="outline"
size="sm"
className="items-stretch"
>
<ItemMedia>
<IconTile
variant="elevated"
size="xs"
className="text-info"
aria-hidden="true"
>
<ServerIcon />
</IconTile>
</ItemMedia>
<ItemContent className="flex min-w-0 flex-col gap-2">
<div className="flex items-center gap-2">
<ItemTitle className="font-mono">{node.ip}</ItemTitle>
<ItemActions className="ml-auto shrink-0">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Удалить ${node.ip}`}
onClick={() => onChange(removeAddressNode(value, node.ip))}
>
<Trash2Icon />
</Button>
</ItemActions>
</div>
<Field className="gap-1.5">
<FieldLabel
htmlFor={`service-ip-extra-add-${node.ip}`}
className="text-muted-foreground text-xs"
>
Доп. FQDN
</FieldLabel>
<div className="flex w-full flex-col gap-2">
{node.extraFqdns.map((fqdn, index) => (
<InputGroup key={`extra-fqdn-${node.ip}-${index}`}>
<InputGroupInput
id={`service-ip-extra-${node.ip}-${index}`}
className="font-mono"
value={fqdn}
placeholder={
zoneHints[0]
? `*.mdns.${zoneHints[0]}`
: '*.mdns.example.com'
}
onChange={(event) =>
onChange(
updateExtraFqdn(
value,
node.ip,
index,
event.target.value,
),
)
}
/>
<ZoneAddon
fqdn={fqdn}
zoneHints={zoneHints}
trailing={
<InputGroupButton
size="icon-xs"
aria-label={`Удалить ${fqdn || 'FQDN'}`}
onClick={() =>
onChange(removeExtraFqdn(value, node.ip, index))
}
>
<Trash2Icon />
</InputGroupButton>
}
/>
</InputGroup>
))}
<InputGroup>
<InputGroupInput
id={`service-ip-extra-add-${node.ip}`}
className="font-mono"
value={pendingExtraByIp[node.ip] ?? ''}
placeholder={
zoneHints[0]
? `необязательно · *.mdns.${zoneHints[0]}`
: 'необязательно · *.mdns.example.com'
}
aria-invalid={
extraInvalidByIp[node.ip] &&
(pendingExtraByIp[node.ip] ?? '').trim().length > 0
? true
: undefined
}
onChange={(event) => {
setPendingExtraByIp((current) => ({
...current,
[node.ip]: event.target.value,
}))
setExtraInvalidByIp((current) => ({
...current,
[node.ip]: false,
}))
}}
onKeyDown={(event) => handleExtraKeyDown(node.ip, event)}
onBlur={() =>
tryAddExtra(node.ip, pendingExtraByIp[node.ip] ?? '')
}
/>
<ZoneAddon
fqdn={pendingExtraByIp[node.ip] ?? ''}
zoneHints={zoneHints}
trailing={
<InputGroupButton
size="sm"
onClick={() =>
tryAddExtra(node.ip, pendingExtraByIp[node.ip] ?? '')
}
>
Добавить
</InputGroupButton>
}
/>
</InputGroup>
</div>
</Field>
</ItemContent>
</Item>
)
})}
</ItemGroup>
)}
<InputGroup>
<InputGroupInput
id="service-pool-ip-add"
className="font-mono"
value={pendingIp}
placeholder="192.168.1.1"
aria-invalid={pendingIpInvalid || undefined}
onChange={(event) => {
setPendingIp(event.target.value)
setIpInvalid(false)
}}
onKeyDown={handleIpKeyDown}
onBlur={() => tryAddIp(pendingIp)}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton size="sm" onClick={() => tryAddIp(pendingIp)}>
Добавить
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,148 @@
import { UnplugIcon } from 'lucide-react'
import { FailoverTimeline } from '@/components/failover-timeline'
import {
mergeFailoverHistory,
toFailoverEvents,
type FailoverBindingPool,
type FailoverHealthInput,
} from '@/lib/failover-events'
import {
latestHealthByIp,
resolveIpDisplayHealth,
type HealthLogProbe,
type HealthLogStatus,
} from '@/lib/health-log'
import type { FailoverLogEntry, ServiceView } from '@/lib/schemas'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
type LbMode = ServiceView['lb_mode']
const PANEL_COPY: Record<LbMode, { title: string; description: string }> = {
failover: {
title: 'Failover (приоритет)',
description: 'Down снимается с общего FQDN. История: кто вышел из пула и кто вернулся',
},
weighted: {
title: 'Веса (подмена IP)',
description: 'На общем FQDN один IP по весам. Down выводится из цикла',
},
round_robin: {
title: 'Round Robin',
description: 'На общем FQDN все живые A. Down снимается с пула',
},
}
function failoverCountLabel(count: number): string {
const mod10 = count % 10
const mod100 = count % 100
if (mod10 === 1 && mod100 !== 11) return `${count} адрес Down`
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
return `${count} адреса Down`
}
return `${count} адресов Down`
}
function alertDescription(events: { kind: string; fqdns: string[] }[]): string {
const removedCount = events.filter((event) => event.kind === 'removed').length
if (removedCount > 0) return 'Сняты с общего FQDN'
if (events.some((event) => event.fqdns.length > 0)) {
return 'Остались в A-записях общего FQDN как last-resort'
}
return 'Down: персональные FQDN не меняются'
}
/**
* Балансировка — текущие Down + кто вышел из общего пула.
* Preview: https://reui.io/preview/base/components/c-timeline-10
* Preview: https://reui.io/preview/base/empty-state-12
* Docs: https://reui.io/docs/components/base/frame
* Docs: https://reui.io/docs/components/base/timeline
* Docs: https://reui.io/docs/components/base/badge
* Docs: https://reui.io/docs/components/base/alert
*/
export function ServiceFailoverPanel({
lbMode = 'round_robin',
hasPool = true,
ipHealth,
bindings,
history,
probes = [],
}: {
lbMode?: LbMode
hasPool?: boolean
ipHealth: readonly FailoverHealthInput[]
bindings: readonly FailoverBindingPool[]
history: readonly FailoverLogEntry[]
probes?: readonly HealthLogProbe[]
}) {
const copy = hasPool
? (PANEL_COPY[lbMode] ?? PANEL_COPY.round_robin)
: {
title: 'без резервирования',
description: 'Один origin IP — балансировка не применяется',
}
const liveByIp = latestHealthByIp(probes)
const overlayHealth = ipHealth.map((row) => {
const live = liveByIp.get(row.ip)
return {
...row,
status: resolveIpDisplayHealth(
row.status as HealthLogStatus,
live?.status,
),
last_error:
live && live.status !== 'unknown' ? live.last_error : row.last_error,
last_checked_at:
live && live.status !== 'unknown'
? live.last_checked_at
: row.last_checked_at,
}
})
const events = toFailoverEvents(overlayHealth, bindings)
const mergedHistory = mergeFailoverHistory(history, probes, bindings)
return (
<Frame stacked spacing="sm" className="min-w-0 w-full">
<FramePanel className="flex flex-col gap-3">
<FrameHeader className="gap-1 px-0 py-0">
<FrameTitle className="flex flex-wrap items-center gap-2">
{copy.title}
{events.length > 0 ? (
<Badge variant="destructive-light" size="xs" radius="full">
{events.length}
</Badge>
) : (
<Badge variant="success-light" size="xs" radius="full">
OK
</Badge>
)}
</FrameTitle>
<FrameDescription>{copy.description}</FrameDescription>
</FrameHeader>
{events.length > 0 ? (
<Alert variant="destructive">
<UnplugIcon aria-hidden="true" />
<AlertTitle>{failoverCountLabel(events.length)}</AlertTitle>
<AlertDescription>{alertDescription(events)}</AlertDescription>
</Alert>
) : null}
<FailoverTimeline events={events} history={mergedHistory} />
</FramePanel>
</Frame>
)
}
@@ -9,40 +9,78 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { UptimeChart, UPTIME_PERIODS, type UptimePeriodKey } from '@/components/reui-kit/uptime-chart'
import { HealthSourceFilterBar } from '@/components/reui-kit/health-source-tiles'
import {
collapseStatusChanges,
filterByPeriod,
filterByProviders,
type HealthLogProbe,
type HealthLogStatus,
} from '@/lib/health-log'
import type { HealthCheckProvider } from '@cfdm/shared'
/**
* Combined uptime chart + status-change timeline (solution-analytics-8 DNA).
* Preview: https://reui.io/preview/base/solution-analytics-8 · https://reui.io/preview/base/chart-17
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/timeline
* Единый блок мониторинга: компактный мультивыбор типа пробы (list-9 / ToggleGroup)
* + график (chart-17) + таймлайн смен статуса.
*
* Preview: https://reui.io/preview/base/list-9
* Preview: https://reui.io/preview/base/chart-17
* Preview: https://reui.io/preview/base/solution-ai-ops-1
* Docs: https://reui.io/docs/components/base/frame
* Docs: https://reui.io/docs/components/base/icon-tile
* Docs: https://reui.io/docs/components/base/timeline
* Docs: https://reui.io/docs/components/base/badge
*/
export function ServiceHealthMonitor({
items,
selectedProviders,
enabledProviders,
statuses,
isLoading = false,
}: {
items: HealthLogProbe[]
selectedProviders: readonly HealthCheckProvider[]
enabledProviders: readonly HealthCheckProvider[]
statuses: Partial<Record<HealthCheckProvider, HealthLogStatus>>
isLoading?: boolean
}) {
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
const [selected, setSelected] = useState<HealthCheckProvider[] | null>(null)
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
const enabled = useMemo(
() => [...enabledProviders],
[enabledProviders],
)
const activeProviders = useMemo((): HealthCheckProvider[] => {
const picked = (selected ?? enabled).filter((provider) =>
enabled.includes(provider),
)
return picked.length > 0 ? picked : enabled
}, [enabled, selected])
const periodItems = useMemo(
() => filterByPeriod(items, days),
[items, days],
)
const filtered = useMemo(
() => filterByProviders(filterByPeriod(items, days), selectedProviders),
[items, days, selectedProviders],
() => filterByProviders(periodItems, activeProviders),
[periodItems, activeProviders],
)
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
return (
<Frame stacked spacing="sm" className="min-w-0 w-full">
<FramePanel>
<HealthSourceFilterBar
enabled={enabled}
selected={activeProviders}
statuses={statuses}
onChange={setSelected}
/>
</FramePanel>
<UptimeChart
items={filtered}
isLoading={isLoading}
@@ -50,12 +88,15 @@ export function ServiceHealthMonitor({
onPeriodChange={setPeriod}
skipPeriodFilter
embedded
hideHeader
/>
<FramePanel className="flex flex-col gap-3">
<FrameHeader className="px-0 py-0">
<FrameTitle>Смены статуса</FrameTitle>
<FrameDescription>
Только переходы up / degraded / down · Cloudflare = Worker, не Health Checks API
Только переходы up / degraded / down · Cloudflare = Worker, не Health
Checks API
</FrameDescription>
</FrameHeader>
<HealthTimeline
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import { toAlignedSeries, type UptimeProbe } from './uptime-chart'
function probe(
overrides: Partial<UptimeProbe> & Pick<UptimeProbe, 'id'>,
): UptimeProbe {
return {
status: 'up',
ok: true,
latency_ms: 10,
checked_at: '2026-01-01T00:00:00.000Z',
provider: 'local',
...overrides,
}
}
describe('toAlignedSeries', () => {
it('puts mixed-source probes in one 60s bucket instead of a sawtooth series', () => {
const { points, keys } = toAlignedSeries([
probe({
id: 1,
provider: 'local',
latency_ms: 4,
checked_at: '2026-01-01T00:00:10.000Z',
}),
probe({
id: 2,
provider: 'cloudflare',
latency_ms: 284,
checked_at: '2026-01-01T00:00:12.000Z',
}),
probe({
id: 3,
provider: 'globalping',
latency_ms: 38,
checked_at: '2026-01-01T00:00:40.000Z',
}),
])
expect(points).toHaveLength(1)
expect(points[0]?.local).toBe(4)
expect(points[0]?.cloudflare).toBe(284)
expect(points[0]?.globalping).toBe(38)
expect(keys).toEqual(['local', 'cloudflare', 'globalping'])
})
it('keeps live latency when another IP in the same bucket is down', () => {
const { points } = toAlignedSeries([
probe({
id: 1,
latency_ms: 18,
checked_at: '2026-01-01T00:00:10.000Z',
}),
probe({
id: 2,
status: 'down',
ok: false,
latency_ms: null,
checked_at: '2026-01-01T00:00:12.000Z',
}),
])
expect(points).toHaveLength(1)
expect(points[0]?.local).toBe(18)
expect(points[0]?.localOk).toBe(true)
expect(points[0]?.ok).toBe(true)
})
it('does not plot down probes as latency 0', () => {
const { points } = toAlignedSeries([
probe({
id: 1,
status: 'down',
ok: false,
latency_ms: 12,
provider: 'local',
}),
])
expect(points).toHaveLength(1)
expect(points[0]?.local).toBeNull()
expect(points[0]?.localOk).toBe(false)
expect(points[0]?.ok).toBe(false)
})
it('splits probes that fall into adjacent minutes', () => {
const { points } = toAlignedSeries([
probe({ id: 1, latency_ms: 10, checked_at: '2026-01-01T00:00:50.000Z' }),
probe({ id: 2, latency_ms: 20, checked_at: '2026-01-01T00:01:10.000Z' }),
])
expect(points).toHaveLength(2)
expect(points[0]?.local).toBe(10)
expect(points[1]?.local).toBe(20)
})
})
+303 -133
View File
@@ -1,6 +1,6 @@
import { useId, useMemo, useState } from 'react'
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
import { Area, AreaChart, XAxis } from 'recharts'
import { Area, ComposedChart, Line, XAxis, YAxis } from 'recharts'
import { EmptyState } from '@/components/empty-state'
import { Badge } from '@/components/reui/badge'
@@ -12,6 +12,7 @@ import { Button } from '@cfdm/ui/components/button'
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@cfdm/ui/components/chart'
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
@@ -21,12 +22,13 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import type { HealthCheckProvider } from '@cfdm/shared'
/**
* Uptime monitoring card — chart-17 DNA (Frame + value + AreaChart + period tabs).
* Preview: https://reui.io/preview/base/chart-17
* Frame: https://reui.io/docs/components/base/frame
* Chart: shadcn Chart + Recharts AreaChart
* Chart: shadcn Chart + Recharts ComposedChart
*/
export interface UptimeProbe {
@@ -35,6 +37,7 @@ export interface UptimeProbe {
ok: boolean
latency_ms: number | null
checked_at: string
provider?: HealthCheckProvider
}
export type UptimePeriodKey = '5D' | '2W' | '1M'
@@ -45,40 +48,112 @@ export const UPTIME_PERIODS: { key: UptimePeriodKey; label: string; days: number
{ key: '1M', label: '1M', days: 30 },
]
export const UPTIME_BUCKET_MS = 60_000
export const UPTIME_PROVIDER_KEYS = ['local', 'cloudflare', 'globalping'] as const
export type UptimeProviderKey = (typeof UPTIME_PROVIDER_KEYS)[number]
const chartConfig = {
latency: {
label: 'Задержка',
color: 'var(--chart-1)',
local: {
label: 'Local',
color: 'var(--info)',
},
cloudflare: {
label: 'Cloudflare',
color: 'var(--warning)',
},
globalping: {
label: 'Globalping',
color: 'var(--success)',
},
} satisfies ChartConfig
interface ChartPoint {
export interface AlignedChartPoint {
period: string
latency: number
ok: boolean
at: string
status: UptimeProbe['status']
ok: boolean
local?: number | null
cloudflare?: number | null
globalping?: number | null
localOk?: boolean
cloudflareOk?: boolean
globalpingOk?: boolean
}
function toSeries(items: UptimeProbe[]): ChartPoint[] {
return [...items]
.sort((a, b) => probeTime(a.checked_at) - probeTime(b.checked_at))
.map((item) => ({
period: formatDate(item.checked_at),
latency: item.latency_ms ?? 0,
ok: item.ok && item.status !== 'down',
at: item.checked_at,
status: item.status,
}))
function isProviderKey(value: string | undefined): value is UptimeProviderKey {
return value === 'local' || value === 'cloudflare' || value === 'globalping'
}
function uptimePercent(points: ChartPoint[]): number | null {
function bucketStart(time: number): number {
return Math.floor(time / UPTIME_BUCKET_MS) * UPTIME_BUCKET_MS
}
function providerOf(item: UptimeProbe): UptimeProviderKey {
return isProviderKey(item.provider) ? item.provider : 'local'
}
function probeOk(item: UptimeProbe): boolean {
return item.ok && item.status !== 'down'
}
/** Align mixed-source probes onto a 60s time axis so Local/CF/GP do not zigzag. */
export function toAlignedSeries(items: UptimeProbe[]): {
points: AlignedChartPoint[]
keys: UptimeProviderKey[]
} {
const buckets = new Map<number, AlignedChartPoint>()
const used = new Set<UptimeProviderKey>()
const sorted = [...items].sort(
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
)
for (const item of sorted) {
const key = providerOf(item)
used.add(key)
const start = bucketStart(probeTime(item.checked_at))
let row = buckets.get(start)
if (!row) {
row = {
period: formatDate(item.checked_at),
at: item.checked_at,
ok: true,
}
buckets.set(start, row)
}
const ok = probeOk(item)
const prevOk = row[`${key}Ok`]
row[`${key}Ok`] = prevOk === true || ok
if (ok && item.latency_ms != null) {
row[key] = item.latency_ms
} else if (row[key] === undefined) {
row[key] = null
}
}
const points = [...buckets.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, row]) => {
const present = UPTIME_PROVIDER_KEYS.filter((key) => row[`${key}Ok`] !== undefined)
return {
...row,
ok: present.length === 0 ? row.ok : present.every((key) => row[`${key}Ok`] !== false),
}
})
const keys = UPTIME_PROVIDER_KEYS.filter((key) => used.has(key))
return { points, keys }
}
function uptimePercent(points: AlignedChartPoint[]): number | null {
if (points.length === 0) return null
const okCount = points.filter((point) => point.ok).length
return (okCount / points.length) * 100
}
function deltaPercent(points: ChartPoint[]): number | null {
function deltaPercent(points: AlignedChartPoint[]): number | null {
if (points.length < 4) return null
const mid = Math.floor(points.length / 2)
const prev = uptimePercent(points.slice(0, mid))
@@ -87,22 +162,51 @@ function deltaPercent(points: ChartPoint[]): number | null {
return next - prev
}
interface UptimeTooltipProps {
active?: boolean
payload?: Array<{ payload: ChartPoint }>
function UptimeDelta({ delta }: { delta: number }) {
if (Math.abs(delta) < 0.05) {
return <span className="text-muted-foreground">без изменений за период</span>
}
if (delta > 0) {
return (
<>
<TrendingUpIcon className="text-success size-4" aria-hidden="true" />
<span className="text-success font-medium">+{delta.toFixed(1)} п.п.</span>
<span className="text-muted-foreground">с начала периода</span>
</>
)
}
return (
<>
<TrendingDownIcon className="text-destructive size-4" aria-hidden="true" />
<span className="text-destructive font-medium">{delta.toFixed(1)} п.п.</span>
<span className="text-muted-foreground">с начала периода</span>
</>
)
}
function UptimeTooltip({ active, payload }: UptimeTooltipProps) {
if (!active || !payload?.[0]) return null
const point = payload[0].payload
return (
<div className="bg-popover text-popover-foreground rounded-md px-3 py-2 text-sm shadow-md">
<p className="font-medium tabular-nums">
{point.latency} мс · {point.ok ? 'OK' : 'Down'}
</p>
<p className="text-muted-foreground text-xs">{point.period}</p>
</div>
)
export function probeUptimePercent(items: UptimeProbe[]): number | null {
return uptimePercent(toAlignedSeries(items).points)
}
export function lastProbeLatency(items: UptimeProbe[]): number | null {
if (items.length === 0) return null
const latest = [...items].sort(
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at),
)[0]
return latest?.latency_ms ?? null
}
function formatUptime(value: number | null): string {
if (value == null) return '—'
return `${value.toFixed(value >= 99.95 ? 2 : 1)}%`
}
function formatPing(value: unknown, ok: boolean | undefined): string {
if (ok === false) return '—'
const ping = typeof value === 'number' ? value : Number(value)
return Number.isFinite(ping) ? `${ping} мс` : '—'
}
interface UptimeChartProps {
@@ -112,6 +216,8 @@ interface UptimeChartProps {
onPeriodChange?: (period: UptimePeriodKey) => void
skipPeriodFilter?: boolean
embedded?: boolean
/** dashboard-4: chrome живёт в родительском FrameHeader (переключатель серий). */
hideHeader?: boolean
}
export function UptimeChart({
@@ -121,65 +227,88 @@ export function UptimeChart({
onPeriodChange,
skipPeriodFilter = false,
embedded = false,
hideHeader = false,
}: UptimeChartProps) {
const gradientId = useId().replace(/:/g, '')
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
const [hovered, setHovered] = useState<AlignedChartPoint | null>(null)
const period = periodProp ?? internalPeriod
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
function handlePeriodChange(next: UptimePeriodKey) {
onPeriodChange?.(next)
if (periodProp == null) setInternalPeriod(next)
setHovered(null)
}
const points = useMemo(
() => toSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
const { points, keys } = useMemo(
() => toAlignedSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
[items, days, skipPeriodFilter],
)
const uptime = uptimePercent(points)
const delta = deltaPercent(points)
const lastOk = points.at(-1)?.ok ?? true
const tileClass = lastOk ? 'text-success' : 'text-destructive'
const single = keys.length <= 1
const areaKey = keys[0] ?? 'local'
const hoverPings = hovered
? keys.map((key) => {
const ok = hovered[`${key}Ok`]
const label = chartConfig[key].label
return `${label} ${formatPing(hovered[key], ok)}`
})
: []
function syncHover(state: {
activeTooltipIndex?: unknown
activeIndex?: unknown
}) {
const index = Number(state.activeTooltipIndex ?? state.activeIndex)
if (!Number.isFinite(index)) return
setHovered(points[index] ?? null)
}
const panel = (
<FramePanel className="flex flex-col gap-6">
<div className="border-border flex items-center justify-between gap-2 border-b border-dashed pb-4">
<div className="flex items-center gap-2.5">
<IconTile
variant="elevated"
className={`size-10.5 ${tileClass}`}
aria-hidden="true"
>
<ActivityIcon />
</IconTile>
<div className="flex flex-col justify-center">
<h3 className="text-base font-semibold">Uptime</h3>
<p className="text-muted-foreground text-sm">
Пробы health-check за период
</p>
</div>
</div>
<TooltipProvider delay={150}>
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label="О графике uptime"
className="text-muted-foreground/70 -mr-1"
size="icon-sm"
type="button"
variant="ghost"
/>
}
<FramePanel className="flex flex-col gap-6 overflow-visible">
{hideHeader ? null : (
<div className="border-border flex items-center justify-between gap-2 border-b border-dashed pb-4">
<div className="flex items-center gap-2.5">
<IconTile
variant="elevated"
className={`size-10.5 ${tileClass}`}
aria-hidden="true"
>
<InfoIcon data-icon="inline-start" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
<p>Доля успешных проб и задержка (мс) по журналу health-log.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<ActivityIcon />
</IconTile>
<div className="flex flex-col justify-center">
<h3 className="text-base font-semibold">Uptime</h3>
<p className="text-muted-foreground text-sm">
Пробы health-check за период
</p>
</div>
</div>
<TooltipProvider delay={150}>
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label="О графике uptime"
className="text-muted-foreground/70 -mr-1"
size="icon-sm"
type="button"
variant="ghost"
/>
}
>
<InfoIcon data-icon="inline-start" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
<p>Доля успешных проб и задержка (мс) по журналу health-log.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
{isLoading ? (
<div className="bg-muted h-40 w-full animate-pulse rounded-xl" />
@@ -195,95 +324,136 @@ export function UptimeChart({
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<div className="text-foreground text-3xl font-semibold tabular-nums">
{uptime == null ? '—' : `${uptime.toFixed(uptime >= 99.95 ? 2 : 1)}%`}
{formatUptime(uptime)}
</div>
<div className="flex items-center gap-2 text-sm">
{delta == null ? (
<Badge variant="outline" size="sm">
{points.length} проб
</Badge>
) : delta >= 0 ? (
<>
<TrendingUpIcon className="text-success size-4" aria-hidden="true" />
<span className="text-success font-medium">
+{delta.toFixed(1)} п.п.
</span>
<span className="text-muted-foreground">к первой половине окна</span>
</>
) : (
<>
<TrendingDownIcon className="text-destructive size-4" aria-hidden="true" />
<span className="text-destructive font-medium">
{delta.toFixed(1)} п.п.
</span>
<span className="text-muted-foreground">к первой половине окна</span>
</>
<UptimeDelta delta={delta} />
)}
</div>
</div>
<div className="h-40 w-full">
{hoverPings.length > 0 ? (
<p className="text-muted-foreground min-h-4 min-w-0 text-xs tabular-nums">
<span className="text-foreground font-medium">Пинг</span>
{' · '}
{formatDate(hovered?.at)}
{' · '}
{hoverPings.join(' · ')}
</p>
) : (
<p className="text-muted-foreground min-h-4 text-xs">
Наведите на точку графика
</p>
)}
<div className="h-40 w-full overflow-visible">
<ChartContainer
config={chartConfig}
className="h-full w-full overflow-hidden rounded-b-xl"
className="[&_.recharts-tooltip-wrapper]:z-50 [&_.recharts-wrapper]:overflow-visible h-full w-full overflow-visible rounded-b-xl"
initialDimension={{ width: 320, height: 160 }}
>
<AreaChart
<ComposedChart
data={points}
margin={{ top: 10, left: 0, right: 0, bottom: 0 }}
margin={{ top: 24, left: 8, right: 8, bottom: 8 }}
accessibilityLayer
onMouseMove={syncHover}
onMouseLeave={() => setHovered(null)}
onClick={syncHover}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-latency)"
stopColor={`var(--color-${areaKey})`}
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-latency)"
stopColor={`var(--color-${areaKey})`}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<XAxis dataKey="period" hide />
<ChartTooltip content={<UptimeTooltip />} />
<Area
dataKey="latency"
type="natural"
fill={`url(#${gradientId})`}
stroke="var(--color-latency)"
strokeWidth={2}
dot={(dotProps) => {
const { cx, cy, payload, index } = dotProps as {
cx?: number
cy?: number
index?: number
payload?: ChartPoint
}
if (cx == null || cy == null) return <g key={index} />
const fill = payload?.ok
? 'var(--color-latency)'
: 'var(--destructive)'
return (
<circle
key={index}
cx={cx}
cy={cy}
r={4}
fill={fill}
stroke="var(--background)"
strokeWidth={2}
/>
)
}}
activeDot={{
r: 6,
stroke: 'var(--background)',
strokeWidth: 2,
}}
<XAxis dataKey="at" hide />
<YAxis hide domain={['auto', 'auto']} />
<ChartTooltip
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
filterNull={false}
shared
isAnimationActive={false}
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
content={
<ChartTooltipContent
labelFormatter={(_label, payload) => {
const at = (payload?.[0]?.payload as AlignedChartPoint | undefined)?.at
return at ? formatDate(at) : String(_label ?? '')
}}
formatter={(value, name, item) => {
const key = String(name)
const row = item.payload as AlignedChartPoint | undefined
const ok =
key === 'local' || key === 'cloudflare' || key === 'globalping'
? row?.[`${key}Ok`]
: row?.ok
const label = chartConfig[key as UptimeProviderKey]?.label ?? key
return (
<div className="flex flex-1 items-center justify-between gap-4">
<span className="text-muted-foreground">
{ok === false ? `${label} · Down` : `Пинг · ${label}`}
</span>
<span className="text-foreground font-mono font-medium tabular-nums">
{formatPing(value, ok)}
</span>
</div>
)
}}
/>
}
/>
</AreaChart>
{single ? (
<Area
dataKey={areaKey}
name={areaKey}
type="monotone"
fill={`url(#${gradientId})`}
stroke={`var(--color-${areaKey})`}
strokeWidth={2}
connectNulls={false}
isAnimationActive={false}
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
activeDot={{
r: 6,
stroke: 'var(--background)',
strokeWidth: 2,
}}
/>
) : (
keys.map((key) => (
<Line
key={key}
dataKey={key}
name={key}
type="monotone"
stroke={`var(--color-${key})`}
strokeWidth={2}
connectNulls={false}
isAnimationActive={false}
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
activeDot={{
r: 6,
stroke: 'var(--background)',
strokeWidth: 2,
}}
/>
))
)}
</ComposedChart>
</ChartContainer>
</div>
</div>
+66 -426
View File
@@ -1,15 +1,10 @@
import { useEffect, useMemo, useState } from 'react'
import { PlusIcon, Trash2Icon } from 'lucide-react'
import { Trash2Icon } from 'lucide-react'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
import { ServiceAddressBlock } from '@/components/reui-kit/service-address-block'
import {
HealthCheckConfigFields,
type LbAndHealthConfig,
type LbMode,
type HealthCheckType,
type HealthProvider,
type HealthAggregate,
} from '@/components/health-check-config-fields'
import type {
CreateServiceWithConfigInput,
@@ -18,8 +13,17 @@ import type {
ServiceView,
UpdateServiceConfigInput,
} from '@/lib/schemas'
import { bindingToFqdn, parseFqdn } from '@/lib/parse-fqdn'
import { Badge } from '@/components/reui/badge'
import {
DEFAULT_BINDING_HEALTH,
emptyAddressBlock,
hydrateAddressBlock,
patchAddressIpMeta,
toBindingDrafts,
toDomainsPayload,
type AddressBlockState,
type BindingHealthConfig,
type ServiceBindingDraft,
} from '@/lib/service-address'
import { toast } from 'sonner'
import {
Sheet,
@@ -29,14 +33,8 @@ import {
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { Button } from '@cfdm/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
Item,
ItemContent,
ItemGroup,
} from '@cfdm/ui/components/item'
import { LoadingButton } from '@/components/loading-button'
import {
Select,
@@ -46,44 +44,7 @@ import {
SelectValue,
} from '@cfdm/ui/components/select'
interface BindingHealthConfig {
enabled: boolean
type: HealthCheckType
port: number | null
path: string | null
expected_status: number | null
interval_sec: number
timeout_ms: number
verify_tls: boolean
provider: HealthProvider
providers: HealthProvider[]
aggregate: HealthAggregate
}
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,
verify_tls: false,
provider: 'local',
providers: ['local'],
aggregate: 'majority',
}
export type { ServiceBindingDraft }
interface ServiceEditSheetProps {
mode: 'create' | 'edit'
@@ -100,104 +61,19 @@ interface ServiceEditSheetProps {
onDelete?: (id: number) => void
}
function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
return (service.domains ?? []).map((binding) => ({
fqdn: bindingToFqdn(binding),
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 === 'http' ? 'http' : 'tcp',
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,
verify_tls: binding.health_check_verify_tls ?? false,
provider: binding.health_check_provider ?? 'local',
providers:
binding.health_check_providers?.length > 0
? binding.health_check_providers
: [binding.health_check_provider ?? 'local'],
aggregate: binding.health_check_aggregate ?? 'majority',
},
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
}))
}
function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
return bindings
.filter((binding) => {
if (!binding.fqdn.trim()) return false
if (binding.record_type === 'CNAME') return Boolean(binding.target_cname.trim())
return binding.target_ips.length > 0
})
.map((binding) =>
binding.record_type === 'CNAME'
? {
fqdn: binding.fqdn.trim(),
target_cname: binding.target_cname.trim(),
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,
health_check_verify_tls: binding.health.verify_tls,
health_check_provider: binding.health.provider,
health_check_providers: binding.health.providers,
health_check_aggregate: binding.health.aggregate,
}
: {
fqdn: binding.fqdn.trim(),
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,
health_check_verify_tls: binding.health.verify_tls,
health_check_provider: binding.health.provider,
health_check_providers: binding.health.providers,
health_check_aggregate: binding.health.aggregate,
},
)
}
function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
function healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
return {
fqdn,
record_type: 'A',
target_ips: [],
target_cname: '',
lb_mode: 'round_robin',
health: { ...defaultHealth },
target_ip_weights: {},
target_ip_priorities: {},
}
}
function withPoolIps(draft: ServiceBindingDraft, pool: string[]): ServiceBindingDraft {
if (draft.record_type !== 'A' || draft.target_ips.length > 0 || pool.length === 0) {
return draft
}
return {
...draft,
target_ips: pool,
target_ip_weights: Object.fromEntries(pool.map((ip) => [ip, draft.target_ip_weights[ip] ?? 1])),
target_ip_priorities: Object.fromEntries(
pool.map((ip) => [ip, draft.target_ip_priorities[ip] ?? 1]),
),
enabled: next.enabled,
type: next.type,
port: next.port,
path: next.path,
expected_status: next.expected_status,
interval_sec: next.interval_sec,
timeout_ms: next.timeout_ms,
verify_tls: next.verify_tls,
provider: next.provider,
providers: next.providers,
aggregate: next.aggregate,
}
}
@@ -218,9 +94,11 @@ export function ServiceEditSheet({
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [serviceGroupId, setServiceGroupId] = useState('none')
const [ips, setIps] = useState<string[]>([])
const [commonFqdn, setCommonFqdn] = useState('')
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
const [address, setAddress] = useState<AddressBlockState>(() => emptyAddressBlock())
const [health, setHealth] = useState<BindingHealthConfig>(() => ({
...DEFAULT_BINDING_HEALTH,
}))
const [lbMode, setLbMode] = useState<LbAndHealthConfig['lb_mode']>('round_robin')
const [lbWeight, setLbWeight] = useState(1)
const [lbPriority, setLbPriority] = useState(1)
@@ -232,6 +110,8 @@ export function ServiceEditSheet({
[groups],
)
// Reset only when the sheet opens or the service id changes.
// Health polling replaces `service` by identity and would wipe unsaved settings.
useEffect(() => {
if (!open) return
if (mode === 'edit' && service) {
@@ -240,10 +120,10 @@ export function ServiceEditSheet({
setServiceGroupId(
service.service_group_id != null ? String(service.service_group_id) : 'none',
)
setIps(service.ips ?? [])
const drafts = toBindingDrafts(service)
setBindings(drafts)
setCommonFqdn(drafts[0]?.fqdn ?? '')
setAddress(hydrateAddressBlock(drafts, service.ips ?? []))
setHealth(drafts[0]?.health ?? { ...DEFAULT_BINDING_HEALTH })
setLbMode(drafts[0]?.lb_mode ?? service.lb_mode ?? 'round_robin')
setLbWeight(service.lb_weight ?? 1)
setLbPriority(service.lb_priority ?? 1)
return
@@ -254,149 +134,40 @@ export function ServiceEditSheet({
setServiceGroupId(
defaultGroupId != null ? String(defaultGroupId) : 'none',
)
setIps([])
setCommonFqdn('')
setBindings([])
setAddress(emptyAddressBlock())
setHealth({ ...DEFAULT_BINDING_HEALTH })
setLbMode('round_robin')
setLbWeight(1)
setLbPriority(1)
}
}, [open, mode, service, defaultGroupId])
}, [open, mode, service?.id, defaultGroupId])
const zoneHints = useMemo(
() => knownDomains.map((domain) => domain.zone_name),
[knownDomains],
)
const extraBindings = bindings.slice(1)
function handleCommonFqdnChange(value: string) {
setCommonFqdn(value)
setBindings((current) => {
if (current.length === 0) return current
return current.map((item, i) => (i === 0 ? { ...item, fqdn: value } : item))
})
}
function handleAddExtraBinding() {
setBindings((current) => {
const extra = withPoolIps(emptyBindingDraft(), ips)
if (current.length === 0) {
return [emptyBindingDraft(commonFqdn), extra]
}
return [...current, extra]
})
}
function handleRemoveExtraBinding(extraIndex: number) {
const index = extraIndex + 1
setBindings((current) => current.filter((_, i) => i !== index))
}
function handleFqdnChange(index: number, fqdn: string) {
setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
)
}
function handleRecordTypeChange(index: number, recordType: 'A' | 'CNAME') {
setBindings((current) =>
current.map((item, i) =>
i === index
? {
...item,
record_type: recordType,
target_ips: recordType === 'A' ? item.target_ips : [],
target_cname: recordType === 'CNAME' ? item.target_cname : '',
}
: item,
),
)
}
function handleCnameChange(index: number, value: string) {
setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, target_cname: value } : item)),
)
}
function handleIpsChange(index: number, targetIps: string[]) {
setBindings((current) =>
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 healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
return {
enabled: next.enabled,
type: next.type,
port: next.port,
path: next.path,
expected_status: next.expected_status,
interval_sec: next.interval_sec,
timeout_ms: next.timeout_ms,
verify_tls: next.verify_tls,
provider: next.provider,
providers: next.providers,
aggregate: next.aggregate,
}
}
function handlePrimaryHealthChange(next: LbAndHealthConfig) {
const health = healthFromConfig(next)
setBindings((current) => {
if (current.length === 0) {
return [
{
...withPoolIps(emptyBindingDraft(commonFqdn), ips),
lb_mode: next.lb_mode,
health,
},
]
}
return current.map((item, index) =>
index === 0 ? { ...item, lb_mode: next.lb_mode, health } : item,
)
})
setLbMode(next.lb_mode)
setHealth(healthFromConfig(next))
}
const primaryHealthValue: LbAndHealthConfig = {
lb_mode: bindings[0]?.lb_mode ?? 'round_robin',
...(bindings[0]?.health ?? defaultHealth),
lb_mode: lbMode,
...health,
}
function resolveServiceGroupId(): number | null {
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
}
function syncCommonDomain(current: ServiceBindingDraft[]): ServiceBindingDraft[] {
const trimmed = commonFqdn.trim()
if (!trimmed) return current
if (current.length === 0) {
return [withPoolIps(emptyBindingDraft(trimmed), ips)]
}
return current.map((item, index) => {
if (index !== 0) return item
return withPoolIps({ ...item, fqdn: trimmed }, ips)
})
}
function handleSubmit() {
const syncedBindings = syncCommonDomain(bindings)
const domains = buildDomainsPayload(syncedBindings)
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
const ips = address.nodes.map((node) => node.ip)
const domains = toDomainsPayload(address, {
lb_mode: lbMode,
health,
})
const normalizedFqdns = domains.map((item) => item.fqdn.trim().toLowerCase())
const hasDuplicateFqdn =
new Set(normalizedFqdns).size !== normalizedFqdns.length
if (hasDuplicateFqdn) {
@@ -440,6 +211,7 @@ export function ServiceEditSheet({
const canSubmit = isCreate
? name.trim().length > 0 && slug.trim().length > 0
: Boolean(service)
const addressResetKey = `${mode}-${service?.id ?? 'new'}-${open ? 'open' : 'closed'}`
return (
<Sheet open={open} onOpenChange={onOpenChange}>
@@ -447,8 +219,8 @@ export function ServiceEditSheet({
<SheetHeader className="shrink-0 border-b pb-4">
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
<SheetDescription>
Общий домен и IP задаются у сервиса. Дополнительные FQDN ниже, зона
определяется автоматически.
Общие FQDN на весь пул IP. У каждого адреса можно указать несколько доп.
FQDN, в том числе wildcard.
</SheetDescription>
</SheetHeader>
@@ -495,162 +267,30 @@ export function ServiceEditSheet({
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-common-domain">
Общий домен (FQDN)
</FieldLabel>
<Input
id="edit-service-common-domain"
className="font-mono"
value={commonFqdn}
placeholder={
zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'
}
onChange={(e) => handleCommonFqdnChange(e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
<TaggedInput
id="edit-service-ips"
value={ips}
onChange={setIps}
placeholder="192.168.1.1"
validate={isValidIpv4}
/>
</Field>
</FieldGroup>
</section>
<ServiceAddressBlock
key={addressResetKey}
value={address}
onChange={setAddress}
zoneHints={zoneHints}
/>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Health check</h3>
<HealthCheckConfigFields
idPrefix="service-health"
value={primaryHealthValue}
onChange={handlePrimaryHealthChange}
ips={address.nodes.map((node) => node.ip)}
weights={address.target_ip_weights}
priorities={address.target_ip_priorities}
onMetaChange={(ip, meta) =>
setAddress((current) => patchAddressIpMeta(current, ip, meta))
}
/>
</section>
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">Доп. FQDN</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddExtraBinding}
>
<PlusIcon data-icon="inline-start" />
Добавить
</Button>
</div>
{extraBindings.length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет дополнительных FQDN
</p>
) : (
<ItemGroup className="gap-2">
{extraBindings.map((binding, extraIndex) => {
const index = extraIndex + 1
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
return (
<Item
key={`extra-binding-${index}`}
variant="outline"
size="sm"
className="items-stretch"
>
<ItemContent className="flex w-full min-w-0 flex-col gap-2">
<div className="flex items-center gap-2">
{parsedZone ? (
<Badge variant="outline" size="xs" className="font-mono">
{parsedZone.zoneName}
</Badge>
) : binding.fqdn.trim() ? (
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
) : (
<span className="text-muted-foreground text-xs">
FQDN
</span>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
className="ml-auto shrink-0"
aria-label="Удалить FQDN"
onClick={() => handleRemoveExtraBinding(extraIndex)}
>
<Trash2Icon />
</Button>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_7.5rem]">
<Input
id={`extra-fqdn-${index}`}
className="font-mono"
value={binding.fqdn}
onChange={(event) =>
handleFqdnChange(index, event.target.value)
}
placeholder={
zoneHints[0]
? `api.${zoneHints[0]}`
: 'api.ivx.su'
}
/>
<Select
items={[
{ label: 'A (IP)', value: 'A' },
{ label: 'CNAME', value: 'CNAME' },
]}
value={binding.record_type}
onValueChange={(value) =>
handleRecordTypeChange(
index,
(value ?? 'A') as 'A' | 'CNAME',
)
}
>
<SelectTrigger
id={`extra-type-${index}`}
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">A (IP)</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
</div>
{binding.record_type === 'CNAME' ? (
<Input
id={`extra-cname-${index}`}
value={binding.target_cname}
placeholder="mmsk.rkns.top"
onChange={(event) =>
handleCnameChange(index, event.target.value)
}
/>
) : (
<ServiceBindingIpInput
id={`extra-ip-${index}`}
value={binding.target_ips}
pool={ips}
onChange={(targetIps) =>
handleIpsChange(index, targetIps)
}
/>
)}
</ItemContent>
</Item>
)
})}
</ItemGroup>
)}
</section>
</div>
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
@@ -1,4 +1,5 @@
import { useMemo, useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { ColumnDef } from '@tanstack/react-table'
import {
GlobeIcon,
@@ -6,7 +7,9 @@ import {
PlusIcon,
SearchIcon,
ServerIcon,
ShieldCheckIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
@@ -15,9 +18,33 @@ import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-colu
import { IconTile } from '@/components/reui/icon-tile'
import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
import { ResourcePage } from '@/components/reui-kit'
import type { ServiceView } from '@/lib/schemas'
import {
latestHealthByIp,
resolveIpDisplayHealth,
type HealthLogProbe,
} from '@/lib/health-log'
import { certRelativeBadge } from '@/components/columns/certificates-columns'
import { certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatDate } from '@/lib/format'
import type { ServiceCertificateRow, ServiceView } from '@/lib/schemas'
import type { CertMonitoring } from '@cfdm/shared'
import {
certKeys,
checkServiceCertificates,
patchBindingCertMonitoring,
serviceCertificatesQueryOptions,
} from '@/queries'
import { Button } from '@cfdm/ui/components/button'
import { Switch } from '@cfdm/ui/components/switch'
import {
ToggleGroup,
ToggleGroupItem,
} from '@cfdm/ui/components/toggle-group'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
type HealthStatus = 'up' | 'down' | 'degraded' | 'unknown'
@@ -60,6 +87,7 @@ const TABS = [
{ id: 'ip', label: 'IP' },
{ id: 'fqdn', label: 'FQDN' },
{ id: 'nodes', label: 'Ноды' },
{ id: 'ssl', label: 'SSL' },
] as const
const HEALTH_OPTIONS = [
@@ -76,8 +104,12 @@ function mapNodeHealth(status: string): HealthStatus {
return 'unknown'
}
function buildIpRows(service: ServiceView): ServiceIpRow[] {
function buildIpRows(
service: ServiceView,
probes: readonly HealthLogProbe[] = [],
): ServiceIpRow[] {
const healthByIp = new Map(service.ip_health.map((row) => [row.ip, row]))
const liveByIp = latestHealthByIp(probes)
const weights = Object.assign(
{},
...service.domains.map((domain) => domain.target_ip_weights ?? {}),
@@ -90,19 +122,25 @@ function buildIpRows(service: ServiceView): ServiceIpRow[] {
return service.ips.map((ip) => {
const health = healthByIp.get(ip)
const live = liveByIp.get(ip)
const status = resolveIpDisplayHealth(health?.status, live?.status)
const extras = live && live.status !== 'unknown' ? live : health
return {
id: ip,
ip,
status: health?.status ?? 'unknown',
status,
enabled: service.ip_enabled[ip] !== false,
active: activeSet.has(ip),
weight: weights[ip] ?? 1,
priority: priorities[ip] ?? 1,
latency_ms: health?.latency_ms ?? null,
last_checked_at: health?.last_checked_at ?? null,
last_error: health?.last_error ?? null,
colo: health?.colo ?? null,
provider: health?.provider ?? null,
latency_ms: extras?.latency_ms ?? null,
last_checked_at: extras?.last_checked_at ?? null,
last_error:
live && live.status !== 'unknown'
? live.last_error
: (health?.last_error ?? null),
colo: extras?.colo ?? null,
provider: extras?.provider ?? null,
}
})
}
@@ -128,17 +166,23 @@ function buildNodeRows(
weight: number
priority: number
}>,
probes: readonly HealthLogProbe[] = [],
): ServiceNodeRow[] {
return nodes.map((node) => ({
id: String(node.id),
nodeId: node.id,
address: node.address,
protocol: node.protocol,
port: node.port,
health_status: mapNodeHealth(node.health_status),
weight: node.weight,
priority: node.priority,
}))
const liveByIp = latestHealthByIp(probes)
return nodes.map((node) => {
const stored = mapNodeHealth(node.health_status)
const live = liveByIp.get(node.address)
return {
id: String(node.id),
nodeId: node.id,
address: node.address,
protocol: node.protocol,
port: node.port,
health_status: resolveIpDisplayHealth(stored, live?.status),
weight: node.weight,
priority: node.priority,
}
})
}
function NameCell({
@@ -176,6 +220,7 @@ interface ServiceDetailGridProps {
weight: number
priority: number
}>
probes?: readonly HealthLogProbe[]
togglingIp: string | null
onToggleIp: (ip: string, enabled: boolean) => void
onChangeIp: (row: ServiceFqdnRow) => void
@@ -188,6 +233,7 @@ interface ServiceDetailGridProps {
export function ServiceDetailGrid({
service,
nodes,
probes = [],
togglingIp,
onToggleIp,
onChangeIp,
@@ -208,10 +254,61 @@ export function ServiceDetailGrid({
createFilter('address', 'contains', ['']),
createFilter('health_status', 'is', ['']),
])
const [sslFilters, setSslFilters] = useState<Filter[]>(() => [
createFilter('hostname', 'contains', ['']),
])
const ipRows = useMemo(() => buildIpRows(service), [service])
const queryClient = useQueryClient()
const certQuery = useQuery(serviceCertificatesQueryOptions(service.id))
const sslRows = certQuery.data ?? []
const patchCertMonitoring = useMutation({
mutationFn: ({
bindingId,
mode,
}: {
bindingId: number
mode: CertMonitoring
}) => patchBindingCertMonitoring(bindingId, mode),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: certKeys.byService(service.id) }),
queryClient.invalidateQueries({ queryKey: certKeys.all }),
queryClient.invalidateQueries({ queryKey: certKeys.summary }),
])
toast.success('Режим проверки SSL обновлён')
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось обновить режим SSL',
)
},
})
const checkSsl = useMutation({
mutationFn: () => checkServiceCertificates(service.id),
onSuccess: async (result) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: certKeys.byService(service.id) }),
queryClient.invalidateQueries({ queryKey: certKeys.all }),
queryClient.invalidateQueries({ queryKey: certKeys.summary }),
])
toast.success(
result.checked > 0
? `Проверено FQDN: ${result.checked}`
: 'Нет FQDN для проверки SSL',
)
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось проверить SSL',
)
},
})
const ipRows = useMemo(() => buildIpRows(service, probes), [service, probes])
const fqdnRows = useMemo(() => buildFqdnRows(service), [service])
const nodeRows = useMemo(() => buildNodeRows(nodes), [nodes])
const nodeRows = useMemo(() => buildNodeRows(nodes, probes), [nodes, probes])
const markActive = service.lb_mode === 'failover' || service.lb_mode === 'weighted'
const tabs = TABS.map((entry) => ({
@@ -221,7 +318,9 @@ export function ServiceDetailGrid({
? ipRows.length
: entry.id === 'fqdn'
? fqdnRows.length
: nodeRows.length,
: entry.id === 'ssl'
? sslRows.length
: nodeRows.length,
}))
const ipFilterFields = useMemo<FilterFieldConfig[]>(
@@ -282,6 +381,20 @@ export function ServiceDetailGrid({
[],
)
const sslFilterFields = useMemo<FilterFieldConfig[]>(
() => [
{
key: 'hostname',
label: 'FQDN',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'Поиск по FQDN…',
},
],
[],
)
const ipColumns = useMemo<ColumnDef<ServiceIpRow>[]>(
() => [
{
@@ -473,6 +586,91 @@ export function ServiceDetailGrid({
[onDeleteNode],
)
const sslColumns = useMemo<ColumnDef<ServiceCertificateRow>[]>(
() => [
{
id: 'hostname',
accessorKey: 'hostname',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="FQDN" />
),
cell: ({ row }) => (
<NameCell
icon={<ShieldCheckIcon />}
label={row.original.hostname}
iconClassName="text-foreground"
/>
),
},
{
id: 'status',
header: 'Статус',
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'expires_at',
accessorKey: 'expires_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Истекает" />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{formatDate(row.original.expires_at)}
</span>
),
},
{
id: 'relative',
header: 'Срок',
cell: ({ row }) =>
certRelativeBadge(row.original.status, row.original.expires_at),
},
{
id: 'mode',
header: 'Режим',
cell: ({ row }) => (
<ToggleGroup
variant="outline"
size="sm"
value={[row.original.cert_monitoring]}
onValueChange={(next) => {
const value = Array.isArray(next) ? next[0] : next
if (
typeof value !== 'string' ||
value === row.original.cert_monitoring
) {
return
}
patchCertMonitoring.mutate({
bindingId: row.original.binding_id,
mode: value as CertMonitoring,
})
}}
>
{certMonitoringOptions.map((option) => (
<ToggleGroupItem key={option.value} value={option.value}>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
),
},
{
id: 'last_checked_at',
accessorKey: 'last_checked_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Проверка" />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{formatDate(row.original.last_checked_at)}
</span>
),
},
],
[patchCertMonitoring],
)
const sharedTabs = {
tabs,
activeTab: tab,
@@ -544,6 +742,52 @@ export function ServiceDetailGrid({
)
}
if (tab === 'ssl') {
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN, ноды и SSL этого сервиса"
{...sharedTabs}
filterFields={sslFilterFields}
filters={sslFilters}
onFiltersChange={setSslFilters}
onClearFilters={() =>
setSslFilters([createFilter('hostname', 'contains', [''])])
}
getFilterFieldValue={(item, field) =>
field === 'hostname' ? item.hostname : ''
}
columns={sslColumns}
data={sslRows}
getRowId={(row) => String(row.binding_id)}
isLoading={isLoading || certQuery.isLoading}
primaryAction={
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Проверить SSL"
disabled={checkSsl.isPending || sslRows.length === 0}
onClick={() => checkSsl.mutate()}
/>
}
>
<ShieldCheckIcon className="size-4.5" aria-hidden />
</TooltipTrigger>
<TooltipContent>Проверить</TooltipContent>
</Tooltip>
}
emptyState={{
title: 'Нет FQDN',
description: 'Привяжите домен к сервису, чтобы мониторить SSL.',
}}
/>
)
}
return (
<ResourcePage
title="Активы сервиса"
@@ -133,6 +133,7 @@ const VISIBLE_IP_LIMIT = 6
interface ServiceIpListProps {
ips: string[]
ipHealth?: ServiceView['ip_health']
healthCheckEnabled?: boolean
ipEnabled?: Record<string, boolean>
togglingIp?: string | null
ipToggleDisabled?: boolean
@@ -151,6 +152,7 @@ interface ServiceIpListProps {
export function ServiceIpList({
ips,
ipHealth = [],
healthCheckEnabled = true,
ipEnabled = {},
togglingIp = null,
ipToggleDisabled = false,
@@ -184,6 +186,10 @@ export function ServiceIpList({
{visible.map((ip) => {
const health = healthByIp.get(ip)
const enabled = ipEnabled[ip] !== false
const monitored = healthCheckEnabled && enabled
const badgeStatus = monitored
? (health?.status ?? 'unknown')
: 'disabled'
return (
<Item
key={ip}
@@ -192,7 +198,7 @@ export function ServiceIpList({
>
<ItemMedia>
<HealthCheckBadge
status={health?.status ?? 'unknown'}
status={badgeStatus}
latencyMs={health?.latency_ms}
lastCheckedAt={health?.last_checked_at}
lastError={health?.last_error}
@@ -5,6 +5,7 @@ import {
Repeat2Icon,
ScaleIcon,
ServerIcon,
UnplugIcon,
type LucideIcon,
} from 'lucide-react'
@@ -21,6 +22,7 @@ import {
ServiceIpList,
} from '@/components/services/service-fqdn-list'
import { serviceDisplayFqdn, serviceDisplayFqdns } from '@/lib/service-utils'
import { uniqueIpCount } from '@/lib/failover-events'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
@@ -71,11 +73,39 @@ const LB_MODE_META: Record<
weighted: {
icon: ScaleIcon,
className: 'text-info',
label: 'Weighted (веса)',
label: 'Веса (подмена IP)',
},
}
export function LbModeTile({ mode }: { mode: LbMode }) {
export function LbModeTile({
mode,
hasPool = true,
}: {
mode: LbMode
hasPool?: boolean
}) {
if (!hasPool) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<IconTile
variant="elevated"
size="xs"
className="shrink-0 text-muted-foreground"
aria-label="без резервирования"
/>
}
>
<UnplugIcon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>без резервирования</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
const meta = LB_MODE_META[mode]
const Icon = meta.icon
@@ -148,7 +178,10 @@ export function ServiceUnitCard({
{service.name}
</Link>
</FrameTitle>
<LbModeTile mode={service.lb_mode} />
<LbModeTile
mode={service.lb_mode}
hasPool={uniqueIpCount(service.ips) >= 2}
/>
</div>
<div className="flex min-w-0 items-center gap-1">
<FrameDescription className="min-w-0 truncate font-mono text-xs">
@@ -240,6 +273,10 @@ export function ServiceUnitCard({
alignWithMenu
ips={service.ips ?? []}
ipHealth={service.ip_health ?? []}
healthCheckEnabled={
service.enabled &&
(service.domains ?? []).some((domain) => domain.health_check_enabled)
}
ipEnabled={service.ip_enabled ?? {}}
ipToggleDisabled={togglingId === service.id}
togglingIp={togglingIp}
@@ -3,10 +3,8 @@ import { useEffect, useMemo } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import type { CertMonitoring } from '@cfdm/shared'
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
import type { SubdomainServiceLink } from '@/hooks/use-domain-page'
import { certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatServiceGroupLabel } from '@/lib/service-utils'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
@@ -30,7 +28,6 @@ import {
const subdomainEditSchema = z.object({
name: z.string().min(1, 'Укажите имя'),
serviceId: z.string(),
certMonitoring: z.enum(['auto', 'required', 'skipped']),
})
export type SubdomainEditValues = z.infer<typeof subdomainEditSchema>
@@ -67,7 +64,6 @@ export function SubdomainEditSheet({
defaultValues: {
name: '',
serviceId: 'none',
certMonitoring: 'auto',
},
})
@@ -85,42 +81,27 @@ export function SubdomainEditSheet({
[services, serviceGroupById],
)
const certMonitoringItems = useMemo(
() =>
certMonitoringOptions.map((option) => ({
label: option.label,
value: option.value,
})),
[],
)
useEffect(() => {
if (!open) return
if (mode === 'edit' && subdomain) {
form.reset({
name: subdomain.name,
serviceId: currentServiceId || 'none',
certMonitoring: subdomain.cert_monitoring,
})
return
}
form.reset({
name: '',
serviceId: 'none',
certMonitoring: 'auto',
})
}, [open, mode, subdomain, currentServiceId, form])
const certMonitoring = form.watch('certMonitoring')
const certHint =
certMonitoringOptions.find((o) => o.value === certMonitoring)?.description
const hasMultipleServices = mode === 'edit' && serviceLinks.length > 1
function handleSubmit(values: SubdomainEditValues) {
onSubmit({
name: values.name.trim(),
serviceId: values.serviceId,
certMonitoring: values.certMonitoring as CertMonitoring,
})
}
@@ -218,39 +199,6 @@ export function SubdomainEditSheet({
)}
/>
</FormFieldSimple>
<FormFieldSimple
label="Мониторинг SSL"
htmlFor="subdomain_cert_monitoring"
hint={certHint}
>
<Controller
control={form.control}
name="certMonitoring"
render={({ field }) => (
<Select
items={certMonitoringItems}
value={field.value}
onValueChange={(value) =>
field.onChange((value ?? 'auto') as CertMonitoring)
}
>
<SelectTrigger
id="subdomain_cert_monitoring"
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{certMonitoringOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FormFieldSimple>
</>
) : null}
</FieldGroup>
-17
View File
@@ -13,7 +13,6 @@ import {
serviceGroupsQueryOptions,
servicesQueryOptions,
subdomainsListQueryOptions,
updateDomain,
updateSubdomain,
} from '@/queries'
import type { CertMonitoring } from '@cfdm/shared'
@@ -172,21 +171,6 @@ export function useDomainPage(domainId: number) {
},
})
const updateDomainCertMonitoringMutation = useMutation({
mutationFn: (certMonitoring: CertMonitoring) =>
updateDomain(domainId, { cert_monitoring: certMonitoring }),
onSuccess: () => {
invalidate()
void queryClient.invalidateQueries({ queryKey: ['domains'] })
toast.success('Режим мониторинга SSL обновлён')
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось обновить мониторинг SSL',
)
},
})
const deleteSubdomainMutation = useMutation({
mutationFn: (id: number) => deleteSubdomain(id),
onSuccess: () => {
@@ -249,7 +233,6 @@ export function useDomainPage(domainId: number) {
syncMutation,
createSubdomainMutation,
updateSubdomainMutation,
updateDomainCertMonitoringMutation,
deleteSubdomainMutation,
linkServiceMutation,
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { dedupeBreadcrumbs, getBreadcrumbs } from './breadcrumbs'
describe('getBreadcrumbs', () => {
it('keeps a single Настройки parent plus the active section', () => {
expect(getBreadcrumbs('/settings/appearance')).toEqual([
{ label: 'Настройки', href: '/settings' },
{ label: 'Внешний вид', href: '/settings/appearance' },
])
expect(getBreadcrumbs('/settings/health')).toEqual([
{ label: 'Настройки', href: '/settings' },
{ label: 'Health-check', href: '/settings/health' },
])
expect(getBreadcrumbs('/settings/integrations')).toEqual([
{ label: 'Настройки', href: '/settings' },
{ label: 'Интеграции', href: '/settings/integrations' },
])
})
it('does not reuse the section href for the parent crumb', () => {
const crumbs = getBreadcrumbs('/settings/appearance')
const hrefs = crumbs.map((crumb) => crumb.href)
expect(new Set(hrefs).size).toBe(hrefs.length)
})
})
describe('dedupeBreadcrumbs', () => {
it('collapses stacked identical labels from repeated navigations', () => {
expect(
dedupeBreadcrumbs([
{ label: 'Настройки', href: '/settings' },
{ label: 'Настройки', href: '/settings' },
{ label: 'Настройки', href: '/settings/appearance' },
{ label: 'Внешний вид', href: '/settings/appearance' },
]),
).toEqual([
{ label: 'Настройки', href: '/settings' },
{ label: 'Внешний вид', href: '/settings/appearance' },
])
})
})
+86
View File
@@ -0,0 +1,86 @@
export interface BreadcrumbCrumb {
label: string
href: string
}
const routeTitles: Record<string, string> = {
'/': 'Панель управления',
'/domains': 'Домены',
'/groups': 'Группы доменов',
'/services': 'Сервисы',
'/certificates': 'Сертификаты',
}
const SETTINGS_SECTIONS: Record<string, string> = {
'/settings/appearance': 'Внешний вид',
'/settings/health': 'Health-check',
'/settings/integrations': 'Интеграции',
}
/** Drop consecutive repeats so «Настройки» does not stack after tab switches. */
export function dedupeBreadcrumbs(crumbs: BreadcrumbCrumb[]): BreadcrumbCrumb[] {
const out: BreadcrumbCrumb[] = []
for (const crumb of crumbs) {
const prev = out.at(-1)
if (prev && prev.label === crumb.label) continue
out.push(crumb)
}
return out
}
export function getBreadcrumbs(
pathname: string,
dynamicLabels: Record<string, string> = {},
): BreadcrumbCrumb[] {
const path = pathname.replace(/\/+$/, '') || '/'
if (path === '/') {
return [{ label: 'Панель управления', href: '/' }]
}
if (path.match(/^\/services\/\d+$/)) {
return [
{ label: 'Сервисы', href: '/services' },
{ label: dynamicLabels[path] ?? 'Сервис', href: path },
]
}
if (path.match(/^\/groups\/\d+$/)) {
return [
{ label: 'Группы доменов', href: '/groups' },
{ label: dynamicLabels[path] ?? 'Группа', href: path },
]
}
if (path.match(/^\/domains\/\d+\/dns$/)) {
const domainId = path.split('/')[2]
const domainPath = `/domains/${domainId}`
return [
{ label: 'Домены', href: '/domains' },
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
{ label: 'DNS', href: path },
]
}
if (path.match(/^\/domains\/\d+$/)) {
return [
{ label: 'Домены', href: '/domains' },
{ label: dynamicLabels[path] ?? 'Обзор домена', href: path },
]
}
if (path === '/settings' || path.startsWith('/settings/')) {
const section = SETTINGS_SECTIONS[path]
return dedupeBreadcrumbs([
{ label: 'Настройки', href: '/settings' },
...(section ? [{ label: section, href: path }] : []),
])
}
const title = routeTitles[path]
if (title) {
return [{ label: title, href: path }]
}
return [{ label: 'Панель управления', href: '/' }]
}
+3 -3
View File
@@ -8,17 +8,17 @@ export const certMonitoringOptions: Array<{
{
value: 'auto',
label: 'Авто',
description: 'Проверять, если хост обслуживается активным сервисом',
description: 'Проверять, если health-check сервиса с verify TLS',
},
{
value: 'required',
label: 'Обязательно',
description: 'Всегда проверять SSL, даже без привязок',
description: 'Всегда проверять SSL для этого FQDN',
},
{
value: 'skipped',
label: 'Не проверять',
description: 'Исключить из мониторинга сертификатов',
description: 'Исключить FQDN из мониторинга сертификатов',
},
]
+336
View File
@@ -0,0 +1,336 @@
import { describe, expect, it } from 'vitest'
import {
failoverEventCopy,
isFailoverEventStatus,
mergeFailoverHistory,
toFailoverEvents,
toIpAliveTransitions,
type FailoverBindingPool,
type FailoverHealthInput,
} from '@/lib/failover-events'
import type { FailoverLogEntry } from '@/lib/schemas'
import type { HealthLogProbe } from '@/lib/health-log'
function row(
overrides: Partial<FailoverHealthInput> & Pick<FailoverHealthInput, 'ip'>,
): FailoverHealthInput {
return {
status: 'up',
consecutive_failures: 0,
last_error: null,
last_checked_at: null,
...overrides,
}
}
const mskHip: FailoverBindingPool[] = [
{
fqdn: 'gt.rkns.top',
configured: ['130.49.213.153', '93.115.203.183'],
active: ['93.115.203.183'],
},
{
fqdn: 'nsgt.rkns.top',
configured: ['130.49.213.153'],
active: ['130.49.213.153'],
},
{
fqdn: 'rutg.rkns.top',
configured: ['93.115.203.183'],
active: ['93.115.203.183'],
},
]
describe('toFailoverEvents', () => {
it('инцидент только при binding down, не при unhealthy ноды', () => {
expect(isFailoverEventStatus('down')).toBe(true)
expect(isFailoverEventStatus('up')).toBe(false)
expect(isFailoverEventStatus('degraded')).toBe(false)
expect(isFailoverEventStatus('unknown')).toBe(false)
expect(isFailoverEventStatus('unhealthy')).toBe(false)
})
it('один IP у сервиса — не инцидент пула', () => {
const events = toFailoverEvents(
[
row({
ip: '10.0.0.1',
status: 'down',
consecutive_failures: 9,
last_error: 'fetch failed',
}),
],
[
{
fqdn: 'solo.example.com',
configured: ['10.0.0.1'],
active: [],
},
],
)
expect(events).toEqual([])
})
it('Down без shared pool не событие балансировки', () => {
const events = toFailoverEvents([
row({
ip: '130.49.213.153',
status: 'down',
consecutive_failures: 9,
last_error: 'fetch failed',
}),
])
expect(events).toEqual([])
})
it('OK вне пула не инцидент (standby)', () => {
const events = toFailoverEvents(
[
row({ ip: '10.0.0.1', status: 'up' }),
row({ ip: '130.49.213.153', status: 'up' }),
],
[
{
fqdn: 'pool.example.com',
configured: ['10.0.0.1', '130.49.213.153'],
active: ['10.0.0.1'],
},
],
)
expect(events).toEqual([])
})
it('unknown и degraded вне пула не инцидент', () => {
const events = toFailoverEvents(
[
row({ ip: '10.0.0.1', status: 'up' }),
row({ ip: '10.0.0.2', status: 'unknown' }),
row({ ip: '10.0.0.3', status: 'degraded' }),
],
[
{
fqdn: 'pool.example.com',
configured: ['10.0.0.1', '10.0.0.2', '10.0.0.3'],
active: ['10.0.0.1'],
},
],
)
expect(events).toEqual([])
})
it('Down на доп. FQDN last-resort, снятая с общего пула', () => {
const events = toFailoverEvents(
[
row({
ip: '130.49.213.153',
status: 'down',
consecutive_failures: 9,
last_error: 'fetch failed',
last_checked_at: '2026-08-20 07:00:00',
}),
row({ ip: '93.115.203.183', status: 'up' }),
],
mskHip,
)
expect(events).toEqual([
{
id: '130.49.213.153',
address: '130.49.213.153',
status: 'down',
kind: 'removed',
fqdns: ['gt.rkns.top'],
consecutiveFailures: 9,
lastFailureReason: 'fetch failed',
lastCheckAt: '2026-08-20 07:00:00',
},
])
expect(failoverEventCopy(events[0]!)).toBe('Снята с gt.rkns.top')
expect(events[0]?.fqdns).not.toContain('nsgt.rkns.top')
})
it('Down только last-resort на своём FQDN', () => {
const events = toFailoverEvents(
[row({ ip: '130.49.213.153', status: 'down' })],
[
{
fqdn: 'nsgt.rkns.top',
configured: ['130.49.213.153'],
active: ['130.49.213.153'],
},
],
)
expect(events).toEqual([])
})
it('down без A-записи на configured FQDN — снятие', () => {
const events = toFailoverEvents(
[
row({
ip: '130.49.213.153',
status: 'down',
consecutive_failures: 9,
last_error: 'fetch failed',
last_checked_at: '2026-08-20 07:00:00',
}),
],
[
{
fqdn: 'pool.example.com',
configured: ['10.0.0.1', '130.49.213.153'],
active: ['10.0.0.1'],
},
],
)
expect(events).toEqual([
{
id: '130.49.213.153',
address: '130.49.213.153',
status: 'down',
kind: 'removed',
fqdns: ['pool.example.com'],
consecutiveFailures: 9,
lastFailureReason: 'fetch failed',
lastCheckAt: '2026-08-20 07:00:00',
},
])
})
})
function probe(
overrides: Partial<HealthLogProbe> & Pick<HealthLogProbe, 'id' | 'status' | 'checked_at'>,
): HealthLogProbe {
return {
ip: '130.49.213.153',
provider: 'local',
ok: overrides.status === 'up',
latency_ms: 12,
colo: null,
error: null,
...overrides,
}
}
function dns(
overrides: Partial<FailoverLogEntry> & Pick<FailoverLogEntry, 'id' | 'action' | 'created_at'>,
): FailoverLogEntry {
return {
service_id: 13,
binding_id: 1,
fqdn: 'gt.rkns.top',
ip: '130.49.213.153',
...overrides,
}
}
describe('toIpAliveTransitions', () => {
it('emits leave then return, not the initial up', () => {
const transitions = toIpAliveTransitions([
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
probe({ id: 2, status: 'up', checked_at: '2026-08-20T09:01:00Z' }),
probe({ id: 3, status: 'down', checked_at: '2026-08-20T09:02:00Z' }),
probe({ id: 4, status: 'down', checked_at: '2026-08-20T09:03:00Z' }),
probe({ id: 5, status: 'up', checked_at: '2026-08-20T09:04:00Z' }),
])
expect(transitions).toEqual([
{ id: 'probe:3', ip: '130.49.213.153', alive: false, at: '2026-08-20T09:02:00Z' },
{ id: 'probe:5', ip: '130.49.213.153', alive: true, at: '2026-08-20T09:04:00Z' },
])
})
it('any-up across providers: leave only when every source is down', () => {
const transitions = toIpAliveTransitions([
probe({
id: 1,
provider: 'local',
status: 'up',
checked_at: '2026-08-20T09:00:00Z',
}),
probe({
id: 2,
provider: 'cloudflare',
status: 'down',
checked_at: '2026-08-20T09:01:00Z',
}),
probe({
id: 3,
provider: 'local',
status: 'down',
checked_at: '2026-08-20T09:02:00Z',
}),
probe({
id: 4,
provider: 'local',
status: 'up',
checked_at: '2026-08-20T09:03:00Z',
}),
])
expect(transitions.map((item) => item.id)).toEqual(['probe:3', 'probe:4'])
})
})
describe('mergeFailoverHistory', () => {
it('fills leave/return from probes when DNS has only the add', () => {
const history = mergeFailoverHistory(
[dns({ id: 10, action: 'added', created_at: '2026-08-20 09:26:00' })],
[
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
probe({ id: 2, status: 'down', checked_at: '2026-08-20T09:10:00Z' }),
probe({ id: 3, status: 'up', checked_at: '2026-08-20T09:26:30Z' }),
],
mskHip,
)
expect(history.map((item) => `${item.action}:${item.source}`)).toEqual([
'added:dns',
'removed:probe',
])
expect(history[0]?.copy).toBe('130.49.213.153 добавлена на gt.rkns.top')
expect(history[1]?.copy).toBe(
'130.49.213.153 вышла из пула (gt.rkns.top)',
)
})
it('keeps DNS over a probe return in the same 2-minute window', () => {
const history = mergeFailoverHistory(
[dns({ id: 10, action: 'added', created_at: '2026-08-20 09:26:00' })],
[probe({ id: 3, status: 'up', checked_at: '2026-08-20T09:26:30Z' })],
mskHip,
)
expect(history).toHaveLength(1)
expect(history[0]?.source).toBe('dns')
})
it('drops probe leave/return when the service has no shared pool', () => {
const history = mergeFailoverHistory(
[],
[
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
probe({ id: 2, status: 'down', checked_at: '2026-08-20T09:10:00Z' }),
],
[
{
fqdn: 'solo.example.com',
configured: ['130.49.213.153'],
active: ['130.49.213.153'],
},
],
)
expect(history).toEqual([])
})
it('drops dedicated extra-FQDN DNS rows', () => {
const history = mergeFailoverHistory(
[
dns({
id: 11,
fqdn: 'nsgt.rkns.top',
action: 'added',
created_at: '2026-08-20 09:26:00',
}),
],
[],
mskHip,
)
expect(history).toEqual([])
})
})
+262
View File
@@ -0,0 +1,262 @@
import {
bestAliveHealthStatus,
probeTime,
type HealthLogProbe,
type HealthLogStatus,
} from '@/lib/health-log'
import type { FailoverLogEntry } from '@/lib/schemas'
export type FailoverEventKind = 'removed' | 'last-resort'
export interface FailoverHistoryItem {
id: string
ip: string
fqdn: string
action: 'added' | 'removed'
created_at: string
copy: string
source: 'dns' | 'probe'
}
export interface FailoverEvent {
id: string
address: string
status: string
kind: FailoverEventKind
fqdns: string[]
consecutiveFailures: number
lastFailureReason: string | null
lastCheckAt?: string | null
}
/** Binding IP health — тот же контур, что таблица активов. */
export interface FailoverHealthInput {
ip: string
status: string
consecutive_failures?: number
last_error?: string | null
last_checked_at?: string | null
}
export interface FailoverBindingPool {
fqdn: string
configured: readonly string[]
active: readonly string[]
}
/** Unique A targets. Duplicates of the same IP are not a pool. */
export function uniqueIpCount(ips: readonly string[]): number {
return new Set(ips.filter(Boolean)).size
}
/** Shared pool FQDN — two or more unique A targets. */
export function isSharedPoolBinding(binding: FailoverBindingPool): boolean {
return uniqueIpCount(binding.configured) >= 2
}
export function hasSharedPool(
bindings: readonly FailoverBindingPool[],
): boolean {
return bindings.some(isSharedPoolBinding)
}
/** Инцидент только при down. up / degraded / unknown — не вывод из пула. */
export function isFailoverEventStatus(status: string): boolean {
return status === 'down'
}
export function failoverEventCopy(event: FailoverEvent): string {
if (event.kind === 'removed') {
return event.fqdns.length > 0
? `Снята с ${event.fqdns.join(', ')}`
: 'Снята с DNS'
}
return event.fqdns.length > 0
? `Down, в A-записях last-resort на ${event.fqdns.join(', ')}`
: 'Down, в A-записях last-resort'
}
/**
* Инциденты пула только если у сервиса есть shared FQDN (2+ уникальных IP).
* Один IP не балансировка и не вывод из пула; Down смотрит health-монитор.
*/
export function toFailoverEvents(
ipHealth: readonly FailoverHealthInput[],
bindings: readonly FailoverBindingPool[] = [],
): FailoverEvent[] {
if (!hasSharedPool(bindings)) return []
return ipHealth.filter((row) => isFailoverEventStatus(row.status)).map((row) => {
const removedFqdns: string[] = []
const lastResortFqdns: string[] = []
for (const binding of bindings) {
if (!isSharedPoolBinding(binding)) continue
const configured = binding.configured.includes(row.ip)
const active = binding.active.includes(row.ip)
if (configured && !active) removedFqdns.push(binding.fqdn)
else if (configured && active) lastResortFqdns.push(binding.fqdn)
}
const kind: FailoverEventKind =
removedFqdns.length > 0 ? 'removed' : 'last-resort'
return {
id: row.ip,
address: row.ip,
status: row.status,
kind,
fqdns: kind === 'removed' ? removedFqdns : lastResortFqdns,
consecutiveFailures: row.consecutive_failures ?? 0,
lastFailureReason: row.last_error ?? null,
lastCheckAt: row.last_checked_at ?? null,
}
})
}
const DEDUPE_WINDOW_MS = 2 * 60 * 1000
function fqdnsForIp(
ip: string,
bindings: readonly FailoverBindingPool[],
): string[] {
return bindings
.filter((binding) => isSharedPoolBinding(binding) && binding.configured.includes(ip))
.map((binding) => binding.fqdn)
}
function isPoolFqdn(
fqdn: string,
bindings: readonly FailoverBindingPool[],
): boolean {
const binding = bindings.find((item) => item.fqdn === fqdn)
if (!binding) return true
return isSharedPoolBinding(binding)
}
function fqdnLabel(fqdns: string[]): string {
return fqdns.join(', ') || 'пул'
}
function isAliveStatus(status: HealthLogStatus): boolean {
return status === 'up' || status === 'degraded'
}
/**
* Per-IP any-up flips: down вышла из пула, up после down вернулась.
* Initial state is not an event.
*/
export function toIpAliveTransitions(
probes: readonly HealthLogProbe[],
): Array<{ id: string; ip: string; alive: boolean; at: string }> {
const byIp = new Map<string, HealthLogProbe[]>()
for (const item of probes) {
const list = byIp.get(item.ip)
if (list) list.push(item)
else byIp.set(item.ip, [item])
}
const out: Array<{ id: string; ip: string; alive: boolean; at: string }> = []
for (const [ip, list] of byIp) {
list.sort(
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
)
const latestByProvider = new Map<string, HealthLogProbe>()
let prevAlive: boolean | undefined
for (const probe of list) {
latestByProvider.set(probe.provider, probe)
const status = bestAliveHealthStatus(
[...latestByProvider.values()].map((item) => item.status),
)
if (status === 'unknown') continue
const alive = isAliveStatus(status)
if (prevAlive !== undefined && alive !== prevAlive) {
out.push({
id: `probe:${probe.id}`,
ip,
alive,
at: probe.checked_at,
})
}
prevAlive = alive
}
}
return out
}
function dnsHistoryItem(item: FailoverLogEntry): FailoverHistoryItem {
return {
id: `dns:${item.id}`,
ip: item.ip,
fqdn: item.fqdn,
action: item.action,
created_at: item.created_at,
copy:
item.action === 'removed'
? `${item.ip} убрана с ${item.fqdn}`
: `${item.ip} добавлена на ${item.fqdn}`,
source: 'dns',
}
}
function probeHistoryItem(
transition: { id: string; ip: string; alive: boolean; at: string },
bindings: readonly FailoverBindingPool[],
): FailoverHistoryItem {
const fqdns = fqdnsForIp(transition.ip, bindings)
const fqdn = fqdnLabel(fqdns)
const action = transition.alive ? 'added' : 'removed'
return {
id: transition.id,
ip: transition.ip,
fqdn,
action,
created_at: transition.at,
copy:
action === 'removed'
? `${transition.ip} вышла из пула (${fqdn})`
: `${transition.ip} вернулась в пул (${fqdn})`,
source: 'probe',
}
}
function eventTime(value: string): number {
return probeTime(value)
}
/**
* DNS add/remove + health leave/return, newest first.
* Same IP+action within 2 minutes: keep the DNS row (it has a concrete FQDN).
*/
export function mergeFailoverHistory(
dns: readonly FailoverLogEntry[],
probes: readonly HealthLogProbe[],
bindings: readonly FailoverBindingPool[] = [],
): FailoverHistoryItem[] {
const merged = [
...dns
.filter((item) => isPoolFqdn(item.fqdn, bindings))
.map(dnsHistoryItem),
...toIpAliveTransitions(probes)
.filter((transition) => fqdnsForIp(transition.ip, bindings).length > 0)
.map((transition) => probeHistoryItem(transition, bindings)),
]
merged.sort(
(a, b) => eventTime(b.created_at) - eventTime(a.created_at) || a.id.localeCompare(b.id),
)
const kept: FailoverHistoryItem[] = []
for (const item of merged) {
const duplicate = kept.find(
(other) =>
other.ip === item.ip &&
other.action === item.action &&
Math.abs(eventTime(other.created_at) - eventTime(item.created_at)) <=
DEDUPE_WINDOW_MS,
)
if (!duplicate) {
kept.push(item)
continue
}
if (duplicate.source === 'probe' && item.source === 'dns') {
kept[kept.indexOf(duplicate)] = item
}
}
return kept
}
+73 -2
View File
@@ -1,9 +1,13 @@
import { describe, expect, it } from 'vitest'
import {
bestAliveHealthStatus,
collapseStatusChanges,
enabledHealthProviders,
latestHealthByIp,
providerHealthStatuses,
resolveIpDisplayHealth,
resolveServiceDisplayHealth,
worstHealthStatus,
type HealthLogProbe,
} from '@/lib/health-log'
@@ -73,7 +77,7 @@ describe('enabledHealthProviders', () => {
})
describe('providerHealthStatuses', () => {
it('uses worst latest-per-ip status', () => {
it('uses any-up among latest-per-ip statuses', () => {
const items = [
probe({ id: 1, ip: '1.1.1.1', status: 'up', checked_at: '2026-01-01T00:02:00Z' }),
probe({ id: 2, ip: '2.2.2.2', status: 'down', checked_at: '2026-01-01T00:01:00Z' }),
@@ -84,7 +88,7 @@ describe('providerHealthStatuses', () => {
checked_at: '2026-01-01T00:00:00Z',
}),
]
expect(providerHealthStatuses(items, ['local']).local).toBe('down')
expect(providerHealthStatuses(items, ['local']).local).toBe('up')
})
})
@@ -95,3 +99,70 @@ describe('worstHealthStatus', () => {
expect(worstHealthStatus([])).toBe('unknown')
})
})
describe('bestAliveHealthStatus', () => {
it('is up if any IP is up', () => {
expect(bestAliveHealthStatus(['up', 'down'])).toBe('up')
expect(bestAliveHealthStatus(['degraded', 'down'])).toBe('degraded')
expect(bestAliveHealthStatus(['down'])).toBe('down')
expect(bestAliveHealthStatus([])).toBe('unknown')
})
})
describe('latestHealthByIp', () => {
it('any-up among latest-per-provider probes', () => {
const items = [
probe({
id: 1,
ip: '130.49.213.153',
provider: 'local',
status: 'up',
checked_at: '2026-08-20T09:00:00Z',
}),
probe({
id: 2,
ip: '130.49.213.153',
provider: 'cloudflare',
status: 'down',
checked_at: '2026-08-20T09:00:01Z',
}),
probe({
id: 3,
ip: '93.115.203.183',
status: 'unknown',
checked_at: '2026-08-20T08:59:00Z',
}),
]
const byIp = latestHealthByIp(items)
expect(byIp.get('130.49.213.153')?.status).toBe('up')
expect(byIp.get('93.115.203.183')?.status).toBe('unknown')
})
})
describe('resolveIpDisplayHealth', () => {
it('prefers a live probe over stored unknown', () => {
expect(resolveIpDisplayHealth('unknown', 'up')).toBe('up')
expect(resolveIpDisplayHealth('down', 'up')).toBe('up')
expect(resolveIpDisplayHealth('up', 'unknown')).toBe('up')
expect(resolveIpDisplayHealth('unknown', undefined)).toBe('unknown')
})
})
describe('resolveServiceDisplayHealth', () => {
it('shows OK when live probes recovered and stored is still unknown', () => {
expect(
resolveServiceDisplayHealth(
'unknown',
[{ ip: '2.59.161.102', status: 'unknown' }],
[
probe({
id: 1,
ip: '2.59.161.102',
status: 'up',
checked_at: '2026-08-20T18:00:00Z',
}),
],
),
).toBe('up')
})
})
+90 -2
View File
@@ -98,7 +98,18 @@ export function worstHealthStatus(statuses: readonly HealthLogStatus[]): HealthL
)
}
/** Latest probe per IP for a provider, then worst among those IPs. */
/** Any up → up; else degraded, then down, then unknown. */
export function bestAliveHealthStatus(
statuses: readonly HealthLogStatus[],
): HealthLogStatus {
if (statuses.length === 0) return 'unknown'
if (statuses.some((status) => status === 'up')) return 'up'
if (statuses.some((status) => status === 'degraded')) return 'degraded'
if (statuses.some((status) => status === 'down')) return 'down'
return 'unknown'
}
/** Latest probe per IP for a provider, then any-up among those IPs. */
export function providerHealthStatuses(
items: readonly HealthLogProbe[],
providers: readonly HealthCheckProvider[],
@@ -120,8 +131,85 @@ export function providerHealthStatuses(
const statuses = [...latestByIp.values()]
.filter((item) => item.provider === provider)
.map((item) => item.status)
result[provider] = worstHealthStatus(statuses)
result[provider] = bestAliveHealthStatus(statuses)
}
return result
}
export interface IpDisplayHealth {
status: HealthLogStatus
latency_ms: number | null
last_checked_at: string
last_error: string | null
colo: string | null
provider: HealthCheckProvider
}
/**
* Latest probe per provider+IP, then any-up among those providers.
* Used by the IP table so hysteresis `unknown` in ip_health does not hide a live OK.
*/
export function latestHealthByIp(
items: readonly HealthLogProbe[],
): Map<string, IpDisplayHealth> {
const latest = new Map<string, HealthLogProbe>()
const sorted = [...items].sort(
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at) || b.id - a.id,
)
for (const item of sorted) {
const key = `${item.provider}\0${item.ip}`
if (!latest.has(key)) latest.set(key, item)
}
const byIp = new Map<string, HealthLogProbe[]>()
for (const item of latest.values()) {
const list = byIp.get(item.ip)
if (list) list.push(item)
else byIp.set(item.ip, [item])
}
const result = new Map<string, IpDisplayHealth>()
for (const [ip, probes] of byIp) {
const status = bestAliveHealthStatus(probes.map((probe) => probe.status))
const preferred =
probes.find((probe) => probe.status === status) ?? probes[0]!
result.set(ip, {
status,
latency_ms: preferred.latency_ms,
last_checked_at: preferred.checked_at,
last_error: preferred.error,
colo: preferred.colo,
provider: preferred.provider,
})
}
return result
}
/** Prefer a concrete live probe over stored hysteresis `unknown`. */
export function resolveIpDisplayHealth(
stored: HealthLogStatus | undefined,
live: HealthLogStatus | undefined,
): HealthLogStatus {
if (live && live !== 'unknown') return live
return stored ?? live ?? 'unknown'
}
/** Service KPI / card: any-up of per-IP overlay (live probes beat hysteresis). */
export function resolveServiceDisplayHealth(
stored: HealthLogStatus | undefined,
ipHealth: readonly { ip: string; status: string }[],
probes: readonly HealthLogProbe[] = [],
): HealthLogStatus {
const liveByIp = latestHealthByIp(probes)
const statuses =
ipHealth.length > 0
? ipHealth.map((row) =>
resolveIpDisplayHealth(
row.status as HealthLogStatus,
liveByIp.get(row.ip)?.status,
),
)
: [...liveByIp.values()].map((row) => row.status)
return resolveIpDisplayHealth(stored, bestAliveHealthStatus(statuses))
}
+33 -2
View File
@@ -82,7 +82,9 @@ export const serviceDomainBindingSchema = z
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
sync_status: z.string().nullable().default(null),
active_ips: z.array(z.string()).default([]),
})
.transform((binding) => ({
...binding,
@@ -124,6 +126,18 @@ export const healthProbeLogSchema = z.object({
checked_at: z.string(),
})
export const failoverLogSchema = z.object({
id: z.number(),
service_id: z.number(),
binding_id: z.number(),
fqdn: z.string(),
ip: z.string(),
action: z.enum(['added', 'removed']),
created_at: z.string(),
})
export type FailoverLogEntry = z.infer<typeof failoverLogSchema>
export const serviceViewSchema = serviceSchema.extend({
subdomain: z.string().default(''),
enabled: z.coerce.boolean().default(false),
@@ -197,6 +211,7 @@ export const serviceBindingSchema = z
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
sync_status: z.string().nullable().default(null),
created_at: z.string(),
updated_at: z.string(),
@@ -234,6 +249,8 @@ export const certificateSchema = z.object({
id: z.number(),
domain_id: z.number(),
subdomain_id: z.number().nullable(),
service_id: z.number().nullable().optional().default(null),
service_name: z.string().nullable().optional().default(null),
hostname: z.string(),
expires_at: z.string().nullable(),
last_checked_at: z.string().nullable(),
@@ -243,6 +260,19 @@ export const certificateSchema = z.object({
updated_at: z.string(),
})
export const serviceCertificateRowSchema = z.object({
binding_id: z.number(),
domain_id: z.number(),
service_id: z.number(),
hostname: z.string(),
cert_monitoring: z.enum(['auto', 'required', 'skipped']),
id: z.number().nullable(),
status: z.string(),
expires_at: z.string().nullable(),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable(),
})
export type Group = z.infer<typeof groupSchema>
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
export type Service = z.infer<typeof serviceSchema>
@@ -256,6 +286,7 @@ export type DomainListItem = z.infer<typeof domainListItemSchema>
export type ServiceBinding = z.infer<typeof serviceBindingSchema>
export type DnsRecord = z.infer<typeof dnsRecordSchema>
export type Certificate = z.infer<typeof certificateSchema>
export type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>
export const createGroupSchema = z.object({
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
@@ -273,14 +304,14 @@ const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted'])
const healthCheckTypeSchema = z.enum(['tcp', 'http', 'ping', 'dns'])
const healthCheckConfigFields = {
health_check_enabled: z.boolean().optional(),
health_check_enabled: z.coerce.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(),
health_check_verify_tls: z.boolean().optional(),
health_check_verify_tls: z.coerce.boolean().optional(),
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).optional(),
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).optional(),
health_check_aggregate: z.enum(['any', 'all', 'majority']).optional(),
+359
View File
@@ -0,0 +1,359 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_BINDING_HEALTH,
addAddressNode,
addCommonFqdn,
addExtraFqdn,
emptyAddressBlock,
emptyBindingDraft,
hydrateAddressBlock,
patchAddressIpMeta,
removeAddressNode,
toAddressBindings,
toDomainsPayload,
type ServiceBindingDraft,
} from '@/lib/service-address'
const primaryMeta = {
lb_mode: 'round_robin' as const,
health: { ...DEFAULT_BINDING_HEALTH, enabled: true },
}
function aRecord(
fqdn: string,
target_ips: string[],
overrides: Partial<ServiceBindingDraft> = {},
): ServiceBindingDraft {
return {
...emptyBindingDraft(fqdn),
record_type: 'A',
target_ips,
target_ip_weights: Object.fromEntries(target_ips.map((ip) => [ip, 1])),
target_ip_priorities: Object.fromEntries(target_ips.map((ip) => [ip, 1])),
...overrides,
}
}
describe('hydrateAddressBlock', () => {
it('схлопывает extra A с одним IP пула в extraFqdn узла (MSK Macloud)', () => {
const drafts = [
aRecord('rutg.rkns.top', ['93.115.203.183', '185.244.181.61']),
aRecord('msk.rutg.rkns.top', ['93.115.203.183']),
]
const state = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
expect(state.commonFqdns).toEqual(['rutg.rkns.top'])
expect(state.nodes).toEqual([
{ ip: '93.115.203.183', extraFqdns: ['msk.rutg.rkns.top'] },
{ ip: '185.244.181.61', extraFqdns: [] },
])
expect(state.preservedBindings).toEqual([])
})
it('кладёт A на весь пул в commonFqdns, CNAME — в preserved', () => {
const cname: ServiceBindingDraft = {
...emptyBindingDraft('alias.rkns.top'),
record_type: 'CNAME',
target_cname: 'rutg.rkns.top',
}
const drafts = [
aRecord('rutg.rkns.top', ['1.1.1.1', '2.2.2.2']),
aRecord('both.rkns.top', ['1.1.1.1', '2.2.2.2']),
cname,
]
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
expect(state.commonFqdns).toEqual(['rutg.rkns.top', 'both.rkns.top'])
expect(state.nodes.every((node) => node.extraFqdns.length === 0)).toBe(true)
expect(state.preservedBindings.map((item) => item.fqdn)).toEqual(['alias.rkns.top'])
})
it('кладёт extra A с IP вне пула в preservedBindings', () => {
const drafts = [
aRecord('gw.example.com', ['10.0.0.1']),
aRecord('edge.example.com', ['8.8.8.8']),
]
const state = hydrateAddressBlock(drafts, ['10.0.0.1'])
expect(state.nodes).toEqual([{ ip: '10.0.0.1', extraFqdns: [] }])
expect(state.commonFqdns).toEqual(['gw.example.com'])
expect(state.preservedBindings).toHaveLength(1)
expect(state.preservedBindings[0]?.fqdn).toBe('edge.example.com')
})
it('при одном IP пула отделяет второй A в extraFqdn узла', () => {
const drafts = [
aRecord('dns.shnt.top', ['130.49.213.176']),
aRecord('ndns.shnt.top', ['130.49.213.176']),
]
const state = hydrateAddressBlock(drafts, ['130.49.213.176'])
expect(state.commonFqdns).toEqual(['dns.shnt.top'])
expect(state.nodes).toEqual([
{ ip: '130.49.213.176', extraFqdns: ['ndns.shnt.top'] },
])
expect(state.preservedBindings).toEqual([])
})
it('кладёт несколько extra A на один IP в extraFqdns, включая wildcard', () => {
const drafts = [
aRecord('dns.shnt.top', ['130.49.213.176']),
aRecord('ndns.shnt.top', ['130.49.213.176']),
aRecord('*.mdns.shnt.top', ['130.49.213.176']),
]
const state = hydrateAddressBlock(drafts, ['130.49.213.176'])
expect(state.commonFqdns).toEqual(['dns.shnt.top'])
expect(state.nodes).toEqual([
{
ip: '130.49.213.176',
extraFqdns: ['ndns.shnt.top', '*.mdns.shnt.top'],
},
])
expect(state.preservedBindings).toEqual([])
})
it('поднимает веса и приоритеты с общего FQDN', () => {
const drafts = [
aRecord('gt.rkns.top', ['130.49.213.153', '93.115.203.183'], {
target_ip_weights: { '130.49.213.153': 3, '93.115.203.183': 1 },
target_ip_priorities: { '130.49.213.153': 2, '93.115.203.183': 1 },
}),
aRecord('nsgt.rkns.top', ['130.49.213.153']),
]
const state = hydrateAddressBlock(drafts, ['130.49.213.153', '93.115.203.183'])
expect(state.target_ip_weights).toEqual({
'130.49.213.153': 3,
'93.115.203.183': 1,
})
expect(state.target_ip_priorities).toEqual({
'130.49.213.153': 2,
'93.115.203.183': 1,
})
expect(state.nodes[0]?.extraFqdns).toEqual(['nsgt.rkns.top'])
})
it('лечит урезанный общий FQDN (legacy toggle) как common, не preserved', () => {
const pool = ['130.49.213.153', '130.49.213.176', '93.115.203.183']
const drafts = [
// corrupted common — missing one pool IP
aRecord('gw.pngs.top', ['130.49.213.153', '130.49.213.176']),
aRecord('gt.rkns.top', pool),
aRecord('nsgt.rkns.top', ['130.49.213.176']),
aRecord('rutg.rkns.top', ['93.115.203.183']),
]
const state = hydrateAddressBlock(drafts, pool)
expect(state.commonFqdns).toEqual(['gw.pngs.top', 'gt.rkns.top'])
expect(state.nodes).toEqual([
{ ip: '130.49.213.153', extraFqdns: [] },
{ ip: '130.49.213.176', extraFqdns: ['nsgt.rkns.top'] },
{ ip: '93.115.203.183', extraFqdns: ['rutg.rkns.top'] },
])
expect(state.preservedBindings).toEqual([])
const payload = toDomainsPayload(state, primaryMeta)
expect(payload.map((item) => item.fqdn)).toEqual([
'gw.pngs.top',
'gt.rkns.top',
'nsgt.rkns.top',
'rutg.rkns.top',
])
expect(payload[0]?.target_ips).toEqual(pool)
expect(new Set(payload.map((item) => item.fqdn.toLowerCase())).size).toBe(4)
})
it('не дублирует FQDN при повторном binding в drafts', () => {
const pool = ['1.1.1.1', '2.2.2.2']
const state = hydrateAddressBlock(
[aRecord('gw.example.com', ['1.1.1.1']), aRecord('gw.example.com', pool)],
pool,
)
expect(state.commonFqdns).toEqual(['gw.example.com'])
expect(state.nodes.every((node) => node.extraFqdns.length === 0)).toBe(true)
})
})
describe('toDomainsPayload', () => {
it('собирает каждый common на весь пул и extra binding на один IP', () => {
const drafts = [
aRecord('rutg.rkns.top', ['93.115.203.183', '185.244.181.61']),
aRecord('msk.rutg.rkns.top', ['93.115.203.183']),
]
const state = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
const payload = toDomainsPayload(state, primaryMeta)
expect(payload).toEqual([
expect.objectContaining({
fqdn: 'rutg.rkns.top',
target_ips: ['93.115.203.183', '185.244.181.61'],
health_check_enabled: true,
}),
expect.objectContaining({
fqdn: 'msk.rutg.rkns.top',
target_ips: ['93.115.203.183'],
health_check_enabled: true,
}),
])
})
it('круг hydrate → payload → hydrate сохраняет два common и extra FQDN', () => {
const drafts = [
aRecord('gt.rkns.top', ['93.115.203.183', '185.244.181.61']),
aRecord('msk.rkns.top', ['93.115.203.183', '185.244.181.61']),
aRecord('nsgt.rkns.top', ['93.115.203.183']),
]
const first = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
expect(first.commonFqdns).toEqual(['gt.rkns.top', 'msk.rkns.top'])
const rebound = toAddressBindings(first, primaryMeta)
const second = hydrateAddressBlock(rebound, rebound[0]?.target_ips ?? [])
expect(second.commonFqdns).toEqual(first.commonFqdns)
expect(second.nodes).toEqual(first.nodes)
expect(second.preservedBindings).toEqual([])
})
it('круг hydrate → payload → hydrate сохраняет extra FQDN при одном IP', () => {
const drafts = [
aRecord('dns.shnt.top', ['130.49.213.176']),
aRecord('ndns.shnt.top', ['130.49.213.176']),
]
const first = hydrateAddressBlock(drafts, ['130.49.213.176'])
expect(first.commonFqdns).toEqual(['dns.shnt.top'])
expect(first.nodes).toEqual([
{ ip: '130.49.213.176', extraFqdns: ['ndns.shnt.top'] },
])
const rebound = toAddressBindings(first, primaryMeta)
const second = hydrateAddressBlock(rebound, ['130.49.213.176'])
expect(second.commonFqdns).toEqual(first.commonFqdns)
expect(second.nodes).toEqual(first.nodes)
expect(second.preservedBindings).toEqual([])
})
it('круг hydrate → payload → hydrate сохраняет несколько extra и wildcard при одном IP', () => {
const drafts = [
aRecord('dns.shnt.top', ['130.49.213.176']),
aRecord('ndns.shnt.top', ['130.49.213.176']),
aRecord('*.mdns.shnt.top', ['130.49.213.176']),
]
const first = hydrateAddressBlock(drafts, ['130.49.213.176'])
expect(first.nodes[0]?.extraFqdns).toEqual(['ndns.shnt.top', '*.mdns.shnt.top'])
const rebound = toAddressBindings(first, primaryMeta)
const second = hydrateAddressBlock(rebound, ['130.49.213.176'])
expect(second.commonFqdns).toEqual(first.commonFqdns)
expect(second.nodes).toEqual(first.nodes)
expect(second.preservedBindings).toEqual([])
})
})
describe('removeAddressNode', () => {
it('удаляет extra FQDN узла и IP из preserved A-bindings', () => {
const state = hydrateAddressBlock(
[
aRecord('gw.example.com', ['10.0.0.1', '10.0.0.2']),
aRecord('msk.example.com', ['10.0.0.1']),
aRecord('edge.example.com', ['9.9.9.9', '10.0.0.1']),
],
['10.0.0.1', '10.0.0.2'],
)
const next = removeAddressNode(state, '10.0.0.1')
expect(next.nodes).toEqual([{ ip: '10.0.0.2', extraFqdns: [] }])
expect(next.preservedBindings).toHaveLength(1)
expect(next.preservedBindings[0]?.target_ips).toEqual(['9.9.9.9'])
})
})
describe('addAddressNode / addCommonFqdn', () => {
it('не добавляет дубликат IP', () => {
const withIp = addAddressNode(
{ ...emptyAddressBlock(), nodes: [{ ip: '1.1.1.1', extraFqdns: [] }] },
'1.1.1.1',
)
expect(withIp.nodes).toHaveLength(1)
})
it('не добавляет дубликат common FQDN', () => {
const state = addCommonFqdn(
{ ...emptyAddressBlock(), commonFqdns: ['gt.rkns.top'] },
'GT.rkns.top',
)
expect(state.commonFqdns).toEqual(['gt.rkns.top'])
})
it('добавляет extra FQDN к IP и отклоняет дубликат', () => {
const withIp = addAddressNode(emptyAddressBlock(), '1.1.1.1')
const withExtra = addExtraFqdn(withIp, '1.1.1.1', 'mdns.shnt.top')
expect(withExtra.nodes[0]?.extraFqdns).toEqual(['mdns.shnt.top'])
expect(addExtraFqdn(withExtra, '1.1.1.1', 'MDNS.shnt.top')).toBe(withExtra)
})
})
describe('patchAddressIpMeta', () => {
it('меняет вес одного IP и не трогает extraFqdns', () => {
const state = {
...addAddressNode(addAddressNode(emptyAddressBlock(), '1.1.1.1'), '2.2.2.2'),
nodes: [
{ ip: '1.1.1.1', extraFqdns: ['msk.example.com'] },
{ ip: '2.2.2.2', extraFqdns: [] },
],
}
const next = patchAddressIpMeta(state, '1.1.1.1', { weight: 7 })
expect(next.target_ip_weights).toEqual({ '1.1.1.1': 7, '2.2.2.2': 1 })
expect(next.target_ip_priorities).toEqual(state.target_ip_priorities)
expect(next.nodes).toEqual(state.nodes)
})
it('clamp веса и приоритета в 1–100', () => {
const state = addAddressNode(emptyAddressBlock(), '10.0.0.1')
expect(patchAddressIpMeta(state, '10.0.0.1', { weight: 0 }).target_ip_weights['10.0.0.1']).toBe(1)
expect(
patchAddressIpMeta(state, '10.0.0.1', { priority: 999 }).target_ip_priorities['10.0.0.1'],
).toBe(100)
})
it('игнорирует IP вне пула', () => {
const state = addAddressNode(emptyAddressBlock(), '10.0.0.1')
expect(patchAddressIpMeta(state, '8.8.8.8', { weight: 5 })).toBe(state)
})
})
describe('CNAME / preservedBindings', () => {
it('сохраняет CNAME в preserved при круге hydrate → payload', () => {
const cname: ServiceBindingDraft = {
...emptyBindingDraft('alias.rkns.top'),
record_type: 'CNAME',
target_cname: 'rutg.rkns.top',
}
const drafts = [
aRecord('rutg.rkns.top', ['1.1.1.1', '2.2.2.2']),
aRecord('msk.rkns.top', ['1.1.1.1']),
cname,
]
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
expect(state.nodes[0]?.extraFqdns).toEqual(['msk.rkns.top'])
expect(state.preservedBindings).toHaveLength(1)
const payload = toDomainsPayload(state, primaryMeta)
expect(payload.map((item) => item.fqdn)).toEqual([
'rutg.rkns.top',
'msk.rkns.top',
'alias.rkns.top',
])
expect(payload[2]).toEqual(
expect.objectContaining({
fqdn: 'alias.rkns.top',
target_cname: 'rutg.rkns.top',
}),
)
})
})
+562
View File
@@ -0,0 +1,562 @@
import { parseHealthProviders } from '@cfdm/shared'
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
import { bindingToFqdn } from '@/lib/parse-fqdn'
import type { ServiceView } from '@/lib/schemas'
export type AddressLbMode = 'round_robin' | 'failover' | 'weighted'
export type AddressHealthCheckType = 'tcp' | 'http'
export interface BindingHealthConfig {
enabled: boolean
type: AddressHealthCheckType
port: number | null
path: string | null
expected_status: number | null
interval_sec: number
timeout_ms: number
verify_tls: boolean
provider: HealthCheckProvider
providers: HealthCheckProvider[]
aggregate: HealthCheckAggregate
}
export interface ServiceBindingDraft {
fqdn: string
record_type: 'A' | 'CNAME'
target_ips: string[]
target_cname: string
lb_mode: AddressLbMode
health: BindingHealthConfig
target_ip_weights: Record<string, number>
target_ip_priorities: Record<string, number>
}
export interface AddressNode {
ip: string
extraFqdns: string[]
}
export interface AddressBlockState {
commonFqdns: string[]
nodes: AddressNode[]
preservedBindings: ServiceBindingDraft[]
target_ip_weights: Record<string, number>
target_ip_priorities: Record<string, number>
}
export interface AddressPrimaryMeta {
lb_mode: AddressLbMode
health: BindingHealthConfig
}
export const DEFAULT_BINDING_HEALTH: BindingHealthConfig = {
enabled: false,
type: 'tcp',
port: null,
path: null,
expected_status: null,
interval_sec: 30,
timeout_ms: 3000,
verify_tls: false,
provider: 'local',
providers: ['local'],
aggregate: 'majority',
}
export function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
return {
fqdn,
record_type: 'A',
target_ips: [],
target_cname: '',
lb_mode: 'round_robin',
health: { ...DEFAULT_BINDING_HEALTH },
target_ip_weights: {},
target_ip_priorities: {},
}
}
export function emptyAddressBlock(): AddressBlockState {
return {
commonFqdns: [],
nodes: [],
preservedBindings: [],
target_ip_weights: {},
target_ip_priorities: {},
}
}
function uniqueIps(...lists: string[][]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const list of lists) {
for (const ip of list) {
const trimmed = ip.trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
out.push(trimmed)
}
}
return out
}
function omitKey(record: Record<string, number>, key: string): Record<string, number> {
const next = { ...record }
delete next[key]
return next
}
function sameIpSet(left: string[], right: string[]): boolean {
if (left.length === 0 || left.length !== right.length) return false
const set = new Set(left.map((ip) => ip.trim()).filter(Boolean))
if (set.size !== left.length) return false
return right.every((ip) => set.has(ip.trim()))
}
function fqdnKey(value: string): string {
return value.trim().toLowerCase()
}
export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
return (service.domains ?? []).map((binding) => ({
fqdn: bindingToFqdn(binding),
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: Boolean(binding.health_check_enabled),
type: binding.health_check_type === 'http' ? 'http' : 'tcp',
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,
verify_tls: Boolean(binding.health_check_verify_tls),
provider: binding.health_check_provider ?? 'local',
providers: parseHealthProviders(
binding.health_check_providers,
binding.health_check_provider ?? 'local',
),
aggregate: binding.health_check_aggregate ?? 'majority',
},
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
}))
}
function isFullPoolA(draft: ServiceBindingDraft, pool: string[]): boolean {
return draft.record_type === 'A' && sameIpSet(draft.target_ips, pool)
}
/** UI contract: common = 2+ IPs all in pool (full or corrupted subset after old toggles). */
function isCommonPoolA(draft: ServiceBindingDraft, poolSet: Set<string>): boolean {
if (draft.record_type !== 'A') return false
const ips = draft.target_ips.map((ip) => ip.trim()).filter(Boolean)
if (ips.length < 2) return false
return ips.every((ip) => poolSet.has(ip))
}
function takeAsCommon(
draft: ServiceBindingDraft,
fqdn: string,
commonFqdns: string[],
seenCommon: Set<string>,
weights: Record<string, number>,
priorities: Record<string, number>,
): { weights: Record<string, number>; priorities: Record<string, number> } {
const key = fqdnKey(fqdn)
if (fqdn && key && !seenCommon.has(key)) {
seenCommon.add(key)
commonFqdns.push(draft.fqdn)
}
if (Object.keys(weights).length === 0) {
return {
weights: { ...draft.target_ip_weights },
priorities: { ...draft.target_ip_priorities },
}
}
return { weights, priorities }
}
export function hydrateAddressBlock(
drafts: ServiceBindingDraft[],
pool: string[] = [],
): AddressBlockState {
const multiIpTargets = drafts
.filter((draft) => draft.record_type === 'A' && draft.target_ips.length > 1)
.map((draft) => draft.target_ips)
const allAIps = drafts
.filter((draft) => draft.record_type === 'A')
.map((draft) => draft.target_ips)
const ips =
pool.length > 0
? uniqueIps(pool)
: uniqueIps(...(multiIpTargets.length > 0 ? multiIpTargets : allAIps))
const poolSet = new Set(ips)
const commonFqdns: string[] = []
const seenCommon = new Set<string>()
const seenExtra = new Set<string>()
const extraByIp = new Map<string, string[]>()
const preservedBindings: ServiceBindingDraft[] = []
let weights: Record<string, number> = {}
let priorities: Record<string, number> = {}
const splitSinglePool =
ips.length === 1 &&
drafts.filter((draft) => isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet))
.length > 1
let assignedFirstSinglePoolCommon = false
function pushExtra(ip: string, fqdn: string) {
const key = fqdnKey(fqdn)
if (!key || seenExtra.has(key) || seenCommon.has(key)) return
seenExtra.add(key)
const list = extraByIp.get(ip) ?? []
list.push(fqdn)
extraByIp.set(ip, list)
}
function promoteToCommon(draft: ServiceBindingDraft, fqdn: string) {
const key = fqdnKey(fqdn)
if (key && seenExtra.has(key)) {
seenExtra.delete(key)
for (const [ip, list] of extraByIp) {
extraByIp.set(
ip,
list.filter((item) => fqdnKey(item) !== key),
)
}
}
const next = takeAsCommon(
draft,
fqdn,
commonFqdns,
seenCommon,
weights,
priorities,
)
weights = next.weights
priorities = next.priorities
}
for (const draft of drafts) {
const fqdn = draft.fqdn.trim()
if (splitSinglePool && (isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet))) {
if (!assignedFirstSinglePoolCommon) {
assignedFirstSinglePoolCommon = true
promoteToCommon(draft, fqdn)
continue
}
const ip = draft.target_ips[0]?.trim() ?? ''
if (ip && poolSet.has(ip) && fqdn) {
pushExtra(ip, draft.fqdn)
continue
}
}
// Full pool OR multi-IP subset of pool → common (heals orphaned toggle damage).
if (isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet)) {
promoteToCommon(draft, fqdn)
continue
}
if (draft.record_type === 'A' && draft.target_ips.length === 1) {
const ip = draft.target_ips[0]?.trim() ?? ''
if (ip && poolSet.has(ip) && fqdn) {
pushExtra(ip, draft.fqdn)
continue
}
}
preservedBindings.push(draft)
}
return {
commonFqdns,
nodes: ips.map((ip) => ({
ip,
extraFqdns: extraByIp.get(ip) ?? [],
})),
preservedBindings,
target_ip_weights: weights,
target_ip_priorities: priorities,
}
}
export function pruneIpFromBindings(
bindings: ServiceBindingDraft[],
ip: string,
): ServiceBindingDraft[] {
return bindings.flatMap((binding) => {
if (binding.record_type !== 'A') return [binding]
if (!binding.target_ips.includes(ip)) return [binding]
const target_ips = binding.target_ips.filter((item) => item !== ip)
if (target_ips.length === 0) return []
return [
{
...binding,
target_ips,
target_ip_weights: omitKey(binding.target_ip_weights, ip),
target_ip_priorities: omitKey(binding.target_ip_priorities, ip),
},
]
})
}
export function removeAddressNode(state: AddressBlockState, ip: string): AddressBlockState {
return {
...state,
nodes: state.nodes.filter((node) => node.ip !== ip),
preservedBindings: pruneIpFromBindings(state.preservedBindings, ip),
target_ip_weights: omitKey(state.target_ip_weights, ip),
target_ip_priorities: omitKey(state.target_ip_priorities, ip),
}
}
export function addAddressNode(state: AddressBlockState, ip: string): AddressBlockState {
const trimmed = ip.trim()
if (!trimmed || state.nodes.some((node) => node.ip === trimmed)) {
return state
}
return {
...state,
nodes: [...state.nodes, { ip: trimmed, extraFqdns: [] }],
target_ip_weights: { ...state.target_ip_weights, [trimmed]: 1 },
target_ip_priorities: { ...state.target_ip_priorities, [trimmed]: 1 },
}
}
const LB_META_MIN = 1
const LB_META_MAX = 100
function clampLbMeta(value: number): number {
if (!Number.isFinite(value)) return LB_META_MIN
return Math.min(LB_META_MAX, Math.max(LB_META_MIN, Math.round(value)))
}
export function patchAddressIpMeta(
state: AddressBlockState,
ip: string,
meta: { weight?: number; priority?: number },
): AddressBlockState {
if (!state.nodes.some((node) => node.ip === ip)) return state
return {
...state,
target_ip_weights:
meta.weight === undefined
? state.target_ip_weights
: { ...state.target_ip_weights, [ip]: clampLbMeta(meta.weight) },
target_ip_priorities:
meta.priority === undefined
? state.target_ip_priorities
: { ...state.target_ip_priorities, [ip]: clampLbMeta(meta.priority) },
}
}
export function addressHasFqdn(state: AddressBlockState, fqdn: string): boolean {
const key = fqdnKey(fqdn)
if (!key) return false
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return true
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
return true
}
return state.preservedBindings.some((item) => fqdnKey(item.fqdn) === key)
}
export function addCommonFqdn(state: AddressBlockState, fqdn: string): AddressBlockState {
const trimmed = fqdn.trim()
if (!trimmed) return state
const key = fqdnKey(trimmed)
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return state
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
return state
}
// Promote out of invisible preserved (corrupted / CNAME-adjacent duplicates).
const preservedBindings = state.preservedBindings.filter(
(item) => fqdnKey(item.fqdn) !== key,
)
return {
...state,
commonFqdns: [...state.commonFqdns, trimmed],
preservedBindings,
}
}
export function removeCommonFqdn(state: AddressBlockState, index: number): AddressBlockState {
return {
...state,
commonFqdns: state.commonFqdns.filter((_, i) => i !== index),
}
}
export function updateCommonFqdn(
state: AddressBlockState,
index: number,
fqdn: string,
): AddressBlockState {
return {
...state,
commonFqdns: state.commonFqdns.map((item, i) => (i === index ? fqdn : item)),
}
}
export function addExtraFqdn(
state: AddressBlockState,
ip: string,
fqdn: string,
): AddressBlockState {
const trimmed = fqdn.trim()
if (!trimmed) return state
const key = fqdnKey(trimmed)
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return state
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
return state
}
if (!state.nodes.some((node) => node.ip === ip)) return state
return {
...state,
preservedBindings: state.preservedBindings.filter((item) => fqdnKey(item.fqdn) !== key),
nodes: state.nodes.map((node) =>
node.ip === ip ? { ...node, extraFqdns: [...node.extraFqdns, trimmed] } : node,
),
}
}
export function removeExtraFqdn(
state: AddressBlockState,
ip: string,
index: number,
): AddressBlockState {
return {
...state,
nodes: state.nodes.map((node) =>
node.ip === ip
? { ...node, extraFqdns: node.extraFqdns.filter((_, i) => i !== index) }
: node,
),
}
}
export function updateExtraFqdn(
state: AddressBlockState,
ip: string,
index: number,
fqdn: string,
): AddressBlockState {
return {
...state,
nodes: state.nodes.map((node) =>
node.ip === ip
? {
...node,
extraFqdns: node.extraFqdns.map((item, i) => (i === index ? fqdn : item)),
}
: node,
),
}
}
export function toAddressBindings(
state: AddressBlockState,
primary: AddressPrimaryMeta,
): ServiceBindingDraft[] {
const ips = state.nodes.map((node) => node.ip)
const weights = Object.fromEntries(
ips.map((ip) => [ip, state.target_ip_weights[ip] ?? 1]),
)
const priorities = Object.fromEntries(
ips.map((ip) => [ip, state.target_ip_priorities[ip] ?? 1]),
)
const drafts: ServiceBindingDraft[] = []
for (const raw of state.commonFqdns) {
const fqdn = raw.trim()
if (!fqdn || ips.length === 0) continue
drafts.push({
fqdn,
record_type: 'A',
target_ips: ips,
target_cname: '',
lb_mode: primary.lb_mode,
health: { ...primary.health },
target_ip_weights: weights,
target_ip_priorities: priorities,
})
}
for (const node of state.nodes) {
for (const raw of node.extraFqdns) {
const extraFqdn = raw.trim()
if (!extraFqdn) continue
drafts.push({
fqdn: extraFqdn,
record_type: 'A',
target_ips: [node.ip],
target_cname: '',
lb_mode: primary.lb_mode,
health: { ...primary.health },
target_ip_weights: { [node.ip]: weights[node.ip] ?? 1 },
target_ip_priorities: { [node.ip]: priorities[node.ip] ?? 1 },
})
}
}
const seen = new Set(drafts.map((item) => fqdnKey(item.fqdn)).filter(Boolean))
for (const preserved of state.preservedBindings) {
const key = fqdnKey(preserved.fqdn)
if (!key || seen.has(key)) continue
seen.add(key)
drafts.push(preserved)
}
return drafts
}
export function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
return bindings
.filter((binding) => {
if (!binding.fqdn.trim()) return false
if (binding.record_type === 'CNAME') return Boolean(binding.target_cname.trim())
return binding.target_ips.length > 0
})
.map((binding) =>
binding.record_type === 'CNAME'
? {
fqdn: binding.fqdn.trim(),
target_cname: binding.target_cname.trim(),
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,
health_check_verify_tls: binding.health.verify_tls,
health_check_provider: binding.health.provider,
health_check_providers: binding.health.providers,
health_check_aggregate: binding.health.aggregate,
}
: {
fqdn: binding.fqdn.trim(),
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,
health_check_verify_tls: binding.health.verify_tls,
health_check_provider: binding.health.provider,
health_check_providers: binding.health.providers,
health_check_aggregate: binding.health.aggregate,
},
)
}
export function toDomainsPayload(
state: AddressBlockState,
primary: AddressPrimaryMeta,
) {
return buildDomainsPayload(toAddressBindings(state, primary))
}
+30 -1
View File
@@ -1,11 +1,14 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { certificateSchema } from '@/lib/schemas'
import { certificateSchema, serviceCertificateRowSchema } from '@/lib/schemas'
import type { CertMonitoring } from '@cfdm/shared'
import { z } from 'zod'
export const certKeys = {
all: ['certificates'] as const,
summary: ['certificates', 'summary'] as const,
byService: (serviceId: number) =>
[...certKeys.all, 'service', serviceId] as const,
}
export const certificatesQueryOptions = () =>
@@ -23,3 +26,29 @@ export const certSummaryQueryOptions = () =>
queryKey: certKeys.summary,
queryFn: () => api.get<[string, number][]>('/api/v1/certificates/summary'),
})
export const serviceCertificatesQueryOptions = (serviceId: number) =>
queryOptions({
queryKey: certKeys.byService(serviceId),
queryFn: async () => {
const data = await api.get<unknown[]>(
`/api/v1/services/${serviceId}/certificates`,
)
return z.array(serviceCertificateRowSchema).parse(data)
},
})
export async function patchBindingCertMonitoring(
bindingId: number,
certMonitoring: CertMonitoring,
) {
return api.patch(`/api/v1/service-bindings/${bindingId}`, {
cert_monitoring: certMonitoring,
})
}
export async function checkServiceCertificates(serviceId: number) {
return api.post<{ checked: number }>(
`/api/v1/services/${serviceId}/certificates/check`,
)
}
+17
View File
@@ -1,6 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import {
failoverLogSchema,
healthProbeLogSchema,
serviceBindingSchema,
serviceGroupsResponseSchema,
@@ -35,6 +36,8 @@ export const serviceGroupsQueryOptions = () =>
}
return parsed.data
},
refetchInterval: 10_000,
staleTime: 5_000,
})
export const servicesQueryOptions = () =>
@@ -89,6 +92,7 @@ export const serviceDetailKeys = {
overview: (id: number) => [...serviceKeys.all, id, 'overview'] as const,
nodes: (id: number) => [...serviceKeys.all, id, 'nodes'] as const,
healthLog: (id: number) => [...serviceKeys.all, id, 'health-log'] as const,
failoverLog: (id: number) => [...serviceKeys.all, id, 'failover-log'] as const,
view: (id: number) => [...serviceKeys.all, id, 'view'] as const,
}
@@ -99,6 +103,8 @@ export const serviceViewQueryOptions = (id: number) =>
const data = await api.get<unknown>(`/api/v1/services/${id}`)
return serviceViewSchema.parse(data)
},
refetchInterval: 10_000,
staleTime: 5_000,
})
export const serviceHealthLogQueryOptions = (id: number) =>
@@ -108,6 +114,17 @@ export const serviceHealthLogQueryOptions = (id: number) =>
const data = await api.get<unknown>(`/api/v1/services/${id}/health-log`)
return z.object({ items: z.array(healthProbeLogSchema) }).parse(data)
},
refetchInterval: 10_000,
staleTime: 5_000,
})
export const serviceFailoverLogQueryOptions = (id: number) =>
queryOptions({
queryKey: serviceDetailKeys.failoverLog(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/services/${id}/failover-log`)
return z.object({ items: z.array(failoverLogSchema) }).parse(data)
},
})
export const serviceOverviewQueryOptions = (id: number) =>
+2 -2
View File
@@ -74,7 +74,7 @@ function CertificatesPage() {
<PageShell>
<PageHeader
title="Сертификаты"
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»"
description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
actions={primaryAction}
/>
<CertKpiStats
@@ -86,7 +86,7 @@ function CertificatesPage() {
/>
<ResourcePage
title="Сертификаты"
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»"
description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
hideHeader
tabs={CERT_TABS.map((tab) => ({ ...tab }))}
activeTab={activeTab}
@@ -6,11 +6,9 @@ import {
GlobeIcon,
Link2Icon,
ServerIcon,
ShieldCheckIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters'
import type { CertMonitoring } from '@cfdm/shared'
import {
domainDetailQueryOptions,
domainServiceBindingsQueryOptions,
@@ -41,19 +39,11 @@ import {
import { DomainBindingsPanel } from '@/components/domain-bindings-panel'
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
import { StatusBadge } from '@/components/status-badge'
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatDate } from '@/lib/format'
import { Badge } from '@/components/reui/badge'
import { LoadingButton } from '@/components/loading-button'
import { TableSkeleton } from '@/components/skeletons'
import { Button } from '@cfdm/ui/components/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { TabsContent } from '@cfdm/ui/components/tabs'
export const Route = createFileRoute('/_auth/domains/$domainId/')({
@@ -108,7 +98,6 @@ function DomainOverviewPage() {
syncMutation,
createSubdomainMutation,
updateSubdomainMutation,
updateDomainCertMonitoringMutation,
deleteSubdomainMutation,
linkServiceMutation,
} = useDomainPage(id)
@@ -167,20 +156,15 @@ function DomainOverviewPage() {
if (!editTarget) return
const nameChanged = values.name !== editTarget.subdomain.name
const certMonitoringChanged =
values.certMonitoring !== editTarget.subdomain.cert_monitoring
const currentServiceId = resolveServiceId(editTarget)
const serviceChanged = values.serviceId !== currentServiceId
const targetServiceId =
values.serviceId === 'none' ? null : Number(values.serviceId)
if (nameChanged || certMonitoringChanged) {
if (nameChanged) {
await updateSubdomainMutation.mutateAsync({
id: editTarget.subdomain.id,
...(nameChanged ? { name: values.name } : {}),
...(certMonitoringChanged
? { cert_monitoring: values.certMonitoring }
: {}),
name: values.name,
})
}
@@ -209,11 +193,6 @@ function DomainOverviewPage() {
updateSubdomainMutation.isPending ||
linkServiceMutation.isPending
const certMonitoringItems = certMonitoringOptions.map((option) => ({
label: option.label,
value: option.value,
}))
const metricCards = useMemo(() => {
if (!domain) return []
return [
@@ -344,40 +323,6 @@ function DomainOverviewPage() {
>
<TabsContent value="overview" className="flex flex-col gap-4">
<DetailPanel.Metrics cards={metricCards} />
<DetailPanel.Section
title="Мониторинг SSL"
description="Настройка проверки сертификата для apex-зоны"
>
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
<span className="text-muted-foreground flex items-center gap-2 text-sm">
<ShieldCheckIcon className="size-4" aria-hidden="true" />
Мониторинг SSL (apex):
</span>
<Select
items={certMonitoringItems}
value={domain.cert_monitoring}
onValueChange={(value) =>
updateDomainCertMonitoringMutation.mutate(
(value ?? 'auto') as CertMonitoring,
)
}
disabled={updateDomainCertMonitoringMutation.isPending}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue>
{certMonitoringLabel(domain.cert_monitoring)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{certMonitoringOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DetailPanel.Section>
</TabsContent>
<TabsContent value="availability" className="flex flex-col gap-4">
@@ -14,7 +14,6 @@ import {
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
import { ChangeIpSheet } from '@/components/change-ip-sheet'
import { EmptyState } from '@/components/empty-state'
import { FailoverTimeline } from '@/components/failover-timeline'
import { FormFieldSimple } from '@/components/form-field'
import { FormSheet } from '@/components/form-sheet'
import { HealthCheckBadge } from '@/components/health-check-badge'
@@ -28,24 +27,18 @@ import {
} from '@/components/services/service-detail-grid'
import { LbModeTile } from '@/components/services/service-unit-card'
import {
HealthProviderStatusTiles,
KpiStatGrid,
ServiceFailoverPanel,
ServiceHealthMonitor,
} from '@/components/reui-kit'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { api } from '@/lib/api-client'
import { hasSharedPool, uniqueIpCount } from '@/lib/failover-events'
import {
enabledHealthProviders,
providerHealthStatuses,
resolveServiceDisplayHealth,
} from '@/lib/health-log'
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
import type { HealthCheckProvider } from '@cfdm/shared'
import {
createServiceNode,
deleteServiceNode,
@@ -53,6 +46,7 @@ import {
domainsListQueryOptions,
serviceBindingKeys,
serviceDetailKeys,
serviceFailoverLogQueryOptions,
serviceGroupKeys,
serviceGroupsQueryOptions,
serviceHealthLogQueryOptions,
@@ -86,6 +80,7 @@ interface OverviewPayload {
priority: number
consecutive_failures: number
last_failure_reason: string | null
last_check_at?: string | null
}>
}
@@ -98,6 +93,7 @@ function ServiceDetailPage() {
const viewQuery = useQuery(serviceViewQueryOptions(id))
const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
const failoverLogQuery = useQuery(serviceFailoverLogQueryOptions(id))
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
const groupsQuery = useQuery(serviceGroupsQueryOptions())
const domainsQuery = useQuery(domainsListQueryOptions())
@@ -108,13 +104,39 @@ function ServiceDetailPage() {
() => logQuery.data?.items ?? [],
[logQuery.data?.items],
)
const failoverHistory = useMemo(
() => failoverLogQuery.data?.items ?? [],
[failoverLogQuery.data?.items],
)
const failoverBindings = useMemo(
() =>
(service?.domains ?? []).map((domain) => ({
fqdn: domain.fqdn,
configured: domain.target_ips,
active: domain.active_ips ?? [],
})),
[service?.domains],
)
const uniqueEnabledIps = useMemo(
() =>
(service?.ips ?? []).filter((ip) => service?.ip_enabled[ip] !== false),
[service?.ips, service?.ip_enabled],
)
const displayHealth = useMemo(
() =>
resolveServiceDisplayHealth(
service?.health_status,
service?.ip_health ?? [],
logItems,
),
[service?.health_status, service?.ip_health, logItems],
)
const showPoolPanel =
uniqueEnabledIps.length >= 2 && hasSharedPool(failoverBindings)
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
const [editOpen, setEditOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [selectedProviders, setSelectedProviders] = useState<
HealthCheckProvider[] | null
>(null)
const [togglingIp, setTogglingIp] = useState<string | null>(null)
const [changeIp, setChangeIp] = useState<{
bindingId: number
@@ -140,6 +162,7 @@ function ServiceDetailPage() {
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.failoverLog(id) }),
])
}
@@ -214,31 +237,10 @@ function ServiceDetailPage() {
const isError = viewQuery.isError || overviewQuery.isError
const error = viewQuery.error ?? overviewQuery.error
const failoverEvents =
(nodes.length > 0 ? nodes : (overview?.nodes ?? []))
.filter(
(node) =>
node.health_status === 'unhealthy' ||
node.health_status === 'down' ||
node.health_status === 'checking',
)
.map((node) => ({
id: node.address,
title: `${node.address}: ${node.health_status}`,
detail: node.last_failure_reason
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
: `fail ${node.consecutive_failures}`,
}))
const enabledProviders = useMemo(
() => enabledHealthProviders(service?.domains ?? []),
[service],
)
const activeProviders =
selectedProviders?.filter((provider) => enabledProviders.includes(provider)) ??
enabledProviders
const effectiveProviders =
activeProviders.length > 0 ? activeProviders : enabledProviders
const providerStatuses = useMemo(
() => providerHealthStatuses(logItems, enabledProviders),
[logItems, enabledProviders],
@@ -266,8 +268,11 @@ function ServiceDetailPage() {
description="Domain → Service → Node → Health → Failover"
actions={
<>
<LbModeTile mode={service.lb_mode} />
<HealthCheckBadge status={service.health_status} />
<LbModeTile
mode={service.lb_mode}
hasPool={uniqueIpCount(service.ips) >= 2}
/>
<HealthCheckBadge status={displayHealth} />
<Tooltip>
<TooltipTrigger
render={
@@ -294,20 +299,20 @@ function ServiceDetailPage() {
id: 'status',
icon: <ActivityIcon />,
label: 'Статус',
value: service.health_status === 'up' ? 'OK' : service.health_status,
value: displayHealth === 'up' ? 'OK' : displayHealth,
variant:
service.health_status === 'down'
displayHealth === 'down'
? 'destructive'
: service.health_status === 'degraded'
: displayHealth === 'degraded'
? 'warning'
: 'default',
iconClassName:
service.health_status === 'down'
displayHealth === 'down'
? 'text-destructive'
: service.health_status === 'degraded'
: displayHealth === 'degraded'
? 'text-warning'
: 'text-success',
hint: <HealthCheckBadge status={service.health_status} size="xs" />,
hint: <HealthCheckBadge status={displayHealth} size="xs" />,
},
{
id: 'fqdn',
@@ -321,45 +326,50 @@ function ServiceDetailPage() {
icon: <NetworkIcon />,
label: 'IP',
value: String(service.ips.length),
hint: `${service.active_ips.length} в пуле`,
hint: showPoolPanel
? `${service.active_ips.length} в пуле`
: uniqueEnabledIps.length <= 1
? 'без балансировки'
: service.ips.join(', ') || 'нет',
},
{
id: 'pool',
icon: <ServerIcon />,
label: 'Активный пул',
value: String((overview?.active_addresses ?? service.active_ips).length),
hint: (overview?.active_addresses ?? service.active_ips).join(', ') || 'нет',
label: showPoolPanel ? 'Активный пул' : 'Адреса',
value: String(
(overview?.active_addresses ?? service.active_ips).length,
),
hint:
(overview?.active_addresses ?? service.active_ips).join(', ') ||
'нет',
},
]}
/>
<HealthProviderStatusTiles
enabled={enabledProviders}
selected={effectiveProviders}
statuses={providerStatuses}
onChange={setSelectedProviders}
/>
<section
aria-label="Мониторинг"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
className={
showPoolPanel
? 'grid min-w-0 items-start gap-2 @3xl:grid-cols-2'
: 'grid min-w-0 items-start gap-2'
}
>
<ServiceHealthMonitor
items={logItems}
selectedProviders={effectiveProviders}
enabledProviders={enabledProviders}
statuses={providerStatuses}
isLoading={logQuery.isLoading}
/>
<Frame dense spacing="sm" className="min-w-0 w-full">
<FrameHeader>
<FrameTitle>Failover</FrameTitle>
<FrameDescription>
Нездоровые ноды и причины последней ошибки
</FrameDescription>
</FrameHeader>
<FramePanel>
<FailoverTimeline events={failoverEvents} />
</FramePanel>
</Frame>
{showPoolPanel ? (
<ServiceFailoverPanel
lbMode={service.lb_mode}
hasPool={uniqueIpCount(service.ips) >= 2}
ipHealth={service.ip_health}
bindings={failoverBindings}
history={failoverHistory}
probes={logItems}
/>
) : null}
</section>
{service.ips.length === 0 && service.domains.length === 0 ? (
@@ -372,6 +382,7 @@ function ServiceDetailPage() {
<ServiceDetailGrid
service={service}
nodes={nodes}
probes={logItems}
togglingIp={togglingIp}
onToggleIp={(ip, enabled) => {
setTogglingIp(ip)
@@ -1,6 +1,7 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { PageShell } from '@/components/page-shell'
import {
serviceFailoverLogQueryOptions,
serviceHealthLogQueryOptions,
serviceNodesQueryOptions,
serviceOverviewQueryOptions,
@@ -14,6 +15,7 @@ export const Route = createFileRoute('/_auth/services/$serviceId')({
queryClient.ensureQueryData(serviceViewQueryOptions(id)),
queryClient.ensureQueryData(serviceOverviewQueryOptions(id)),
queryClient.ensureQueryData(serviceHealthLogQueryOptions(id)),
queryClient.ensureQueryData(serviceFailoverLogQueryOptions(id)),
queryClient.ensureQueryData(serviceNodesQueryOptions(id)),
])
return { breadcrumb: view.name }
+4 -3
View File
@@ -44,9 +44,10 @@ health-check работают на двух уровнях:
- **Change Domain** — перенос привязок между зонами `POST /api/v1/services/:id/change-domain`.
Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted`
работает как `round_robin` (одна A на IP). `unknown` **не** считается healthy и
не попадает в пул, пока нет успешных проб; восстановление — `UNHEALTHY → CHECKING → HEALTHY`
после `HEALTH_SUCCESS_RECOVERIES` (default 2). Пороги и cron движка задаются в
работает как `round_robin` (одна A на IP). В A-пул попадает всё, кроме **Down**
(`unknown` / checking / Slow возвращаются в DNS на первой успешной пробе).
Бейдж Healthy — `UNHEALTHY → CHECKING → HEALTHY` после `HEALTH_SUCCESS_RECOVERIES`
(default 2). Пороги и cron движка задаются в
**Настройки → Health-check** (env — fallback, пока значения не сохранены в UI).
### Источники проб: Local, Cloudflare Worker, Globalping
+369 -4
View File
File diff suppressed because one or more lines are too long
+151 -21
View File
@@ -123,6 +123,7 @@ var serviceBindings = sqliteTable(
health_check_provider: text("health_check_provider").notNull().default("local"),
health_check_providers: text("health_check_providers").notNull().default('["local"]'),
health_check_aggregate: text("health_check_aggregate").notNull().default("majority"),
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
operation_version: integer("operation_version").notNull().default(0),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
@@ -228,6 +229,9 @@ var certificates = sqliteTable("certificates", {
subdomain_id: integer("subdomain_id").references(() => subdomains.id, {
onDelete: "set null"
}),
service_id: integer("service_id").references(() => services.id, {
onDelete: "set null"
}),
hostname: text("hostname").notNull().unique(),
expires_at: text("expires_at"),
last_checked_at: text("last_checked_at"),
@@ -351,6 +355,15 @@ var notificationLog = sqliteTable("notification_log", {
message: text("message").notNull(),
created_at: text("created_at").notNull().default(sql`datetime('now')`)
});
var failoverLog = sqliteTable("failover_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
binding_id: integer("binding_id").notNull().references(() => serviceBindings.id, { onDelete: "cascade" }),
fqdn: text("fqdn").notNull(),
ip: text("ip").notNull(),
action: text("action").notNull(),
created_at: text("created_at").notNull().default(sql`datetime('now')`)
});
var auditLog = sqliteTable("audit_log", {
id: text("id").primaryKey(),
event_id: text("event_id"),
@@ -391,6 +404,7 @@ var schema = {
domainMonitorResults,
healthProbeLog,
notificationLog,
failoverLog,
auditLog
};
@@ -711,6 +725,7 @@ __export(repos_exports, {
getSyncJob: () => getSyncJob,
insertBinding: () => insertBinding,
insertDnsRecord: () => insertDnsRecord,
insertFailoverLog: () => insertFailoverLog,
insertHealthProbeLog: () => insertHealthProbeLog,
insertNotificationLog: () => insertNotificationLog,
linkBindingRecord: () => linkBindingRecord,
@@ -734,6 +749,7 @@ __export(repos_exports, {
listDomains: () => listDomains,
listDomainsEnriched: () => listDomainsEnriched,
listEnabledDomainMonitors: () => listEnabledDomainMonitors,
listFailoverLogForService: () => listFailoverLogForService,
listGroupDnsRecords: () => listGroupDnsRecords,
listGroups: () => listGroups,
listHealthCheckTargets: () => listHealthCheckTargets,
@@ -1221,15 +1237,16 @@ function mapServiceBinding(row) {
cname_target: row.cname_target,
dns_record_id: row.dns_record_id,
lb_mode: row.lb_mode,
health_check_enabled: row.health_check_enabled,
health_check_enabled: Boolean(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,
health_check_verify_tls: row.health_check_verify_tls,
health_check_verify_tls: Boolean(row.health_check_verify_tls),
...mapHealthFields(row),
cert_monitoring: row.cert_monitoring ?? "auto",
routing_strategy: row.routing_strategy,
operation_version: row.operation_version,
created_at: row.created_at,
@@ -1625,6 +1642,8 @@ function updateBindingLbConfig(db, bindingId, patch) {
update.health_check_timeout_ms = patch.health_check_timeout_ms;
if (patch.health_check_verify_tls !== void 0)
update.health_check_verify_tls = patch.health_check_verify_tls;
if (patch.cert_monitoring !== void 0)
update.cert_monitoring = patch.cert_monitoring;
Object.assign(update, healthProviderColumns(patch));
db.update(serviceBindings).set(update).where(eq3(serviceBindings.id, bindingId)).run();
}
@@ -1685,7 +1704,7 @@ var SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hos
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.health_check_verify_tls, sb.health_check_provider,
sb.health_check_providers, sb.health_check_aggregate, sb.cname_target,
sb.health_check_providers, sb.health_check_aggregate, sb.cert_monitoring, 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,
@@ -1733,6 +1752,7 @@ function enrichServiceBindingView(db, row) {
return {
...row,
cname_target: row.cname_target ?? null,
cert_monitoring: row.cert_monitoring ?? "auto",
...mapHealthFields(row),
target_ips,
target_ip: target_ips[0] ?? null,
@@ -1765,11 +1785,15 @@ function listBindingsByDomain(db, domainId) {
`).map((row) => enrichServiceBindingView(db, row));
}
function listBindingsByService(db, serviceId) {
return db.all(sql2`
const rows = db.all(sql2`
SELECT sb.*, d.zone_name FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
WHERE sb.service_id = ${serviceId}
`);
return rows.map((row) => ({
...mapServiceBinding(row),
zone_name: row.zone_name
}));
}
function getBinding(db, id) {
const row = db.select().from(serviceBindings).where(eq3(serviceBindings.id, id)).get();
@@ -1839,23 +1863,57 @@ function deleteBinding(db, id) {
const result = db.delete(serviceBindings).where(eq3(serviceBindings.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service binding ${id}`);
}
var CERTIFICATE_SELECT = `c.id, c.domain_id, c.subdomain_id, c.service_id, c.hostname,
c.expires_at, c.last_checked_at, c.last_error, c.status, c.created_at, c.updated_at,
s.name AS service_name`;
function mapCertificate(row) {
return {
id: row.id,
domain_id: row.domain_id,
subdomain_id: row.subdomain_id,
service_id: row.service_id ?? null,
service_name: row.service_name ?? null,
hostname: row.hostname,
expires_at: row.expires_at,
last_checked_at: row.last_checked_at,
last_error: row.last_error,
status: row.status,
created_at: row.created_at,
updated_at: row.updated_at
};
}
function listCertificates(db, status) {
if (status) {
return db.select().from(certificates).where(eq3(certificates.status, status)).orderBy(asc(certificates.expires_at)).all();
}
return db.select().from(certificates).orderBy(asc(certificates.expires_at)).all();
const rows = status ? db.all(sql2`
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
FROM certificates c
LEFT JOIN services s ON s.id = c.service_id
WHERE c.status = ${status}
ORDER BY c.expires_at ASC
`) : db.all(sql2`
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
FROM certificates c
LEFT JOIN services s ON s.id = c.service_id
ORDER BY c.expires_at ASC
`);
return rows.map(mapCertificate);
}
function getCertificate(db, id) {
const row = db.select().from(certificates).where(eq3(certificates.id, id)).get();
const row = db.all(sql2`
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
FROM certificates c
LEFT JOIN services s ON s.id = c.service_id
WHERE c.id = ${id}
`)[0];
if (!row) throw new NotFoundError(`certificate ${id}`);
return row;
return mapCertificate(row);
}
function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt, status, lastError) {
function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt, status, lastError, serviceId) {
const existing = db.select().from(certificates).where(eq3(certificates.hostname, hostname)).get();
if (existing) {
db.update(certificates).set({
domain_id: domainId,
subdomain_id: subdomainId,
service_id: serviceId === void 0 ? existing.service_id : serviceId,
expires_at: expiresAt,
last_checked_at: sql2`datetime('now')`,
last_error: lastError,
@@ -1867,6 +1925,7 @@ function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt,
const id = db.insert(certificates).values({
domain_id: domainId,
subdomain_id: subdomainId,
service_id: serviceId ?? null,
hostname,
expires_at: expiresAt,
last_checked_at: sql2`datetime('now')`,
@@ -1948,6 +2007,12 @@ var WORST_HEALTH_SQL = sql2.raw(`CASE
END) = 1 THEN 'up'
ELSE 'unknown'
END`);
var BEST_ALIVE_HEALTH_SQL = sql2.raw(`CASE
WHEN MAX(CASE WHEN status = 'up' THEN 1 ELSE 0 END) = 1 THEN 'up'
WHEN MAX(CASE WHEN status = 'degraded' THEN 1 ELSE 0 END) = 1 THEN 'degraded'
WHEN MAX(CASE WHEN status = 'down' THEN 1 ELSE 0 END) = 1 THEN 'down'
ELSE 'unknown'
END`);
function aggregateIpHealthByRefs(db, scope, refIds) {
const result = /* @__PURE__ */ new Map();
if (refIds.length === 0) return result;
@@ -2008,7 +2073,7 @@ function aggregateIpHealthByServiceIds(db, serviceIds) {
);
const rows = db.all(sql2`
SELECT sb.service_id AS service_id,
${WORST_HEALTH_SQL} AS health_status,
${BEST_ALIVE_HEALTH_SQL} AS health_status,
MAX(ihs.latency_ms) AS health_latency_ms
FROM ip_health_status ihs
INNER JOIN service_bindings sb
@@ -2170,6 +2235,40 @@ function pruneStaleIpHealthStatus(db, activeTargets) {
}
return deleted;
}
function normalizeCnameHost(target, zoneName) {
const trimmed = target.trim().toLowerCase().replace(/\.+$/, "");
if (!trimmed) return "";
if (trimmed.includes(".")) return trimmed;
const zone = zoneName.trim().toLowerCase().replace(/\.+$/, "");
return zone ? `${trimmed}.${zone}` : trimmed;
}
function resolveCnameProbeIps(db, cnameTarget, zoneName, serviceId) {
const fqdn = normalizeCnameHost(cnameTarget, zoneName);
if (fqdn) {
const fromDns = listOriginIpsForFqdn(db, fqdn).filter(isIpLiteral);
if (fromDns.length > 0) return [...new Set(fromDns)];
}
if (serviceId > 0) {
return [...new Set(listServiceIps(db, serviceId).filter(isIpLiteral))];
}
return [];
}
function expandCnameHealthTargets(db, rows) {
const expanded = [];
for (const row of rows) {
const ips = resolveCnameProbeIps(
db,
row.ip,
row.zone_name ?? "",
row.service_id ?? 0
);
const resolved = ips.length > 0 ? ips : [row.ip];
for (const ip of resolved) {
expanded.push({ ...row, ip });
}
}
return expanded;
}
function listHealthCheckTargets(db) {
const fqdnExpr = sql2`CASE WHEN sb.hostname = '@' OR sb.hostname IS NULL THEN d.zone_name ELSE sb.hostname || '.' || d.zone_name END`;
const bindingTargets = db.all(sql2`
@@ -2188,7 +2287,7 @@ function listHealthCheckTargets(db) {
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN domains d ON d.id = sb.domain_id
WHERE sb.health_check_enabled = 1
`);
`).filter((t) => isIpLiteral(t.ip));
const groupTargets = db.all(sql2`
SELECT DISTINCT 'group' AS scope, sg.id AS ref_id, sbi.ip,
sg.domain AS hostname,
@@ -2211,7 +2310,7 @@ function listHealthCheckTargets(db) {
AND s.enabled = 1
AND sg.enabled = 1
AND (sb.cname_target IS NULL OR sb.cname_target = '')
`);
`).filter((t) => isIpLiteral(t.ip));
const groupInheritedBindingTargets = db.all(sql2`
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
${fqdnExpr} AS hostname,
@@ -2234,8 +2333,10 @@ function listHealthCheckTargets(db) {
AND s.enabled = 1
AND sg.enabled = 1
AND sb.health_check_enabled = 0
`);
const cnameBindingTargets = db.all(sql2`
`).filter((t) => isIpLiteral(t.ip));
const cnameBindingTargets = expandCnameHealthTargets(
db,
db.all(sql2`
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
${fqdnExpr} AS hostname,
sb.health_check_type AS type,
@@ -2246,7 +2347,9 @@ function listHealthCheckTargets(db) {
sb.health_check_verify_tls AS verify_tls,
sb.health_check_providers AS providers_json,
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sb.health_check_provider, 'local') AS provider
COALESCE(sb.health_check_provider, 'local') AS provider,
d.zone_name AS zone_name,
sb.service_id AS service_id
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
JOIN services s ON s.id = sb.service_id
@@ -2254,8 +2357,11 @@ function listHealthCheckTargets(db) {
AND sb.cname_target IS NOT NULL
AND sb.cname_target <> ''
AND s.enabled = 1
`);
const groupInheritedCnameBindingTargets = db.all(sql2`
`)
);
const groupInheritedCnameBindingTargets = expandCnameHealthTargets(
db,
db.all(sql2`
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
${fqdnExpr} AS hostname,
sg.health_check_type AS type,
@@ -2266,7 +2372,9 @@ function listHealthCheckTargets(db) {
sg.health_check_verify_tls AS verify_tls,
sg.health_check_providers AS providers_json,
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sg.health_check_provider, 'local') AS provider
COALESCE(sg.health_check_provider, 'local') AS provider,
d.zone_name AS zone_name,
sb.service_id AS service_id
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
JOIN services s ON s.id = sb.service_id
@@ -2278,7 +2386,8 @@ function listHealthCheckTargets(db) {
AND sb.health_check_enabled = 0
AND sb.cname_target IS NOT NULL
AND sb.cname_target <> ''
`);
`)
);
return [
...bindingTargets,
...groupTargets,
@@ -2454,6 +2563,26 @@ function listNotificationLog(db, limit = 50) {
LIMIT ${limit}
`);
}
function insertFailoverLog(db, input) {
for (const entry of input.entries) {
db.insert(failoverLog).values({
service_id: input.serviceId,
binding_id: input.bindingId,
fqdn: input.fqdn,
ip: entry.ip,
action: entry.action
}).run();
}
}
function listFailoverLogForService(db, serviceId, limit = 100) {
return db.all(sql2`
SELECT id, service_id, binding_id, fqdn, ip, action, created_at
FROM failover_log
WHERE service_id = ${serviceId}
ORDER BY created_at DESC, id DESC
LIMIT ${limit}
`);
}
export {
ConflictError,
NotFoundError,
@@ -2469,6 +2598,7 @@ export {
domainMonitors,
domainTags,
domains,
failoverLog,
getAppSettings,
getAppSettingsSecrets,
groups,
@@ -0,0 +1,37 @@
ALTER TABLE service_bindings ADD COLUMN cert_monitoring TEXT NOT NULL DEFAULT 'auto'
CHECK (cert_monitoring IN ('auto', 'required', 'skipped'));
ALTER TABLE certificates ADD COLUMN service_id INTEGER REFERENCES services(id) ON DELETE SET NULL;
UPDATE service_bindings
SET cert_monitoring = COALESCE(
(
SELECT d.cert_monitoring FROM domains d
WHERE d.id = service_bindings.domain_id
),
'auto'
)
WHERE hostname = '@';
UPDATE service_bindings
SET cert_monitoring = COALESCE(
(
SELECT s.cert_monitoring FROM subdomains s
WHERE s.domain_id = service_bindings.domain_id
AND s.name = service_bindings.hostname
),
'auto'
)
WHERE hostname != '@';
UPDATE certificates
SET service_id = (
SELECT sb.service_id
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
WHERE CASE
WHEN sb.hostname = '@' THEN d.zone_name
ELSE sb.hostname || '.' || d.zone_name
END = certificates.hostname
LIMIT 1
);
@@ -0,0 +1,11 @@
CREATE TABLE failover_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE,
binding_id INTEGER NOT NULL REFERENCES service_bindings(id) ON DELETE CASCADE,
fqdn TEXT NOT NULL,
ip TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('added', 'removed')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_failover_log_service ON failover_log(service_id, created_at DESC);
+327 -34
View File
@@ -49,6 +49,7 @@ import {
nodes,
bindingNodes,
notificationLog,
failoverLog,
serviceBindingIps,
serviceBindingRecords,
serviceBindings,
@@ -842,15 +843,16 @@ function mapServiceBinding(
cname_target: row.cname_target,
dns_record_id: row.dns_record_id,
lb_mode: row.lb_mode as LbMode,
health_check_enabled: row.health_check_enabled,
health_check_enabled: Boolean(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,
health_check_verify_tls: row.health_check_verify_tls,
health_check_verify_tls: Boolean(row.health_check_verify_tls),
...mapHealthFields(row),
cert_monitoring: row.cert_monitoring ?? "auto",
routing_strategy: row.routing_strategy as LbMode,
operation_version: row.operation_version,
created_at: row.created_at,
@@ -1530,6 +1532,7 @@ export interface BindingLbPatch {
health_check_provider?: HealthCheckProvider;
health_check_providers?: HealthCheckProvider[];
health_check_aggregate?: HealthCheckAggregate;
cert_monitoring?: string;
}
export function updateBindingLbConfig(
@@ -1560,6 +1563,8 @@ export function updateBindingLbConfig(
update.health_check_timeout_ms = patch.health_check_timeout_ms;
if (patch.health_check_verify_tls !== undefined)
update.health_check_verify_tls = patch.health_check_verify_tls;
if (patch.cert_monitoring !== undefined)
update.cert_monitoring = patch.cert_monitoring;
Object.assign(update, healthProviderColumns(patch));
db.update(serviceBindings)
.set(update)
@@ -1669,7 +1674,7 @@ const SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.h
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.health_check_verify_tls, sb.health_check_provider,
sb.health_check_providers, sb.health_check_aggregate, sb.cname_target,
sb.health_check_providers, sb.health_check_aggregate, sb.cert_monitoring, 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,
@@ -1733,6 +1738,7 @@ function enrichServiceBindingView(
return {
...row,
cname_target: row.cname_target ?? null,
cert_monitoring: row.cert_monitoring ?? "auto",
...mapHealthFields(row),
target_ips,
target_ip: target_ips[0] ?? null,
@@ -1772,11 +1778,15 @@ export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingVi
}
export function listBindingsByService(db: Db, serviceId: number): Array<ServiceBinding & { zone_name: string }> {
return db.all(sql`
const rows = db.all<typeof serviceBindings.$inferSelect & { zone_name: string }>(sql`
SELECT sb.*, d.zone_name FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
WHERE sb.service_id = ${serviceId}
`);
return rows.map((row) => ({
...mapServiceBinding(row),
zone_name: row.zone_name,
}));
}
export function getBinding(db: Db, id: number): ServiceBinding {
@@ -1914,26 +1924,69 @@ export function deleteBinding(db: Db, id: number): void {
// --- Certificates ---
const CERTIFICATE_SELECT = `c.id, c.domain_id, c.subdomain_id, c.service_id, c.hostname,
c.expires_at, c.last_checked_at, c.last_error, c.status, c.created_at, c.updated_at,
s.name AS service_name`;
type CertificateRow = {
id: number;
domain_id: number;
subdomain_id: number | null;
service_id: number | null;
hostname: string;
expires_at: string | null;
last_checked_at: string | null;
last_error: string | null;
status: string;
created_at: string;
updated_at: string;
service_name: string | null;
};
function mapCertificate(row: CertificateRow): Certificate {
return {
id: row.id,
domain_id: row.domain_id,
subdomain_id: row.subdomain_id,
service_id: row.service_id ?? null,
service_name: row.service_name ?? null,
hostname: row.hostname,
expires_at: row.expires_at,
last_checked_at: row.last_checked_at,
last_error: row.last_error,
status: row.status,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
export function listCertificates(db: Db, status?: string): Certificate[] {
if (status) {
return db
.select()
.from(certificates)
.where(eq(certificates.status, status))
.orderBy(asc(certificates.expires_at))
.all() as Certificate[];
}
return db
.select()
.from(certificates)
.orderBy(asc(certificates.expires_at))
.all() as Certificate[];
const rows = status
? db.all<CertificateRow>(sql`
SELECT ${sql.raw(CERTIFICATE_SELECT)}
FROM certificates c
LEFT JOIN services s ON s.id = c.service_id
WHERE c.status = ${status}
ORDER BY c.expires_at ASC
`)
: db.all<CertificateRow>(sql`
SELECT ${sql.raw(CERTIFICATE_SELECT)}
FROM certificates c
LEFT JOIN services s ON s.id = c.service_id
ORDER BY c.expires_at ASC
`);
return rows.map(mapCertificate);
}
export function getCertificate(db: Db, id: number): Certificate {
const row = db.select().from(certificates).where(eq(certificates.id, id)).get();
const row = db.all<CertificateRow>(sql`
SELECT ${sql.raw(CERTIFICATE_SELECT)}
FROM certificates c
LEFT JOIN services s ON s.id = c.service_id
WHERE c.id = ${id}
`)[0];
if (!row) throw new NotFoundError(`certificate ${id}`);
return row as Certificate;
return mapCertificate(row);
}
export function upsertCertificateCheck(
@@ -1944,6 +1997,7 @@ export function upsertCertificateCheck(
expiresAt: string | null,
status: string,
lastError: string | null,
serviceId?: number | null,
): Certificate {
const existing = db
.select()
@@ -1956,6 +2010,7 @@ export function upsertCertificateCheck(
.set({
domain_id: domainId,
subdomain_id: subdomainId,
service_id: serviceId === undefined ? existing.service_id : serviceId,
expires_at: expiresAt,
last_checked_at: sql`datetime('now')`,
last_error: lastError,
@@ -1972,6 +2027,7 @@ export function upsertCertificateCheck(
.values({
domain_id: domainId,
subdomain_id: subdomainId,
service_id: serviceId ?? null,
hostname,
expires_at: expiresAt,
last_checked_at: sql`datetime('now')`,
@@ -2108,6 +2164,14 @@ const WORST_HEALTH_SQL = sql.raw(`CASE
ELSE 'unknown'
END`);
/** Service is up if any binding IP is up. */
const BEST_ALIVE_HEALTH_SQL = sql.raw(`CASE
WHEN MAX(CASE WHEN status = 'up' THEN 1 ELSE 0 END) = 1 THEN 'up'
WHEN MAX(CASE WHEN status = 'degraded' THEN 1 ELSE 0 END) = 1 THEN 'degraded'
WHEN MAX(CASE WHEN status = 'down' THEN 1 ELSE 0 END) = 1 THEN 'down'
ELSE 'unknown'
END`);
/** Worst status across ip_health_status rows for each ref_id in a scope. */
export function aggregateIpHealthByRefs(
db: Db,
@@ -2181,7 +2245,7 @@ export function aggregateGroupScopeHealthByIds(
return result;
}
/** Worst binding-scope health rolled up per service_id. */
/** Binding-scope health rolled up per service_id: any up → service up. */
export function aggregateIpHealthByServiceIds(
db: Db,
serviceIds: number[],
@@ -2198,7 +2262,7 @@ export function aggregateIpHealthByServiceIds(
health_latency_ms: number | null;
}>(sql`
SELECT sb.service_id AS service_id,
${WORST_HEALTH_SQL} AS health_status,
${BEST_ALIVE_HEALTH_SQL} AS health_status,
MAX(ihs.latency_ms) AS health_latency_ms
FROM ip_health_status ihs
INNER JOIN service_bindings sb
@@ -2277,6 +2341,113 @@ export function listIpHealthByServiceIds(
return result;
}
function parseIpHealthState(status: string | null): IpHealthState | undefined {
if (
status === "up" ||
status === "down" ||
status === "degraded" ||
status === "unknown"
) {
return status;
}
return undefined;
}
function bestAliveIpState(statuses: readonly IpHealthState[]): IpHealthState {
if (statuses.some((status) => status === "up")) return "up";
if (statuses.some((status) => status === "degraded")) return "degraded";
if (statuses.some((status) => status === "down")) return "down";
return "unknown";
}
/**
* Latest probe per service+IP+provider from health_probe_log, then any-up per IP.
* Display overlay hysteresis in ip_health_status is unchanged (DNS).
*/
export function listLatestLiveHealthByServiceIds(
db: Db,
serviceIds: number[],
): Map<number, ServiceIpHealthRow[]> {
const result = new Map<number, ServiceIpHealthRow[]>();
if (serviceIds.length === 0) return result;
const idList = sql.join(
serviceIds.map((id) => sql`${id}`),
sql`, `,
);
const rows = db.all<{
service_id: number;
ip: string;
provider: string | null;
status: string | null;
latency_ms: number | null;
last_error: string | null;
colo: string | null;
checked_at: string | null;
}>(sql`
SELECT sb.service_id AS service_id,
l.ip AS ip,
l.provider AS provider,
l.status AS status,
l.latency_ms AS latency_ms,
l.error AS last_error,
l.colo AS colo,
l.checked_at AS checked_at
FROM health_probe_log l
INNER JOIN service_bindings sb
ON l.scope = 'binding' AND l.ref_id = sb.id
INNER JOIN (
SELECT sb2.service_id AS service_id,
l2.ip AS ip,
l2.provider AS provider,
MAX(l2.id) AS max_id
FROM health_probe_log l2
INNER JOIN service_bindings sb2
ON l2.scope = 'binding' AND l2.ref_id = sb2.id
WHERE sb2.service_id IN (${idList})
GROUP BY sb2.service_id, l2.ip, l2.provider
) latest
ON latest.max_id = l.id
`);
const byServiceIp = new Map<
string,
{ serviceId: number; ip: string; probes: ServiceIpHealthRow[] }
>();
for (const row of rows) {
const status = parseIpHealthState(row.status);
if (!status) continue;
const key = `${row.service_id}\0${row.ip}`;
const probe: ServiceIpHealthRow = {
ip: row.ip,
status,
latency_ms: row.latency_ms,
last_checked_at: row.checked_at,
last_error: row.last_error,
provider: normalizeStatusProvider(row.provider),
colo: row.colo,
};
const bucket = byServiceIp.get(key);
if (bucket) bucket.probes.push(probe);
else {
byServiceIp.set(key, {
serviceId: row.service_id,
ip: row.ip,
probes: [probe],
});
}
}
for (const { serviceId, ip, probes } of byServiceIp.values()) {
const status = bestAliveIpState(probes.map((probe) => probe.status));
const preferred =
probes.find((probe) => probe.status === status) ?? probes[0]!;
const list = result.get(serviceId) ?? [];
list.push({ ...preferred, ip, status });
result.set(serviceId, list);
}
return result;
}
export function mergeHealthAggregates(
parts: Array<HealthAggregate | undefined | null>,
): HealthAggregate {
@@ -2438,13 +2609,73 @@ export function pruneStaleIpHealthStatus(
// --- Health Check Targets ---
function normalizeCnameHost(target: string, zoneName: string): string {
const trimmed = target.trim().toLowerCase().replace(/\.+$/, "");
if (!trimmed) return "";
if (trimmed.includes(".")) return trimmed;
const zone = zoneName.trim().toLowerCase().replace(/\.+$/, "");
return zone ? `${trimmed}.${zone}` : trimmed;
}
/**
* Unwrap a CNAME health target to origin IPs:
* 1) A/AAAA from local dns_records (follows CNAME chain)
* 2) this service's IP pool
*
* Hostname is kept as fallback so probes still run when nothing resolves.
*/
function resolveCnameProbeIps(
db: Db,
cnameTarget: string,
zoneName: string,
serviceId: number,
): string[] {
const fqdn = normalizeCnameHost(cnameTarget, zoneName);
if (fqdn) {
const fromDns = listOriginIpsForFqdn(db, fqdn).filter(isIpLiteral);
if (fromDns.length > 0) return [...new Set(fromDns)];
}
if (serviceId > 0) {
return [...new Set(listServiceIps(db, serviceId).filter(isIpLiteral))];
}
return [];
}
type RawHealthTarget = HealthCheckTarget & {
providers_json?: string | null;
aggregate?: string | null;
zone_name?: string | null;
service_id?: number | null;
};
function expandCnameHealthTargets(
db: Db,
rows: RawHealthTarget[],
): RawHealthTarget[] {
const expanded: RawHealthTarget[] = [];
for (const row of rows) {
const ips = resolveCnameProbeIps(
db,
row.ip,
row.zone_name ?? "",
row.service_id ?? 0,
);
const resolved = ips.length > 0 ? ips : [row.ip];
for (const ip of resolved) {
expanded.push({ ...row, ip });
}
}
return expanded;
}
export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
// FQDN for a binding: "@" => zone_name, else "<hostname>.<zone_name>".
// Used as SNI / Host header for HTTP(S) probes — the raw `sb.hostname` is just the record name
// (e.g. "de" or "@"), which would break TLS SNI (ssl alert 112 "unrecognized name").
const fqdnExpr = sql`CASE WHEN sb.hostname = '@' OR sb.hostname IS NULL THEN d.zone_name ELSE sb.hostname || '.' || d.zone_name END`;
const bindingTargets = db.all<HealthCheckTarget>(sql`
const bindingTargets = db
.all<RawHealthTarget>(sql`
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
${fqdnExpr} AS hostname,
sb.health_check_type AS type,
@@ -2460,12 +2691,14 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN domains d ON d.id = sb.domain_id
WHERE sb.health_check_enabled = 1
`);
`)
.filter((t) => isIpLiteral(t.ip));
// Group FQDN probes the same A-record IPs that DNS publishes (binding IPs of
// enabled services) — NOT the full service_ips pool. Pool-only dead IPs must
// not mark the group Down while the published domain stays healthy.
const groupTargets = db.all<HealthCheckTarget>(sql`
const groupTargets = db
.all<RawHealthTarget>(sql`
SELECT DISTINCT 'group' AS scope, sg.id AS ref_id, sbi.ip,
sg.domain AS hostname,
sg.health_check_type AS type,
@@ -2487,13 +2720,14 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
AND s.enabled = 1
AND sg.enabled = 1
AND (sb.cname_target IS NULL OR sb.cname_target = '')
`);
`)
.filter((t) => isIpLiteral(t.ip));
// IPs of A-bindings of enabled services in a group whose group has health-check enabled.
// These inherit the group's health-check config (scope='binding', ref_id=binding_id),
// so per-domain badges reflect group rules. Skipped for bindings that already have
// their own health_check_enabled=1 (covered by bindingTargets above).
const groupInheritedBindingTargets = db.all<HealthCheckTarget>(sql`
const groupInheritedBindingTargets = db.all<RawHealthTarget>(sql`
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
${fqdnExpr} AS hostname,
sg.health_check_type AS type,
@@ -2515,10 +2749,13 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
AND s.enabled = 1
AND sg.enabled = 1
AND sb.health_check_enabled = 0
`);
`)
.filter((t) => isIpLiteral(t.ip));
// CNAME-bindings with their own health_check_enabled: probe the CNAME target host.
const cnameBindingTargets = db.all<HealthCheckTarget>(sql`
// CNAME-bindings: unwrap to origin/service IPs so ip_health keys match the IP table.
const cnameBindingTargets = expandCnameHealthTargets(
db,
db.all<RawHealthTarget>(sql`
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
${fqdnExpr} AS hostname,
sb.health_check_type AS type,
@@ -2529,7 +2766,9 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
sb.health_check_verify_tls AS verify_tls,
sb.health_check_providers AS providers_json,
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sb.health_check_provider, 'local') AS provider
COALESCE(sb.health_check_provider, 'local') AS provider,
d.zone_name AS zone_name,
sb.service_id AS service_id
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
JOIN services s ON s.id = sb.service_id
@@ -2537,11 +2776,14 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
AND sb.cname_target IS NOT NULL
AND sb.cname_target <> ''
AND s.enabled = 1
`);
`),
);
// CNAME-bindings of services in a group with group health-check enabled
// (inherit group config). Only for bindings without their own health_check_enabled.
const groupInheritedCnameBindingTargets = db.all<HealthCheckTarget>(sql`
const groupInheritedCnameBindingTargets = expandCnameHealthTargets(
db,
db.all<RawHealthTarget>(sql`
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
${fqdnExpr} AS hostname,
sg.health_check_type AS type,
@@ -2552,7 +2794,9 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
sg.health_check_verify_tls AS verify_tls,
sg.health_check_providers AS providers_json,
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sg.health_check_provider, 'local') AS provider
COALESCE(sg.health_check_provider, 'local') AS provider,
d.zone_name AS zone_name,
sb.service_id AS service_id
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
JOIN services s ON s.id = sb.service_id
@@ -2564,7 +2808,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
AND sb.health_check_enabled = 0
AND sb.cname_target IS NOT NULL
AND sb.cname_target <> ''
`);
`),
);
return [
...bindingTargets,
@@ -2926,3 +3171,51 @@ export function listNotificationLog(
`);
}
export type FailoverLogAction = "added" | "removed";
export type FailoverLogRow = {
id: number;
service_id: number;
binding_id: number;
fqdn: string;
ip: string;
action: FailoverLogAction;
created_at: string;
};
export function insertFailoverLog(
db: Db,
input: {
serviceId: number;
bindingId: number;
fqdn: string;
entries: Array<{ ip: string; action: FailoverLogAction }>;
},
): void {
for (const entry of input.entries) {
db.insert(failoverLog)
.values({
service_id: input.serviceId,
binding_id: input.bindingId,
fqdn: input.fqdn,
ip: entry.ip,
action: entry.action,
})
.run();
}
}
export function listFailoverLogForService(
db: Db,
serviceId: number,
limit = 100,
): FailoverLogRow[] {
return db.all<FailoverLogRow>(sql`
SELECT id, service_id, binding_id, fqdn, ip, action, created_at
FROM failover_log
WHERE service_id = ${serviceId}
ORDER BY created_at DESC, id DESC
LIMIT ${limit}
`);
}
+21
View File
@@ -177,6 +177,7 @@ export const serviceBindings = sqliteTable(
health_check_aggregate: text("health_check_aggregate")
.notNull()
.default("majority"),
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
operation_version: integer("operation_version").notNull().default(0),
created_at: text("created_at")
@@ -324,6 +325,9 @@ export const certificates = sqliteTable("certificates", {
subdomain_id: integer("subdomain_id").references(() => subdomains.id, {
onDelete: "set null",
}),
service_id: integer("service_id").references(() => services.id, {
onDelete: "set null",
}),
hostname: text("hostname").notNull().unique(),
expires_at: text("expires_at"),
last_checked_at: text("last_checked_at"),
@@ -493,6 +497,22 @@ export const notificationLog = sqliteTable("notification_log", {
.default(sql`datetime('now')`),
});
export const failoverLog = sqliteTable("failover_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
service_id: integer("service_id")
.notNull()
.references(() => services.id, { onDelete: "cascade" }),
binding_id: integer("binding_id")
.notNull()
.references(() => serviceBindings.id, { onDelete: "cascade" }),
fqdn: text("fqdn").notNull(),
ip: text("ip").notNull(),
action: text("action").notNull(),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
});
export const auditLog = sqliteTable("audit_log", {
id: text("id").primaryKey(),
event_id: text("event_id"),
@@ -536,5 +556,6 @@ export const schema = {
domainMonitorResults,
healthProbeLog,
notificationLog,
failoverLog,
auditLog,
};
+142 -39
View File
@@ -96,6 +96,7 @@ interface ServiceBinding {
health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
cert_monitoring: string;
routing_strategy: LbMode;
operation_version: number;
created_at: string;
@@ -129,6 +130,7 @@ interface ServiceBindingView {
health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
cert_monitoring: string;
sync_status: string | null;
created_at: string;
updated_at: string;
@@ -141,6 +143,7 @@ interface ServiceDomainBindingView {
fqdn: string;
record_type: "A" | "CNAME";
target_ips: string[];
active_ips: string[];
target_ip_weights: Record<string, number>;
target_ip_priorities: Record<string, number>;
target_cname: string | null;
@@ -156,6 +159,7 @@ interface ServiceDomainBindingView {
health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
cert_monitoring: string;
sync_status: string | null;
}
interface ServiceView$1 {
@@ -424,11 +428,11 @@ declare const healthCheckAggregateSchema: z.ZodEnum<{
all: "all";
majority: "majority";
}>;
declare const healthCheckProvidersSchema: z.ZodPipe<z.ZodArray<z.ZodEnum<{
declare const healthCheckProvidersSchema: z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>;
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
declare const healthCheckScopeSchema: z.ZodEnum<{
binding: "binding";
group: "group";
@@ -502,6 +506,19 @@ declare const healthProbeLogSchema: z.ZodObject<{
checked_at: z.ZodString;
}, z.core.$strip>;
type HealthProbeLog = z.infer<typeof healthProbeLogSchema>;
declare const failoverLogSchema: z.ZodObject<{
id: z.ZodNumber;
service_id: z.ZodNumber;
binding_id: z.ZodNumber;
fqdn: z.ZodString;
ip: z.ZodString;
action: z.ZodEnum<{
added: "added";
removed: "removed";
}>;
created_at: z.ZodString;
}, z.core.$strip>;
type FailoverLogEntry = z.infer<typeof failoverLogSchema>;
declare const groupSchema: z.ZodObject<{
id: z.ZodNumber;
name: z.ZodString;
@@ -560,11 +577,11 @@ declare const serviceGroupSchema: z.ZodObject<{
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
@@ -624,17 +641,23 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
cert_monitoring: z.ZodDefault<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
target_ip_weights: Record<string, number>;
@@ -658,7 +681,9 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ip?: string | null | undefined;
}, {
binding_id: number;
@@ -679,7 +704,9 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined;
@@ -737,17 +764,23 @@ declare const serviceViewSchema: z.ZodObject<{
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
cert_monitoring: z.ZodDefault<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
target_ip_weights: Record<string, number>;
@@ -771,7 +804,9 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ip?: string | null | undefined;
}, {
binding_id: number;
@@ -792,7 +827,9 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined;
@@ -869,11 +906,11 @@ declare const serviceGroupViewSchema: z.ZodObject<{
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
@@ -932,17 +969,23 @@ declare const serviceGroupViewSchema: z.ZodObject<{
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
cert_monitoring: z.ZodDefault<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
target_ip_weights: Record<string, number>;
@@ -966,7 +1009,9 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ip?: string | null | undefined;
}, {
binding_id: number;
@@ -987,7 +1032,9 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined;
@@ -1073,11 +1120,11 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
@@ -1136,17 +1183,23 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
cert_monitoring: z.ZodDefault<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
target_ip_weights: Record<string, number>;
@@ -1170,7 +1223,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ip?: string | null | undefined;
}, {
binding_id: number;
@@ -1191,7 +1246,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined;
@@ -1291,17 +1348,23 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
cert_monitoring: z.ZodDefault<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
target_ip_weights: Record<string, number>;
@@ -1325,7 +1388,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ip?: string | null | undefined;
}, {
binding_id: number;
@@ -1346,7 +1411,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined;
@@ -1471,6 +1538,11 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
cert_monitoring: z.ZodDefault<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
created_at: z.ZodString;
updated_at: z.ZodString;
@@ -1498,6 +1570,7 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
created_at: string;
updated_at: string;
@@ -1522,6 +1595,7 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
created_at: string;
updated_at: string;
@@ -1549,6 +1623,8 @@ declare const certificateSchema: z.ZodObject<{
id: z.ZodNumber;
domain_id: z.ZodNumber;
subdomain_id: z.ZodNullable<z.ZodNumber>;
service_id: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
service_name: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
hostname: z.ZodString;
expires_at: z.ZodNullable<z.ZodString>;
last_checked_at: z.ZodNullable<z.ZodString>;
@@ -1557,6 +1633,22 @@ declare const certificateSchema: z.ZodObject<{
created_at: z.ZodString;
updated_at: z.ZodString;
}, z.core.$strip>;
declare const serviceCertificateRowSchema: z.ZodObject<{
binding_id: z.ZodNumber;
domain_id: z.ZodNumber;
service_id: z.ZodNumber;
hostname: z.ZodString;
cert_monitoring: z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>;
id: z.ZodNullable<z.ZodNumber>;
status: z.ZodString;
expires_at: z.ZodNullable<z.ZodString>;
last_checked_at: z.ZodNullable<z.ZodString>;
last_error: z.ZodNullable<z.ZodString>;
}, z.core.$strip>;
type Group = z.infer<typeof groupSchema>;
type GroupWithStats = z.infer<typeof groupWithStatsSchema>;
type Service = z.infer<typeof serviceSchema>;
@@ -1569,12 +1661,13 @@ type Domain = z.infer<typeof domainSchema>;
type DomainListItem = z.infer<typeof domainListItemSchema>;
type DnsRecord = z.infer<typeof dnsRecordSchema>;
type Certificate = z.infer<typeof certificateSchema>;
type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>;
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_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
@@ -1586,17 +1679,17 @@ declare const healthCheckConfigSchema: z.ZodObject<{
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>;
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodEnum<{
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
any: "any";
all: "all";
@@ -1616,7 +1709,7 @@ declare const createServiceWithConfigSchema: z.ZodObject<{
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_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
@@ -1628,17 +1721,17 @@ declare const createServiceWithConfigSchema: z.ZodObject<{
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>;
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodEnum<{
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
any: "any";
all: "all";
@@ -1819,7 +1912,7 @@ declare const updateServiceConfigSchema: z.ZodObject<{
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_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
@@ -1831,17 +1924,17 @@ declare const updateServiceConfigSchema: z.ZodObject<{
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>;
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodEnum<{
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
any: "any";
all: "all";
@@ -1861,7 +1954,7 @@ declare const updateServiceConfigSchema: z.ZodObject<{
}, z.core.$strip>;
type UpdateServiceConfigInput = z.infer<typeof updateServiceConfigSchema>;
declare const createServiceGroupSchema: z.ZodObject<{
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
@@ -1873,17 +1966,17 @@ declare const createServiceGroupSchema: z.ZodObject<{
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>;
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodEnum<{
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
any: "any";
all: "all";
@@ -1906,7 +1999,7 @@ declare const createServiceGroupSchema: z.ZodObject<{
}>>;
}, z.core.$strip>;
declare const updateServiceGroupSchema: z.ZodObject<{
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
@@ -1918,17 +2011,17 @@ declare const updateServiceGroupSchema: z.ZodObject<{
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>;
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodEnum<{
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
any: "any";
all: "all";
@@ -2154,6 +2247,11 @@ declare const cfdmBindingSyncItemSchema: z.ZodObject<{
hostname: z.ZodString;
ips: z.ZodArray<z.ZodString>;
cnameTarget: z.ZodOptional<z.ZodString>;
lbMode: z.ZodOptional<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
deleted: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>;
declare const cfdmSyncBindingsBodySchema: z.ZodObject<{
@@ -2167,6 +2265,11 @@ declare const cfdmSyncBindingsBodySchema: z.ZodObject<{
hostname: z.ZodString;
ips: z.ZodArray<z.ZodString>;
cnameTarget: z.ZodOptional<z.ZodString>;
lbMode: z.ZodOptional<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
deleted: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>;
fullSync: z.ZodOptional<z.ZodBoolean>;
@@ -2348,4 +2451,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{
}, z.core.$strip>;
type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>;
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, HEALTH_CHECK_AGGREGATES, HEALTH_CHECK_PROVIDERS, HEALTH_KV_CURSOR_KEY, HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, HEALTH_PROBE_BATCH, HEALTH_PROBE_CONCURRENCY, HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, HEALTH_STATUS_PROVIDERS, type HealthCheckAggregate, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthProbeLog, type HealthProbeResultItem, type HealthProbeResultsDoc, type HealthProbeTargetItem, type HealthProbeTargetsDoc, type HealthStatusProvider, type HealthStatusQuery, type HealthWorkerStatus, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type ToggleServiceIpInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, aggregateHealthOk, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, clampGlobalpingLimit, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, derivePrimaryProvider, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckAggregateSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckProvidersSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthProbeLogSchema, healthStatusProviderSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, normalizeProbeProvider, normalizeStatusProvider, notificationLogSchema, originHealthCheckSchema, parseFqdn, parseGlobalpingLocations, parseHealthAggregate, parseHealthProviders, reorderServicesSchema, serializeHealthProviders, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, targetHasProvider, targetProviders, toggleEnabledSchema, toggleServiceIpSchema, uniqueHealthProviders, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type FailoverLogEntry, type Group, type GroupWithStats, HEALTH_CHECK_AGGREGATES, HEALTH_CHECK_PROVIDERS, HEALTH_KV_CURSOR_KEY, HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, HEALTH_PROBE_BATCH, HEALTH_PROBE_CONCURRENCY, HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, HEALTH_STATUS_PROVIDERS, type HealthCheckAggregate, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthProbeLog, type HealthProbeResultItem, type HealthProbeResultsDoc, type HealthProbeTargetItem, type HealthProbeTargetsDoc, type HealthStatusProvider, type HealthStatusQuery, type HealthWorkerStatus, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceCertificateRow, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type ToggleServiceIpInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, aggregateHealthOk, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, clampGlobalpingLimit, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, derivePrimaryProvider, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, failoverLogSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckAggregateSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckProvidersSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthProbeLogSchema, healthStatusProviderSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, normalizeProbeProvider, normalizeStatusProvider, notificationLogSchema, originHealthCheckSchema, parseFqdn, parseGlobalpingLocations, parseHealthAggregate, parseHealthProviders, reorderServicesSchema, serializeHealthProviders, serviceBindingSchema, serviceCertificateRowSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, targetHasProvider, targetProviders, toggleEnabledSchema, toggleServiceIpSchema, uniqueHealthProviders, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
+48 -8
View File
@@ -19,7 +19,10 @@ var CERT_MONITORING_VALUES = [
];
// src/validators.ts
var 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_])?)*)$/;
var LABEL_RE = "[a-zA-Z0-9_](?:[a-zA-Z0-9_-]*[a-zA-Z0-9_])?";
var NAME_RE = new RegExp(
`^(@|\\*|(\\*\\.)?${LABEL_RE}(?:\\.${LABEL_RE})*)$`
);
var IPV4_RE = /^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
var IPV6_RE = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
var ALLOWED_TYPES = ["A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA"];
@@ -106,6 +109,7 @@ function dnsNameToSubdomainLabel(recordName, zoneName) {
const prefix = rn.slice(0, rn.length - zoneSuffix.length);
return prefix || "@";
}
if (rn.startsWith("*.")) return rn;
if (!rn.includes(".")) return rn;
return null;
}
@@ -276,10 +280,16 @@ var nodeHealthStateSchema = z.enum([
var healthCheckProviderSchema = z.enum(HEALTH_CHECK_PROVIDERS);
var healthStatusProviderSchema = z.enum(HEALTH_STATUS_PROVIDERS);
var healthCheckAggregateSchema = z.enum(HEALTH_CHECK_AGGREGATES);
var healthCheckProvidersSchema = z.array(healthCheckProviderSchema).min(1).transform((arr) => {
const unique = uniqueHealthProviders(arr);
return unique.length > 0 ? unique : ["local"];
});
var healthCheckProvidersSchema = z.preprocess(
(value) => {
if (value === void 0) return void 0;
return Array.isArray(value) ? value : parseHealthProviders(value);
},
z.array(healthCheckProviderSchema).min(1).transform((arr) => {
const unique = uniqueHealthProviders(arr);
return unique.length > 0 ? unique : ["local"];
})
);
var healthCheckScopeSchema = z.enum(["binding", "group"]);
var ipHealthStatusSchema = z.object({
scope: healthCheckScopeSchema,
@@ -316,6 +326,15 @@ var healthProbeLogSchema = z.object({
error: z.string().nullable(),
checked_at: z.string()
});
var failoverLogSchema = z.object({
id: z.number(),
service_id: z.number(),
binding_id: z.number(),
fqdn: z.string(),
ip: z.string(),
action: z.enum(["added", "removed"]),
created_at: z.string()
});
var groupSchema = z.object({
id: z.number(),
name: z.string(),
@@ -392,7 +411,9 @@ var serviceDomainBindingSchema = z.object({
health_check_provider: healthCheckProviderSchema.catch("local"),
health_check_providers: healthCheckProvidersSchema.catch(["local"]),
health_check_aggregate: healthCheckAggregateSchema.catch("majority"),
sync_status: z.string().nullable().default(null)
cert_monitoring: certMonitoringSchema.default("auto"),
sync_status: z.string().nullable().default(null),
active_ips: z.array(z.string()).default([])
}).transform((binding) => ({
...binding,
target_ips: binding.target_ips && binding.target_ips.length > 0 ? binding.target_ips : binding.target_ip ? [binding.target_ip] : [],
@@ -465,6 +486,7 @@ var serviceBindingSchema = z.object({
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3e3),
health_check_verify_tls: z.coerce.boolean().default(false),
cert_monitoring: certMonitoringSchema.default("auto"),
sync_status: z.string().nullable().default(null),
created_at: z.string(),
updated_at: z.string()
@@ -494,6 +516,8 @@ var certificateSchema = z.object({
id: z.number(),
domain_id: z.number(),
subdomain_id: z.number().nullable(),
service_id: z.number().nullable().optional().default(null),
service_name: z.string().nullable().optional().default(null),
hostname: z.string(),
expires_at: z.string().nullable(),
last_checked_at: z.string().nullable(),
@@ -502,6 +526,18 @@ var certificateSchema = z.object({
created_at: z.string(),
updated_at: z.string()
});
var serviceCertificateRowSchema = z.object({
binding_id: z.number(),
domain_id: z.number(),
service_id: z.number(),
hostname: z.string(),
cert_monitoring: certMonitoringSchema,
id: z.number().nullable(),
status: z.string(),
expires_at: z.string().nullable(),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable()
});
var createGroupSchema = z.object({
name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435"),
slug: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 slug")
@@ -512,14 +548,14 @@ var ipv4Schema = z.string().regex(
);
var nodeAddressSchema = z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0438\u043B\u0438 hostname").max(255);
var healthCheckConfigFields = {
health_check_enabled: z.boolean().optional(),
health_check_enabled: z.coerce.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(),
health_check_verify_tls: z.boolean().optional(),
health_check_verify_tls: z.coerce.boolean().optional(),
health_check_provider: healthCheckProviderSchema.optional(),
health_check_providers: healthCheckProvidersSchema.optional(),
health_check_aggregate: healthCheckAggregateSchema.optional()
@@ -814,6 +850,8 @@ var cfdmBindingSyncItemSchema = z3.object({
ips: z3.array(z3.string()),
/** CNAME-цель (FQDN), если binding — CNAME; для матчинга в VPS Tracker. */
cnameTarget: z3.string().optional(),
/** HA-режим binding (fallback — service group). Optional для старых payload. */
lbMode: z3.enum(["round_robin", "failover", "weighted"]).optional(),
deleted: z3.boolean().optional()
});
var cfdmSyncBindingsBodySchema = z3.object({
@@ -997,6 +1035,7 @@ export {
domainMonitorSchema,
domainMonitorTypeSchema,
domainSchema,
failoverLogSchema,
fqdnToDisplay,
groupSchema,
groupWithStatsSchema,
@@ -1029,6 +1068,7 @@ export {
reorderServicesSchema,
serializeHealthProviders,
serviceBindingSchema,
serviceCertificateRowSchema,
serviceDomainBindingSchema,
serviceGroupSchema,
serviceGroupTypeSchema,
@@ -11,6 +11,8 @@ export const cfdmBindingSyncItemSchema = z.object({
ips: z.array(z.string()),
/** CNAME-цель (FQDN), если binding — CNAME; для матчинга в VPS Tracker. */
cnameTarget: z.string().optional(),
/** HA-режим binding (fallback — service group). Optional для старых payload. */
lbMode: z.enum(["round_robin", "failover", "weighted"]).optional(),
deleted: z.boolean().optional(),
});
+42 -7
View File
@@ -3,6 +3,7 @@ import {
HEALTH_CHECK_AGGREGATES,
HEALTH_CHECK_PROVIDERS,
HEALTH_STATUS_PROVIDERS,
parseHealthProviders,
uniqueHealthProviders,
} from './health-providers.js'
@@ -41,13 +42,16 @@ export const healthStatusProviderSchema = z.enum(HEALTH_STATUS_PROVIDERS)
export const healthCheckAggregateSchema = z.enum(HEALTH_CHECK_AGGREGATES)
export const healthCheckProvidersSchema = z
.array(healthCheckProviderSchema)
.min(1)
.transform((arr) => {
export const healthCheckProvidersSchema = z.preprocess(
(value) => {
if (value === undefined) return undefined
return Array.isArray(value) ? value : parseHealthProviders(value)
},
z.array(healthCheckProviderSchema).min(1).transform((arr) => {
const unique = uniqueHealthProviders(arr)
return unique.length > 0 ? unique : (['local'] as const)
})
}),
)
export const healthCheckScopeSchema = z.enum(['binding', 'group'])
export type HealthCheckScope = z.infer<typeof healthCheckScopeSchema>
@@ -96,6 +100,18 @@ export const healthProbeLogSchema = z.object({
export type HealthProbeLog = z.infer<typeof healthProbeLogSchema>
export const failoverLogSchema = z.object({
id: z.number(),
service_id: z.number(),
binding_id: z.number(),
fqdn: z.string(),
ip: z.string(),
action: z.enum(['added', 'removed']),
created_at: z.string(),
})
export type FailoverLogEntry = z.infer<typeof failoverLogSchema>
export const groupSchema = z.object({
id: z.number(),
name: z.string(),
@@ -178,7 +194,9 @@ export const serviceDomainBindingSchema = z
health_check_provider: healthCheckProviderSchema.catch('local'),
health_check_providers: healthCheckProvidersSchema.catch(['local']),
health_check_aggregate: healthCheckAggregateSchema.catch('majority'),
cert_monitoring: certMonitoringSchema.default('auto'),
sync_status: z.string().nullable().default(null),
active_ips: z.array(z.string()).default([]),
})
.transform((binding) => ({
...binding,
@@ -266,6 +284,7 @@ export const serviceBindingSchema = z
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
health_check_verify_tls: z.coerce.boolean().default(false),
cert_monitoring: certMonitoringSchema.default('auto'),
sync_status: z.string().nullable().default(null),
created_at: z.string(),
updated_at: z.string(),
@@ -303,6 +322,8 @@ export const certificateSchema = z.object({
id: z.number(),
domain_id: z.number(),
subdomain_id: z.number().nullable(),
service_id: z.number().nullable().optional().default(null),
service_name: z.string().nullable().optional().default(null),
hostname: z.string(),
expires_at: z.string().nullable(),
last_checked_at: z.string().nullable(),
@@ -312,6 +333,19 @@ export const certificateSchema = z.object({
updated_at: z.string(),
})
export const serviceCertificateRowSchema = z.object({
binding_id: z.number(),
domain_id: z.number(),
service_id: z.number(),
hostname: z.string(),
cert_monitoring: certMonitoringSchema,
id: z.number().nullable(),
status: z.string(),
expires_at: z.string().nullable(),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable(),
})
export type Group = z.infer<typeof groupSchema>
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
export type Service = z.infer<typeof serviceSchema>
@@ -324,6 +358,7 @@ export type Domain = z.infer<typeof domainSchema>
export type DomainListItem = z.infer<typeof domainListItemSchema>
export type DnsRecord = z.infer<typeof dnsRecordSchema>
export type Certificate = z.infer<typeof certificateSchema>
export type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>
export const createGroupSchema = z.object({
name: z.string().min(1, 'Укажите название'),
@@ -343,14 +378,14 @@ const nodeAddressSchema = z
.max(255)
const healthCheckConfigFields = {
health_check_enabled: z.boolean().optional(),
health_check_enabled: z.coerce.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(),
health_check_verify_tls: z.boolean().optional(),
health_check_verify_tls: z.coerce.boolean().optional(),
health_check_provider: healthCheckProviderSchema.optional(),
health_check_providers: healthCheckProvidersSchema.optional(),
health_check_aggregate: healthCheckAggregateSchema.optional(),
+1
View File
@@ -19,6 +19,7 @@ export function dnsNameToSubdomainLabel(
return prefix || "@";
}
if (rn.startsWith("*.")) return rn;
if (!rn.includes(".")) return rn;
return null;
+6
View File
@@ -111,6 +111,8 @@ export interface Certificate {
id: number;
domain_id: number;
subdomain_id: number | null;
service_id: number | null;
service_name: string | null;
hostname: string;
expires_at: string | null;
last_checked_at: string | null;
@@ -139,6 +141,7 @@ export interface ServiceBinding {
health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
cert_monitoring: string;
routing_strategy: LbMode;
operation_version: number;
created_at: string;
@@ -173,6 +176,7 @@ export interface ServiceBindingView {
health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
cert_monitoring: string;
sync_status: string | null;
created_at: string;
updated_at: string;
@@ -186,6 +190,7 @@ export interface ServiceDomainBindingView {
fqdn: string;
record_type: "A" | "CNAME";
target_ips: string[];
active_ips: string[];
target_ip_weights: Record<string, number>;
target_ip_priorities: Record<string, number>;
target_cname: string | null;
@@ -201,6 +206,7 @@ export interface ServiceDomainBindingView {
health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
cert_monitoring: string;
sync_status: string | null;
}
+5 -2
View File
@@ -5,8 +5,11 @@ import {
} from "./constants.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_])?)*)$/;
const LABEL_RE = "[a-zA-Z0-9_](?:[a-zA-Z0-9_-]*[a-zA-Z0-9_])?";
/** Apex `@`, zone `*`, labels, or nested wildcard (`*.ndns`, `*.ndns.shnt.top`). */
const NAME_RE = new RegExp(
`^(@|\\*|(\\*\\.)?${LABEL_RE}(?:\\.${LABEL_RE})*)$`,
);
const IPV4_RE =
/^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
const IPV6_RE = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
+16
View File
@@ -23,6 +23,13 @@ describe("normalizeDnsRecordName", () => {
expect(normalizeDnsRecordName("@", ZONE)).toBe("rkns.top");
expect(normalizeDnsRecordName("rkns.top", ZONE)).toBe("rkns.top");
});
it("normalizes nested wildcard relative name to FQDN", () => {
expect(normalizeDnsRecordName("*.mdns", ZONE)).toBe("*.mdns.rkns.top");
expect(normalizeDnsRecordName("*.mdns.rkns.top", ZONE)).toBe(
"*.mdns.rkns.top",
);
});
});
describe("dnsRecordNamesMatch", () => {
@@ -34,10 +41,19 @@ describe("dnsRecordNamesMatch", () => {
it("does not match different hosts", () => {
expect(dnsRecordNamesMatch("de", "mhome.rkns.top", ZONE)).toBe(false);
});
it("matches nested wildcard relative name and FQDN", () => {
expect(dnsRecordNamesMatch("*.mdns", "*.mdns.rkns.top", ZONE)).toBe(true);
});
});
describe("dnsNameToSubdomainLabel", () => {
it("extracts label from FQDN", () => {
expect(dnsNameToSubdomainLabel("de.rkns.top", ZONE)).toBe("de");
});
it("keeps nested wildcard relative names", () => {
expect(dnsNameToSubdomainLabel("*.mdns", ZONE)).toBe("*.mdns");
expect(dnsNameToSubdomainLabel("*.mdns.rkns.top", ZONE)).toBe("*.mdns");
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { validateDnsRecord, ValidationError } from "../src/validators.js";
function assertValidName(name: string): void {
expect(() =>
validateDnsRecord("A", name, "1.2.3.4", 1, false),
).not.toThrow();
}
function assertInvalidName(name: string): void {
expect(() => validateDnsRecord("A", name, "1.2.3.4", 1, false)).toThrow(
ValidationError,
);
expect(() => validateDnsRecord("A", name, "1.2.3.4", 1, false)).toThrow(
`invalid record name: ${name}`,
);
}
describe("validateDnsRecord name", () => {
it("accepts apex and zone wildcard", () => {
assertValidName("@");
assertValidName("*");
});
it("accepts regular labels and FQDN", () => {
assertValidName("ndns");
assertValidName("ndns.shnt.top");
});
it("accepts nested wildcard relative name and FQDN", () => {
assertValidName("*.ndns");
assertValidName("*.ndns.shnt.top");
assertValidName("*.mdns");
assertValidName("*.mdns.rkns.top");
});
it("rejects wildcard not as leftmost label", () => {
assertInvalidName("foo.*.bar");
assertInvalidName("ndns.*");
});
});