Compare commits

...
33 Commits
Author SHA1 Message Date
DenozordecandCursor 8b028d04b4 feat(web): выровнять палитру и KPI под макет
CD / update-wiki (push) Successful in 5s
quality / commitlint (push) Skipped
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 52s
CD / publish (push) Successful in 1m38s
quality / changes (push) Successful in 9s
CD / quality (push) Successful in 1m3s
Светлая тема и сайдбар совпадают с артбордом, показатели собраны в одну рамку с цветом статуса.

Co-authored-by: Cursor <[email protected]>
2026-11-02 21:36:39 +07:00
DenozordecandCursor aa07f08684 fix(web): убрать неиспользуемый queueDescription
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / web (push) Successful in 51s
CD / publish (push) Successful in 4m21s
quality / changes (push) Successful in 10s
CD / quality (push) Successful in 1m6s
Сборка падала: описание очереди никто не передаёт и не показывает.

Co-authored-by: Cursor <[email protected]>
2026-11-02 20:30:21 +07:00
DenozordecandCursor 2418344551 feat(web): собрать экраны из блоков ReUI PRO
CD / update-wiki (push) Successful in 6s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Failing after 52s
CD / publish (push) Skipped
CD / quality (push) Failing after 1m5s
Подключить stats-12, card-12, settings-3 и empty-state-12 к оболочке, спискам, карточкам и настройкам, чтобы метрики и пустые состояния шли из одних блоков.

Co-authored-by: Cursor <[email protected]>
2026-11-02 19:40:18 +07:00
DenozordecandCursor 73f5380768 fix(db): собрать listLatestLiveHealthByServiceIds в dist
CD / update-wiki (push) Successful in 7s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / api (push) Successful in 1m13s
CD / publish (push) Successful in 2m55s
CD / quality (push) Successful in 2m7s
Очередь health-check видит собранную функцию и больше не падает.

Co-authored-by: Cursor <[email protected]>
2026-11-02 18:39:40 +07:00
DenozordecandCursor 0ef57c673c feat(web): перенести оболочку и карточки на дизайн CF Domain Manager
quality / changes (push) Successful in 9s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / web (push) Successful in 1m29s
quality / api (push) Successful in 1m10s
CD / publish (push) Successful in 42s
CD / quality (push) Successful in 2m54s
Собрать панель, списки и настройки в одной оболочке, чтобы статусы, очередь и свежесть данных были видны на доменах, сервисах и группах.

Co-authored-by: Cursor <[email protected]>
2026-11-02 18:33:27 +07:00
DenozordecandCursor b078fa0a03 fix(health): не показывать Down при выключенном health-check
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
quality / api (push) Successful in 49s
CD / publish (push) Successful in 1m25s
quality / changes (push) Successful in 8s
CD / quality (push) Successful in 1m58s
Маскируем устаревший 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 / publish (push) Successful in 1m35s
CD / quality (push) Successful in 58s
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 / publish (push) Successful in 1m26s
CD / quality (push) Successful in 1m2s
Больше не авто-подхватывать чужой 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 / publish (push) Successful in 1m40s
CD / quality (push) Successful in 2m26s
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
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
CD / quality (push) Successful in 1m16s
quality / changes (push) Successful in 9s
- 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 / docker-check (push) Skipped
quality / web (push) Successful in 1m20s
quality / api (push) Successful in 1m4s
CD / publish (push) Successful in 56s
quality / changes (push) Successful in 9s
CD / quality (push) Successful in 2m38s
В 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 / docker-check (push) Skipped
quality / web (push) Successful in 53s
quality / api (push) Successful in 49s
CD / publish (push) Successful in 1m29s
quality / changes (push) Successful in 8s
CD / quality (push) Successful in 1m54s
Тип 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 / docker-check (push) Skipped
quality / web (push) Successful in 1m9s
quality / api (push) Successful in 1m3s
CD / publish (push) Successful in 1m49s
quality / changes (push) Successful in 12s
CD / quality (push) Successful in 2m29s
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 / publish (push) Successful in 1m50s
CD / quality (push) Successful in 2m44s
У 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 / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m10s
CD / publish (push) Successful in 1m41s
quality / changes (push) Successful in 9s
CD / quality (push) Successful in 1m23s
При одном 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 / publish (push) Successful in 2m4s
CD / quality (push) Successful in 1m31s
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 / docker-check (push) Skipped
quality / web (push) Successful in 55s
quality / api (push) Successful in 52s
CD / publish (push) Successful in 1m51s
quality / changes (push) Successful in 8s
CD / quality (push) Successful in 2m4s
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 / docker-check (push) Skipped
quality / web (push) Successful in 1m23s
quality / api (push) Successful in 59s
CD / publish (push) Successful in 1m58s
quality / changes (push) Successful in 10s
CD / quality (push) Successful in 2m36s
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 / publish (push) Successful in 1m36s
CD / quality (push) Successful in 1m57s
Веса задают долю слотов на общем 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 / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
CD / publish (push) Successful in 1m43s
quality / changes (push) Successful in 8s
CD / quality (push) Successful in 1m5s
После объединения адресов в форме пропали 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 / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / publish (push) Successful in 2m3s
quality / changes (push) Successful in 8s
CD / quality (push) Successful in 1m14s
Таблица 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 / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
CD / publish (push) Successful in 1m38s
quality / changes (push) Successful in 8s
CD / quality (push) Successful in 1m7s
Если в минутном бакете есть живой 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 / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 1m1s
CD / publish (push) Successful in 2m4s
quality / changes (push) Successful in 11s
CD / quality (push) Successful in 2m30s
Сервис 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 / publish (push) Successful in 1m43s
CD / quality (push) Successful in 1m50s
Панель брала 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 / api (push) Skipped
quality / docker-check (push) Skipped
CD / publish (push) Canceled after 0s
quality / web (push) Canceled after 22s
quality / changes (push) Successful in 9s
CD / quality (push) Canceled after 34s
Красный инцидент только если адреса нет в 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 / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 53s
CD / publish (push) Successful in 1m39s
quality / changes (push) Successful in 9s
CD / quality (push) Successful in 1m6s
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 / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m6s
CD / publish (push) Successful in 2m1s
quality / changes (push) Successful in 9s
CD / quality (push) Successful in 1m18s
Каждый общий домен смотрит на весь пул 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 / publish (push) Successful in 1m36s
CD / quality (push) Successful in 54s
Схлопывание дубликатов в группе вызывало 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 / publish (push) Successful in 1m36s
CD / quality (push) Successful in 1m6s
Общий 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 / publish (push) Successful in 1m57s
CD / quality (push) Successful in 1m23s
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 / publish (push) Successful in 1m58s
CD / quality (push) Successful in 1m15s
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
154 changed files with 11641 additions and 2980 deletions

No files matched your search

+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,
+12
View File
@@ -78,6 +78,18 @@ 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(
+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]!];
}
+438 -86
View File
@@ -29,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;
@@ -210,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;
}
}
@@ -226,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(
@@ -253,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);
@@ -284,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,
@@ -311,13 +439,13 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
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);
}
}
@@ -403,6 +531,49 @@ function fallbackCnameHealth(
);
}
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[],
@@ -410,10 +581,13 @@ 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 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) =>
@@ -421,21 +595,46 @@ function attachServiceHealth(
),
);
const ip_health = (view.ips ?? []).map((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,
};
});
@@ -538,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,
@@ -570,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(
@@ -584,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) {
@@ -657,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) {
@@ -676,6 +956,14 @@ async function syncBindingADns(
if (desiredIps.length === 0) {
repos.setBindingDnsRecordId(db, bindingId, null);
recordFailoverDnsDiff(
db,
bindingId,
hostname,
zoneName,
existingRecords,
desiredIps,
);
return;
}
@@ -684,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,
});
}
@@ -708,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);
@@ -724,6 +1026,14 @@ async function syncBindingADns(
}
repos.setBindingDnsRecordId(db, bindingId, primaryId);
recordFailoverDnsDiff(
db,
bindingId,
hostname,
zoneName,
existingRecords,
desiredIps,
);
}
async function cleanupBindingDns(
@@ -942,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,
@@ -955,7 +1260,7 @@ async function syncServiceBindingsToDns(
binding.id,
binding.domain_id,
binding.hostname,
targetIps,
desiredIps,
null,
);
}
@@ -969,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);
}
}
}
@@ -987,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;
@@ -1002,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,
});
}
@@ -1025,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);
@@ -1082,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,
@@ -1092,6 +1413,7 @@ async function syncGroupDomainDns(
domainId,
hostname,
desiredIps,
ttlForBinding(group.lb_mode, fallbackIps),
);
}
@@ -1272,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,
@@ -1485,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)) {
@@ -1585,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,
@@ -1609,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 },
),
);
}
+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"]);
});
});
+225
View File
@@ -291,6 +291,77 @@ describe("health-check state derivation via runAllChecks", () => {
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", () => {
@@ -305,6 +376,7 @@ describe("CNAME health mapped onto service IPs", () => {
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",
@@ -326,4 +398,157 @@ describe("CNAME health mapped onto service IPs", () => {
}),
]);
});
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"]);
});
});
+192 -28
View File
@@ -6,6 +6,7 @@ import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import {
listGroupViews,
toggleServiceIp,
updateConfig,
} from "../src/services/service-config-service.js";
@@ -250,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,
@@ -280,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,
@@ -289,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`,
@@ -319,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();
});
});
+269 -50
View File
@@ -1,14 +1,24 @@
import { useCallback, useState } from 'react'
import { Link, useRouterState } from '@tanstack/react-router'
import {
LayoutDashboardIcon,
GlobeIcon,
ChevronRightIcon,
FolderTreeIcon,
GlobeIcon,
LayoutDashboardIcon,
PinIcon,
ServerIcon,
ShieldCheckIcon,
SettingsIcon,
ShieldCheckIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/nav-user'
import { useSystemAttention } from '@/hooks/use-system-attention'
import { cn } from '@cfdm/ui/lib/utils'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@cfdm/ui/components/collapsible'
import {
Sidebar,
SidebarContent,
@@ -18,25 +28,114 @@ import {
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@cfdm/ui/components/sidebar'
const infrastructureNav = [
const PINNED_KEY = 'cfdm.sidebar.pinned'
const COLLAPSED_KEY = 'cfdm.sidebar.collapsed'
type NavAttention = 'services-down' | 'certs-expiring'
type AppNavTo =
| '/'
| '/domains'
| '/services'
| '/groups'
| '/certificates'
| '/settings/appearance'
type NavItem = {
to: AppNavTo
label: string
icon: typeof GlobeIcon
exact: boolean
matchPrefix?: string
attention?: NavAttention
}
const infrastructureNav: NavItem[] = [
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
{ to: '/domains', label: 'Домены', icon: GlobeIcon, exact: false },
] as const
]
const operationsNav = [
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
{ to: '/settings/appearance', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' },
] as const
const operationsNav: NavItem[] = [
{
to: '/services',
label: 'Сервисы',
icon: ServerIcon,
exact: false,
attention: 'services-down',
},
{
to: '/settings/appearance',
label: 'Настройки',
icon: SettingsIcon,
exact: false,
matchPrefix: '/settings',
},
]
const secondaryNav = [
const secondaryNav: NavItem[] = [
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
{
to: '/certificates',
label: 'Сертификаты',
icon: ShieldCheckIcon,
exact: false,
attention: 'certs-expiring',
},
]
const NAV_GROUPS = [
{ id: 'infrastructure', label: 'Инфраструктура', items: infrastructureNav },
{ id: 'operations', label: 'Операции', items: operationsNav },
{ id: 'secondary', label: 'Прочее', items: secondaryNav },
] as const
const NAV_ITEMS = NAV_GROUPS.flatMap((group) => group.items)
function readStringList(key: string) {
try {
const raw = localStorage.getItem(key)
if (!raw) return []
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.filter((item): item is string => typeof item === 'string')
} catch {
return []
}
}
function writeStringList(key: string, values: string[]) {
try {
localStorage.setItem(key, JSON.stringify(values))
} catch {
// хранилище недоступно — состояние остаётся в памяти
}
}
function useStringList(key: string) {
const [values, setValues] = useState<string[]>(() => readStringList(key))
const toggle = useCallback(
(id: string) => {
setValues((current) => {
const next = current.includes(id)
? current.filter((item) => item !== id)
: [...current, id]
writeStringList(key, next)
return next
})
},
[key],
)
return { values, toggle }
}
function isNavActive(
pathname: string,
to: string,
@@ -50,56 +149,148 @@ function isNavActive(
return pathname === to || pathname.startsWith(`${to}/`)
}
function NavItems({
items,
pathname,
pinned,
onTogglePin,
servicesDown,
certsExpiring,
}: {
items: readonly NavItem[]
pathname: string
pinned: readonly string[]
onTogglePin: (to: string) => void
servicesDown: number
certsExpiring: number
}) {
return (
<SidebarMenu>
{items.map((item) => {
const isPinned = pinned.includes(item.to)
const showServicesDot = item.attention === 'services-down' && servicesDown > 0
const showCertsDot = item.attention === 'certs-expiring' && certsExpiring > 0
const showDot = showServicesDot || showCertsDot
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
tooltip={item.label}
isActive={isNavActive(pathname, item.to, item.exact, item.matchPrefix)}
render={<Link to={item.to} activeOptions={{ exact: item.exact }} />}
>
<item.icon />
<span className="truncate">{item.label}</span>
{showDot ? (
<span
className={cn(
'ml-auto size-1.5 shrink-0 rounded-full group-data-[collapsible=icon]:hidden',
showServicesDot ? 'bg-destructive' : 'bg-warning',
)}
/>
) : null}
{showDot ? (
<span className="sr-only">
{showServicesDot ? ', есть недоступные' : ', истекают в ближайшие 14 дней'}
</span>
) : null}
</SidebarMenuButton>
{showDot ? (
<span
aria-hidden="true"
className={cn(
'pointer-events-none absolute top-1 right-1 hidden size-1.5 rounded-full group-data-[collapsible=icon]:block',
showServicesDot ? 'bg-destructive' : 'bg-warning',
)}
/>
) : null}
<SidebarMenuAction
showOnHover={!isPinned}
aria-label={isPinned ? `Открепить «${item.label}»` : `Закрепить «${item.label}»`}
onClick={() => onTogglePin(item.to)}
>
<PinIcon />
</SidebarMenuAction>
</SidebarMenuItem>
)
})}
</SidebarMenu>
)
}
function NavSection({
id,
label,
items,
pathname,
collapsed,
forceOpen,
onToggleCollapsed,
pinned,
onTogglePin,
servicesDown,
certsExpiring,
}: {
id: string
label: string
items: readonly {
to: string
label: string
icon: typeof GlobeIcon
exact: boolean
matchPrefix?: string
}[]
items: readonly NavItem[]
pathname: string
collapsed: boolean
forceOpen: boolean
onToggleCollapsed: (id: string) => void
pinned: readonly string[]
onTogglePin: (to: string) => void
servicesDown: number
certsExpiring: number
}) {
const open = forceOpen || !collapsed
return (
<SidebarGroup>
<SidebarGroupLabel>{label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
tooltip={item.label}
isActive={isNavActive(
pathname,
item.to,
item.exact,
item.matchPrefix,
)}
render={
<Link
to={item.to}
activeOptions={{ exact: item.exact }}
/>
}
>
<item.icon className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<Collapsible
open={open}
onOpenChange={(nextOpen) => {
if (forceOpen) return
if (nextOpen === open) return
onToggleCollapsed(id)
}}
className="group/nav-section"
>
<SidebarGroup>
<SidebarGroupLabel className="w-full" render={<CollapsibleTrigger />}>
<span>{label}</span>
<ChevronRightIcon
className={cn(
'ml-auto transition-transform duration-200 motion-reduce:transition-none',
open && 'rotate-90',
)}
/>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<NavItems
items={items}
pathname={pathname}
pinned={pinned}
onTogglePin={onTogglePin}
servicesDown={servicesDown}
certsExpiring={certsExpiring}
/>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)
}
export function AppSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { state, isMobile } = useSidebar()
const attention = useSystemAttention()
const pins = useStringList(PINNED_KEY)
const collapsed = useStringList(COLLAPSED_KEY)
const forceOpen = state === 'collapsed' && !isMobile
const pinnedItems = pins.values
.map((to) => NAV_ITEMS.find((item) => item.to === to))
.filter((item): item is NavItem => item != null)
return (
<Sidebar collapsible="icon">
@@ -107,9 +298,37 @@ export function AppSidebar() {
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
<NavSection label="Операции" items={operationsNav} pathname={pathname} />
<NavSection label="Прочее" items={secondaryNav} pathname={pathname} />
{pinnedItems.length > 0 ? (
<SidebarGroup>
<SidebarGroupLabel>Закреплённые</SidebarGroupLabel>
<SidebarGroupContent>
<NavItems
items={pinnedItems}
pathname={pathname}
pinned={pins.values}
onTogglePin={pins.toggle}
servicesDown={attention.servicesDown}
certsExpiring={attention.certsExpiring}
/>
</SidebarGroupContent>
</SidebarGroup>
) : null}
{NAV_GROUPS.map((group) => (
<NavSection
key={group.id}
id={group.id}
label={group.label}
items={group.items}
pathname={pathname}
collapsed={collapsed.values.includes(group.id)}
forceOpen={forceOpen}
onToggleCollapsed={collapsed.toggle}
pinned={pins.values}
onTogglePin={pins.toggle}
servicesDown={attention.servicesDown}
certsExpiring={attention.certsExpiring}
/>
))}
</SidebarContent>
<SidebarFooter>
<NavUser />
@@ -0,0 +1,16 @@
import { Frame } from "@/components/reui/frame"
import { CardItem } from "./card-item"
import { CARDS } from "./data"
export function CardGrid() {
return (
<Frame className="w-full">
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
{CARDS.map((card) => (
<CardItem key={card.title} card={card} />
))}
</div>
</Frame>
)
}
@@ -0,0 +1,19 @@
import { FramePanel } from "@/components/reui/frame"
import { ICard } from "./data"
export function CardItem({ card }: { card: ICard }) {
return (
<a
href={card.href}
className="group/card focus-visible:ring-ring/50 hover:[&_svg]:text-primary block h-full w-full min-w-0 cursor-pointer rounded-(--frame-radius) outline-none focus-visible:ring-[3px] [&_svg]:size-5 [&_svg]:opacity-60 hover:[&_svg]:opacity-100"
>
<FramePanel className="space-y-7.5 p-3.5">
{card.icon}
<h3 className="group-hover/card:text-primary text-sm leading-relaxed transition-colors">
{card.title}
</h3>
</FramePanel>
</a>
)
}
@@ -0,0 +1,53 @@
import { type ReactNode } from "react"
import { ActivityIcon, BookOpenIcon, ShieldIcon, CircleDollarSignIcon, BarChart3Icon, TrendingUp } from "lucide-react"
export interface ICard {
title: string
href: string
icon: ReactNode
}
export const CARDS: ICard[] = [
{
title: "Valuation",
href: "#valuation",
icon: (
<ActivityIcon aria-hidden="true" />
),
},
{
title: "Ledger",
href: "#ledger",
icon: (
<BookOpenIcon aria-hidden="true" />
),
},
{
title: "Funding",
href: "#funding",
icon: (
<ShieldIcon aria-hidden="true" />
),
},
{
title: "Cash Flow",
href: "#cash-flow",
icon: (
<CircleDollarSignIcon aria-hidden="true" />
),
},
{
title: "Metrics",
href: "#metrics",
icon: (
<BarChart3Icon aria-hidden="true" />
),
},
{
title: "Earnings",
href: "#earnings",
icon: (
<TrendingUp aria-hidden="true" />
),
},
]
@@ -0,0 +1,9 @@
import { CardGrid } from "./components/card-grid"
export function Page() {
return (
<div className="flex min-h-svh w-full max-w-lg items-center justify-center p-6">
<CardGrid />
</div>
)
}
@@ -0,0 +1,48 @@
export type ToolbarOption<T extends string> = {
value: T
label: string
}
export const EXPORT_AUDIENCE_OPTIONS = [
{ value: "everyone", label: "Everyone" },
{ value: "ops-leads", label: "Ops leads" },
{ value: "finance-reviewers", label: "Finance reviewers" },
{ value: "client-owners", label: "Client owners" },
] as const satisfies readonly ToolbarOption<string>[]
export const EXPORT_SCOPE_OPTIONS = [
{ value: "all-workspaces", label: "All workspaces" },
{ value: "harbor-field", label: "Harbor field" },
{ value: "market-lab", label: "Market lab" },
{ value: "support-desk", label: "Support desk" },
] as const satisfies readonly ToolbarOption<string>[]
export const EXPORT_RANGE_OPTIONS = [
{ value: "last-30-days", label: "Last 30 days" },
{ value: "this-quarter", label: "This quarter" },
{ value: "previous-cycle", label: "Previous cycle" },
{ value: "custom-window", label: "Custom window" },
] as const satisfies readonly ToolbarOption<string>[]
export const EXPORT_FORMAT_OPTIONS = [
{
value: "csv",
label: "CSV bundle",
description: "Spreadsheet-ready activity rows",
},
{
value: "pdf",
label: "PDF brief",
description: "A concise review packet for stakeholders",
},
{
value: "schedule",
label: "Schedule delivery",
description: "Send this export every Friday morning",
},
] as const
export type ExportAudience = (typeof EXPORT_AUDIENCE_OPTIONS)[number]["value"]
export type ExportScope = (typeof EXPORT_SCOPE_OPTIONS)[number]["value"]
export type ExportRange = (typeof EXPORT_RANGE_OPTIONS)[number]["value"]
export type ExportFormat = (typeof EXPORT_FORMAT_OPTIONS)[number]["value"]
@@ -0,0 +1,249 @@
"use client"
import { useState } from "react"
import { IconStack } from "@/components/reui/icon-stack"
import { toast } from "sonner"
import { Button } from "@cfdm/ui/components/button"
import {
ButtonGroup,
ButtonGroupSeparator,
} from "@cfdm/ui/components/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@cfdm/ui/components/dropdown-menu"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@cfdm/ui/components/empty"
import { Separator } from "@cfdm/ui/components/separator"
import {
EXPORT_AUDIENCE_OPTIONS,
EXPORT_FORMAT_OPTIONS,
EXPORT_RANGE_OPTIONS,
EXPORT_SCOPE_OPTIONS,
type ExportAudience,
type ExportFormat,
type ExportRange,
type ExportScope,
type ToolbarOption,
} from "./data"
import { ChevronDownIcon, PlayIcon, FileDownIcon, BookOpenIcon, CalendarClockIcon, ArchiveIcon } from "lucide-react"
type ToolbarFilterProps<T extends string> = {
label: string
value: T
options: readonly ToolbarOption<T>[]
onValueChange: (value: T) => void
}
function getOptionLabel<T extends string>(
options: readonly ToolbarOption<T>[],
value: T
) {
return options.find((option) => option.value === value)?.label ?? value
}
function ToolbarFilter<T extends string>({
label,
value,
options,
onValueChange,
}: ToolbarFilterProps<T>) {
const selectedLabel = getOptionLabel(options, value)
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="outline"
size="sm"
aria-label={`${label}: ${selectedLabel}`}
>
<span className="truncate">{selectedLabel}</span>
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
</Button>
}
/>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuRadioGroup
value={value}
onValueChange={(nextValue) => {
if (nextValue !== null) {
onValueChange(nextValue as T)
}
}}
>
{options.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
closeOnClick
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function EmptyState() {
const [audience, setAudience] = useState<ExportAudience>(
EXPORT_AUDIENCE_OPTIONS[0].value
)
const [scope, setScope] = useState<ExportScope>(EXPORT_SCOPE_OPTIONS[0].value)
const [range, setRange] = useState<ExportRange>(EXPORT_RANGE_OPTIONS[0].value)
const audienceLabel = getOptionLabel(EXPORT_AUDIENCE_OPTIONS, audience)
const scopeLabel = getOptionLabel(EXPORT_SCOPE_OPTIONS, scope)
const rangeLabel = getOptionLabel(EXPORT_RANGE_OPTIONS, range)
const showExportToast = (format: ExportFormat = "csv") => {
const formatOption =
EXPORT_FORMAT_OPTIONS.find((option) => option.value === format) ??
EXPORT_FORMAT_OPTIONS[0]
toast.message(`${formatOption.label} is ready to wire`, {
description: `${formatOption.description}. ${audienceLabel} · ${scopeLabel} · ${rangeLabel}. Connect this action to your export job when activity records exist.`,
})
}
return (
<section
className="flex min-h-[430px] w-full max-w-4xl flex-col"
aria-labelledby="export-ledger-heading"
>
{/* Header */}
<div className="flex flex-col gap-3 pb-4 lg:flex-row lg:items-end lg:justify-between">
{/* Heading */}
<div className="flex min-w-0 flex-col gap-5">
{/* Title and Description */}
<div className="flex flex-col gap-0.5">
{/* Title */}
<h2
id="export-ledger-heading"
className="text-2xl font-semibold tracking-tight"
>
Activity Exports
</h2>
{/* Description */}
<p className="text-muted-foreground text-sm">
Download scoped activity packets for billing review, staffing
audits, and client handoffs.
</p>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-2">
<ToolbarFilter
label="Audience"
value={audience}
options={EXPORT_AUDIENCE_OPTIONS}
onValueChange={setAudience}
/>
<ToolbarFilter
label="Workspace"
value={scope}
options={EXPORT_SCOPE_OPTIONS}
onValueChange={setScope}
/>
<ToolbarFilter
label="Date range"
value={range}
options={EXPORT_RANGE_OPTIONS}
onValueChange={setRange}
/>
</div>
</div>
{/* Download Action */}
<ButtonGroup className="w-full **:data-[slot=button]:border-r-0 sm:w-fit">
<Button
type="button"
className="flex-1 sm:flex-none"
onClick={() => showExportToast()}
>
<PlayIcon className="fill-current" data-icon="inline-start" aria-hidden="true" />
<span>Execute</span>
</Button>
<ButtonGroupSeparator className="bg-primary/72" />
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="icon"
className="border-primary-foreground/20 rounded-l-none border-l"
aria-label="Open download options"
/>
}
>
<ChevronDownIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={8} align="end" className="w-52">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => showExportToast("csv")}>
<FileDownIcon aria-hidden="true" />
CSV bundle
</DropdownMenuItem>
<DropdownMenuItem onClick={() => showExportToast("pdf")}>
<BookOpenIcon aria-hidden="true" />
PDF brief
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => showExportToast("schedule")}>
<CalendarClockIcon aria-hidden="true" />
Schedule delivery
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</div>
<Separator />
{/* Empty State */}
<div className="flex flex-1 items-center justify-center py-14 sm:py-16">
<Empty className="max-w-md flex-none bg-transparent p-0">
<EmptyHeader className="gap-5 text-center">
<EmptyMedia className="mb-0">
<IconStack aria-hidden="true">
<ArchiveIcon strokeWidth="1.9" aria-hidden="true" />
</IconStack>
</EmptyMedia>
{/* Empty State Content */}
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">
No exportable activity yet
</EmptyTitle>
<EmptyDescription className="max-w-sm text-sm/relaxed">
Capture approved activity and this view will assemble your next
review packet.
</EmptyDescription>
</div>
</EmptyHeader>
</Empty>
</div>
</section>
)
}
@@ -0,0 +1,15 @@
import { EmptyState } from "./components/empty-state"
export function Page() {
return (
<main
className="flex min-h-svh w-full items-center justify-center p-4 sm:p-8 md:p-10"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Activity exports empty state
</h1>
<EmptyState />
</main>
)
}
@@ -0,0 +1,40 @@
import { useState } from "react"
import { cn } from "@cfdm/ui/lib/utils"
import { FieldLegend, FieldSet } from "@cfdm/ui/components/field"
import { ACCENT_COLORS } from "./data"
import { CheckIcon } from "lucide-react"
// ── Color Picker ──
export function ColorPicker() {
const [selected, setSelected] = useState(ACCENT_COLORS[0].value)
return (
<FieldSet className="gap-0">
<FieldLegend className="sr-only">Accent color</FieldLegend>
{/* Actions */}
<div className="flex items-center gap-3">
{ACCENT_COLORS.map((color) => (
<button
key={color.value}
type="button"
aria-label={color.name}
onClick={() => setSelected(color.value)}
className={cn(
"flex size-7 items-center justify-center rounded-full transition-shadow",
selected === color.value &&
"ring-ring ring-offset-background ring-2 ring-offset-2"
)}
style={{ backgroundColor: color.value }}
>
{selected === color.value ? (
<CheckIcon className="size-3 text-white" aria-hidden="true" />
) : null}
</button>
))}
</div>
</FieldSet>
)
}
@@ -0,0 +1,109 @@
"use client"
import { type ReactNode } from "react"
// ── Types ──
export type SelectOption = {
value: string
label: string
}
export type Preference = {
id: string
label: string
description: string
defaultChecked: boolean
}
export type AccentColorOption = {
name: string
value: string
}
export type SettingBadgeVariant =
| "primary-light"
| "destructive-light"
| "info-light"
export type SettingFieldProps = {
title: string
description: string
badge?: { label: string; variant: SettingBadgeVariant }
children: ReactNode
last?: boolean
}
export type DateFormatOption = {
value: string
label: string
}
// ── Data ──
export const DAYS: SelectOption[] = [
{ value: "monday", label: "Monday" },
{ value: "tuesday", label: "Tuesday" },
{ value: "wednesday", label: "Wednesday" },
{ value: "thursday", label: "Thursday" },
{ value: "friday", label: "Friday" },
{ value: "saturday", label: "Saturday" },
{ value: "sunday", label: "Sunday" },
]
export const CURRENCIES: SelectOption[] = [
{ value: "usd", label: "USD · US Dollar" },
{ value: "eur", label: "EUR · Euro" },
{ value: "gbp", label: "GBP · British Pound" },
{ value: "jpy", label: "JPY · Japanese Yen" },
]
export const REGIONS: SelectOption[] = [
{ value: "us", label: "United States" },
{ value: "eu", label: "European Union" },
{ value: "uk", label: "United Kingdom" },
{ value: "jp", label: "Japan" },
{ value: "au", label: "Australia" },
]
export const ACCENT_COLORS: AccentColorOption[] = [
{ name: "Slate", value: "#64748b" },
{ name: "Rose", value: "#f43f5e" },
{ name: "Orange", value: "#f97316" },
{ name: "Violet", value: "#8b5cf6" },
{ name: "Emerald", value: "#10b981" },
{ name: "Sky", value: "#0ea5e9" },
]
export const PREFERENCES: Preference[] = [
{
id: "keyboard-shortcuts",
label: "Enable keyboard shortcuts.",
description: "Use shortcuts to speed up your workflow.",
defaultChecked: true,
},
{
id: "spellcheck",
label: "Auto spell-check.",
description: "Highlight spelling errors in text fields.",
defaultChecked: true,
},
{
id: "compact-mode",
label: "Compact Mode.",
description: "Reduce spacing for a denser interface layout.",
defaultChecked: false,
},
{
id: "animations",
label: "Reduce animations.",
description: "Minimize motion effects throughout the UI.",
defaultChecked: false,
},
]
export const DATE_FORMAT_OPTIONS: DateFormatOption[] = [
{ value: "mdy", label: "MM/DD/YYYY" },
{ value: "dmy", label: "DD/MM/YYYY" },
{ value: "ymd", label: "YYYY/MM/DD" },
]
@@ -0,0 +1,57 @@
import { useState } from "react"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@cfdm/ui/components/field"
import { Switch } from "@cfdm/ui/components/switch"
import { PREFERENCES } from "./data"
// ── Editor Preferences ──
export function EditorPreferences() {
const [values, setValues] = useState<Record<string, boolean>>(() =>
Object.fromEntries(PREFERENCES.map((p) => [p.id, p.defaultChecked]))
)
return (
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Editor preferences</FieldLegend>
{/* Description */}
<FieldDescription className="sr-only">
Configure editing behavior and interface density.
</FieldDescription>
{/* List */}
<FieldGroup className="gap-3">
{PREFERENCES.map((pref) => (
<Field key={pref.id} orientation="horizontal" className="gap-3">
<Switch
id={`settings-3-${pref.id}`}
checked={values[pref.id]}
onCheckedChange={(value) =>
setValues((prev) => ({ ...prev, [pref.id]: value }))
}
className="mt-0.5"
/>
<FieldContent className="gap-0.5">
<FieldLabel htmlFor={`settings-3-${pref.id}`}>
{pref.label}
</FieldLabel>
<FieldDescription className="text-xs">
{pref.description}
</FieldDescription>
</FieldContent>
</Field>
))}
</FieldGroup>
</FieldSet>
)
}
@@ -0,0 +1,267 @@
"use client"
import { useState } from "react"
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { Button } from "@cfdm/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@cfdm/ui/components/field"
import { Input } from "@cfdm/ui/components/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@cfdm/ui/components/input-group"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@cfdm/ui/components/select"
import {
ToggleGroup,
ToggleGroupItem,
} from "@cfdm/ui/components/toggle-group"
import { ColorPicker } from "./color-picker"
import { CURRENCIES, DAYS, REGIONS } from "./data"
import { EditorPreferences } from "./editor-preferences"
import { SettingField } from "./setting-field"
import { PlusIcon, UploadIcon, GlobeIcon, CircleDollarSignIcon } from "lucide-react"
export function GeneralSettings() {
const [dateFormat, setDateFormat] = useState(["mdy"])
const [startOfWeek, setStartOfWeek] = useState("monday")
const [region, setRegion] = useState("us")
const [currency, setCurrency] = useState("usd")
const handleStartOfWeekChange = (value: string | null) => {
if (value !== null) {
setStartOfWeek(value)
}
}
const handleRegionChange = (value: string | null) => {
if (value !== null) {
setRegion(value)
}
}
const handleCurrencyChange = (value: string | null) => {
if (value !== null) {
setCurrency(value)
}
}
return (
<Frame className="w-full max-w-3xl">
<FrameHeader className="px-2! py-2.5!">
<FrameTitle>General Settings</FrameTitle>
<FrameDescription>Core app preferences.</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<FieldGroup className="gap-0">
{/* ── Project name ── */}
<SettingField
title="Project name"
description="The display name for your project across the platform."
labelFor="settings-3-project-name"
>
<Input id="settings-3-project-name" defaultValue="Acme Dashboard" />
</SettingField>
{/* ── API endpoint ── */}
<SettingField
title="API endpoint"
description="Set the base URL for your project API."
badge={{ label: "Required", variant: "destructive-light" }}
labelFor="settings-3-api-endpoint"
>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-3-api-endpoint"
defaultValue="api.acme.io"
/>
</InputGroup>
</SettingField>
{/* ── Start of week ── */}
<SettingField
title="Start of week"
description="Choose which day marks the start of your week."
labelFor="settings-3-start-of-week"
>
<Select value={startOfWeek} onValueChange={handleStartOfWeekChange}>
<SelectTrigger id="settings-3-start-of-week" className="w-full">
<SelectValue>{getOptionLabel(DAYS, startOfWeek)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{DAYS.map((day) => (
<SelectItem key={day.value} value={day.value}>
{day.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingField>
{/* ── Allowed origins ── */}
<SettingField
title="Allowed origins"
description="Define the trusted domains for CORS requests."
>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button variant="outline">
<PlusIcon aria-hidden="true" />
Add origin
</Button>
<Button variant="outline">
<UploadIcon aria-hidden="true" />
Import from file
</Button>
</div>
</SettingField>
{/* ── Accent color ── */}
<SettingField
title="Accent color"
description="Select a color to represent your brand."
badge={{ label: "Upgrade to Pro", variant: "primary-light" }}
>
<ColorPicker />
</SettingField>
{/* ── Region & currency ── */}
<SettingField
title="Region & currency"
description="Adjust your regional preferences and currency."
contentClassName="@md/field-group:w-[22rem]"
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Region and currency</FieldLegend>
<FieldDescription className="sr-only">
Regional and financial formatting preferences.
</FieldDescription>
<FieldGroup className="gap-3">
<Field>
<FieldLabel htmlFor="settings-3-region">Region</FieldLabel>
<Select value={region} onValueChange={handleRegionChange}>
<SelectTrigger id="settings-3-region" className="w-full">
<GlobeIcon aria-hidden="true" />
<SelectValue>
{getOptionLabel(REGIONS, region)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{REGIONS.map((region) => (
<SelectItem key={region.value} value={region.value}>
{region.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="settings-3-currency">
Currency
</FieldLabel>
<Select value={currency} onValueChange={handleCurrencyChange}>
<SelectTrigger id="settings-3-currency" className="w-full">
<CircleDollarSignIcon aria-hidden="true" />
<SelectValue>
{getOptionLabel(CURRENCIES, currency)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{CURRENCIES.map((currency) => (
<SelectItem
key={currency.value}
value={currency.value}
>
{currency.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
</FieldSet>
</SettingField>
{/* ── Date display format ── */}
<SettingField
title="Date display format"
description="Choose your preferred format for dates."
>
<ToggleGroup
multiple={false}
value={dateFormat}
onValueChange={(value) => {
if (value.length > 0) setDateFormat(value)
}}
variant="outline"
size="sm"
aria-label="Date display format"
>
<ToggleGroupItem value="mdy">MM/DD/YYYY</ToggleGroupItem>
<ToggleGroupItem value="dmy">DD/MM/YYYY</ToggleGroupItem>
<ToggleGroupItem value="ymd">YYYY/MM/DD</ToggleGroupItem>
</ToggleGroup>
</SettingField>
{/* ── Editor preferences ── */}
<SettingField
title="Editor preferences"
description="Adjust your editing environment and display options."
last
contentClassName="@md/field-group:w-[22rem]"
>
<EditorPreferences />
</SettingField>
</FieldGroup>
</FramePanel>
<FrameFooter className="flex flex-row justify-end gap-3">
<Button variant="outline">Reset</Button>
<Button>Save changes</Button>
</FrameFooter>
</Frame>
)
}
// ── Helper functions ──
function getOptionLabel(
options: Array<{ value: string; label: string }>,
value: string
) {
return options.find((option) => option.value === value)?.label ?? value
}
@@ -0,0 +1,69 @@
import { type ComponentProps, type ReactNode } from "react"
import { Badge } from "@/components/reui/badge"
import { cn } from "@cfdm/ui/lib/utils"
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from "@cfdm/ui/components/field"
interface SettingFieldProps {
title: string
description: string
badge?: {
label: string
variant: ComponentProps<typeof Badge>["variant"]
}
children: ReactNode
last?: boolean
labelFor?: string
contentClassName?: string
}
// ── Setting Field ──
export function SettingField({
title,
description,
badge,
children,
last,
labelFor,
contentClassName,
}: SettingFieldProps) {
return (
<>
<Field orientation="responsive" className="gap-4 px-4 py-4">
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{badge ? (
<Badge variant={badge.variant} size="sm">
{badge.label}
</Badge>
) : null}
</div>
<FieldDescription className="text-xs">{description}</FieldDescription>
</div>
<FieldContent
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
>
{children}
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
)
}
@@ -0,0 +1,9 @@
import { GeneralSettings } from "./components/general-settings"
export function Page() {
return (
<div className="flex min-h-svh w-full items-start justify-center p-8 md:p-16">
<GeneralSettings />
</div>
)
}
@@ -0,0 +1,47 @@
"use client"
import { ReactNode } from "react"
import { Badge } from "@/components/reui/badge"
import { Headphones, CircleCheckIcon, SmileIcon } from "lucide-react"
// ── Types ──
export interface CardData {
icon: ReactNode
iconBg: string
value: string | number
label: string
info: ReactNode
}
// ── Data ──
export const cards: CardData[] = [
{
icon: (
<Headphones aria-hidden="true" />
),
iconBg: "text-blue-600 dark:text-blue-400",
value: 320,
label: "Support Tickets",
info: <Badge variant="info-light">12 Open, 308 Closed</Badge>,
},
{
icon: (
<CircleCheckIcon aria-hidden="true" />
),
iconBg: "text-emerald-600 dark:text-emerald-400",
value: "98%",
label: "Resolved",
info: <Badge variant="success-light">+2.1% this month</Badge>,
},
{
icon: (
<SmileIcon aria-hidden="true" />
),
iconBg: "text-amber-600 dark:text-amber-400",
value: "4.8",
label: "Satisfaction Rate",
info: <Badge variant="warning-light">Avg. (out of 5)</Badge>,
},
]
@@ -0,0 +1,43 @@
import { Frame, FramePanel } from "@/components/reui/frame"
import { cn } from "@cfdm/ui/lib/utils"
import { Item, ItemMedia } from "@cfdm/ui/components/item"
import { cards } from "./data"
export function Stats() {
return (
<div className="@container w-full grow">
{/* Grid */}
<div className="mx-auto grid max-w-5xl grow grid-cols-1 gap-5 @3xl:grid-cols-3">
{cards.map((card, i) => (
<Frame key={i}>
<FramePanel className="flex flex-col items-start gap-6">
<Item
className={cn(
"border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5",
card.iconBg
)}
>
<ItemMedia variant="icon" className="size-auto">
{card.icon}
</ItemMedia>
</Item>
<div className="space-y-0.5">
<div className="text-foreground text-2xl leading-none font-bold">
{card.value}
</div>
<div className="text-muted-foreground text-sm font-medium">
{card.label}
</div>
</div>
{card.info}
</FramePanel>
</Frame>
))}
</div>
</div>
)
}
@@ -0,0 +1,9 @@
import { Stats } from "./components/stats"
export function Page() {
return (
<div className="flex w-full items-center justify-center p-10 md:p-20">
<Stats />
</div>
)
}
+1 -5
View File
@@ -93,15 +93,11 @@ export function CertKpiStats({
]
}, [summary, total, activeTab, onSelectTab])
const hasData = (total ?? totalFromSummary(summary)) > 0
return (
<KpiStatGrid
cards={hasData ? cards : []}
cards={cards}
isLoading={isLoading}
skeletonCount={4}
emptyIcon={<ShieldCheckIcon className="text-muted-foreground size-5" aria-hidden />}
emptyMessage="Нет данных — запустите проверку сертификатов"
/>
)
}
@@ -207,7 +207,7 @@ export function useDomainColumns({
},
{
id: 'status',
header: 'Зона CF',
header: 'Статус',
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
@@ -4,6 +4,7 @@ import type { ColumnDef } from '@tanstack/react-table'
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatRelative, sqliteUtcToIso } from '@/lib/format'
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
import { Button } from '@cfdm/ui/components/button'
import {
@@ -18,6 +19,8 @@ export interface GroupCatalogRow {
name: string
slug: string
domainCount: number
serviceCount: number
createdAt: string
}
export const GROUP_TABS = [
@@ -114,9 +117,32 @@ export function useGroupColumns({
<span className="tabular-nums">{row.original.domainCount}</span>
),
},
{
id: 'serviceCount',
accessorKey: 'serviceCount',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Сервисы" />
),
cell: ({ row }) => (
<span className="tabular-nums">{row.original.serviceCount}</span>
),
},
{
id: 'createdAt',
accessorKey: 'createdAt',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Создана" />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{formatRelative(sqliteUtcToIso(row.original.createdAt) ?? row.original.createdAt)}
</span>
),
},
{
id: 'actions',
enableSorting: false,
enableHiding: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<DropdownMenu>
+8 -1
View File
@@ -19,6 +19,8 @@ interface ConfirmDialogProps {
description: string
confirmLabel?: string
cancelLabel?: string
/** Кнопка подтверждения. Удаление остаётся destructive. */
confirmVariant?: 'default' | 'destructive'
onConfirm: () => void
disabled?: boolean
}
@@ -31,6 +33,7 @@ export function ConfirmDialog({
description,
confirmLabel = 'Удалить',
cancelLabel = 'Отмена',
confirmVariant = 'destructive',
onConfirm,
disabled,
}: ConfirmDialogProps) {
@@ -46,7 +49,11 @@ export function ConfirmDialog({
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
<AlertDialogAction
variant={confirmVariant}
onClick={onConfirm}
disabled={disabled}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
@@ -0,0 +1,427 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { FolderTreeIcon, GlobeIcon, Link2Icon, ServerIcon } from 'lucide-react'
import { toast } from 'sonner'
import { ChangeIpSheet } from '@/components/change-ip-sheet'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { FormFieldSimple } from '@/components/form-field'
import { FormSheet } from '@/components/form-sheet'
import { LoadingButton } from '@/components/loading-button'
import { QueryState } from '@/components/query-state'
import { SettingRow } from '@/components/setting-row'
import { StatusBadge } from '@/components/status-badge'
import {
SubdomainEditSheet,
type SubdomainEditValues,
} from '@/components/subdomain-edit-sheet'
import { TableSkeleton } from '@/components/skeletons'
import { DetailPanel, KpiStatGrid } from '@/components/reui-kit'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { useDomainPage } from '@/hooks/use-domain-page'
import {
type DomainDetailTab,
} from '@/components/domains/domain-detail-tabs'
import { formatDate } from '@/lib/format'
import type { ServiceBinding } from '@/lib/schemas'
import {
dnsListQueryOptions,
groupsQueryOptions,
groupKeys,
domainKeys,
updateDomain,
} from '@/queries'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { FieldGroup } from '@cfdm/ui/components/field'
const groupFormSchema = z.object({
group_id: z.string(),
})
type GroupFormValues = z.infer<typeof groupFormSchema>
interface DomainDetailActions {
openCreateSubdomain: () => void
}
function DomainChromeSkeleton() {
return (
<div className="flex flex-col gap-4">
<TableSkeleton rows={1} cols={2} />
<div className="grid gap-4 md:grid-cols-4">
<TableSkeleton rows={2} cols={1} />
<TableSkeleton rows={2} cols={1} />
<TableSkeleton rows={2} cols={1} />
<TableSkeleton rows={2} cols={1} />
</div>
</div>
)
}
/**
* Общая шапка, KPI и полоса вкладок карточки домена.
* Preview: https://reui.io/preview/base/stats-12
* Preview: https://reui.io/preview/base/form-7
* Preview: https://reui.io/preview/base/settings-16
* Docs: https://reui.io/docs/components/base/frame
* Docs: https://reui.io/docs/components/base/icon-tile
*/
export function DomainDetailChrome({
domainId,
activeTab,
children,
}: {
domainId: string
activeTab: DomainDetailTab
children: (actions: DomainDetailActions) => ReactNode
}) {
const id = Number(domainId)
const navigate = useNavigate()
const queryClient = useQueryClient()
const page = useDomainPage(id)
const groupsQuery = useQuery(groupsQueryOptions())
const dnsQuery = useQuery(dnsListQueryOptions(id))
const [groupOpen, setGroupOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [changeIp, setChangeIp] = useState<ServiceBinding | null>(null)
const domain = page.domain
const groupForm = useForm<GroupFormValues>({
resolver: zodResolver(groupFormSchema),
defaultValues: { group_id: 'none' },
})
useEffect(() => {
if (!groupOpen || !domain) return
groupForm.reset({
group_id: domain.group_id != null ? String(domain.group_id) : 'none',
})
}, [groupOpen, domain, groupForm])
const groupName = useMemo(() => {
if (!domain?.group_id) return null
return groupsQuery.data?.find((group) => group.id === domain.group_id)?.name ?? null
}, [domain, groupsQuery.data])
const groupItems = useMemo(
() => [
{ label: 'Без группы', value: 'none' },
...(groupsQuery.data ?? []).map((group) => ({
label: group.name,
value: String(group.id),
})),
],
[groupsQuery.data],
)
const updateGroupMutation = useMutation({
mutationFn: (groupId: number | null) => updateDomain(id, { group_id: groupId }),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: domainKeys.detail(id) }),
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
queryClient.invalidateQueries({ queryKey: groupKeys.all }),
])
setGroupOpen(false)
toast.success('Группа домена обновлена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось сменить группу')
},
})
function openCreateSubdomain() {
setCreateOpen(true)
}
async function handleCreateSubdomain(values: SubdomainEditValues) {
await page.createSubdomainMutation.mutateAsync(values.name)
setCreateOpen(false)
}
function handleTabChange(next: string) {
if (next === 'dns') {
void navigate({
to: '/domains/$domainId/dns',
params: { domainId },
search: { host: undefined },
})
return
}
if (
next === 'overview' ||
next === 'availability' ||
next === 'subdomains' ||
next === 'bindings'
) {
void navigate({
to: '/domains/$domainId',
params: { domainId },
search: { tab: next },
})
}
}
const kpiCards = domain
? [
{
id: 'status',
icon: <GlobeIcon />,
label: 'Статус',
value: domain.status,
hint: <StatusBadge status={domain.status} />,
},
{
id: 'group',
icon: <FolderTreeIcon />,
label: 'Группа',
value: groupName ?? '—',
hint: groupName ? 'В группе' : 'Без группы',
},
{
id: 'bindings',
icon: <Link2Icon />,
label: 'Привязки',
value: page.bindings.length,
hint: 'Сервисы в зоне',
},
{
id: 'sync',
icon: <ServerIcon />,
label: 'Синхронизация',
value: formatDate(domain.last_synced_at),
hint: 'Последняя синхронизация',
},
]
: []
const bindings = page.bindings
return (
<QueryState
isLoading={page.isLoading}
isError={page.isError}
error={page.error}
onRetry={page.refetch}
skeleton={<DomainChromeSkeleton />}
>
{domain ? (
<DetailPanel>
<DetailPanel.Header
title={domain.zone_name}
description="Карточка домена"
actions={
<>
<Button type="button" variant="outline" onClick={() => setGroupOpen(true)}>
Сменить группу
</Button>
{bindings.length === 1 ? (
<Button
type="button"
variant="outline"
onClick={() => setChangeIp(bindings[0] ?? null)}
>
Сменить IP
</Button>
) : bindings.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline">
Сменить IP
</Button>
}
/>
<DropdownMenuContent align="end">
{bindings.map((binding) => (
<DropdownMenuItem
key={binding.id}
onClick={() => setChangeIp(binding)}
>
{binding.hostname}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button type="button" onClick={openCreateSubdomain}>
Добавить поддомен
</Button>
</>
}
/>
<KpiStatGrid cards={kpiCards} />
<CountedLineTabs
tabs={[
{ id: 'overview', label: 'Обзор' },
{ id: 'availability', label: 'Доступность' },
{
id: 'subdomains',
label: 'Поддомены',
count: page.subdomainRows.length,
},
{
id: 'dns',
label: 'DNS',
count: dnsQuery.data ? dnsQuery.data.length : undefined,
},
{ id: 'bindings', label: 'Привязки', count: bindings.length },
]}
value={activeTab}
onValueChange={handleTabChange}
className="flex w-full flex-col gap-4"
>
{activeTab === 'overview' ? <DomainOverviewFacts domainId={id} /> : null}
{children({ openCreateSubdomain })}
</CountedLineTabs>
<FormSheet
open={groupOpen}
onOpenChange={setGroupOpen}
title="Сменить группу"
description={`Назначить группу для ${domain.zone_name}`}
form={groupForm}
onSubmit={(values) => {
const next = values.group_id === 'none' ? null : Number(values.group_id)
updateGroupMutation.mutate(next)
}}
footer={
<LoadingButton
type="submit"
className="w-full"
isLoading={updateGroupMutation.isPending}
loadingLabel="Сохранение…"
>
Сохранить
</LoadingButton>
}
>
<FieldGroup>
<FormFieldSimple label="Группа" htmlFor="domain-group-id">
<Controller
control={groupForm.control}
name="group_id"
render={({ field }) => (
<Select
items={groupItems}
value={field.value}
onValueChange={(value) => field.onChange(value ?? 'none')}
>
<SelectTrigger id="domain-group-id" className="w-full">
<SelectValue placeholder="Без группы" />
</SelectTrigger>
<SelectContent>
{groupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FormFieldSimple>
</FieldGroup>
</FormSheet>
<SubdomainEditSheet
mode="create"
subdomain={null}
zoneName={domain.zone_name}
services={page.services}
serviceGroupById={page.serviceGroupById}
currentServiceId="none"
open={createOpen}
isSaving={page.createSubdomainMutation.isPending}
onOpenChange={setCreateOpen}
onSubmit={handleCreateSubdomain}
/>
<ChangeIpSheet
open={changeIp != null}
onOpenChange={(open) => {
if (!open) setChangeIp(null)
}}
bindingId={changeIp?.id ?? null}
serviceId={changeIp?.service_id ?? null}
currentIp={changeIp?.target_ip ?? changeIp?.target_ips?.[0]}
/>
</DetailPanel>
) : null}
</QueryState>
)
}
function DomainOverviewFacts({ domainId }: { domainId: number }) {
const page = useDomainPage(domainId)
const groupsQuery = useQuery(groupsQueryOptions())
const domain = page.domain
if (!domain) return null
const groupName = domain.group_id
? groupsQuery.data?.find((group) => group.id === domain.group_id)?.name
: null
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Зона</FrameTitle>
<FrameDescription>Параметры зоны Cloudflare</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<SettingRow title="Зона">
<span className="text-sm font-medium">{domain.zone_name}</span>
</SettingRow>
<SettingRow title="Группа">
{domain.group_id ? (
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link to="/groups/$groupId" params={{ groupId: String(domain.group_id) }} />
}
>
{groupName ?? 'Открыть группу'}
</Button>
) : (
<Badge variant="outline">Без группы</Badge>
)}
</SettingRow>
<SettingRow title="Zone ID">
<span className="font-mono text-sm break-all">{domain.cf_zone_id}</span>
</SettingRow>
<SettingRow title="Последняя синхронизация" last>
<span className="text-sm tabular-nums">{formatDate(domain.last_synced_at)}</span>
</SettingRow>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,9 @@
export const DOMAIN_SECTION_TABS = [
'overview',
'availability',
'subdomains',
'bindings',
] as const
export type DomainSectionTab = (typeof DOMAIN_SECTION_TABS)[number]
export type DomainDetailTab = DomainSectionTab | 'dns'
@@ -9,15 +9,11 @@ import {
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Input } from '@cfdm/ui/components/input'
interface DomainsBulkToolbarProps {
count: number
isPending?: boolean
groupItems: { label: string; value: string }[]
onAssignGroup: (groupId: number | null) => void
onSetEnvironment: (env: 'prod' | 'staging' | 'dev' | null) => void
onAddTag: (tag: string) => void
onClear: () => void
}
@@ -26,23 +22,12 @@ export function DomainsBulkToolbar({
isPending = false,
groupItems,
onAssignGroup,
onSetEnvironment,
onAddTag,
onClear,
}: DomainsBulkToolbarProps) {
const [groupValue, setGroupValue] = useState('none')
const [envValue, setEnvValue] = useState('none')
const [tag, setTag] = useState('')
if (count === 0) return null
const envItems = [
{ label: 'Без env', value: 'none' },
{ label: 'prod', value: 'prod' },
{ label: 'staging', value: 'staging' },
{ label: 'dev', value: 'dev' },
]
return (
<Frame dense spacing="sm" className="mb-3 w-full">
<FramePanel className="flex flex-wrap items-center gap-2 bg-muted/30">
@@ -74,56 +59,16 @@ export function DomainsBulkToolbar({
onAssignGroup(groupValue === 'none' ? null : Number(groupValue))
}
>
В группу
Назначить группу
</Button>
<Select
items={envItems}
value={envValue}
onValueChange={(v) => setEnvValue(v ?? 'none')}
>
<SelectTrigger className="w-32" size="sm">
<SelectValue placeholder="Env" />
</SelectTrigger>
<SelectContent>
{envItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() =>
onSetEnvironment(
envValue === 'none'
? null
: (envValue as 'prod' | 'staging' | 'dev'),
)
}
onClick={() => onAssignGroup(null)}
>
Env
</Button>
<Input
className="h-8 w-32"
placeholder="тег"
value={tag}
onChange={(e) => setTag(e.target.value)}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending || !tag.trim()}
onClick={() => {
onAddTag(tag.trim())
setTag('')
}}
>
+ тег
Снять группу
</Button>
<Button
type="button"
+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'
@@ -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,6 +196,10 @@ 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>) {
@@ -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="Кто пробирует цель. Можно выбрать несколько источников."
@@ -0,0 +1,33 @@
import { CircleAlertIcon } from 'lucide-react'
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { Button } from '@cfdm/ui/components/button'
interface IncidentAlertProps {
title: string
description?: string
onShow: () => void
}
/**
* Баннер самого тяжёлого инцидента.
* Preview: https://reui.io/docs/components/base/alert
*/
export function IncidentAlert({ title, description, onShow }: IncidentAlertProps) {
return (
<Alert variant="destructive">
<CircleAlertIcon aria-hidden />
<AlertTitle>{title}</AlertTitle>
{description ? <AlertDescription>{description}</AlertDescription> : null}
<AlertAction>
<Button type="button" variant="outline" size="xs" onClick={onShow}>
Показать
</Button>
</AlertAction>
</Alert>
)
}
@@ -2,8 +2,10 @@ import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { TriangleAlertIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { FormFieldSimple } from '@/components/form-field'
@@ -57,11 +59,23 @@ export function VpsTrackerIntegrationCard({
const syncBadgeVariant = settings?.vpsTrackerSyncEnabled ? 'success' : 'secondary'
const hasUnsaved = form.formState.isDirty
return (
<form
className="flex flex-col gap-4"
onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}
>
{hasUnsaved ? (
<Alert variant="warning" role="status">
<TriangleAlertIcon aria-hidden="true" />
<AlertTitle>Есть несохранённые изменения</AlertTitle>
<AlertDescription>
URL, токен или синхронизация отличаются от последних сохранённых
значений.
</AlertDescription>
</Alert>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Badge variant={syncBadgeVariant}>
{settings?.vpsTrackerSyncEnabled ? 'Синхронизация включена' : 'Синхронизация выключена'}
@@ -126,6 +140,20 @@ export function VpsTrackerIntegrationCard({
<LoadingButton type="submit" isLoading={isSaving} disabled={!form.formState.isDirty}>
Сохранить
</LoadingButton>
<Button
type="button"
variant="outline"
disabled={!hasUnsaved}
onClick={() =>
form.reset({
vpsTrackerUrl: settings?.vpsTrackerUrl ?? '',
vpsTrackerIntegrationToken: '',
vpsTrackerSyncEnabled: settings?.vpsTrackerSyncEnabled ?? false,
})
}
>
Сбросить
</Button>
<Button type="button" variant="outline" onClick={() => void handleTest()}>
Проверить связь
</Button>
+30 -21
View File
@@ -1,34 +1,43 @@
import type { CSSProperties, ReactNode } from 'react'
import { useEffect, type CSSProperties, type ReactNode } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { AppSidebar } from '@/components/app-sidebar'
import { CommandPalette } from '@/components/layout/command-palette'
import { SiteHeader } from '@/components/layout/site-header'
import { SearchMenu } from '@/components/layout/search-menu'
import { SystemAttentionProvider } from '@/components/layout/system-attention-provider'
import { bindLiveRefresh } from '@/lib/live-refresh'
import { TooltipProvider } from '@cfdm/ui/components/tooltip'
import { SidebarInset, SidebarProvider } from '@cfdm/ui/components/sidebar'
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
/** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */
export function AppShell({ children }: { children: ReactNode }) {
const queryClient = useQueryClient()
useEffect(() => bindLiveRefresh(queryClient), [queryClient])
return (
<TooltipProvider delay={0}>
<SidebarProvider
style={
{
'--sidebar-width': '240px',
} as CSSProperties
}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content">
<SiteHeader />
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</div>
</SidebarInset>
<SearchMenu hotkeyOnly />
</SidebarProvider>
<SystemAttentionProvider>
<SidebarProvider
style={
{
'--sidebar-width': '240px',
} as CSSProperties
}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content">
<SiteHeader />
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</div>
</SidebarInset>
<CommandPalette />
</SidebarProvider>
</SystemAttentionProvider>
</TooltipProvider>
)
}
@@ -0,0 +1,225 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import {
FolderTreeIcon,
GlobeIcon,
LayoutDashboardIcon,
PaletteIcon,
ServerIcon,
SettingsIcon,
ShieldCheckIcon,
} from 'lucide-react'
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@cfdm/ui/components/command'
import {
domainsListQueryOptions,
groupsQueryOptions,
serviceGroupsQueryOptions,
} from '@/queries'
export const OPEN_COMMAND_PALETTE_EVENT = 'cfdm-open-command-palette'
const SECTION_ITEMS = [
{
to: '/',
label: 'Панель',
keywords: 'dashboard панель управление обзор',
icon: LayoutDashboardIcon,
},
{
to: '/domains',
label: 'Домены',
keywords: 'domains зоны cloudflare',
icon: GlobeIcon,
},
{
to: '/services',
label: 'Сервисы',
keywords: 'services сервисы fqdn',
icon: ServerIcon,
},
{
to: '/groups',
label: 'Группы',
keywords: 'groups группы доменов',
icon: FolderTreeIcon,
},
{
to: '/certificates',
label: 'Сертификаты',
keywords: 'certificates ssl tls',
icon: ShieldCheckIcon,
},
{
to: '/settings/appearance',
label: 'Настройки',
keywords: 'settings настройки',
icon: SettingsIcon,
},
] as const
type PaletteTarget = (typeof SECTION_ITEMS)[number]['to']
/** Палитра ⌘K / Ctrl+K. Кнопки поиска в шапке нет. */
export function CommandPalette() {
const [open, setOpen] = useState(false)
const navigate = useNavigate()
const { theme, setTheme } = useTheme()
const domainsQuery = useQuery({ ...domainsListQueryOptions(), enabled: open })
const groupsQuery = useQuery({ ...groupsQueryOptions(), enabled: open })
const servicesQuery = useQuery({ ...serviceGroupsQueryOptions(), enabled: open })
const services = useMemo(() => {
const data = servicesQuery.data
if (!data) return []
return [...data.groups.flatMap((group) => group.services), ...data.ungrouped]
}, [servicesQuery.data])
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.repeat) return
if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey)) return
event.preventDefault()
setOpen((current) => !current)
}
function onOpen() {
setOpen(true)
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpen)
return () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpen)
}
}, [])
function go(to: PaletteTarget) {
setOpen(false)
void navigate({ to })
}
return (
<CommandDialog
open={open}
onOpenChange={setOpen}
title="Палитра команд"
description="Разделы и действия приложения"
className="sm:max-w-md"
>
<Command key={open ? 'open' : 'closed'}>
<CommandInput autoFocus placeholder="Раздел, домен, сервис или действие…" />
<CommandList>
<CommandEmpty>Ничего не найдено</CommandEmpty>
<CommandGroup heading="Действия">
<CommandItem value="Синхронизировать домены" onSelect={() => go('/domains')}>
<GlobeIcon />
<span>Синхронизировать домены</span>
</CommandItem>
<CommandItem value="Создать сервис" onSelect={() => go('/services')}>
<ServerIcon />
<span>Создать сервис</span>
</CommandItem>
<CommandItem value="Создать группу" onSelect={() => go('/groups')}>
<FolderTreeIcon />
<span>Создать группу</span>
</CommandItem>
<CommandItem value="Проверить сертификаты" onSelect={() => go('/certificates')}>
<ShieldCheckIcon />
<span>Проверить сертификаты</span>
</CommandItem>
<CommandItem
value="Переключить тему"
onSelect={() => {
setTheme(theme === 'light' ? 'dark' : 'light')
setOpen(false)
}}
>
<PaletteIcon />
<span>Переключить тему</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Разделы">
{SECTION_ITEMS.map((item) => (
<CommandItem
key={item.to}
value={`${item.label} ${item.keywords}`}
onSelect={() => go(item.to)}
>
<item.icon />
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Домены">
{(domainsQuery.data ?? []).slice(0, 8).map((domain) => (
<CommandItem
key={domain.id}
value={`домен ${domain.zone_name}`}
onSelect={() => {
setOpen(false)
void navigate({
to: '/domains/$domainId',
params: { domainId: String(domain.id) },
})
}}
>
<GlobeIcon />
<span>{domain.zone_name}</span>
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Сервисы">
{services.slice(0, 8).map((service) => (
<CommandItem
key={service.id}
value={`сервис ${service.name}`}
onSelect={() => {
setOpen(false)
void navigate({
to: '/services/$serviceId',
params: { serviceId: String(service.id) },
})
}}
>
<ServerIcon />
<span>{service.name}</span>
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Группы">
{(groupsQuery.data ?? []).slice(0, 8).map((group) => (
<CommandItem
key={group.id}
value={`группа ${group.name} ${group.slug}`}
onSelect={() => {
setOpen(false)
void navigate({
to: '/groups/$groupId',
params: { groupId: String(group.id) },
})
}}
>
<FolderTreeIcon />
<span>{group.name}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
)
}
@@ -0,0 +1,91 @@
import { useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Switch } from '@cfdm/ui/components/switch'
import { formatRelative } from '@/lib/format'
import { isLiveRefresh, setLiveRefresh } from '@/lib/live-refresh'
function useLatestDataUpdatedAt() {
const queryClient = useQueryClient()
const [updatedAt, setUpdatedAt] = useState(0)
useEffect(() => {
const cache = queryClient.getQueryCache()
let queued = false
let active = true
const read = () => {
let latest = 0
for (const query of cache.getAll()) {
if (query.getObserversCount() === 0) continue
if (query.state.dataUpdatedAt > latest) latest = query.state.dataUpdatedAt
}
setUpdatedAt((prev) => (prev === latest ? prev : latest))
}
// Cache notifies synchronously while another route renders (observerAdded).
const schedule = () => {
if (queued) return
queued = true
queueMicrotask(() => {
queued = false
if (active) read()
})
}
read()
const unsubscribe = cache.subscribe(schedule)
return () => {
active = false
unsubscribe()
}
}, [queryClient])
return updatedAt
}
function formatUpdatedAt(updatedAt: number) {
if (updatedAt === 0) return '—'
return formatRelative(new Date(updatedAt).toISOString())
}
/** Компактный статус свежести слева от AppsMenu. */
export function DataFreshness() {
const queryClient = useQueryClient()
const updatedAt = useLatestDataUpdatedAt()
const [live, setLive] = useState(() => isLiveRefresh())
const [label, setLabel] = useState('—')
useEffect(() => {
const read = () => setLabel(formatUpdatedAt(updatedAt))
read()
if (updatedAt === 0) return
const timer = window.setInterval(read, 15_000)
return () => window.clearInterval(timer)
}, [updatedAt])
function handleCheckedChange(checked: boolean) {
const next = Boolean(checked)
setLiveRefresh(queryClient, next)
setLive(next)
}
return (
<div className="flex items-center gap-2">
<p className="text-muted-foreground hidden items-center gap-1.5 text-xs sm:flex">
<span className="tabular-nums">
Обновлено{' '}
<time dateTime={updatedAt ? new Date(updatedAt).toISOString() : undefined}>
{label}
</time>
</span>
<span aria-hidden="true">·</span>
<span>{live ? 'Live' : 'Пауза'}</span>
</p>
<Switch
size="sm"
checked={live}
onCheckedChange={handleCheckedChange}
aria-label="Автообновление"
/>
<span className="text-muted-foreground hidden text-xs sm:inline">авто</span>
</div>
)
}
@@ -1,189 +0,0 @@
import { useEffect, useId, useMemo, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router'
import {
FolderTreeIcon,
GlobeIcon,
HeartPulseIcon,
LayoutDashboardIcon,
SearchIcon,
ServerIcon,
SettingsIcon,
ShieldCheckIcon,
} from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@cfdm/ui/components/dialog'
import { Input } from '@cfdm/ui/components/input'
import {
Item,
ItemContent,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@cfdm/ui/components/item'
const NAV_ITEMS = [
{
to: '/',
label: 'Панель управления',
keywords: ['dashboard', 'панель', 'обзор'],
icon: LayoutDashboardIcon,
},
{
to: '/domains',
label: 'Домены',
keywords: ['domains', 'зоны', 'cloudflare'],
icon: GlobeIcon,
},
{
to: '/groups',
label: 'Группы доменов',
keywords: ['groups', 'группы', 'канбан'],
icon: FolderTreeIcon,
},
{
to: '/services',
label: 'Сервисы',
keywords: ['services', 'сервисы', 'fqdn'],
icon: ServerIcon,
},
{
to: '/certificates',
label: 'Сертификаты',
keywords: ['certificates', 'ssl', 'tls'],
icon: ShieldCheckIcon,
},
{
to: '/settings/health',
label: 'Health-check',
keywords: ['health', 'health-check', 'cron', 'пороги', 'настройки'],
icon: HeartPulseIcon,
},
{
to: '/settings/integrations',
label: 'Настройки',
keywords: ['settings', 'интеграции', 'app switcher'],
icon: SettingsIcon,
},
] as const
/** Command-K search — hotkey dialog (no header chrome trigger). Etalon: EvoBGP CommandPalette hotkeyOnly. */
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const searchInputId = useId()
const navigate = useNavigate()
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
setOpen(true)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
useEffect(() => {
if (!open) setQuery('')
}, [open])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return NAV_ITEMS
return NAV_ITEMS.filter(
(item) =>
item.label.toLowerCase().includes(q) ||
item.keywords.some((k) => k.includes(q)),
)
}, [query])
function goTo(to: string) {
setOpen(false)
void navigate({ to })
}
return (
<>
{hotkeyOnly ? null : (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Поиск"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
>
<SearchIcon
className="size-4.5 transition-colors"
aria-hidden="true"
/>
</Button>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogHeader className="sr-only">
<DialogTitle>Поиск</DialogTitle>
<DialogDescription>
Переход к разделам приложения
</DialogDescription>
</DialogHeader>
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60">
<div className="relative flex items-center gap-3 border-b px-4 py-2">
<SearchIcon
aria-hidden="true"
className="pointer-events-none size-4 opacity-60 select-none"
/>
<Input
id={searchInputId}
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
autoFocus
placeholder="Перейти к разделу…"
aria-label="Поиск разделов"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filtered[0]) {
e.preventDefault()
goTo(filtered[0].to)
}
}}
/>
</div>
<ItemGroup className="max-h-72 overflow-y-auto p-2">
{filtered.length === 0 ? (
<p className="text-muted-foreground px-2 py-4 text-center text-sm">
Ничего не найдено
</p>
) : (
filtered.map((item) => (
<Item
key={item.to}
size="sm"
variant="muted"
className="cursor-pointer border-0"
render={<Link to={item.to} onClick={() => setOpen(false)} />}
>
<ItemMedia variant="icon">
<item.icon aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle>{item.label}</ItemTitle>
</ItemContent>
</Item>
))
)}
</ItemGroup>
</DialogContent>
</Dialog>
</>
)
}
+26 -80
View File
@@ -1,5 +1,6 @@
import { Fragment, useMemo } from 'react'
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
import { useMemo } from 'react'
import { SearchIcon } from 'lucide-react'
import {
Breadcrumb,
BreadcrumbItem,
@@ -9,85 +10,17 @@ import {
BreadcrumbSeparator,
} from '@cfdm/ui/components/breadcrumb'
import { Separator } from '@cfdm/ui/components/separator'
import { DataFreshness } from '@/components/layout/data-freshness'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { Button } from '@cfdm/ui/components/button'
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
import { OPEN_COMMAND_PALETTE_EVENT } from '@/components/layout/command-palette'
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(() => {
@@ -102,11 +35,14 @@ function useDynamicBreadcrumbLabels() {
}, [matches])
}
/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */
/** Шапка макета: триггер, крошки, свежесть и состояние. Приложения — в сайдбаре. */
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 +53,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,13 +68,23 @@ export function SiteHeader() {
</BreadcrumbLink>
)}
</BreadcrumbItem>
</span>
</Fragment>
)
})}
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<AppsMenu />
<DataFreshness />
<Button
type="button"
variant="outline"
size="icon"
aria-label="Командная палитра"
className="md:hidden"
onClick={() => window.dispatchEvent(new Event(OPEN_COMMAND_PALETTE_EVENT))}
>
<SearchIcon aria-hidden />
</Button>
<SystemMonitorPopover />
</div>
</header>
@@ -0,0 +1,15 @@
import type { ReactNode } from 'react'
import {
SystemAttentionContext,
useSystemAttentionData,
} from '@/hooks/use-system-attention'
export function SystemAttentionProvider({ children }: { children: ReactNode }) {
const attention = useSystemAttentionData()
return (
<SystemAttentionContext.Provider value={attention}>
{children}
</SystemAttentionContext.Provider>
)
}
@@ -1,5 +1,5 @@
import { useMemo, type CSSProperties, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import {
Activity,
HeartPulse,
@@ -8,18 +8,14 @@ import {
ShieldCheck,
} from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { useSystemAttention } from '@/hooks/use-system-attention'
import type { SystemIncident } from '@/lib/system-attention'
import { cn } from '@cfdm/ui/lib/utils'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
import { Progress } from '@cfdm/ui/components/progress'
import {
certSummaryQueryOptions,
domainsListQueryOptions,
notificationLogQueryOptions,
serviceGroupsQueryOptions,
} from '@/queries'
const VISIBLE_INCIDENTS = 8
type MonitorMetric = {
id: string
@@ -32,12 +28,6 @@ type MonitorMetric = {
alert: boolean
}
type ReadyResponse = {
status: string
database?: boolean
cloudflare?: boolean
}
function toneColor(tone: MonitorMetric['tone']) {
switch (tone) {
case 'success':
@@ -81,110 +71,95 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
)
}
function countByStatus(summary: [string, number][] | undefined, statuses: string[]) {
if (!summary) return 0
return summary
.filter(([status]) => statuses.includes(status))
.reduce((sum, [, count]) => sum + count, 0)
function IncidentLink({ incident }: { incident: SystemIncident }) {
const className =
'hover:bg-muted flex items-center gap-2 rounded-md px-1.5 py-1 text-xs'
const body = (
<>
<span
aria-hidden="true"
className={cn(
'size-1.5 shrink-0 rounded-full',
incident.tone === 'destructive' ? 'bg-destructive' : 'bg-warning',
)}
/>
<span className="min-w-0 flex-1 truncate">{incident.title}</span>
<span className="text-muted-foreground shrink-0">{incident.detail}</span>
</>
)
if (incident.href.to === '/certificates') {
return (
<Link to="/certificates" className={className}>
{body}
</Link>
)
}
return (
<Link
to="/services/$serviceId"
params={{ serviceId: incident.href.serviceId }}
className={className}
>
{body}
</Link>
)
}
/** Live system monitor popover (app-shell pattern, CFDM API data). */
export function SystemMonitorPopover() {
const readyQ = useQuery({
queryKey: ['ready'],
queryFn: async (): Promise<ReadyResponse> => {
const res = await fetch('/ready')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<ReadyResponse>
},
refetchInterval: 30_000,
})
const servicesQ = useQuery({ ...serviceGroupsQueryOptions(), refetchInterval: 30_000 })
const certsQ = useQuery({ ...certSummaryQueryOptions(), refetchInterval: 30_000 })
const domainsQ = useQuery({ ...domainsListQueryOptions(), refetchInterval: 30_000 })
const notifyQ = useQuery({ ...notificationLogQueryOptions(20), refetchInterval: 30_000 })
const readyOk = readyQ.data?.status === 'ready' && readyQ.isSuccess
const readyDegraded = readyQ.data?.status === 'degraded' || readyQ.isError
let healthDown = 0
let healthDegraded = 0
for (const group of servicesQ.data?.groups ?? []) {
for (const service of group.services) {
if (service.health_status === 'down') healthDown += 1
if (service.health_status === 'degraded') healthDegraded += 1
}
}
for (const service of servicesQ.data?.ungrouped ?? []) {
if (service.health_status === 'down') healthDown += 1
if (service.health_status === 'degraded') healthDegraded += 1
}
const healthAlert = healthDown > 0
const healthWarn = healthDegraded > 0
const certWarnings = countByStatus(certsQ.data, ['warning', 'expired', 'error'])
const failedJobs = (notifyQ.data ?? []).filter((n) =>
/fail|error/i.test(`${n.kind} ${n.title} ${n.message}`),
).length
const ungroupedCount = (domainsQ.data ?? []).filter((d) => d.group_id == null).length
const attentionCount = healthDown + healthDegraded
const attention = useSystemAttention()
const visibleIncidents = attention.incidents.slice(0, VISIBLE_INCIDENTS)
const hiddenIncidents = attention.incidents.length - visibleIncidents.length
const metrics = useMemo<MonitorMetric[]>(
() => [
{
id: 'ready',
label: 'Ready',
value: readyOk ? 'OK' : readyDegraded ? '!' : '—',
value: attention.readyOk ? 'OK' : attention.readyDegraded ? '!' : '—',
unit: '',
percent: readyOk ? 100 : readyDegraded ? 40 : 0,
percent: attention.readyOk ? 100 : attention.readyDegraded ? 40 : 0,
icon: <HeartPulse aria-hidden />,
tone: readyOk ? 'success' : 'destructive',
alert: !readyOk,
tone: attention.readyOk ? 'success' : 'destructive',
alert: !attention.readyOk,
},
{
id: 'health',
label: 'Health-check',
value: String(healthDown + healthDegraded),
value: String(attention.servicesDown + attention.servicesDegraded),
unit: 'шт.',
percent: Math.min(100, (healthDown + healthDegraded) * 20),
percent: Math.min(100, (attention.servicesDown + attention.servicesDegraded) * 20),
icon: <Server aria-hidden />,
tone: healthAlert ? 'destructive' : healthWarn ? 'warning' : 'success',
alert: healthAlert || healthWarn,
tone: attention.servicesDown > 0 ? 'destructive' : attention.servicesDegraded > 0 ? 'warning' : 'success',
alert: attention.servicesDown > 0 || attention.servicesDegraded > 0,
},
{
id: 'certs',
label: 'Сертификаты',
value: String(certWarnings),
value: String(attention.certWarnings),
unit: 'шт.',
percent: Math.min(100, certWarnings * 20),
percent: Math.min(100, attention.certWarnings * 20),
icon: <ShieldCheck aria-hidden />,
tone: certWarnings > 0 ? 'warning' : 'success',
alert: certWarnings > 0,
tone: attention.certWarnings > 0 ? 'warning' : 'success',
alert: attention.certWarnings > 0,
},
{
id: 'jobs',
label: 'Ошибки',
value: String(failedJobs),
value: String(attention.failedJobs),
unit: 'шт.',
percent: Math.min(100, failedJobs * 15),
percent: Math.min(100, attention.failedJobs * 15),
icon: <ListChecks aria-hidden />,
tone: failedJobs > 0 ? 'warning' : 'success',
alert: failedJobs > 0,
tone: attention.failedJobs > 0 ? 'warning' : 'success',
alert: attention.failedJobs > 0,
},
],
[
certWarnings,
failedJobs,
healthAlert,
healthDegraded,
healthDown,
healthWarn,
readyDegraded,
readyOk,
],
[attention],
)
const spiking = metrics.some((m) => m.alert)
const spiking = metrics.some((metric) => metric.alert)
return (
<Popover>
@@ -192,7 +167,11 @@ export function SystemMonitorPopover() {
render={
<button
type="button"
aria-label="Монитор системы"
aria-label={
spiking
? `Состояние системы: внимание, ошибок ${attention.servicesDown}, предупреждений ${attention.servicesDegraded + attention.certWarnings}`
: 'Состояние системы: норма'
}
className={cn(
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
@@ -212,19 +191,20 @@ export function SystemMonitorPopover() {
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
) : null}
</span>
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
<Badge
variant={spiking ? 'destructive-light' : 'success-light'}
size="xs"
className="h-4 px-1.5 text-[10px]"
>
<span className="text-foreground hidden text-xs font-medium sm:inline">
{spiking ? 'Внимание' : 'Норма'}
</Badge>
</span>
<span className="text-muted-foreground hidden items-center gap-1 text-[11px] tabular-nums md:inline-flex">
<i className="bg-destructive inline-block size-1.5 rounded-full" aria-hidden />
{attention.servicesDown}
<i className="bg-warning inline-block size-1.5 rounded-full" aria-hidden />
{attention.servicesDegraded + attention.certWarnings}
</span>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<span className="text-foreground text-xs font-medium">Монитор CFDM</span>
<span className="text-foreground text-sm font-semibold">Состояние системы</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{new Date().toLocaleTimeString('ru-RU')}
</span>
@@ -242,12 +222,29 @@ export function SystemMonitorPopover() {
</div>
))}
</div>
<div className="border-border flex flex-col gap-1 border-t px-2 py-2">
<span className="text-muted-foreground px-1.5 text-xs font-medium">Инциденты</span>
{visibleIncidents.length === 0 ? (
<p className="text-muted-foreground px-1.5 py-1 text-xs">Нет инцидентов</p>
) : (
<div className="flex flex-col gap-0.5">
{visibleIncidents.map((incident) => (
<IncidentLink key={incident.id} incident={incident} />
))}
</div>
)}
{hiddenIncidents > 0 ? (
<p className="text-muted-foreground px-1.5 text-xs">Ещё {hiddenIncidents}</p>
) : null}
</div>
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
Без группы:{' '}
<span className="text-foreground font-medium tabular-nums">{ungroupedCount}</span>
<span className="text-foreground font-medium tabular-nums">{attention.ungroupedCount}</span>
{' · '}
Внимание:{' '}
<span className="text-foreground font-medium tabular-nums">{attentionCount}</span>
<span className="text-foreground font-medium tabular-nums">
{attention.servicesDown + attention.servicesDegraded}
</span>
</div>
</PopoverContent>
</Popover>
+101
View File
@@ -41,6 +41,14 @@ import {
redirectToPortalLogout,
resetPortalHandoff,
} from '@/lib/auth'
import {
readContrast,
readDensity,
writeContrast,
writeDensity,
type ContrastMode,
type DensityMode,
} from '@/lib/appearance-mode'
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
@@ -107,6 +115,98 @@ function ThemeSegmentedToggle() {
)
}
function SegmentedChoice<T extends string>({
label,
value,
options,
onChange,
}: {
label: string
value: T
options: readonly { value: T; label: string }[]
onChange: (value: T) => void
}) {
return (
<div
role="radiogroup"
aria-label={label}
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
{options.map((option) => {
const isActive = value === option.value
return (
<Button
key={option.value}
type="button"
role="radio"
aria-checked={isActive}
size="xs"
variant={isActive ? 'secondary' : 'ghost'}
onClick={() => onChange(option.value)}
>
{option.label}
</Button>
)
})}
</div>
)
}
function AppearanceToggles() {
const [contrast, setContrast] = useState<ContrastMode>('standard')
const [density, setDensity] = useState<DensityMode>('normal')
const [mounted, setMounted] = useState(false)
useEffect(() => {
setContrast(readContrast())
setDensity(readDensity())
setMounted(true)
}, [])
if (!mounted) return null
return (
<>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<span className="text-sm">Контраст</span>
<div className="ml-auto">
<SegmentedChoice
label="Контраст"
value={contrast}
options={[
{ value: 'standard', label: 'Обычный' },
{ value: 'high', label: 'Высокий' },
]}
onChange={(next) => {
setContrast(next)
writeContrast(next)
}}
/>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<span className="text-sm">Плотность</span>
<div className="ml-auto">
<SegmentedChoice
label="Плотность"
value={density}
options={[
{ value: 'normal', label: 'Обычная' },
{ value: 'compact', label: 'Компакт' },
]}
onChange={(next) => {
setDensity(next)
writeDensity(next)
}}
/>
</div>
</DropdownMenuItem>
</>
)
}
function initials(name: string, email: string): string {
const base = name.trim() || email.trim()
if (!base) return '?'
@@ -202,6 +302,7 @@ export function NavUser() {
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
<AppearanceToggles />
</DropdownMenuGroup>
<DropdownMenuSeparator />
+4 -2
View File
@@ -11,12 +11,14 @@ export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-px">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
) : null}
</div>
)
}
@@ -1,6 +1,11 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { CircleAlertIcon, type LucideIcon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import {
Frame,
@@ -9,12 +14,14 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { Button } from '@cfdm/ui/components/button'
import { cn } from '@cfdm/ui/lib/utils'
/**
* Sibling Frame columns for dashboard attention queue.
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
* Each column is its own Frame; children must not wrap another Frame.
* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/dashboard-1
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/data-grid
*/
export interface AttentionQueueColumn {
id: string
@@ -26,6 +33,9 @@ export interface AttentionQueueColumn {
emptyTitle: string
emptyDescription: string
emptyAction?: ReactNode
isError?: boolean
errorMessage?: string
onRetry?: () => void
children: ReactNode
}
@@ -40,13 +50,14 @@ export function AttentionQueue({ columns, className }: AttentionQueueProps) {
return (
<div
className={cn(
'grid min-w-0 items-start gap-2 @3xl:grid-cols-3',
'flex min-w-0 flex-col items-stretch gap-4',
className,
)}
>
{columns.map((column) => {
const Icon = column.icon
const isEmpty = column.count === 0
const isError = column.isError === true
const isEmpty = !isError && column.count === 0
return (
<Frame key={column.id} dense spacing="sm" className="min-w-0 w-full">
<FrameHeader>
@@ -71,8 +82,30 @@ export function AttentionQueue({ columns, className }: AttentionQueueProps) {
) : null}
</div>
</FrameHeader>
<FramePanel className="min-w-0">
{isEmpty ? (
<FramePanel className={cn('min-w-0', !isEmpty && !isError && 'p-0')}>
{isError ? (
<div className="flex min-h-28 items-center py-4">
<Alert variant="destructive" className="w-full">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col items-start gap-2">
<span>
{column.errorMessage ?? 'Не удалось загрузить данные'}
</span>
{column.onRetry ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={column.onRetry}
>
Повторить
</Button>
) : null}
</AlertDescription>
</Alert>
</div>
) : isEmpty ? (
<div className="flex min-h-28 items-center justify-center py-4">
<EmptyState
icon={Icon}
@@ -1,4 +1,5 @@
import { useMemo, useEffect, useRef } from 'react'
import { ActivityIcon } from 'lucide-react'
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis } from 'recharts'
import { StatusBadge } from '@/components/status-badge'
@@ -229,3 +230,31 @@ export function GroupDomainsChart({ data }: GroupDomainsChartProps) {
</Frame>
)
}
/**
* Блок «Здоровье нод» в форме area-chart (chart-17): заголовок и легенда.
* Почасового ряда Healthy/Unhealthy API не отдаёт — пустое состояние, без выдуманных точек.
* Preview: https://reui.io/preview/base/chart-17
* Docs: https://reui.io/docs/components/base/frame
*/
export function NodeHealthChart() {
return (
<Frame className="w-full">
<FrameHeader>
<FrameTitle>Здоровье нод</FrameTitle>
<FrameDescription>
Healthy / Unhealthy по проверкам health-check · последние 24 часа
</FrameDescription>
</FrameHeader>
<FramePanel className="flex min-h-52 flex-col">
<EmptyState
icon={ActivityIcon}
title="Нет ряда за 24 часа"
description="Сводка нод есть. Почасовой ряд Healthy и Unhealthy API не отдаёт, поэтому точки не подставляются."
stackedIcon={false}
centered={false}
/>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,314 @@
import { useMemo, useState } from 'react'
import { Link } from '@tanstack/react-router'
import {
getCoreRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type SortingState,
} from '@tanstack/react-table'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { formatRelative, sqliteUtcToIso } from '@/lib/format'
import type { Certificate, DomainListItem, ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
/**
* Compact queue tables for the dashboard. Parent Frame owns the shell —
* this grid does not render another Frame.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Docs: https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame
*/
function formatWhen(value: string | null | undefined) {
if (!value) return '—'
return formatRelative(sqliteUtcToIso(value) ?? value)
}
function nodeLabel(service: ServiceView) {
const health = service.ip_health
const total = Math.max(service.ips.length, health.length)
if (total === 0) return '—'
if (health.length === 0) return String(total)
const healthy = health.filter((row) => row.status === 'up').length
return `${healthy}/${total}`
}
function latestCheck(service: ServiceView) {
let checkedAt: string | null = null
for (const row of service.ip_health) {
if (!row.last_checked_at) continue
if (!checkedAt || row.last_checked_at > checkedAt) checkedAt = row.last_checked_at
}
return checkedAt
}
function QueueGrid<T extends object>({
data,
columns,
getRowId,
}: {
data: T[]
columns: ColumnDef<T, unknown>[]
getRowId: (row: T) => string
}) {
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns,
getRowId,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
return (
<DataGrid
table={table}
recordCount={data.length}
tableLayout={{
dense: true,
headerBorder: true,
rowBorder: true,
width: 'auto',
}}
>
<DataGridScrollArea orientation="horizontal">
<DataGridTable />
</DataGridScrollArea>
</DataGrid>
)
}
interface HealthProblemGridProps {
services: ServiceView[]
}
export function HealthProblemGrid({ services }: HealthProblemGridProps) {
const columns = useMemo<ColumnDef<ServiceView, unknown>[]>(
() => [
{
id: 'name',
accessorKey: 'name',
size: 140,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Сервис" />
),
cell: ({ row }) => (
<Button
variant="link"
className="h-auto max-w-full truncate p-0 font-medium"
nativeButton={false}
render={
<Link to="/services" search={{ serviceId: row.original.id }} />
}
>
{row.original.name}
</Button>
),
},
{
id: 'status',
accessorKey: 'health_status',
size: 96,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => (
<HealthCheckBadge status={row.original.health_status} size="xs" />
),
},
{
id: 'latency',
accessorKey: 'health_latency_ms',
size: 88,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Задержка" />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{row.original.health_latency_ms != null
? `${row.original.health_latency_ms} мс`
: '—'}
</span>
),
},
{
id: 'nodes',
accessorFn: (row) => Math.max(row.ips.length, row.ip_health.length),
size: 72,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Ноды" />
),
cell: ({ row }) => (
<span className="tabular-nums">{nodeLabel(row.original)}</span>
),
},
{
id: 'checked',
accessorFn: (row) => latestCheck(row) ?? '',
size: 104,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Когда" />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{formatWhen(latestCheck(row.original))}
</span>
),
},
],
[],
)
return (
<QueueGrid
data={services}
columns={columns}
getRowId={(row) => String(row.id)}
/>
)
}
interface ExpiringCertGridProps {
certs: Certificate[]
}
export function ExpiringCertGrid({ certs }: ExpiringCertGridProps) {
const columns = useMemo<ColumnDef<Certificate, unknown>[]>(
() => [
{
id: 'hostname',
accessorKey: 'hostname',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Хост" />
),
cell: ({ row }) => (
<Button
variant="link"
className="h-auto max-w-full truncate p-0 font-medium"
nativeButton={false}
render={<Link to="/certificates" />}
>
{row.original.hostname}
</Button>
),
},
{
id: 'status',
accessorKey: 'status',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'service',
accessorFn: (row) => row.service_name ?? '',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Сервис" />
),
cell: ({ row }) => (
<span className="truncate">
{row.original.service_name?.trim() || '—'}
</span>
),
},
{
id: 'expires',
accessorKey: 'expires_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Истекает" />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{formatWhen(row.original.expires_at)}
</span>
),
},
],
[],
)
return (
<QueueGrid data={certs} columns={columns} getRowId={(row) => String(row.id)} />
)
}
interface UngroupedDomainGridProps {
domains: DomainListItem[]
}
export function UngroupedDomainGrid({ domains }: UngroupedDomainGridProps) {
const columns = useMemo<ColumnDef<DomainListItem, unknown>[]>(
() => [
{
id: 'zone_name',
accessorKey: 'zone_name',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Домен" />
),
cell: ({ row }) => (
<Button
variant="link"
className="h-auto max-w-full truncate p-0 font-medium"
nativeButton={false}
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(row.original.id) }}
/>
}
>
{row.original.zone_name}
</Button>
),
},
{
id: 'status',
accessorKey: 'status',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'services',
accessorKey: 'service_count',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Сервисы" />
),
cell: ({ row }) => (
<span className="tabular-nums">{row.original.service_count}</span>
),
},
{
id: 'synced',
accessorKey: 'last_synced_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Синхронизация" />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{formatWhen(row.original.last_synced_at)}
</span>
),
},
],
[],
)
return (
<QueueGrid
data={domains}
columns={columns}
getRowId={(row) => String(row.id)}
/>
)
}
@@ -298,45 +298,50 @@ export function HealthSourceFilterBar({
const active = selected.length > 0 ? selected : enabled
return (
<ToggleGroup
multiple
variant="outline"
size="sm"
className="w-full min-w-0"
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}
className="min-w-0 flex-1 gap-1.5"
>
<IconTile
variant="elevated"
size="xs"
className={item.iconClassName}
aria-hidden="true"
<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]"
>
{item.icon}
</IconTile>
<span className="truncate">{item.title}</span>
<HealthCheckBadge
status={statuses[item.id] ?? 'unknown'}
provider={item.id}
size="xs"
/>
</ToggleGroupItem>
))}
</ToggleGroup>
<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>
)
}
+8 -1
View File
@@ -1,7 +1,8 @@
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 { CertStatusChart, GroupDomainsChart, NodeHealthChart } from './dashboard-analytics'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
export {
KpiStatGrid,
@@ -16,6 +17,11 @@ export {
} from './kpi-stat-grid'
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { AttentionQueue, type AttentionQueueColumn } from './attention-queue'
export {
ExpiringCertGrid,
HealthProblemGrid,
UngroupedDomainGrid,
} from './dashboard-queue-grid'
export { OpsDashboard } from './ops-dashboard'
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
@@ -28,3 +34,4 @@ export {
type HealthProvider,
type HealthAggregate,
} from './health-source-tiles'
export { ServiceAddressBlock } from './service-address-block'
+131 -108
View File
@@ -2,10 +2,9 @@ import type { KeyboardEvent, ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { Badge } from '@/components/reui/badge'
import { cn } from '@cfdm/ui/lib/utils'
import { kpiCols } from './kpi-cols'
import { IconTile } from '@/components/reui/icon-tile'
import { Skeleton } from '@cfdm/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
@@ -40,14 +39,70 @@ export type OpsKpiCard = KpiStatCardData
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
export type KpiStatCard = KpiStatCardData
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
type KpiTone = 'neutral' | 'ok' | 'in' | 'wn' | 'er' | 'primary'
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
default: 'text-foreground',
warning: 'text-warning',
destructive: 'text-destructive',
const TILE: Record<KpiTone, string> = {
neutral: 'text-muted-foreground',
ok: 'text-success',
in: 'text-info',
wn: 'text-warning',
er: 'text-destructive',
primary: 'text-primary',
}
const VALUE: Record<KpiTone, string> = {
neutral: 'text-foreground',
ok: 'text-foreground',
in: 'text-foreground',
primary: 'text-foreground',
wn: 'text-warning',
er: 'text-destructive',
}
const BADGE: Record<KpiTone, 'outline' | 'success-light' | 'info-light' | 'warning-light' | 'destructive-light' | 'primary-light'> = {
neutral: 'outline',
ok: 'success-light',
in: 'info-light',
wn: 'warning-light',
er: 'destructive-light',
primary: 'primary-light',
}
const GLYPH: Record<KpiTone, string> = {
neutral: '',
ok: '●',
in: '',
primary: '',
wn: '▲',
er: '✕',
}
const COLS: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-2 max-[640px]:grid-cols-1',
3: 'grid-cols-1 min-[1240px]:grid-cols-3',
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
}
function iconTone(item: KpiStatItem): KpiTone {
if (item.iconClassName?.includes('text-destructive')) return 'er'
if (item.iconClassName?.includes('text-warning')) return 'wn'
if (item.iconClassName?.includes('text-success')) return 'ok'
if (item.iconClassName?.includes('text-info')) return 'in'
if (item.iconClassName?.includes('text-primary')) return 'primary'
if (item.variant === 'destructive') return 'er'
if (item.variant === 'warning') return 'wn'
return 'neutral'
}
function statusTone(item: KpiStatItem): KpiTone {
if (item.variant === 'destructive') return 'er'
if (item.variant === 'warning') return 'wn'
return iconTone(item)
}
/** Полоса KPI: одна рамка, иконка слева, значение и чип. https://reui.io/preview/base/stats-12 */
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
@@ -63,11 +118,16 @@ function isSelected(item: KpiStatItem): boolean {
return Boolean(item.selected ?? item.active)
}
function resolveFooter(item: KpiStatItem): ReactNode {
function resolveFooter(item: KpiStatItem, tone: KpiTone): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === 'string') {
return (
<Badge variant="outline" size="sm">
<Badge variant={BADGE[tone]} size="sm" className="max-w-full whitespace-nowrap">
{GLYPH[tone] ? (
<span aria-hidden className="text-[0.55rem]">
{GLYPH[tone]}
</span>
) : null}
{item.hint}
</Badge>
)
@@ -77,49 +137,43 @@ function resolveFooter(item: KpiStatItem): ReactNode {
}
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const footer = resolveFooter(item)
const valueVariant = item.variant ?? 'default'
const tone = iconTone(item)
const status = statusTone(item)
const footer = resolveFooter(item, status)
return (
<div className="relative z-10 flex h-full items-start gap-3">
<div className="flex min-w-0 items-center gap-3">
{item.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[tone])}>
{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>
<div
className={cn(
'text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{item.value}
<div className="flex min-w-0 flex-col gap-1">
<div className="text-muted-foreground truncate text-[13px]">{item.label}</div>
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[status])}>
{item.value}
</span>
{footer}
</div>
</div>
</div>
)
}
function panelClassName(item: KpiStatItem, className?: string) {
function panelClassName(item: KpiStatItem, className?: string, flush = false) {
const onActivate = resolveActivate(item)
const clickable = Boolean(item.to || onActivate)
const selected = isSelected(item)
return cn(
'relative isolate flex h-full 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',
flush
? 'bg-card flex h-full min-w-0 items-center'
: 'relative isolate flex h-full min-w-0 flex-col',
clickable && 'hover:bg-muted/40 cursor-pointer transition-colors',
!flush && clickable && 'focus-within:ring-ring focus-within:ring-2',
selected && (flush ? 'bg-accent/70' : 'ring-primary/30 bg-muted/30 ring-1'),
className,
)
}
@@ -128,28 +182,31 @@ function panelClassName(item: KpiStatItem, className?: string) {
export function KpiStatCardTile({
item,
embedded = false,
flush = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
flush?: boolean
className?: string
}) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item, className)
const panelClass = panelClassName(item, className, flush)
const Shell = flush ? 'div' : FramePanel
let panel: ReactNode
if (item.to) {
panel = (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<Shell className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none w-full">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
</Shell>
)
} else if (onActivate) {
panel = (
<FramePanel
<Shell
className={panelClass}
onClick={onActivate}
role="button"
@@ -157,16 +214,18 @@ export function KpiStatCardTile({
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
</Shell>
)
} else {
panel = (
<FramePanel className={panelClass}>
<Shell className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
</Shell>
)
}
if (flush) return panel
if (embedded) {
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
}
@@ -187,23 +246,26 @@ export function KpiStatCard({
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
}
function statGridClass(count: number) {
return COLS[Math.min(Math.max(count, 1), 4)] ?? COLS[4]
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<Frame className="@container w-full">
<div className={cn('grid gap-2', kpiCols(count))}>
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex items-start gap-3">
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4.5 w-14 rounded-full" />
<Frame spacing="sm" className="w-full min-w-0">
<FramePanel className="overflow-hidden p-0">
<div className={cn('bg-border grid gap-px', statGridClass(count))}>
{Array.from({ length: count }).map((_, index) => (
<div key={index} className="bg-card flex items-center gap-3 px-[18px] py-3.5">
<Skeleton className="size-10 rounded-lg" />
<div className="flex flex-col gap-1.5">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="h-7 w-12" />
</div>
<Skeleton className="h-7 w-16" />
</div>
</FramePanel>
))}
</div>
))}
</div>
</FramePanel>
</Frame>
)
}
@@ -223,44 +285,10 @@ interface KpiStatGridProps {
'aria-label'?: string
}
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item)
if (item.to) {
return (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
}
if (onActivate) {
return (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
/**
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
* KPI grid from stats-12: one Frame per metric, icon tile, value, label, badge.
* Preview: https://reui.io/preview/base/stats-12
* Docs: https://reui.io/blocks/application/stats/stats-12
*/
export function KpiStatGrid({
items,
@@ -270,7 +298,6 @@ export function KpiStatGrid({
emptyIcon,
className,
skeletonCount = 4,
embedded = false,
'aria-label': ariaLabel,
}: KpiStatGridProps) {
if (isLoading) {
@@ -292,25 +319,21 @@ export function KpiStatGrid({
)
}
if (embedded) {
return (
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
return (
<Frame spacing="sm" aria-label={ariaLabel} className={cn('w-full min-w-0', className)}>
<FramePanel className="overflow-hidden p-0">
<div className={cn('bg-border grid gap-px', statGridClass(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
<div key={kpiStatItemKey(item, index)} className="bg-card min-w-0">
<KpiStatCardTile
item={item}
flush
className="bg-card px-[18px] py-3.5 shadow-none ring-0"
/>
</div>
))}
</div>
</section>
)
}
return (
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
))}
</div>
</FramePanel>
</Frame>
)
}
@@ -11,7 +11,6 @@ interface OpsDashboardProps {
charts: ReactNode
queue: ReactNode
queueTitle?: string
queueDescription?: string
isLoading?: boolean
}
@@ -48,7 +47,6 @@ export function OpsDashboard({
charts,
queue,
queueTitle = 'Требуют внимания',
queueDescription = 'Проблемы health-check, истекающие сертификаты и домены без группы',
isLoading = false,
}: OpsDashboardProps) {
if (isLoading) {
@@ -63,22 +61,11 @@ export function OpsDashboard({
{afterKpi}
<section
aria-label="Аналитика"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
>
<section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
{charts}
</section>
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-2">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
{queueDescription ? (
<p className="text-muted-foreground max-w-prose text-sm">
{queueDescription}
</p>
) : null}
</div>
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
{queue}
</section>
</div>
@@ -8,9 +8,7 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@cfdm/ui/lib/utils'
import { kpiCols } from './kpi-cols'
export interface QuickActionItem {
id: string
@@ -20,6 +18,7 @@ export interface QuickActionItem {
search?: Record<string, unknown>
icon?: ReactNode
iconClassName?: string
badgeVariant?: 'info-light' | 'warning-light'
}
interface QuickActionGridProps {
@@ -29,39 +28,10 @@ interface QuickActionGridProps {
className?: string
}
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{action.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">
<span className="text-foreground text-sm font-medium">{action.title}</span>
<Badge variant="outline" size="sm" className="shrink-0">
Перейти
</Badge>
</div>
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{action.description}
</p>
</div>
</div>
)
}
/**
* KPI-like quick actions strip (horizontal Frame tiles).
* Preview: https://reui.io/preview/base/stats-12
* Quick links from card-12, with the artboard title, description and «Перейти».
* Preview: https://reui.io/preview/base/card-12
* Docs: https://reui.io/blocks/application/card/card-12
*/
export function QuickActionGrid({
actions,
@@ -72,28 +42,37 @@ export function QuickActionGrid({
if (actions.length === 0) return null
return (
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
{(title || description) && (
<Frame className={cn('w-full', className)}>
{title || description ? (
<FrameHeader>
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
)}
<div className={cn('grid gap-2', kpiCols(actions.length))}>
) : null}
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
{actions.map((action) => (
<FramePanel
<Link
key={action.id}
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
to={action.to}
search={action.search}
aria-label={`${action.title}: ${action.description}`}
className="group/card focus-visible:ring-ring/50 hover:[&_svg]:text-primary block h-full min-w-0 cursor-pointer rounded-(--frame-radius) outline-none focus-visible:ring-[3px] [&_svg]:size-5 [&_svg]:opacity-60 hover:[&_svg]:opacity-100"
>
<Link
to={action.to}
search={action.search}
className="focus-visible:outline-none"
aria-label={`${action.title}: ${action.description}`}
>
<QuickActionBody action={action} />
</Link>
</FramePanel>
<FramePanel className="flex h-full flex-col gap-3 p-3.5">
<span className={cn('[&_svg]:size-5', action.iconClassName)} aria-hidden="true">
{action.icon}
</span>
<div className="flex items-start justify-between gap-2">
<h3 className="group-hover/card:text-primary text-sm leading-relaxed transition-colors">
{action.title}
</h3>
<Badge variant={action.badgeVariant ?? 'info-light'} size="sm">
Перейти
</Badge>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">{action.description}</p>
</FramePanel>
</Link>
))}
</div>
</Frame>
@@ -8,12 +8,21 @@ import {
type PaginationState,
type RowSelectionState,
type SortingState,
type VisibilityState,
} from '@tanstack/react-table'
import { CircleAlertIcon, FilterIcon, FunnelXIcon } from 'lucide-react'
import {
CircleAlertIcon,
Columns3Icon,
FilterIcon,
FunnelXIcon,
SearchIcon,
type LucideIcon,
} from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
@@ -31,6 +40,7 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { Separator } from '@cfdm/ui/components/separator'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import {
@@ -67,7 +77,27 @@ export interface ResourcePageProps<T extends object> {
error?: Error | null
onRetry?: () => void
primaryAction?: ReactNode
emptyState?: { title: string; description?: string; action?: ReactNode }
emptyState?: {
title: string
description?: string
action?: ReactNode
icon?: LucideIcon
}
/** Пустой результат фильтров или вкладки. Preview: https://reui.io/preview/base/empty-state-12 */
filteredEmptyState?: {
title: string
description?: string
resetLabel?: string
icon?: LucideIcon
}
onFilteredEmptyReset?: () => void
/** Ошибка загрузки через EmptyState вместо общего Alert. */
errorState?: {
title: string
description?: string
action?: ReactNode
icon?: LucideIcon
}
pageSize?: number
enableRowSelection?: boolean
selectionToolbar?: (ctx: {
@@ -119,6 +149,9 @@ export function ResourcePage<T extends object>({
onRetry,
primaryAction,
emptyState,
filteredEmptyState,
onFilteredEmptyReset,
errorState,
pageSize = 10,
enableRowSelection = false,
selectionToolbar,
@@ -129,6 +162,8 @@ export function ResourcePage<T extends object>({
const activeTab = controlledTab ?? internalTab
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [query, setQuery] = useState('')
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
@@ -143,11 +178,15 @@ export function ResourcePage<T extends object>({
const filteredData = useMemo(() => {
let result = applyFiltersToData(data, filters, getFilterFieldValue)
const needle = query.trim().toLowerCase()
if (needle) {
result = result.filter((item) => JSON.stringify(item).toLowerCase().includes(needle))
}
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
result = result.filter((item) => tabFilter(item, activeTab))
}
return result
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
}, [data, filters, getFilterFieldValue, query, tabs, tabFilter, activeTab])
const tabCounts = useMemo(() => {
if (!tabs?.length || !tabFilter) return {}
@@ -177,9 +216,10 @@ export function ResourcePage<T extends object>({
data: filteredData,
columns,
getRowId,
state: { sorting, rowSelection, pagination },
state: { sorting, columnVisibility, rowSelection, pagination },
enableRowSelection,
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
@@ -224,6 +264,25 @@ export function ResourcePage<T extends object>({
}
if (isError) {
if (errorState) {
return (
<Frame dense variant="default" spacing="sm" className="w-full">
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-stretch justify-center p-0">
<EmptyState
icon={errorState.icon ?? CircleAlertIcon}
title={errorState.title}
description={
errorState.description ??
error?.message ??
'Не удалось загрузить данные'
}
action={errorState.action}
/>
</FramePanel>
</Frame>
)
}
return (
<Alert variant="destructive">
<CircleAlertIcon />
@@ -262,6 +321,7 @@ export function ResourcePage<T extends object>({
) : null}
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-stretch justify-center p-0">
<EmptyState
icon={emptyState.icon}
title={emptyState.title}
description={emptyState.description}
action={emptyState.action}
@@ -271,7 +331,30 @@ export function ResourcePage<T extends object>({
)
}
const emptyMessage = 'Нет записей по выбранным фильтрам.'
const emptyMessage = filteredEmptyState ? (
<EmptyState
className="text-foreground"
icon={filteredEmptyState.icon}
title={filteredEmptyState.title}
description={filteredEmptyState.description}
action={
filteredEmptyState.resetLabel ? (
<Button
type="button"
variant="outline"
onClick={() => {
handleClear()
onFilteredEmptyReset?.()
}}
>
{filteredEmptyState.resetLabel}
</Button>
) : null
}
/>
) : (
'Нет записей по выбранным фильтрам.'
)
return (
<div className="w-full">
@@ -338,19 +421,46 @@ export function ResourcePage<T extends object>({
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
}
/>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<div className="relative w-full max-w-xs">
<SearchIcon
className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
value={query}
onChange={(event) => {
setQuery(event.target.value)
resetPagination()
}}
aria-label="Поиск"
placeholder="Поиск"
className="pl-8"
/>
</div>
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
}
/>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<DataGridColumnVisibility
table={table}
trigger={
<Button type="button" variant="outline" aria-label="Колонки">
<Columns3Icon className="size-4" aria-hidden />
Колонки
</Button>
}
/>
{toolbarExtra}
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
@@ -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>
)
}
@@ -1,104 +1,67 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { HeartPulseIcon, PaletteIcon, SettingsIcon } from 'lucide-react'
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
import { cn } from '@cfdm/ui/lib/utils'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
export interface SettingsTabConfig {
id: string
to: string
label: string
icon?: ReactNode
const SETTINGS_TABS = [
{ to: '/settings/appearance', label: 'Внешний вид' },
{ to: '/settings/health', label: 'Health-check' },
{ to: '/settings/integrations', label: 'Интеграции' },
] as const
type SettingsTab = (typeof SETTINGS_TABS)[number]['to']
function activeTab(pathname: string): SettingsTab {
const match = SETTINGS_TABS.find(
(tab) => pathname === tab.to || pathname.startsWith(`${tab.to}/`),
)
return match?.to ?? '/settings/appearance'
}
const DEFAULT_TABS: SettingsTabConfig[] = [
{
id: 'appearance',
to: '/settings/appearance',
label: 'Внешний вид',
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
},
{
id: 'health',
to: '/settings/health',
label: 'Health-check',
icon: <HeartPulseIcon className="size-4" aria-hidden="true" />,
},
{
id: 'integrations',
to: '/settings/integrations',
label: 'Интеграции',
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
},
]
interface SettingsShellProps {
title?: string
description?: string
tabs?: SettingsTabConfig[]
}
export function SettingsShell({
title = 'Настройки',
description = 'Внешний вид, health-check и интеграции',
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
/**
* Настройки CfSettings: вкладки внутри страницы, не боковой rail.
* Строки секций — settings-3 SettingField / SettingRow.
* Preview: https://reui.io/preview/base/settings-3
* Preview: https://reui.io/preview/base/settings-16
* Docs: https://reui.io/blocks/application/settings
*/
export function SettingsShell() {
const navigate = useNavigate()
const pathname = useRouterState({ select: (state) => state.location.pathname })
const value = activeTab(pathname)
return (
<PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<PageHeader title={title} description={description} />
<div
className={cn(
'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
{tabs.length > 1 ? (
<nav
aria-label="Разделы настроек"
className={cn(
'flex gap-1',
isMobile
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
: 'w-44 shrink-0 flex-col',
)}
<PageHeader
title="Настройки"
description="Интерфейс, health-check и интеграции"
/>
<Tabs
value={value}
onValueChange={(next) => {
const tab = SETTINGS_TABS.find((item) => item.to === next)
if (!tab) return
void navigate({ to: tab.to })
}}
>
<TabsList variant="line" className="w-full justify-start gap-5">
{SETTINGS_TABS.map((tab) => (
<TabsTrigger
key={tab.to}
value={tab.to}
nativeButton={false}
render={<Link to={tab.to} />}
className="text-muted-foreground hover:text-foreground h-auto px-0 pb-3"
>
{tabs.map((tab) => {
const isActive = pathname.startsWith(tab.to)
return (
<Link
key={tab.id}
to={tab.to}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0',
!isMobile && 'w-full',
isActive
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
{tab.icon}
{tab.label}
</Link>
)
})}
</nav>
) : null}
<div className="min-w-0 flex-1">
<Outlet />
</div>
</div>
</div>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Outlet />
</PageShell>
)
}
export type { SettingsTab as SettingsTabConfig }
@@ -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)
})
})
+206 -79
View File
@@ -1,6 +1,6 @@
import { useEffect, useId, useMemo, useState } from 'react'
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'
@@ -22,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 {
@@ -36,6 +37,7 @@ export interface UptimeProbe {
ok: boolean
latency_ms: number | null
checked_at: string
provider?: HealthCheckProvider
}
export type UptimePeriodKey = '5D' | '2W' | '1M'
@@ -46,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))
@@ -113,7 +187,7 @@ function UptimeDelta({ delta }: { delta: number }) {
}
export function probeUptimePercent(items: UptimeProbe[]): number | null {
return uptimePercent(toSeries(items))
return uptimePercent(toAlignedSeries(items).points)
}
export function lastProbeLatency(items: UptimeProbe[]): number | null {
@@ -129,6 +203,12 @@ function formatUptime(value: number | null): string {
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 {
items: UptimeProbe[]
isLoading?: boolean
@@ -151,30 +231,45 @@ export function UptimeChart({
}: UptimeChartProps) {
const gradientId = useId().replace(/:/g, '')
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
const [tooltipPortal, setTooltipPortal] = useState<HTMLElement | null>(null)
const [hovered, setHovered] = useState<AlignedChartPoint | null>(null)
const period = periodProp ?? internalPeriod
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
useEffect(() => {
setTooltipPortal(document.body)
}, [])
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">
<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">
@@ -242,48 +337,78 @@ export function UptimeChart({
</div>
</div>
{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-visible 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: 16, left: 8, right: 8, bottom: 4 }}
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 />
<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 }}
portal={tooltipPortal ?? undefined}
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
content={
<ChartTooltipContent
formatter={(value, _name, item) => {
const point = item.payload as ChartPoint | undefined
const ping = Number(value)
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">
{point?.ok === false ? 'Down' : 'Пинг'}
{ok === false ? `${label} · Down` : `Пинг · ${label}`}
</span>
<span className="text-foreground font-mono font-medium tabular-nums">
{Number.isFinite(ping) ? `${ping} мс` : '—'}
{formatPing(value, ok)}
</span>
</div>
)
@@ -291,42 +416,44 @@ export function UptimeChart({
/>
}
/>
<Area
dataKey="latency"
name="latency"
type="natural"
fill={`url(#${gradientId})`}
stroke="var(--color-latency)"
strokeWidth={2}
isAnimationActive={false}
dot={(dotProps) => {
const { cx, cy, payload, index } = dotProps
if (cx == null || cy == null) return <g key={index} />
const point = payload as ChartPoint | undefined
return (
<circle
key={index}
cx={cx}
cy={cy}
r={4}
fill={
point?.ok
? 'var(--color-latency)'
: 'var(--destructive)'
}
stroke="var(--background)"
strokeWidth={2}
pointerEvents="none"
/>
)
}}
activeDot={{
r: 6,
stroke: 'var(--background)',
strokeWidth: 2,
}}
/>
</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>
@@ -26,7 +26,7 @@ function DataGridColumnVisibility<TData>({
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Toggle Columns
Колонки
</DropdownMenuLabel>
{table
.getAllColumns()
+33 -28
View File
@@ -3,28 +3,30 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
/**
* CSS variable architecture for FramePanel theming:
*
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
* border-(--frame-panel-border-color). This means:
*
* - variant="inverse" overrides those vars on Frame → all panels pick it up
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
* :not() or !important needed
* Frame sets --frame-panel-bg and --frame-panel-border-color; FramePanel reads
* them back as bg-(--frame-panel-bg) and border-(--frame-panel-border-color).
* So variant="inverse" re-points every panel from one place, and a consumer's
* own bg-* on a panel still wins on source order, with no :not() and no `!`.
*/
const frameVariants = cva(
[
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
// Each rung is the radius that style's own shadcn Card resolves through, so
// a Frame and a Card side by side agree. lyra/sera are 0px, NOT
// var(--radius-none): no such token exists, and it only reached 0 by being
// invalid, which also left --frame-radius empty for anything reading it.
"[--frame-radius:var(--radius-xl)]",
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
// Default panel token values — overridden per-variant below
"[--frame-panel-px:calc(var(--frame-panel-px-base)+var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)+var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)+var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)+var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)+var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)+var(--frame-panel-footer-py-adjust))]",
// Luma alone re-times the frame: wider gap and padding, roomier bars.
"",
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
// Concentric: the panel nests inside the frame's corner rather than copying
// it. It is inset by the 1px border plus --frame-px, so subtracting exactly
// that keeps the two arcs parallel. `ghost` drops the border term (no
// border), `dense` pins it to the frame radius (panels sit flush).
"[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px)-1px)]",
],
{
variants: {
@@ -32,14 +34,18 @@ const frameVariants = cva(
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
ghost: "",
ghost: "[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px))]",
},
// Bars read as chrome, not a second content block: py runs 0.5/1.5/2/2.5
// against a body py of 2/3.5/4/5, while px stays level with the body so
// header, content and footer left-align. xs floors at 0.5 (2px), below
// which it stops reading as padding. No style-*.css overrides these.
spacing: {
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
default:
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(2)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(2.5)]",
},
stacked: {
true: [
@@ -55,8 +61,9 @@ const frameVariants = cva(
],
},
dense: {
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
// Parent selectors, not CSS vars: these are positional. Panels are
// pulled flush (-mx-px), so corners align with the frame's own radius.
true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
false: "",
},
},
@@ -98,13 +105,11 @@ function FramePanel({
return (
<div
className={cn(
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
// via className overrides these by Tailwind source order - no ! needed.
"relative overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
"relative overflow-hidden rounded-(--frame-panel-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
/* `fit` sizes the panel to its content; otherwise it grows to fill
the frame. */
!fit && "grow",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)-1px)] before:shadow-black/5",
"dark:bg-clip-border dark:before:shadow-white/5",
"px-(--frame-panel-px) py-(--frame-panel-py)",
className
+63 -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,9 +13,17 @@ import type {
ServiceView,
UpdateServiceConfigInput,
} from '@/lib/schemas'
import { parseHealthProviders } from '@cfdm/shared'
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,
@@ -30,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,
@@ -47,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'
@@ -101,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: 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 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,
}
}
@@ -219,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)
@@ -243,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
@@ -257,9 +134,9 @@ 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)
}
@@ -270,136 +147,27 @@ export function ServiceEditSheet({
[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) {
@@ -443,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}>
@@ -450,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>
@@ -498,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">
@@ -0,0 +1,115 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { EmptyState } from '@/components/empty-state'
import { QueryState } from '@/components/query-state'
import {
ServiceDetailGrid,
type ServiceFqdnRow,
} from '@/components/services/service-detail-grid'
import { useServiceDetailActions } from '@/components/services/service-detail-context'
import { api } from '@/lib/api-client'
import type { ServiceView } from '@/lib/schemas'
import {
deleteServiceNode,
invalidateServiceDetail,
serviceHealthLogQueryOptions,
serviceNodesQueryOptions,
serviceViewQueryOptions,
} from '@/queries'
interface ServiceNodeRow {
id: number
address: string
protocol: string
port: number | null
health_status: string
weight: number
priority: number
}
export function ServiceAssetSection({
serviceId,
section,
}: {
serviceId: string
section: 'ip' | 'fqdn' | 'nodes' | 'ssl'
}) {
const id = Number(serviceId)
const queryClient = useQueryClient()
const actions = useServiceDetailActions()
const viewQuery = useQuery(serviceViewQueryOptions(id))
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
const [togglingIp, setTogglingIp] = useState<string | null>(null)
const service = viewQuery.data
const probes = logQuery.data?.items ?? []
const nodes = Array.isArray(nodesQuery.data)
? (nodesQuery.data as ServiceNodeRow[])
: []
const toggleIpMutation = useMutation({
mutationFn: ({ ip, enabled }: { ip: string; enabled: boolean }) =>
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, { ip, enabled }),
onSuccess: async (_data, { enabled }) => {
await invalidateServiceDetail(queryClient, id)
toast.success(
enabled
? 'IP включён и добавлен в DNS-привязки'
: 'IP выключен и снят с DNS-привязок',
)
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось переключить IP')
},
onSettled: () => setTogglingIp(null),
})
const deleteNodeMut = useMutation({
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
onSuccess: async () => {
toast.success('Нода удалена')
await invalidateServiceDetail(queryClient, id)
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить ноду')
},
})
return (
<QueryState
isLoading={viewQuery.isLoading}
isError={viewQuery.isError}
error={viewQuery.error}
onRetry={() => {
void viewQuery.refetch()
}}
>
{!service ? (
<EmptyState
title="Сервис не найден"
description="Вернитесь в каталог и выберите сервис."
/>
) : (
<ServiceDetailGrid
service={service}
nodes={nodes}
probes={probes}
section={section}
togglingIp={togglingIp}
isLoading={nodesQuery.isLoading}
onToggleIp={(ip, enabled) => {
setTogglingIp(ip)
toggleIpMutation.mutate({ ip, enabled })
}}
onChangeIp={(row: ServiceFqdnRow) => actions.openChangeIp(row)}
onChangeDomain={() => actions.openChangeDomain()}
onAddNode={actions.openAddNode}
onDeleteNode={(nodeId) => deleteNodeMut.mutate(nodeId)}
/>
)}
</QueryState>
)
}
@@ -0,0 +1,29 @@
import { createContext, createElement, useContext, type ReactNode } from 'react'
import type { ServiceFqdnRow } from '@/components/services/service-detail-grid'
export interface ServiceDetailActions {
openAddNode: () => void
openChangeIp: (row: ServiceFqdnRow) => void
openChangeDomain: () => void
}
const ServiceDetailActionsContext = createContext<ServiceDetailActions | null>(null)
export function ServiceDetailActionsProvider({
value,
children,
}: {
value: ServiceDetailActions
children: ReactNode
}) {
return createElement(ServiceDetailActionsContext.Provider, { value }, children)
}
export function useServiceDetailActions() {
const value = useContext(ServiceDetailActionsContext)
if (!value) {
throw new Error('Действия сервиса доступны только внутри карточки сервиса')
}
return value
}
@@ -18,6 +18,11 @@ 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 {
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'
@@ -99,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 ?? {}),
@@ -113,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,
}
})
}
@@ -151,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({
@@ -199,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
@@ -206,11 +228,14 @@ interface ServiceDetailGridProps {
onAddNode: () => void
onDeleteNode: (nodeId: number) => void
isLoading?: boolean
/** Фиксирует одну секцию без внутренних вкладок IP / FQDN / Ноды / SSL. */
section?: 'ip' | 'fqdn' | 'nodes' | 'ssl'
}
export function ServiceDetailGrid({
service,
nodes,
probes = [],
togglingIp,
onToggleIp,
onChangeIp,
@@ -218,8 +243,10 @@ export function ServiceDetailGrid({
onAddNode,
onDeleteNode,
isLoading = false,
section,
}: ServiceDetailGridProps) {
const [tab, setTab] = useState<(typeof TABS)[number]['id']>('ip')
const [tabState, setTab] = useState<(typeof TABS)[number]['id']>(section ?? 'ip')
const tab = section ?? tabState
const [ipFilters, setIpFilters] = useState<Filter[]>(() => [
createFilter('ip', 'contains', ['']),
createFilter('status', 'is', ['']),
@@ -283,9 +310,9 @@ export function ServiceDetailGrid({
},
})
const ipRows = useMemo(() => buildIpRows(service), [service])
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) => ({
@@ -648,17 +675,34 @@ export function ServiceDetailGrid({
[patchCertMonitoring],
)
const sharedTabs = {
tabs,
activeTab: tab,
onTabChange: (id: string) => setTab(id as typeof tab),
}
const heading =
section === 'nodes'
? { title: 'Ноды', description: 'Адреса пула и состояние проверок' }
: section === 'fqdn'
? { title: 'Поддомены', description: 'FQDN, привязанные к сервису' }
: section === 'ip'
? {
title: 'Маршрутизация',
description: 'Веса, приоритеты и включённые адреса',
}
: {
title: 'Активы сервиса',
description: 'IP, FQDN и ноды этого сервиса',
}
const sharedTabs = section
? {}
: {
tabs,
activeTab: tab,
onTabChange: (id: string) => setTab(id as typeof tab),
}
if (tab === 'fqdn') {
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN и ноды этого сервиса"
title={heading.title}
description={heading.description}
{...sharedTabs}
filterFields={fqdnFilterFields}
filters={fqdnFilters}
@@ -688,8 +732,8 @@ export function ServiceDetailGrid({
if (tab === 'nodes') {
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN и ноды этого сервиса"
title={heading.title}
description={heading.description}
{...sharedTabs}
filterFields={nodeFilterFields}
filters={nodeFilters}
@@ -710,10 +754,25 @@ export function ServiceDetailGrid({
getRowId={(row) => row.id}
isLoading={isLoading}
primaryAction={
<Button size="sm" onClick={onAddNode}>
<PlusIcon className="size-4" aria-hidden />
Добавить ноду
</Button>
section ? undefined : (
<Button size="sm" onClick={onAddNode}>
<PlusIcon className="size-4" aria-hidden />
Добавить ноду
</Button>
)
}
emptyState={
section === 'nodes'
? {
title: 'Нет нод',
description: 'Добавьте ноду в пул сервиса.',
action: (
<Button type="button" variant="outline" onClick={onAddNode}>
Добавить ноду
</Button>
),
}
: undefined
}
/>
)
@@ -722,8 +781,12 @@ export function ServiceDetailGrid({
if (tab === 'ssl') {
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN, ноды и SSL этого сервиса"
title={section === 'ssl' ? 'SSL' : 'Активы сервиса'}
description={
section === 'ssl'
? 'Сертификаты привязанных FQDN'
: 'IP, FQDN, ноды и SSL этого сервиса'
}
{...sharedTabs}
filterFields={sslFilterFields}
filters={sslFilters}
@@ -767,8 +830,8 @@ export function ServiceDetailGrid({
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN и ноды этого сервиса"
title={heading.title}
description={heading.description}
{...sharedTabs}
filterFields={ipFilterFields}
filters={ipFilters}
@@ -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}
@@ -0,0 +1,142 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ActivityIcon, UnplugIcon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import { FailoverTimeline } from '@/components/failover-timeline'
import { QueryState } from '@/components/query-state'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { ServiceHealthMonitor } from '@/components/reui-kit'
import {
enabledHealthProviders,
providerHealthStatuses,
type HealthLogProbe,
} from '@/lib/health-log'
import type { FailoverHistoryItem } from '@/lib/failover-events'
import type { FailoverLogEntry } from '@/lib/schemas'
import {
serviceFailoverLogQueryOptions,
serviceHealthLogQueryOptions,
serviceViewQueryOptions,
} from '@/queries'
function failoverHistory(items: readonly FailoverLogEntry[]): FailoverHistoryItem[] {
return items.map((item) => ({
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 hasLatencySeries(items: readonly HealthLogProbe[]) {
return items.some((item) => item.latency_ms != null && item.checked_at.length > 0)
}
/**
* Health карточки сервиса: график задержки и timeline failover
* только по строкам журналов, без синтетических точек.
*
* Preview: https://reui.io/preview/base/chart-17
* Preview: https://reui.io/preview/base/components/c-timeline-10
* Docs: https://reui.io/docs/components/base/timeline
* Docs: https://reui.io/docs/components/base/frame
*/
export function ServiceHealthSection({ serviceId }: { serviceId: string }) {
const id = Number(serviceId)
const viewQuery = useQuery(serviceViewQueryOptions(id))
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
const failoverQuery = useQuery(serviceFailoverLogQueryOptions(id))
const probes = useMemo(() => logQuery.data?.items ?? [], [logQuery.data?.items])
const failoverItems = useMemo(
() => failoverQuery.data?.items ?? [],
[failoverQuery.data?.items],
)
const history = useMemo(() => failoverHistory(failoverItems), [failoverItems])
const showLatency = hasLatencySeries(probes)
const enabledProviders = useMemo(
() => enabledHealthProviders(viewQuery.data?.domains ?? []),
[viewQuery.data?.domains],
)
const providerStatuses = useMemo(
() => providerHealthStatuses(probes, enabledProviders),
[probes, enabledProviders],
)
const isLoading = logQuery.isLoading || failoverQuery.isLoading
const isError = logQuery.isError || failoverQuery.isError
const error = logQuery.error ?? failoverQuery.error
return (
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => {
void logQuery.refetch()
void failoverQuery.refetch()
}}
>
<div className="flex flex-col gap-4">
{showLatency ? (
<ServiceHealthMonitor
items={probes}
enabledProviders={enabledProviders}
statuses={providerStatuses}
isLoading={logQuery.isFetching}
/>
) : (
<Frame className="w-full">
<FrameHeader>
<FrameTitle>Задержка и ошибки</FrameTitle>
<FrameDescription>Проба health-check · последние 24 часа</FrameDescription>
</FrameHeader>
<FramePanel>
<EmptyState
icon={ActivityIcon}
title="Нет ряда за 24 часа"
description="Журнал health-log не содержит замеров задержки. Точки не подставляются."
stackedIcon={false}
centered={false}
/>
</FramePanel>
</Frame>
)}
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>История failover</FrameTitle>
<FrameDescription>Переключения между нодами</FrameDescription>
</FrameHeader>
<FramePanel>
{history.length > 0 ? (
<FailoverTimeline events={[]} history={history} />
) : (
<EmptyState
icon={UnplugIcon}
title="Нет событий failover"
description="Журнал failover пуст — timeline строится только по его записям."
stackedIcon={false}
centered={false}
/>
)}
</FramePanel>
</Frame>
</div>
</QueryState>
)
}
@@ -0,0 +1,67 @@
import { useQuery } from '@tanstack/react-query'
import { SettingRow } from '@/components/setting-row'
import { ServiceAssetSection } from '@/components/services/service-asset-section'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import type { ServiceView } from '@/lib/schemas'
import { serviceOverviewQueryOptions, serviceViewQueryOptions } from '@/queries'
const LB_LABEL: Record<ServiceView['lb_mode'], string> = {
round_robin: 'Round robin',
failover: 'Failover',
weighted: 'По весам',
}
interface OverviewRouting {
routing_strategy?: string
active_addresses?: string[]
}
/**
* Preview: https://reui.io/preview/base/settings-16
* Docs: https://reui.io/docs/components/base/frame
*/
export function ServiceRoutingSection({ serviceId }: { serviceId: string }) {
const id = Number(serviceId)
const viewQuery = useQuery(serviceViewQueryOptions(id))
const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
const service = viewQuery.data
const overview = overviewQuery.data as OverviewRouting | undefined
const active = overview?.active_addresses ?? service?.active_ips ?? []
return (
<div className="flex flex-col gap-4">
{service ? (
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Маршрутизация</FrameTitle>
<FrameDescription>Режим балансировки и активные адреса</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<SettingRow title="Режим">
<Badge variant="outline" size="sm">
{LB_LABEL[service.lb_mode]}
</Badge>
</SettingRow>
<SettingRow title="Стратегия">
<span className="text-sm">
{overview?.routing_strategy ?? LB_LABEL[service.lb_mode]}
</span>
</SettingRow>
<SettingRow title="Активные адреса" last>
<span className="text-sm font-mono">{active.join(', ') || '—'}</span>
</SettingRow>
</FramePanel>
</Frame>
) : null}
<ServiceAssetSection serviceId={serviceId} section="ip" />
</div>
)
}
@@ -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}
@@ -249,11 +249,11 @@ export function ServicesGroupedCatalog({
return (
<EmptyState
icon={ServerIcon}
title="Сервисы не найдены"
title="Нет сервисов"
description={
domainLabel
? `Нет сервисов с привязками к ${domainLabel}`
: 'Сначала создайте группу, затем добавьте сервисы.'
: 'Создайте сервис и добавьте поддомен с нодами.'
}
action={emptyAction}
/>
+6 -1
View File
@@ -1,7 +1,12 @@
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import type { ReactNode } from 'react'
import { useEffect, type ReactNode } from 'react'
import { applyAppearance, readContrast, readDensity } from '@/lib/appearance-mode'
export function ThemeProvider({ children }: { children: ReactNode }) {
useEffect(() => {
applyAppearance(readContrast(), readDensity())
}, [])
return (
<NextThemesProvider
attribute="class"
@@ -0,0 +1,68 @@
import { createContext, useContext, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { deriveSystemAttention, type SystemAttention } from '@/lib/system-attention'
import {
certificatesQueryOptions,
certSummaryQueryOptions,
domainsListQueryOptions,
notificationLogQueryOptions,
serviceGroupsQueryOptions,
} from '@/queries'
const POLL_MS = 30_000
type ReadyResponse = {
status: string
}
export const SystemAttentionContext = createContext<SystemAttention | null>(null)
export function useSystemAttentionData(): SystemAttention {
const readyQ = useQuery({
queryKey: ['ready'],
queryFn: async (): Promise<ReadyResponse> => {
const res = await fetch('/ready')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<ReadyResponse>
},
refetchInterval: POLL_MS,
})
const servicesQ = useQuery({ ...serviceGroupsQueryOptions(), refetchInterval: POLL_MS })
const certSummaryQ = useQuery({ ...certSummaryQueryOptions(), refetchInterval: POLL_MS })
const certificatesQ = useQuery({ ...certificatesQueryOptions(), refetchInterval: POLL_MS })
const domainsQ = useQuery({ ...domainsListQueryOptions(), refetchInterval: POLL_MS })
const notifyQ = useQuery({ ...notificationLogQueryOptions(20), refetchInterval: POLL_MS })
return useMemo(
() =>
deriveSystemAttention({
services: servicesQ.data,
certificates: certificatesQ.data,
certSummary: certSummaryQ.data,
notifications: notifyQ.data,
domains: domainsQ.data,
readyStatus: readyQ.data?.status,
readyIsSuccess: readyQ.isSuccess,
readyIsError: readyQ.isError,
}),
[
certificatesQ.data,
certSummaryQ.data,
domainsQ.data,
notifyQ.data,
readyQ.data?.status,
readyQ.isError,
readyQ.isSuccess,
servicesQ.data,
],
)
}
export function useSystemAttention() {
const attention = useContext(SystemAttentionContext)
if (!attention) {
throw new Error('useSystemAttention должен вызываться внутри SystemAttentionProvider')
}
return attention
}
+44
View File
@@ -0,0 +1,44 @@
const CONTRAST_KEY = 'cfdm.contrast'
const DENSITY_KEY = 'cfdm.density'
export type ContrastMode = 'standard' | 'high'
export type DensityMode = 'normal' | 'compact'
export function readContrast(): ContrastMode {
try {
return localStorage.getItem(CONTRAST_KEY) === 'high' ? 'high' : 'standard'
} catch {
return 'standard'
}
}
export function readDensity(): DensityMode {
try {
return localStorage.getItem(DENSITY_KEY) === 'compact' ? 'compact' : 'normal'
} catch {
return 'normal'
}
}
export function applyAppearance(contrast: ContrastMode, density: DensityMode) {
document.documentElement.dataset.contrast = contrast
document.documentElement.dataset.density = density
}
export function writeContrast(contrast: ContrastMode) {
try {
localStorage.setItem(CONTRAST_KEY, contrast)
} catch {
// хранилище недоступно
}
applyAppearance(contrast, readDensity())
}
export function writeDensity(density: DensityMode) {
try {
localStorage.setItem(DENSITY_KEY, density)
} catch {
// хранилище недоступно
}
applyAppearance(readContrast(), density)
}
+67
View File
@@ -0,0 +1,67 @@
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('keeps the service name on nested service tabs', () => {
expect(
getBreadcrumbs('/services/9/health', { '/services/9': '[IHOR] Москва' }),
).toEqual([
{ label: 'Сервисы', href: '/services' },
{ label: '[IHOR] Москва', href: '/services/9' },
{ label: 'Health', href: '/services/9/health' },
])
expect(getBreadcrumbs('/services/9/routing')).toEqual([
{ label: 'Сервисы', href: '/services' },
{ label: 'Сервис', href: '/services/9' },
{ label: 'Маршрутизация', href: '/services/9/routing' },
])
expect(getBreadcrumbs('/services/9/nodes')).toEqual([
{ label: 'Сервисы', href: '/services' },
{ label: 'Сервис', href: '/services/9' },
{ label: 'Ноды', href: '/services/9/nodes' },
])
expect(getBreadcrumbs('/services/9/subdomains')).toEqual([
{ label: 'Сервисы', href: '/services' },
{ label: 'Сервис', href: '/services/9' },
{ label: 'Поддомены', href: '/services/9/subdomains' },
])
})
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' },
])
})
})
+100
View File
@@ -0,0 +1,100 @@
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: '/' }]
}
const serviceMatch = path.match(
/^\/services\/(\d+)(?:\/(nodes|health|routing|subdomains))?$/,
)
if (serviceMatch) {
const servicePath = `/services/${serviceMatch[1]}`
const section = serviceMatch[2]
const sectionLabels: Record<string, string> = {
nodes: 'Ноды',
health: 'Health',
routing: 'Маршрутизация',
subdomains: 'Поддомены',
}
return [
{ label: 'Сервисы', href: '/services' },
{ label: dynamicLabels[servicePath] ?? 'Сервис', href: servicePath },
...(section
? [{ label: sectionLabels[section] ?? section, 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: '/' }]
}
+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))
}
+43
View File
@@ -0,0 +1,43 @@
import { QueryClient, QueryObserver } from '@tanstack/react-query'
import { afterEach, describe, expect, it } from 'vitest'
import { bindLiveRefresh, isLiveRefresh, setLiveRefresh } from './live-refresh'
describe('setLiveRefresh', () => {
afterEach(() => {
if (isLiveRefresh()) return
const client = new QueryClient()
setLiveRefresh(client, true)
client.clear()
})
it('гасит интервал и возвращает его, даже если наблюдатель ставит свой снова', () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const unbind = bindLiveRefresh(client)
const observer = new QueryObserver(client, {
queryKey: ['live-refresh'],
queryFn: async () => 'ok',
refetchInterval: 10_000,
})
const unsubscribe = observer.subscribe(() => {})
setLiveRefresh(client, false)
expect(observer.options.refetchInterval).toBe(false)
observer.setOptions({
queryKey: ['live-refresh'],
queryFn: async () => 'ok',
refetchInterval: 10_000,
})
expect(observer.options.refetchInterval).toBe(false)
setLiveRefresh(client, true)
expect(observer.options.refetchInterval).toBe(10_000)
unsubscribe()
unbind()
client.clear()
})
})
+70
View File
@@ -0,0 +1,70 @@
import type { QueryClient } from '@tanstack/react-query'
const STORAGE_KEY = 'cfdm.live-refresh'
type RefetchInterval = number | false | undefined | ((query: unknown) => number | false | undefined)
type IntervalObserver = {
options: { refetchInterval?: RefetchInterval }
setOptions: (options: IntervalObserver['options']) => void
}
const intendedInterval = new WeakMap<IntervalObserver, RefetchInterval>()
function readStoredLiveRefresh() {
try {
return localStorage.getItem(STORAGE_KEY) !== '0'
} catch {
return true
}
}
let liveEnabled = readStoredLiveRefresh()
export function isLiveRefresh() {
return liveEnabled
}
function asObserver(observer: object): IntervalObserver {
return observer as IntervalObserver
}
function remember(observer: IntervalObserver) {
if (!intendedInterval.has(observer)) {
intendedInterval.set(observer, observer.options.refetchInterval)
}
}
function syncObserver(observer: IntervalObserver, live: boolean) {
remember(observer)
const next = live ? intendedInterval.get(observer) : false
if (Object.is(observer.options.refetchInterval, next)) return
observer.setOptions({ ...observer.options, refetchInterval: next })
}
function applyLiveRefresh(queryClient: QueryClient, live: boolean) {
for (const query of queryClient.getQueryCache().getAll()) {
for (const observer of query.observers) {
syncObserver(asObserver(observer), live)
}
}
}
/** Пауза и возобновление уже заданных refetchInterval. Новые запросы без интервала не запускают опрос. */
export function setLiveRefresh(queryClient: QueryClient, enabled: boolean) {
liveEnabled = enabled
try {
localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0')
} catch {
// приватный режим без хранилища — переключатель живёт до перезагрузки
}
applyLiveRefresh(queryClient, enabled)
}
export function bindLiveRefresh(queryClient: QueryClient) {
applyLiveRefresh(queryClient, liveEnabled)
return queryClient.getQueryCache().subscribe((event) => {
if (event.type !== 'observerAdded' && event.type !== 'observerOptionsUpdated') return
syncObserver(asObserver(event.observer), liveEnabled)
})
}
+13
View File
@@ -84,6 +84,7 @@ export const serviceDomainBindingSchema = z
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,
@@ -125,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),
+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))
}
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import { deriveSystemAttention, isCertExpiringWithin } from './system-attention'
const NOW = Date.parse('2026-11-02T00:00:00.000Z')
function inDays(days: number) {
return new Date(NOW + days * 86_400_000).toISOString()
}
describe('deriveSystemAttention', () => {
it('считает Down и деградацию в группах и без группы', () => {
const attention = deriveSystemAttention({
now: NOW,
readyIsSuccess: true,
readyIsError: false,
readyStatus: 'ready',
services: {
groups: [
{
services: [
{ id: 1, name: 'Альфа', health_status: 'down' },
{ id: 2, name: 'Бета', health_status: 'up' },
],
},
],
ungrouped: [{ id: 3, name: 'Гамма', health_status: 'degraded' }],
},
})
expect(attention.servicesDown).toBe(1)
expect(attention.servicesDegraded).toBe(1)
expect(attention.incidents.map((item) => item.title)).toEqual(['Альфа', 'Гамма'])
expect(attention.incidents[0]).toMatchObject({
tone: 'destructive',
href: { to: '/services/$serviceId', serviceId: '1' },
})
})
it('берёт сертификаты с остатком не больше 14 дней, включая истёкшие', () => {
expect(isCertExpiringWithin(inDays(14), 14, NOW)).toBe(true)
expect(isCertExpiringWithin(inDays(14.01), 14, NOW)).toBe(false)
expect(isCertExpiringWithin(inDays(-1), 14, NOW)).toBe(true)
const attention = deriveSystemAttention({
now: NOW,
readyIsSuccess: false,
readyIsError: false,
certificates: [
{ id: 10, hostname: 'soon.example', expires_at: inDays(3), service_id: 7 },
{ id: 11, hostname: 'later.example', expires_at: inDays(20), service_id: 7 },
{ id: 12, hostname: 'dead.example', expires_at: inDays(-2), service_id: null },
{ id: 13, hostname: 'unknown.example', expires_at: null },
],
certSummary: [
['warning', 2],
['expired', 1],
['ok', 4],
],
notifications: [
{ kind: 'sync', title: 'Sync failed', message: 'timeout' },
{ kind: 'info', title: 'Готово', message: 'ok' },
],
domains: [{ group_id: null }, { group_id: 1 }],
})
expect(attention.certsExpiring).toBe(2)
expect(attention.certWarnings).toBe(3)
expect(attention.failedJobs).toBe(1)
expect(attention.ungroupedCount).toBe(1)
expect(attention.incidents.map((item) => item.title)).toEqual([
'dead.example',
'soon.example',
])
expect(attention.incidents[0]?.href).toEqual({ to: '/certificates' })
expect(attention.incidents[1]?.href).toEqual({
to: '/services/$serviceId',
serviceId: '7',
})
})
})
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff. Show more