feat(api): enhance health check and DNS management
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m9s
quality / api (push) Successful in 40s
CD / quality (push) Successful in 2m0s
CD / publish (push) Successful in 2m38s

- Added batch endpoint for health status queries to reduce the number of requests.
- Improved DNS record management with caching and invalidation mechanisms.
- Updated health check service to handle new configurations and improve performance.
- Refactored certificate service to utilize concurrency for checks, enhancing efficiency.
- Removed unused dependencies and optimized package configurations.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-03 16:16:41 +07:00
co-authored by Cursor
parent de3dbe8521
commit 1e9cc04a59
42 changed files with 6036 additions and 2836 deletions
@@ -0,0 +1,141 @@
---
name: "CFDM: план оптимизации"
overview: "Оптимизация CFDM (cloudflare-domain-manager) на скорость и минимальное ресурсопотребление: критические исправления API (утечка undici-агентов, N+1 на горячих путях, retention логов), фронтенда (code-splitting 1.7MB бандла, поллинг, debug-телеметрия) и инфраструктуры (turbo-кэш в CI, тюнинг SQLite/Docker). Все изменения сохраняют текущее поведение — только быстрее и дешевле."
todos: []
isProject: false
---
# CFDM — план оптимизации производительности
**Проект:** `C:\Users\shats\Dev\cloudflare-domain-manager` (pnpm-монорепо: Fastify 5 + better-sqlite3 API, React 19 + Vite 8 + TanStack web, Docker)
**Цель:** скорость работы и минимальное ресурсопотребление (CPU, память, диск, внешние CF API-вызовы), без изменения функциональности.
**Контекст:** стек — Node 22 / Fastify 5 / Drizzle + better-sqlite3 (синхронный!) / React 19 / Vite 8 / TanStack Router+Query / toad-scheduler. Развёртывание — один Docker-контейнер alpine.
---
## Фаза 1 — Критические исправления (высокий эффект, низкий риск)
### 1.1 Устранить утечку undici-агентов в HTTP-пробах
[health-check-service.ts:107-166](../cloudflare-domain-manager/apps/api/src/services/health-check-service.ts) — `createIpPinnedAgent` создаёт `new Agent` на каждую пробу и никогда не вызывает `destroy()`. Пробы идут каждые 2 мин × N целей — накапливаются сокеты/фд/память.
- Вызывать `dispatcher.destroy()` в `finally` после каждой пробы, либо переиспользовать кэш агентов по ключу `ip|tls|verify|timeout` с `keepAliveTimeout` и общим `destroy()` по выключению.
### 1.2 Убрать debug-телеметрию из продакшн-бандла
[debug-agent-log.ts](../cloudflare-domain-manager/apps/web/src/lib/debug-agent-log.ts) шлёт POST на `http://127.0.0.1:7580` при загрузке приложения и на каждый рендер дашборда ([_auth/index.tsx:205-222](../cloudflare-domain-manager/apps/web/src/routes/_auth/index.tsx), [dashboard-analytics.tsx:26-48](../cloudflare-domain-manager/apps/web/src/components/reui-kit/dashboard-analytics.tsx), [main.tsx:13-17](../cloudflare-domain-manager/apps/web/src/main.tsx)).
- Удалить файл и все 4 точки вызова (маркирован «remove after investigation»).
### 1.3 Остановить неконтролируемый рост лог-таблиц (retention)
Нет очистки у `notification_log` ([repos.ts:3138](../cloudflare-domain-manager/packages/db/src/repos.ts)), `failover_log` (3186), `audit_log` ([audit-log.ts:60-87](../cloudflare-domain-manager/packages/db/src/audit-log.ts)), `sync_jobs` (2068). `health_probe_log` и `domain_monitor_results` уже ограничены (50/100) — расширить паттерн.
- Добавить функцию `pruneLogs(db)` (DELETE по `id NOT IN (SELECT ... ORDER BY id DESC LIMIT N)`) и вызывать из cron certificate-check раз в 6 ч; лимиты: notification 500, failover 1000, audit 2000, sync_jobs 500.
- Разово `PRAGMA wal_checkpoint(TRUNCATE)` после prune (сейчас WAL растёт без ограничений — локально 4MB WAL при БД 180KB).
### 1.4 `/ready` без внешнего вызова Cloudflare
[health.ts:14-30](../cloudflare-domain-manager/apps/api/src/routes/health.ts) — readiness дёргает `cf.listZones()` на каждый вызов. Его поллит хедер UI (30с) + Docker healthcheck (30с) = ~120 CF API-вызовов/час впустую.
- Кэшировать результат `listZones` с TTL 5 мин в облачном модуле (ленивый инвалидацией) и использовать в `/ready`, `collectKnownZones`, `createDomain` — одно изменение закрывает сразу H2/M7/M8 из отчёта API-разведчика.
### 1.5 Убрать мёртвые зависимости из бандла
- `react-phone-number-input` ([package.json:37](../cloudflare-domain-manager/apps/web/package.json)) — ноль импортов в коде.
- `date-selector.tsx` — ноль импортов, тянет `react-day-picker` + `date-fns`.
- Удалить dep + файл; проверить `@dnd-kit/*` на фактическое использование (используется kanban — оставить).
---
## Фаза 2 — Горячие пути API (N+1 и избыточная работа)
### 2.1 Пакетная загрузка в `buildView` / `listGroupViews`
[service-config-service.ts:343-439, 569-648](../cloudflare-domain-manager/apps/api/src/services/service-config-service.ts): на каждый сервис×биндинг×IP отдельные запросы (`getService`, `listBindingsByService`, `listRecordsForBinding`, `listBindingIpsWithMeta` дважды, `getIpHealthStatusRow` на каждый IP). Эндпоинт `/service-groups` поллится UI каждые 10с — это главный источник CPU-нагрузки на API.
- Загрузить все данные пакетно (JOIN-запросами из [repos.ts](../cloudflare-domain-manager/packages/db/src/repos.ts), паттерн `listIpHealthByServiceIds` уже есть), собрать views в памяти в один проход.
- Целевая структура: 5-8 запросов суммарно вместо O(services×bindings×ips).
### 2.2 `/certificates`: убрать prune с read-пути
[certificate-service.ts:23-37, 188-229, 270-273](../cloudflare-domain-manager/apps/api/src/services/certificate-service.ts): каждый GET делает `resolveCertificateTargets` (N+1 по всем биндингам) + `deleteCertificatesNotIn` (запись при чтении).
- Перенести prune в cron certificate-check (раз в 6 ч); GET возвращает чистый SELECT.
- `runAllChecks`: параллелить TLS-проверки через `p-limit(5)` (dep уже есть) вместо последовательных с 10с-таймаутом каждая.
### 2.3 Weighted-reconcile: не работать, когда нечего ротировать
[weighted-dns-scheduler.ts:31-34](../cloudflare-domain-manager/apps/api/src/services/weighted-dns-scheduler.ts) — job каждые 60с всегда делает `listAllBindings` (N+1) + `cf.listZones()`.
- Дешёвый предикат `SELECT EXISTS(... lb_mode='weighted' ...)` перед полным обходом; выходить сразу, если weighted-биндингов нет.
- Использовать кэш зон из 1.4.
### 2.4 Ограничить `findOrImportDnsRecord` внешние листинги
[service-config-service.ts:1000-1045, 834-876](../cloudflare-domain-manager/apps/api/src/services/service-config-service.ts): на каждый отсутствующий IP — полный `cf.listDnsRecords(zone)` (до 50 страниц).
- Кэш листинга зоны на время reconcile-прохода (Map по zoneId, TTL = длительность прохода); в одном проходе зона листингуется один раз.
### 2.5 Общий кэш зон Cloudflare
`cf.listZones()` вызывается из `/ready`, `collectKnownZones` (каждый PATCH конфига), `createDomain`, weighted-reconcile каждую минуту — без кэша.
- Инвалидация по TTL 5 мин + явный сброс при 429/401 в [http.ts](../cloudflare-domain-manager/apps/api/src/lib/cloudflare/http.ts).
---
## Фаза 3 — Фронтенд: размер бандла и рендеринг
### 3.1 Code-splitting роутов + ручные vendor-чанки
Сейчас: один JS-чанк 1.68MB (gzip 477KB), 24 роута статически импортированы в [routeTree.gen.ts](../cloudflare-domain-manager/apps/web/src/routeTree.gen.ts), `defaultPreload: 'intent'` в [main.tsx:18-21](../cloudflare-domain-manager/apps/web/src/main.tsx) не работает без сплиттинга.
- Перевести тяжёлые роуты на `.lazy.tsx` / `lazyRouteComponent` (сервисы, kanban-доска, дашборд с графиками, настройки).
- [vite.config.ts](../cloudflare-domain-manager/apps/web/vite.config.ts): добавить `build.rollupOptions.output.manualChunks` — вынести `recharts` (lazy-import в `uptime-chart.tsx`/`dashboard-analytics.tsx`), `@dnd-kit`, TanStack-стек в отдельные чанки.
- Цель: initial JS < 400KB, recharts грузится только на страницах с графиками.
### 3.2 Разумный поллинг
- [system-monitor-popover.tsx:96-105](../cloudflare-domain-manager/apps/web/src/components/layout/system-monitor-popover.tsx): 5 запросов каждые 30с вечно, даже когда поповер закрыт → `enabled: open` (данные грузятся при открытии) + увеличить интервал до 60с.
- [use-domain-health.ts:14-18](../cloudflare-domain-manager/apps/web/src/hooks/use-domain-health.ts): N+1 — один health-query на каждый биндинг с `refetchInterval: 10_000` → один batch-эндпоинт `GET /health-status?ref_ids=1,2,3` или наследовать интервал от родительского запроса.
- Опционально (отдельным шагом, если захочется): SSE-эндпоинт для push-обновлений вместо поллинга. В базовый план не включаю — поллинг с правильными интервалами уже решает 90% проблемы.
### 3.3 Точечные invalidation и стабильность референсов
- [change-ip-sheet.tsx:85](../cloudflare-domain-manager/apps/web/src/components/change-ip-sheet.tsx) и [change-domain-sheet.tsx:73](../cloudflare-domain-manager/apps/web/src/components/change-domain-sheet.tsx): `invalidateQueries()` без ключа инвалидирует весь кэш → заменить на префиксные ключи (`serviceKeys.all`, `domainKeys.all`).
- [$serviceId/index.tsx:156-166](../cloudflare-domain-manager/apps/web/src/routes/_auth/services/$serviceId/index.tsx): 9 параллельных invalidations, где `serviceKeys.all` уже префиксно покрывает детальные — сократить до 2-3.
- `queryClient.ts`: `refetchOnWindowFocus: false` глобально (поллинг-запросы со staleTime 5с сейчас рефетчатся на каждый alt-tab).
- [use-services-kanban.ts:18-22](../cloudflare-domain-manager/apps/web/src/hooks/use-services-kanban.ts): `boardColumnsToKanbanValue` без `useMemo` — каждый 10с-тик перерисовывает всю kanban-доску.
- [uptime-chart.tsx:266-269](../cloudflare-domain-manager/apps/web/src/components/reui-kit/uptime-chart.tsx): `setHovered` на каждое движение мыши перерисовывает всю карточку → rAF-throttle или `useDeferredValue`.
### 3.4 Zod-парсинг не должен ломать structural sharing
Каждый поллинг-тик делает `z.array(schema).parse(data)` — новые объекты, все consumer-компоненты перерисовываются.
- Для поллинг-запросов заменить `.parse()` на `.parse` только при первом fetch + `structuralSharing` в queryOptions, либо использовать `z.parse` в `select` с кэшированием. Минимальный вариант: убрать double-parse (валидация + parse) в [queries/services.ts:24-37](../cloudflare-domain-manager/apps/web/src/queries/services.ts), оставить один проход.
---
## Фаза 4 — Инфраструктура и сборка
### 4.1 Turbo-кэш в CI (самая дорогая находка по скорости сборки)
В [quality.yaml:163-176](../cloudflare-domain-manager/.gitea/workflows/quality.yaml) (и cd.yaml) `actions/cache` не включает `.turbo` — каждый PR собирает web+api с нуля (vite + tsc -b + tsup).
- Добавить `**/.turbo` и `node_modules/.tmp` в пути кэша; ключ `turbo-${{ runner.os }}-<lockfile-hash>`.
- [turbo.json](../cloudflare-domain-manager/turbo.json): дополнить `outputs` для web (`src/routeTree.gen.ts`, `node_modules/.tmp/*.tsbuildinfo`), добавить `inputs` для `test`/`lint` (только `src/**`, `test/**`).
### 4.2 Runtime-контейнер: ограничения и корректность
[Dockerfile](../cloudflare-domain-manager/deploy/docker/cfdm/Dockerfile):
- `USER node` + `chown` на `/data`/`/app` (сейчас root).
- Healthcheck заменить `node -e fetch(...)` на busybox `wget -q -O /dev/null http://127.0.0.1:8080/health` (каждые 30с сейчас поднимают полный V8 ~30-50MB RSS).
- [docker-compose.yml](../cloudflare-domain-manager/docker-compose.yml): `init: true`, `mem_limit: 512m`, синхронизировать `start_period` с Dockerfile.
- `CMD ["node", "--max-old-space-size=384", "dist/server.js"]` — предсказуемый потолок памяти.
### 4.3 SQLite-тюнинг
[client.ts:20-28](../cloudflare-domain-manager/packages/db/src/client.ts): добавить `busy_timeout=5000`, `cache_size=-16000` (16MB), `wal_autocheckpoint=1000`; периодический `wal_checkpoint(TRUNCATE)` из cron (вместе с 1.3).
### 4.4 Дубли prod-зависимостей API
[apps/api/package.json](../cloudflare-domain-manager/apps/api/package.json): `@fastify/schedule` + `toad-scheduler` (обёртка и ядро), `p-limit` + `p-queue`.
- Оставить `toad-scheduler` напрямую (уже используется) + `p-limit`; убрать дубли. Экономия в размере образа/памяти скромная, но порядок в deps.
### 4.5 Source maps API-бандла
[tsup.config.ts](../cloudflare-domain-manager/apps/api/tsup.config.ts): `sourcemap: true` + `NODE_OPTIONS=--enable-source-maps` в CMD — стек-трейсы прода указывают на исходники (не ресурс, а диагностика).
---
## Порядок работ и приоритет
| Порядок | Работы | Эффект |
|---|---|---|
| 1 | Фаза 1 (1.1-1.5) | Стоп утечкам памяти/диска, минус ~120 CF-вызовов/час, чистый бандл |
| 2 | Фаза 2 (2.1-2.5) | API-латентность дашборда в разы, меньше CPU на поллинг |
| 3 | Фаза 3.1-3.2 | Initial JS 1.68MB → <400KB, минус 80% фоновых запросов |
| 4 | Фаза 3.3-3.4 | Стабильные 60fps на дашборде при поллинге |
| 5 | Фаза 4 | Быстрая CI (+turbo-кэш), безопасный лёгкий контейнер |
## Проверка после каждого шага
- `pnpm turbo build && pnpm turbo test` — сборка и тесты зелёные (в apps/api есть тесты на sync/vps-tracker, в web — vitest).
- Размер чанков: `ls apps/web/dist/assets` — контроль initial JS.
- Smoke: `docker compose up` — дашборд, списки сервисов, health-лог, сертификаты отвечают.
## Что осознанно НЕ входит
- Переезд на SSE/WebSocket — поллинг с правильными интервалами уже достаточен; push-модель отдельным этапом.
- Переписывание repos.ts на batch-запросы целиком — только горячие пути (buildView, certificates, opsSummary).
- Разделение контейнеров API/web — текущий all-in-one соответствует концепции проекта.
+12
View File
@@ -170,6 +170,12 @@ jobs:
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
.turbo
apps/web/.turbo
apps/api/.turbo
packages/ui/.turbo
packages/shared/.turbo
packages/db/.turbo
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
@@ -208,6 +214,12 @@ jobs:
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
.turbo
apps/web/.turbo
apps/api/.turbo
packages/ui/.turbo
packages/shared/.turbo
packages/db/.turbo
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
+228
View File
@@ -0,0 +1,228 @@
/**
* CFDM health-probe Worker. Cron Trigger reads KV `targets`, probes TCP/HTTP
* from the edge (UptimeFlare-style), writes KV `results`. CFDM is SoT in SQLite.
*/
const TARGETS_KEY = "targets";
const RESULTS_KEY = "results";
const CURSOR_KEY = "cursor";
const BATCH = 48;
const CONCURRENCY = 5;
const COOLDOWN_MS = 3 * 60 * 1000;
const UA = "CFDM-health-probe/1.0";
export default {
async fetch() {
return new Response(JSON.stringify({ ok: true, service: "cfdm-health-probe" }), {
headers: { "content-type": "application/json" },
});
},
async scheduled(_event, env) {
await probeBatch(env);
},
};
async function probeBatch(env) {
const raw = await env.HEALTH_KV.get(TARGETS_KEY);
if (!raw) return;
let doc;
try {
doc = JSON.parse(raw);
} catch {
return;
}
const items = Array.isArray(doc.items) ? doc.items : [];
if (items.length === 0) return;
let offset = 0;
const cursorRaw = await env.HEALTH_KV.get(CURSOR_KEY);
if (cursorRaw) {
try {
const cursor = JSON.parse(cursorRaw);
if (Number.isFinite(cursor.offset) && cursor.offset >= 0) {
offset = cursor.offset % items.length;
}
} catch {
offset = 0;
}
}
const slice = items.slice(offset, offset + BATCH);
const nextOffset = offset + slice.length >= items.length ? 0 : offset + slice.length;
const colo = await readColo();
const probed = await mapPool(slice, CONCURRENCY, async (target) => {
const type = target.type === "http" ? "http" : "tcp";
const port = Number(target.port) || (type === "http" ? 80 : 80);
const timeoutMs = Math.min(Math.max(Number(target.timeoutMs) || 3000, 100), 25_000);
const hostname = String(target.hostname ?? "").trim() || target.ip;
try {
const result =
type === "http"
? await httpProbe({
ip: target.ip,
hostname,
port,
path: target.path || "/",
expectedStatus: target.expectedStatus ?? 200,
timeoutMs,
verifyTls: Boolean(target.verifyTls),
})
: await tcpProbe(target.ip, port, timeoutMs);
return { key: target.key, ...result };
} catch (err) {
return {
key: target.key,
ok: false,
latencyMs: 0,
error: err instanceof Error ? err.message : "probe failed",
};
}
});
const fingerprint = resultFingerprint(probed);
const previousRaw = await env.HEALTH_KV.get(RESULTS_KEY);
let skipWrite = false;
if (previousRaw) {
try {
const prev = JSON.parse(previousRaw);
const age = Date.now() - Date.parse(prev.probedAt);
if (prev.fingerprint === fingerprint && Number.isFinite(age) && age < COOLDOWN_MS) {
skipWrite = true;
}
} catch {
skipWrite = false;
}
}
if (!skipWrite) {
const results = {
probedAt: new Date().toISOString(),
colo,
fingerprint,
items: probed,
};
await env.HEALTH_KV.put(RESULTS_KEY, JSON.stringify(results));
}
if (items.length > BATCH || offset !== 0) {
await env.HEALTH_KV.put(CURSOR_KEY, JSON.stringify({ offset: nextOffset }));
}
}
function resultFingerprint(items) {
return items
.map((item) => `${item.key}:${item.ok ? "1" : "0"}:${item.error ?? ""}`)
.sort()
.join("|");
}
async function mapPool(items, concurrency, fn) {
if (items.length === 0) return [];
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const idx = next;
next += 1;
results[idx] = await fn(items[idx]);
}
}
const n = Math.min(concurrency, items.length);
await Promise.all(Array.from({ length: n }, () => worker()));
return results;
}
async function readColo() {
try {
const res = await fetch("https://www.cloudflare.com/cdn-cgi/trace", {
cf: { cacheTtlByStatus: { "100-599": -1 } },
});
const text = await res.text();
const line = text.split("\n").find((row) => row.startsWith("colo="));
return line ? line.slice(5).trim() || null : null;
} catch {
return null;
}
}
function withTimeout(promise, timeoutMs, label) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${label} timeout`)), timeoutMs);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(err) => {
clearTimeout(timer);
reject(err);
},
);
});
}
async function tcpProbe(ip, port, timeoutMs) {
const started = Date.now();
const { connect } = await import("cloudflare:sockets");
const socket = connect({ hostname: ip, port });
try {
await withTimeout(socket.opened, timeoutMs, "tcp");
return { ok: true, latencyMs: Date.now() - started, error: null };
} catch (err) {
const message = err instanceof Error ? err.message : "tcp failed";
return { ok: false, latencyMs: Date.now() - started, error: message };
} finally {
try {
socket.close();
} catch {
// ignore
}
}
}
async function httpProbe(opts) {
const started = Date.now();
const useTls = opts.verifyTls || opts.port === 443;
const host = opts.ip.includes(":") ? `[${opts.ip}]` : opts.ip;
const path = opts.path.startsWith("/") ? opts.path : `/${opts.path}`;
const url = `${useTls ? "https" : "http"}://${host}:${opts.port}${path}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
try {
const res = await fetch(url, {
method: "GET",
headers: {
Host: opts.hostname,
"User-Agent": UA,
},
signal: controller.signal,
redirect: "manual",
cf: { cacheTtlByStatus: { "100-599": -1 } },
});
try {
await res.body?.cancel();
} catch {
// ignore
}
const latencyMs = Date.now() - started;
if (res.status !== opts.expectedStatus) {
return {
ok: false,
latencyMs,
error: `HTTP ${res.status} (ожидали ${opts.expectedStatus})`,
};
}
return { ok: true, latencyMs, error: null };
} catch (err) {
const message =
err instanceof Error
? err.name === "AbortError"
? "http timeout"
: err.message
: "http failed";
return { ok: false, latencyMs: Date.now() - started, error: message };
} finally {
clearTimeout(timer);
}
}
+4484 -1093
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
-2
View File
@@ -16,7 +16,6 @@
"@fastify/helmet": "^13.0.1",
"@fastify/jwt": "^9.1.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/schedule": "^6.0.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"@fastify/type-provider-zod": "^1.0.0",
@@ -24,7 +23,6 @@
"fastify": "^5.4.0",
"fastify-plugin": "^5.0.1",
"p-limit": "^6.2.0",
"p-queue": "^8.1.0",
"toad-scheduler": "^4.0.1",
"undici": "^8.5.0",
"zod": "^4.2.0"
+13 -3
View File
@@ -32,6 +32,7 @@ import {
import { settingsRoutes } from "./routes/settings.js";
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
import { auditRoutes } from "./routes/audit.js";
import { repos, walCheckpointTruncate } from "@cfdm/db";
import * as certificateService from "./services/certificate-service.js";
import {
createHealthCheckTask,
@@ -43,7 +44,7 @@ import {
scheduleWeightedDnsJob,
} from "./services/weighted-dns-scheduler.js";
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
import { AsyncTask, CronJob } from "toad-scheduler";
import { AsyncTask, CronJob, ToadScheduler } from "toad-scheduler";
export interface BuildAppOptions {
config?: AppConfig;
@@ -111,11 +112,20 @@ export async function buildApp(opts: BuildAppOptions = {}) {
}
if (!opts.memory) {
await app.register(import("@fastify/schedule"));
const scheduler = new ToadScheduler();
app.decorate("scheduler", scheduler);
app.addHook("onClose", async () => {
scheduler.stop();
});
const certTask = new AsyncTask(
"certificate-check",
async () => {
const pruned = repos.pruneLogs(app.db);
if (pruned > 0) {
app.log.info({ pruned }, "log retention pruned");
}
walCheckpointTruncate(app.sqlite);
const n = await certificateService.runAllChecks(app.db);
app.log.info({ checked: n }, "certificate check completed");
},
@@ -124,7 +134,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
},
);
app.scheduler.addCronJob(
scheduler.addCronJob(
new CronJob(
{ cronExpression: config.certCheckCron },
certTask,
+9 -2
View File
@@ -38,12 +38,19 @@ export class CloudflareClient {
return this.zones.listZones();
}
invalidateZonesCache(): void {
this.zones.invalidateZonesCache();
}
getZone(zoneId: string): Promise<CfZone> {
return this.zones.getZone(zoneId);
}
listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return this.dns.listDnsRecords(zoneId);
listDnsRecords(
zoneId: string,
cache?: Map<string, CfDnsRecord[]>,
): Promise<CfDnsRecord[]> {
return this.dns.listDnsRecords(zoneId, cache);
}
createDnsRecord(zoneId: string, payload: CreateDnsRecordPayload): Promise<CfDnsRecord> {
+12 -5
View File
@@ -4,9 +4,14 @@ import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js";
export function createDnsAdapter(token: string) {
return {
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return withRetry(async () => {
const all: CfDnsRecord[] = [];
async listDnsRecords(
zoneId: string,
cache?: Map<string, CfDnsRecord[]>,
): Promise<CfDnsRecord[]> {
const cached = cache?.get(zoneId);
if (cached) return cached;
const all = await withRetry(async () => {
const records: CfDnsRecord[] = [];
let page = 1;
while (page <= 50) {
const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`);
@@ -28,11 +33,13 @@ export function createDnsAdapter(token: string) {
"list_dns_records",
);
if (batch.length === 0) break;
all.push(...batch);
records.push(...batch);
page += 1;
}
return all;
return records;
});
cache?.set(zoneId, all);
return all;
},
async createDnsRecord(
+3
View File
@@ -1,6 +1,7 @@
import type { CfDnsRecord } from "@cfdm/shared";
import { AppError } from "../../errors.js";
import { parseRetryAfter } from "../cf-retry.js";
import { notifyZoneCacheInvalidated } from "./zone-cache-events.js";
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
@@ -17,6 +18,7 @@ export function mapCloudflareFailure(
): AppError {
const lower = message.toLowerCase();
if (status === 401 || status === 403 || lower.includes("authentication")) {
notifyZoneCacheInvalidated();
if (
operation.includes("workers") ||
operation.includes("kv_") ||
@@ -31,6 +33,7 @@ export function mapCloudflareFailure(
);
}
if (status === 429 || lower.includes("rate limit")) {
notifyZoneCacheInvalidated();
return AppError.rateLimited();
}
if (lower.includes("zone") && (lower.includes("not found") || status === 404)) {
@@ -0,0 +1,21 @@
/**
* Tiny module-level hook registry: http.ts signals CF auth/rate-limit failures,
* zone-service subscribes to drop its TTL cache. Kept separate to avoid a
* circular import between http.ts and zone-service.ts.
*/
const subscribers = new Set<() => void>();
export function subscribeZoneCacheInvalidation(cb: () => void): () => void {
subscribers.add(cb);
return () => subscribers.delete(cb);
}
export function notifyZoneCacheInvalidated(): void {
for (const cb of subscribers) {
try {
cb();
} catch {
// subscriber cleanup must never break the CF response path
}
}
}
+56 -21
View File
@@ -1,34 +1,69 @@
import type { CfZone } from "@cfdm/shared";
import { withRetry } from "../cf-retry.js";
import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js";
import { subscribeZoneCacheInvalidation } from "./zone-cache-events.js";
const ZONE_CACHE_TTL_MS = 5 * 60_000;
/** TTL cache for the zones list: /ready, collectKnownZones, weighted-reconcile
* poll it on a hot schedule; zones rarely change, so one CF API call per 5 min. */
export function createZoneAdapter(token: string) {
let cachedAt = 0;
let cachedZones: CfZone[] | null = null;
let inflight: Promise<CfZone[]> | null = null;
async function fetchZones(): Promise<CfZone[]> {
const all: CfZone[] = [];
let page = 1;
while (true) {
const url = new URL(`${CF_API_BASE}/zones`);
url.searchParams.set("per_page", "50");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("list_zones", response.status, String(response.status));
}
const batch = await handleCfResponse<CfZone[]>(response, "list_zones");
if (batch.length === 0) break;
all.push(...batch);
if (batch.length < 50) break;
page += 1;
}
return all;
}
function invalidate(): void {
cachedAt = 0;
cachedZones = null;
}
subscribeZoneCacheInvalidation(invalidate);
return {
async listZones(): Promise<CfZone[]> {
return withRetry(async () => {
const all: CfZone[] = [];
let page = 1;
while (true) {
const url = new URL(`${CF_API_BASE}/zones`);
url.searchParams.set("per_page", "50");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
const now = Date.now();
if (cachedZones && now - cachedAt < ZONE_CACHE_TTL_MS) {
return cachedZones;
}
if (!inflight) {
inflight = withRetry(fetchZones)
.then((zones) => {
cachedAt = Date.now();
cachedZones = zones;
return zones;
})
.finally(() => {
inflight = null;
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("list_zones", response.status, String(response.status));
}
const batch = await handleCfResponse<CfZone[]>(response, "list_zones");
if (batch.length === 0) break;
all.push(...batch);
if (batch.length < 50) break;
page += 1;
}
return all;
});
}
return inflight;
},
invalidateZonesCache: invalidate,
async getZone(zoneId: string): Promise<CfZone> {
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, {
headers: { Authorization: `Bearer ${token}` },
+26
View File
@@ -1,4 +1,6 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { IpHealthStatus } from "@cfdm/shared";
import { healthStatusQuerySchema } from "@cfdm/shared";
import { getAppSettings, repos } from "@cfdm/db";
import * as healthCheckService from "../services/health-check-service.js";
@@ -19,6 +21,30 @@ export async function healthCheckRoutes(app: FastifyInstance) {
);
});
// Batch endpoint for lists: one request instead of N per-binding polls.
app.get("/health-status/batch", async (request) => {
const raw = (request.query as Record<string, unknown>) ?? {};
const idsParam = typeof raw.ref_ids === "string" ? raw.ref_ids : "";
const refIds = z
.array(z.coerce.number().int().positive())
.max(200)
.parse(
idsParam
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0),
);
const grouped = repos.listIpHealthStatusByBindingIds(
request.server.db,
refIds,
);
const items: { ref_id: number; rows: IpHealthStatus[] }[] = [];
for (const id of refIds) {
items.push({ ref_id: id, rows: grouped.get(id) ?? [] });
}
return { items };
});
app.post("/health-check/run", async (request) => {
const config = request.server.config;
const fallbacks = healthEngineFallbacksFromConfig(config);
+15 -19
View File
@@ -1,5 +1,6 @@
import { connect } from "node:net";
import { connect as tlsConnect } from "node:tls";
import pLimit from "p-limit";
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { Certificate, ServiceCertificateRow, Subdomain } from "@cfdm/shared";
@@ -21,19 +22,10 @@ export interface CertificateTarget {
hostname: string;
}
function pruneStaleCertificates(db: Db): void {
const targets = resolveCertificateTargets(db);
repos.deleteCertificatesNotIn(
db,
targets.map((t) => t.hostname),
);
}
export function listCertificates(
db: Db,
status?: string,
): Certificate[] {
pruneStaleCertificates(db);
return repos.listCertificates(db, status);
}
@@ -231,15 +223,20 @@ export function resolveCertificateTargets(db: Db): CertificateTarget[] {
export async function runAllChecks(db: Db): Promise<number> {
const targets = resolveCertificateTargets(db);
for (const target of targets) {
await checkAndStore(
db,
target.domainId,
target.subdomainId,
target.hostname,
target.serviceId,
);
}
const limit = pLimit(5);
await Promise.all(
targets.map((target) =>
limit(() =>
checkAndStore(
db,
target.domainId,
target.subdomainId,
target.hostname,
target.serviceId,
),
),
),
);
repos.deleteCertificatesNotIn(
db,
targets.map((t) => t.hostname),
@@ -268,6 +265,5 @@ export async function runServiceChecks(
}
export function statusSummary(db: Db): Array<[string, number]> {
pruneStaleCertificates(db);
return repos.countCertificatesByStatus(db);
}
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify";
import { AsyncTask, CronJob } from "toad-scheduler";
import { AsyncTask, CronJob, type ToadScheduler } from "toad-scheduler";
import {
getAppSettings,
getAppSettingsSecrets,
@@ -18,6 +18,7 @@ import { cronStaleAfterMs } from "./health/mailbox.js";
declare module "fastify" {
interface FastifyInstance {
scheduler?: ToadScheduler;
reloadHealthCheckJob?: () => void;
}
}
@@ -168,6 +168,8 @@ async function httpProbe(
dispatcher,
});
const latency = Date.now() - started;
// Consume body so the socket is released before agent teardown.
await response.body?.cancel().catch(() => {});
if (target.expected_status != null) {
if (response.status !== target.expected_status) {
return {
@@ -192,6 +194,9 @@ async function httpProbe(
latencyMs: Date.now() - started,
error: err instanceof Error ? err.message : String(err),
};
} finally {
// Per-probe agents must not leak sockets/fds; probes run every ~2 min × N targets.
await dispatcher?.destroy().catch(() => {});
}
}
+198 -121
View File
@@ -1,6 +1,7 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type {
CfDnsRecord,
DnsRecord,
HealthCheckAggregate,
HealthCheckProvider,
@@ -9,6 +10,7 @@ import type {
IpHealthState,
LbMode,
Service,
ServiceBinding,
ServiceGroup,
ServiceGroupsResponse,
ServiceView,
@@ -341,101 +343,148 @@ async function collectKnownZones(
return zones;
}
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
const service = repos.getService(db, serviceId);
const ipRows = repos.listServiceIpRows(db, serviceId);
const ips = ipRows.map((row) => row.ip);
const ip_enabled = Object.fromEntries(
ipRows.map((row) => [row.ip, row.enabled]),
);
const bindings = repos.listBindingsByService(db, serviceId);
interface BindingLbContext {
ipMetaByBinding: Map<number, repos.BindingIpMeta[]>;
healthByBinding: Map<number, Map<string, { status: string; latency_ms: number | null }>>;
}
const domainViews = bindings.map((binding) => {
const records = repos.listRecordsForBinding(db, binding.id);
const statuses = records.map((r) => r.sync_status);
const targetIpsWithMeta = repos.listBindingIpsWithMeta(db, binding.id);
const targetIps = targetIpsWithMeta.map((entry) => entry.ip);
const linkedCname = records.find(
(record) => record.record_type.toUpperCase() === "CNAME",
);
const targetCname =
binding.cname_target?.trim() || linkedCname?.content?.trim() || null;
const target_ip_weights: Record<string, number> = {};
const target_ip_priorities: Record<string, number> = {};
for (const entry of targetIpsWithMeta) {
target_ip_weights[entry.ip] = entry.weight;
target_ip_priorities[entry.ip] = entry.priority;
}
for (const ip of targetIps) {
if (target_ip_weights[ip] === undefined) target_ip_weights[ip] = 1;
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
}
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,
zone_name: binding.zone_name,
hostname: binding.hostname,
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
record_type: targetCname ? ("CNAME" as const) : ("A" as const),
target_ips: targetCname ? [] : targetIps,
target_ip_weights,
target_ip_priorities,
target_cname: targetCname,
function bindingLbStateFromBatch(
binding: ServiceBinding,
ctx: BindingLbContext,
): { config: LbTargetConfig; rows: LbIpRow[] } {
const ipMetas = ctx.ipMetaByBinding.get(binding.id) ?? [];
const healthByIp = ctx.healthByBinding.get(binding.id);
const rows: LbIpRow[] = ipMetas.map((entry) => ({
ip: entry.ip,
weight: entry.weight,
priority: entry.priority,
health: (healthByIp?.get(entry.ip)?.status as IpHealthState) ?? "unknown",
}));
return {
config: {
lb_mode: binding.lb_mode,
health_check_enabled: binding.health_check_enabled,
health_check_type: binding.health_check_type,
health_check_port: binding.health_check_port,
health_check_path: binding.health_check_path,
health_check_expected_status: binding.health_check_expected_status,
health_check_interval_sec: binding.health_check_interval_sec,
health_check_timeout_ms: binding.health_check_timeout_ms,
health_check_verify_tls: binding.health_check_verify_tls,
health_check_provider: binding.health_check_provider ?? "local",
health_check_providers: binding.health_check_providers ?? [
binding.health_check_provider ?? "local",
],
health_check_aggregate: binding.health_check_aggregate ?? "majority",
cert_monitoring: binding.cert_monitoring ?? "auto",
sync_status: aggregateSyncStatus(statuses),
active_ips: bindingActiveIps,
},
rows,
};
}
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
const [view] = await buildViews(db, [serviceId]);
return view!;
}
/** Batch view builder: 6 fixed queries for N services instead of O(services×bindings×ips). */
async function buildViews(db: Db, serviceIds: number[]): Promise<ServiceView[]> {
const services = serviceIds.map((id) => repos.getService(db, id));
const ipRowsByService = repos.listServiceIpRowsByServiceIds(db, serviceIds);
const bindingsByService = repos.listBindingsByServiceIds(db, serviceIds);
const allBindings = [...bindingsByService.values()].flat();
const bindingIds = allBindings.map((b) => b.id);
const recordsByBinding = repos.listRecordsByBindingIds(db, bindingIds);
const ipMetaByBinding = repos.listBindingIpsWithMetaByBindingIds(db, bindingIds);
const healthByBinding = repos.listBindingIpHealthByBindingIds(db, bindingIds);
const lbCtx: BindingLbContext = { ipMetaByBinding, healthByBinding };
const now = Date.now();
return services.map((service) => {
const ipRows = ipRowsByService.get(service.id) ?? [];
const ips = ipRows.map((row) => row.ip);
const ip_enabled = Object.fromEntries(
ipRows.map((row) => [row.ip, row.enabled]),
);
const bindings = bindingsByService.get(service.id) ?? [];
const domainViews = bindings.map((binding) => {
const records = recordsByBinding.get(binding.id) ?? [];
const statuses = records.map((r) => r.sync_status);
const targetIpsWithMeta = ipMetaByBinding.get(binding.id) ?? [];
const targetIps = targetIpsWithMeta.map((entry) => entry.ip);
const linkedCname = records.find(
(record) => record.record_type.toUpperCase() === "CNAME",
);
const targetCname =
binding.cname_target?.trim() || linkedCname?.content?.trim() || null;
const target_ip_weights: Record<string, number> = {};
const target_ip_priorities: Record<string, number> = {};
for (const entry of targetIpsWithMeta) {
target_ip_weights[entry.ip] = entry.weight;
target_ip_priorities[entry.ip] = entry.priority;
}
for (const ip of targetIps) {
if (target_ip_weights[ip] === undefined) target_ip_weights[ip] = 1;
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
}
const { config, rows } = bindingLbStateFromBatch(binding, lbCtx);
const bindingActiveIps = targetCname
? []
: resolveDesiredAIps(config, rows, targetIps, now, ips);
return {
binding_id: binding.id,
domain_id: binding.domain_id,
zone_name: binding.zone_name,
hostname: binding.hostname,
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
record_type: targetCname ? ("CNAME" as const) : ("A" as const),
target_ips: targetCname ? [] : targetIps,
target_ip_weights,
target_ip_priorities,
target_cname: targetCname,
lb_mode: binding.lb_mode,
health_check_enabled: binding.health_check_enabled,
health_check_type: binding.health_check_type,
health_check_port: binding.health_check_port,
health_check_path: binding.health_check_path,
health_check_expected_status: binding.health_check_expected_status,
health_check_interval_sec: binding.health_check_interval_sec,
health_check_timeout_ms: binding.health_check_timeout_ms,
health_check_verify_tls: binding.health_check_verify_tls,
health_check_provider: binding.health_check_provider ?? "local",
health_check_providers: binding.health_check_providers ?? [
binding.health_check_provider ?? "local",
],
health_check_aggregate: binding.health_check_aggregate ?? "majority",
cert_monitoring: binding.cert_monitoring ?? "auto",
sync_status: aggregateSyncStatus(statuses),
active_ips: bindingActiveIps,
};
});
const activeIps = new Set<string>();
for (const domain of domainViews) {
for (const ip of domain.active_ips) {
activeIps.add(ip);
}
}
return {
id: service.id,
name: service.name,
slug: service.slug,
service_group_id: service.service_group_id ?? null,
subdomain: service.subdomain ?? "",
enabled: Boolean(service.enabled),
computed_fqdn: null,
lb_weight: service.lb_weight,
lb_priority: service.lb_priority,
created_at: service.created_at,
updated_at: service.updated_at,
ips,
ip_enabled,
domains: domainViews,
health_status: "unknown",
health_latency_ms: null,
ip_health: [],
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
active_ips: [...activeIps],
};
});
const activeIps = new Set<string>();
for (const domain of domainViews) {
for (const ip of domain.active_ips) {
activeIps.add(ip);
}
}
return {
id: service.id,
name: service.name,
slug: service.slug,
service_group_id: service.service_group_id ?? null,
subdomain: service.subdomain ?? "",
enabled: Boolean(service.enabled),
computed_fqdn: null,
lb_weight: service.lb_weight,
lb_priority: service.lb_priority,
created_at: service.created_at,
updated_at: service.updated_at,
ips,
ip_enabled,
domains: domainViews,
health_status: "unknown",
health_latency_ms: null,
ip_health: [],
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
active_ips: [...activeIps],
};
}
const HEALTH_RANK: Record<string, number> = {
@@ -566,9 +615,8 @@ function attachServiceHealth(
}
export async function listViews(db: Db): Promise<ServiceView[]> {
const views = await Promise.all(
repos.listServices(db).map((s) => buildView(db, s.id)),
);
const ids = repos.listServices(db).map((s) => s.id);
const views = await buildViews(db, ids);
return attachServiceHealth(db, views);
}
@@ -580,25 +628,23 @@ export async function getView(db: Db, id: number): Promise<ServiceView> {
export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
const groups = repos.listServiceGroups(db);
const groupViewsRaw = await Promise.all(
groups.map(async (group) => {
const services = repos.listServicesByGroup(db, group.id);
const serviceViews = await Promise.all(
services.map((s) => buildView(db, s.id)),
);
return { ...group, services: serviceViews };
}),
);
const servicesByGroup = repos.listServicesByGroupIds(db, groups.map((g) => g.id));
const ungroupedServices = repos.listUngroupedServices(db);
const ungroupedRaw = await Promise.all(
ungroupedServices.map((s) => buildView(db, s.id)),
);
const allServiceViews = [
...groupViewsRaw.flatMap((g) => g.services),
...ungroupedRaw,
const allIds = [
...[...servicesByGroup.values()].flat().map((s) => s.id),
...ungroupedServices.map((s) => s.id),
];
const allServiceViews = await buildViews(db, allIds);
const viewsById = new Map(allServiceViews.map((v) => [v.id, v]));
const groupViewsRaw = groups.map((group) => ({
...group,
services: (servicesByGroup.get(group.id) ?? [])
.map((s) => viewsById.get(s.id))
.filter((v): v is ServiceView => v !== undefined),
}));
const withHealth = attachServiceHealth(db, allServiceViews);
const healthById = new Map(withHealth.map((v) => [v.id, v]));
@@ -632,16 +678,21 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
};
});
const ungrouped = ungroupedRaw.map(
(s) =>
healthById.get(s.id) ?? {
...s,
health_status: "unknown" as const,
health_latency_ms: null,
ip_health: [],
ip_enabled: {},
},
);
const ungrouped = ungroupedServices.map((s) => {
const view = viewsById.get(s.id);
return (
healthById.get(s.id) ??
(view
? {
...view,
health_status: "unknown" as const,
health_latency_ms: null,
ip_health: [],
ip_enabled: {},
}
: view!)
);
});
return { groups: groupViews, ungrouped };
}
@@ -661,6 +712,7 @@ async function syncBindingDns(
hostname: string,
desiredIps: string[],
cnameTarget: string | null,
listingCache?: ZoneDnsListingCache,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
@@ -674,6 +726,8 @@ async function syncBindingDns(
zoneName,
hostname,
"CNAME",
undefined,
listingCache,
);
if (existingCname) {
effectiveCname = existingCname.content;
@@ -690,6 +744,7 @@ async function syncBindingDns(
domainId,
hostname,
effectiveCname,
listingCache,
);
return;
}
@@ -704,6 +759,7 @@ async function syncBindingDns(
hostname,
desiredIps,
ttlForBinding(binding.lb_mode, configuredIps),
listingCache,
);
}
@@ -714,6 +770,7 @@ async function syncBindingCnameDns(
domainId: number,
hostname: string,
cnameTarget: string,
listingCache?: ZoneDnsListingCache,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
@@ -755,6 +812,7 @@ async function syncBindingCnameDns(
hostname,
"CNAME",
normalized,
listingCache,
);
if (adopted) {
repos.linkBindingRecord(db, bindingId, adopted.id);
@@ -792,6 +850,7 @@ async function syncBindingADns(
hostname: string,
desiredIps: string[],
ttl: number,
listingCache?: ZoneDnsListingCache,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
@@ -849,6 +908,7 @@ async function syncBindingADns(
hostname,
"A",
ip,
listingCache,
);
if (adopted) {
repos.linkBindingRecord(db, bindingId, adopted.id);
@@ -991,6 +1051,9 @@ function findLocalDnsRecord(
);
}
/** Per-reconcile DNS listing cache: one zone = one CF API listing per pass. */
export type ZoneDnsListingCache = Map<string, CfDnsRecord[]>;
async function findOrImportDnsRecord(
db: Db,
cf: CloudflareClient,
@@ -999,6 +1062,7 @@ async function findOrImportDnsRecord(
hostname: string,
recordType: "A" | "CNAME",
content?: string,
listingCache?: ZoneDnsListingCache,
): Promise<DnsRecord | null> {
const local = findLocalDnsRecord(
db,
@@ -1011,7 +1075,7 @@ async function findOrImportDnsRecord(
if (local) return local;
const domain = repos.getDomain(db, domainId);
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const remote = await cf.listDnsRecords(domain.cf_zone_id, listingCache);
for (const cfRec of remote) {
if (cfRec.type.toUpperCase() !== recordType) continue;
if (content != null) {
@@ -1052,6 +1116,7 @@ async function findOrImportDnsARecord(
zoneName: string,
hostname: string,
content: string,
listingCache?: ZoneDnsListingCache,
): Promise<DnsRecord | null> {
return findOrImportDnsRecord(
db,
@@ -1061,6 +1126,7 @@ async function findOrImportDnsARecord(
hostname,
"A",
content,
listingCache,
);
}
@@ -1084,6 +1150,7 @@ async function syncServiceBindingsToDns(
throw AppError.validation("добавьте IP-адреса в пул сервиса");
}
const listingCache: ZoneDnsListingCache = new Map();
for (const binding of bindings) {
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) {
@@ -1095,6 +1162,7 @@ async function syncServiceBindingsToDns(
binding.hostname,
[],
cnameTarget,
listingCache,
);
continue;
}
@@ -1117,6 +1185,7 @@ async function syncServiceBindingsToDns(
binding.hostname,
desiredIps,
null,
listingCache,
);
}
}
@@ -1148,6 +1217,7 @@ async function syncGroupDomainDnsRecords(
hostname: string,
desiredIps: string[],
ttl: number = AUTO_DNS_TTL,
listingCache?: ZoneDnsListingCache,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
@@ -1185,6 +1255,7 @@ async function syncGroupDomainDnsRecords(
zoneName,
hostname,
ip,
listingCache,
);
if (adopted) {
repos.linkGroupDnsRecord(db, groupId, adopted.id);
@@ -1245,6 +1316,7 @@ async function syncGroupDomainDns(
db: Db,
cf: CloudflareClient,
groupId: number,
listingCache?: ZoneDnsListingCache,
): Promise<void> {
const group = repos.getServiceGroup(db, groupId);
if (!group.enabled) {
@@ -1267,6 +1339,7 @@ async function syncGroupDomainDns(
hostname,
desiredIps,
ttlForBinding(group.lb_mode, fallbackIps),
listingCache,
);
}
@@ -1792,7 +1865,10 @@ export async function reconcileWeightedDns(
db: Db,
cf: CloudflareClient,
): Promise<number> {
// Cheap predicate first: skip the full binding walk when nothing is weighted.
if (!repos.hasWeightedBindings(db)) return 0;
let n = 0;
const listingCache: ZoneDnsListingCache = new Map();
for (const binding of repos.listAllBindings(db)) {
if (binding.lb_mode !== "weighted") continue;
if (binding.cname_target?.trim()) continue;
@@ -1818,6 +1894,7 @@ export async function reconcileWeightedDns(
latest.hostname,
desiredIps,
null,
listingCache,
);
n += 1;
});
@@ -1829,7 +1906,7 @@ export async function reconcileWeightedDns(
if (group.lb_mode !== "weighted") continue;
if (!group.enabled || !group.domain?.trim()) continue;
try {
await syncGroupDomainDns(db, cf, group.id);
await syncGroupDomainDns(db, cf, group.id, listingCache);
n += 1;
} catch {
continue;
+10 -2
View File
@@ -513,7 +513,7 @@ describe("certificates", () => {
await testApp.close();
});
it("GET /certificates prunes stale rows without running check", async () => {
it("runAllChecks prunes stale rows; GET /certificates is a plain read", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
@@ -563,13 +563,21 @@ describe("certificates", () => {
expect(repos.listCertificates(testApp.db)).toHaveLength(2);
// Read path must not mutate: stale rows survive until the cron prune.
const listRes = await testApp.inject({
method: "GET",
url: "/api/v1/certificates",
headers,
});
expect(listRes.statusCode).toBe(200);
expect(listRes.json()).toEqual([]);
expect(listRes.json()).toHaveLength(2);
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: null,
error: "network unreachable",
});
await certificateService.runAllChecks(testApp.db);
expect(repos.listCertificates(testApp.db)).toEqual([]);
await testApp.close();
});
+1
View File
@@ -7,6 +7,7 @@ export default defineConfig({
entry: ["src/server.ts"],
format: ["esm"],
dts: true,
sourcemap: true,
async onSuccess() {
const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
copyFileSync(
-3
View File
@@ -27,14 +27,11 @@
"@tanstack/router-vite-plugin": "^1.167.18",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.18.0",
"next-themes": "^0.4.6",
"react": "^19.2.6",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.6",
"react-hook-form": "^7.79.0",
"react-phone-number-input": "^3.4.17",
"recharts": "^3.8.0",
"sonner": "^2.0.7",
"tailwindcss": "^4.3.1",
@@ -14,7 +14,14 @@ import {
} from '@cfdm/ui/components/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { changeServiceDomain, domainsListQueryOptions } from '@/queries'
import {
changeServiceDomain,
domainKeys,
domainsListQueryOptions,
serviceBindingKeys,
serviceGroupKeys,
serviceKeys,
} from '@/queries'
interface ChangeDomainSheetProps {
open: boolean
@@ -70,7 +77,12 @@ export function ChangeDomainSheet({
}),
onSuccess: async (result: { message?: string }) => {
toast.success(result.message ?? 'Привязки перенесены')
await queryClient.invalidateQueries()
await Promise.all([
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
])
setConfirmOpen(false)
onOpenChange(false)
},
+6 -2
View File
@@ -15,7 +15,7 @@ import {
SelectValue,
} from '@cfdm/ui/components/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { changeBindingIp, serviceNodesQueryOptions } from '@/queries'
import { changeBindingIp, serviceBindingKeys, serviceGroupKeys, serviceKeys, serviceNodesQueryOptions } from '@/queries'
interface ChangeIpSheetProps {
open: boolean
@@ -82,7 +82,11 @@ export function ChangeIpSheet({
onSuccess: async (result) => {
setPreview(result.message)
toast.success(result.message)
await queryClient.invalidateQueries()
await Promise.all([
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
])
onOpenChange(false)
},
onError: (e: unknown) =>
@@ -1,4 +1,4 @@
import { useMemo, useEffect, useRef } from 'react'
import { useMemo, useRef } from 'react'
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis } from 'recharts'
import { StatusBadge } from '@/components/status-badge'
@@ -18,34 +18,6 @@ import {
} from '@cfdm/ui/components/chart'
import { Separator } from '@cfdm/ui/components/separator'
import { cn } from '@cfdm/ui/lib/utils'
import { debugAgentLog } from '@/lib/debug-agent-log'
function useChartSizeLog(chartId: string, dataLen: number) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const svg = el.querySelector('svg.recharts-surface')
const chartSlot = el.querySelector('[data-slot=chart]') as HTMLElement | null
debugAgentLog(
'dashboard-analytics.tsx:chart-mount',
'chart container dimensions',
{
chartId,
dataLen,
containerW: el.clientWidth,
containerH: el.clientHeight,
chartSlotW: chartSlot?.clientWidth ?? 0,
chartSlotH: chartSlot?.clientHeight ?? 0,
svgW: svg?.getAttribute('width') ?? null,
svgH: svg?.getAttribute('height') ?? null,
hasSvg: Boolean(svg),
},
'D',
)
}, [chartId, dataLen])
return ref
}
const statusChartConfig = {
count: { label: 'Сертификаты' },
@@ -74,7 +46,7 @@ interface CertStatusChartProps {
}
export function CertStatusChart({ data }: CertStatusChartProps) {
const chartRef = useChartSizeLog('cert-status', data.length)
const chartRef = useRef<HTMLDivElement>(null)
const total = useMemo(
() => data.reduce((sum, entry) => sum + entry.count, 0),
[data],
@@ -166,7 +138,7 @@ interface GroupDomainsChartProps {
}
export function GroupDomainsChart({ data }: GroupDomainsChartProps) {
const chartRef = useChartSizeLog('group-domains', data.length)
const chartRef = useRef<HTMLDivElement>(null)
return (
<Frame dense spacing="sm" className="w-full">
@@ -1,4 +1,4 @@
import { useId, useMemo, useState } from 'react'
import { useId, useMemo, useRef, useState } from 'react'
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
import { Area, ComposedChart, Line, XAxis, YAxis } from 'recharts'
@@ -259,13 +259,20 @@ export function UptimeChart({
})
: []
// rAF-throttled: mouse-move must not re-render the card on every pixel.
const rafRef = useRef(0)
function syncHover(state: {
activeTooltipIndex?: unknown
activeIndex?: unknown
}) {
const index = Number(state.activeTooltipIndex ?? state.activeIndex)
if (!Number.isFinite(index)) return
setHovered(points[index] ?? null)
const next = points[index] ?? null
if (rafRef.current) cancelAnimationFrame(rafRef.current)
rafRef.current = requestAnimationFrame(() => {
rafRef.current = 0
setHovered(next)
})
}
const panel = (
File diff suppressed because it is too large Load Diff
+13 -10
View File
@@ -1,26 +1,29 @@
import { useQueries } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { healthStatusQueryOptions } from '@/queries'
import { bindingsHealthBatchQueryOptions } from '@/queries'
import type { IpHealthStatus, ServiceBinding } from '@/lib/schemas'
/** Map IP → worst health status across enabled bindings. */
/** Map IP → worst health status across enabled bindings (single batch request). */
export function useDomainHealthByIp(bindings: ServiceBinding[] | undefined) {
const enabledBindings = useMemo(
() => (bindings ?? []).filter((b) => b.health_check_enabled),
[bindings],
)
const bindingIds = useMemo(
() => enabledBindings.map((b) => b.id),
[enabledBindings],
)
const queries = useQueries({
queries: enabledBindings.map((b) => ({
...healthStatusQueryOptions('binding', b.id),
})),
const batch = useQuery({
...bindingsHealthBatchQueryOptions(bindingIds),
enabled: bindingIds.length > 0,
})
return useMemo(() => {
const map: Record<string, IpHealthStatus> = {}
const rank = { up: 0, unknown: 1, degraded: 2, down: 3 } as const
for (const q of queries) {
for (const row of q.data ?? []) {
for (const rows of batch.data?.values() ?? []) {
for (const row of rows) {
const prev = map[row.ip]
if (!prev || rank[row.status] > rank[prev.status]) {
map[row.ip] = row
@@ -28,5 +31,5 @@ export function useDomainHealthByIp(bindings: ServiceBinding[] | undefined) {
}
}
return map
}, [queries])
}, [batch.data])
}
+11 -6
View File
@@ -1,4 +1,4 @@
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import type { ServiceView } from '@/lib/schemas'
import {
boardColumnsToKanbanValue,
@@ -15,11 +15,16 @@ interface UseServicesKanbanOptions {
export function useServicesKanban(options: UseServicesKanbanOptions) {
const boardHook = useServicesBoard(options)
const kanbanValue = boardColumnsToKanbanValue(
boardHook.board.columns.map((column) => ({
id: column.id,
items: column.items,
})),
// Memoized: a 10s polling tick must not rebuild kanban value identity.
const kanbanValue = useMemo(
() =>
boardColumnsToKanbanValue(
boardHook.board.columns.map((column) => ({
id: column.id,
items: column.items,
})),
),
[boardHook.board.columns],
)
const handleKanbanValueChange = useCallback(
-28
View File
@@ -1,28 +0,0 @@
/** Debug session 943716 — remove after layout/chart investigation */
export const DEBUG_BUILD_STAMP = 'layout-charts-v1'
export function debugAgentLog(
location: string,
message: string,
data: Record<string, unknown>,
hypothesisId: string,
) {
// #region agent log
fetch('http://127.0.0.1:7580/ingest/5c1b60ca-3f59-41ce-8435-d25bcc12c3cf', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Debug-Session-Id': '943716',
},
body: JSON.stringify({
sessionId: '943716',
location,
message,
data,
hypothesisId,
timestamp: Date.now(),
runId: 'pre-fix',
}),
}).catch(() => {})
// #endregion
}
+2
View File
@@ -4,6 +4,8 @@ import { ApiError } from './api-client'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Polling queries carry staleTime 5s; a refetch on every alt-tab is waste.
refetchOnWindowFocus: false,
staleTime: 1000 * 60,
retry: (count, error) => {
if (error instanceof ApiError && error.status === 404) return false
-6
View File
@@ -8,12 +8,6 @@ import { Toaster } from '@cfdm/ui/components/sonner'
import { routeTree } from './routeTree.gen'
import { queryClient } from './lib/queryClient'
import '@cfdm/ui/globals.css'
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
debugAgentLog('main.tsx:boot', 'app boot', {
buildStamp: DEBUG_BUILD_STAMP,
href: typeof window !== 'undefined' ? window.location.href : '',
}, 'B')
const router = createRouter({
routeTree,
+21
View File
@@ -34,6 +34,27 @@ export function healthStatusQueryOptions(
})
}
/** Batch: IP health for many bindings in one request (lists avoid N+1 polls). */
export function bindingsHealthBatchQueryOptions(bindingIds: number[]) {
const ids = [...bindingIds].sort((a, b) => a - b)
return queryOptions({
queryKey: [...healthStatusKeys.all, 'batch', ids] as const,
queryFn: async () => {
const data = await api.get<{ items: { ref_id: number; rows: unknown[] }[] }>(
`/api/v1/health-status/batch?ref_ids=${ids.join(',')}`,
)
const byId = new Map<number, z.infer<typeof ipHealthStatusSchema>[]>()
for (const item of data.items) {
byId.set(item.ref_id, z.array(ipHealthStatusSchema).parse(item.rows))
}
return byId
},
// Polling pauses when the tab is hidden (refetchIntervalInBackground=false default).
refetchInterval: 10_000,
staleTime: 5_000,
})
}
export async function runHealthCheck() {
return api.post<{ checked: number }>('/api/v1/health-check/run', {})
}
+1 -21
View File
@@ -1,6 +1,6 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState, useEffect } from 'react'
import { useMemo, useState } from 'react'
import {
ActivityIcon,
AlertTriangleIcon,
@@ -41,7 +41,6 @@ import {
} from '@cfdm/ui/components/item'
import { Button } from '@cfdm/ui/components/button'
import { formatRelative } from '@/lib/format'
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
import { api } from '@/lib/api-client'
export const Route = createFileRoute('/_auth/')({
@@ -201,25 +200,6 @@ function DashboardPage() {
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
useEffect(() => {
if (isLoading) return
debugAgentLog(
'index.tsx:dashboard-data',
'dashboard chart inputs',
{
buildStamp: DEBUG_BUILD_STAMP,
summaryRaw: summary ?? null,
statusChartLen: statusChartData.length,
statusChartData,
groupChartLen: groupChartData.length,
groupChartData,
domainsLen: domains?.length ?? 0,
groupsLen: groups?.length ?? 0,
},
'C',
)
}, [isLoading, summary, statusChartData, groupChartData, domains, groups])
const kpiCards: KpiStatCard[] = [
{
id: 'domains',
@@ -42,10 +42,8 @@ import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
import {
createServiceNode,
deleteServiceNode,
domainKeys,
domainsListQueryOptions,
serviceBindingKeys,
serviceDetailKeys,
serviceFailoverLogQueryOptions,
serviceGroupKeys,
serviceGroupsQueryOptions,
@@ -153,16 +151,11 @@ function ServiceDetailPage() {
: []
async function invalidateService() {
// Targeted invalidation: only service-scoped data the mutation touched.
await Promise.all([
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.failoverLog(id) }),
])
}
+45
View File
@@ -10,6 +10,51 @@ export default defineConfig({
react(),
tailwindcss(),
],
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{
name: 'react-vendor',
test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/,
priority: 20,
},
{
name: 'router-vendor',
test: /node_modules[\\/]@tanstack[\\/](react-router|router-core|history|store|react-store)[\\/]/,
priority: 19,
},
{
name: 'query-vendor',
test: /node_modules[\\/]@tanstack[\\/](react-query|query-core|query-devtools|mutation-core)[\\/]/,
priority: 18,
},
{
name: 'charts-vendor',
test: /node_modules[\\/](recharts|d3-[a-z]+|victory-vendor|internmap)[\\/]/,
priority: 17,
},
{
name: 'ui-vendor',
test: /node_modules[\\/](@base-ui|@dnd-kit|lucide-react|cmdk|sonner|next-themes|class-variance-authority)[\\/]/,
priority: 16,
},
{
name: 'forms-vendor',
test: /node_modules[\\/](react-hook-form|@hookform|zod)[\\/]/,
priority: 15,
},
{
name: 'vendor',
test: /node_modules/,
priority: 1,
},
],
},
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
+9 -5
View File
@@ -29,24 +29,28 @@ RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
&& rm -rf /out/src /out/test /out/.turbo \
&& rm -rf /out/node_modules/@cfdm/db/src /out/node_modules/@cfdm/db/scripts /out/node_modules/@cfdm/db/.turbo \
&& rm -rf /out/node_modules/@cfdm/shared/src /out/node_modules/@cfdm/shared/.turbo \
&& find /out/dist /out/node_modules/@cfdm -type f \( -name '*.d.ts' -o -name '*.map' -o -name 'tsconfig*.json' -o -name 'vitest.config.ts' -o -name 'drizzle.config.ts' \) -delete
&& find /out/node_modules/@cfdm -type f \( -name '*.d.ts' -o -name '*.map' -o -name 'tsconfig*.json' -o -name 'vitest.config.ts' -o -name 'drizzle.config.ts' \) -delete \
&& find /out/dist -type f \( -name '*.d.ts' -o -name 'tsconfig*.json' -o -name 'vitest.config.ts' -o -name 'drizzle.config.ts' \) -delete
FROM ${BASE_NODE} AS runtime
WORKDIR /app
RUN apk add --no-cache ca-certificates
RUN apk add --no-cache ca-certificates wget
ARG VERSION=dev
ARG GIT_SHA=unknown
ARG BUILD_TIME=
ENV NODE_ENV=production \
NODE_OPTIONS=--enable-source-maps \
STATIC_DIR=/app/static \
DATABASE_URL=sqlite:/data/app.db \
SERVER_PORT=8080 \
APP_VERSION=${VERSION} \
GIT_SHA=${GIT_SHA} \
BUILD_TIME=${BUILD_TIME}
COPY --from=build /out ./
COPY --from=build --chown=node:node /out ./
RUN mkdir -p /data && chown node:node /data /app
USER node
EXPOSE 8080
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
CMD ["node", "dist/server.js"]
CMD ["wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"]
CMD ["node", "--max-old-space-size=384", "dist/server.js"]
+4 -2
View File
@@ -19,9 +19,11 @@ services:
volumes:
- ./data:/data
restart: unless-stopped
init: true
mem_limit: 512m
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
start_period: 10s
+50 -2
View File
File diff suppressed because one or more lines are too long
+314 -1
View File
@@ -425,6 +425,9 @@ function createDb(databaseUrl) {
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("synchronous = NORMAL");
sqlite.pragma("foreign_keys = ON");
sqlite.pragma("busy_timeout = 5000");
sqlite.pragma("cache_size = -16000");
sqlite.pragma("wal_autocheckpoint = 1000");
const db = drizzle(sqlite, { schema });
return { db, sqlite };
}
@@ -455,6 +458,9 @@ function runMigrations(sqlite) {
function healthCheck(sqlite) {
sqlite.prepare("SELECT 1").get();
}
function walCheckpointTruncate(sqlite) {
sqlite.pragma("wal_checkpoint(TRUNCATE)");
}
// src/errors.ts
var NotFoundError = class extends Error {
@@ -669,6 +675,7 @@ function touchVpsTrackerSync(db) {
// src/repos.ts
var repos_exports = {};
__export(repos_exports, {
LOG_RETENTION_LIMITS: () => LOG_RETENTION_LIMITS,
addDomainTags: () => addDomainTags,
aggregateGroupScopeHealthByIds: () => aggregateGroupScopeHealthByIds,
aggregateIpHealthByRefs: () => aggregateIpHealthByRefs,
@@ -723,6 +730,7 @@ __export(repos_exports, {
getServiceGroup: () => getServiceGroup,
getSubdomain: () => getSubdomain,
getSyncJob: () => getSyncJob,
hasWeightedBindings: () => hasWeightedBindings,
insertBinding: () => insertBinding,
insertDnsRecord: () => insertDnsRecord,
insertFailoverLog: () => insertFailoverLog,
@@ -734,11 +742,14 @@ __export(repos_exports, {
listAllDomains: () => listAllDomains,
listAllNodes: () => listAllNodes,
listAllSubdomains: () => listAllSubdomains,
listBindingIpHealthByBindingIds: () => listBindingIpHealthByBindingIds,
listBindingIps: () => listBindingIps,
listBindingIpsWithMeta: () => listBindingIpsWithMeta,
listBindingIpsWithMetaByBindingIds: () => listBindingIpsWithMetaByBindingIds,
listBindingNodes: () => listBindingNodes,
listBindingsByDomain: () => listBindingsByDomain,
listBindingsByService: () => listBindingsByService,
listBindingsByServiceIds: () => listBindingsByServiceIds,
listCertificates: () => listCertificates,
listDnsByDomain: () => listDnsByDomain,
listDnsRecords: () => listDnsRecords,
@@ -757,19 +768,25 @@ __export(repos_exports, {
listHealthProbeLogForService: () => listHealthProbeLogForService,
listIpHealthByServiceIds: () => listIpHealthByServiceIds,
listIpHealthStatus: () => listIpHealthStatus,
listIpHealthStatusByBindingIds: () => listIpHealthStatusByBindingIds,
listLatestLiveHealthByServiceIds: () => listLatestLiveHealthByServiceIds,
listNodes: () => listNodes,
listNotificationLog: () => listNotificationLog,
listOriginIpsForFqdn: () => listOriginIpsForFqdn,
listRecordsByBindingIds: () => listRecordsByBindingIds,
listRecordsForBinding: () => listRecordsForBinding,
listServiceGroups: () => listServiceGroups,
listServiceIpRows: () => listServiceIpRows,
listServiceIpRowsByServiceIds: () => listServiceIpRowsByServiceIds,
listServiceIps: () => listServiceIps,
listServices: () => listServices,
listServicesByGroup: () => listServicesByGroup,
listServicesByGroupIds: () => listServicesByGroupIds,
listSubdomainsByDomain: () => listSubdomainsByDomain,
listUngroupedServices: () => listUngroupedServices,
markDnsPendingDelete: () => markDnsPendingDelete,
mergeHealthAggregates: () => mergeHealthAggregates,
pruneLogs: () => pruneLogs,
pruneStaleIpHealthStatus: () => pruneStaleIpHealthStatus,
reorderServices: () => reorderServices,
replaceBindingIps: () => replaceBindingIps,
@@ -1135,6 +1152,29 @@ function listServices(db) {
function listServicesByGroup(db, groupId) {
return db.select().from(services).where(eq3(services.service_group_id, groupId)).orderBy(asc(services.sort_order), asc(services.name)).all();
}
function listServicesByGroupIds(db, groupIds) {
const result = /* @__PURE__ */ new Map();
if (groupIds.length === 0) return result;
const idList = sql2.join(
groupIds.map((id) => sql2`${id}`),
sql2`, `
);
const rows = db.all(sql2`
SELECT * FROM services
WHERE service_group_id IN (${idList})
ORDER BY service_group_id, sort_order, name
`);
for (const row of rows) {
const groupId = row.service_group_id;
let list = result.get(groupId);
if (!list) {
list = [];
result.set(groupId, list);
}
list.push(row);
}
return result;
}
function listUngroupedServices(db) {
return db.select().from(services).where(isNull(services.service_group_id)).orderBy(asc(services.sort_order), asc(services.name)).all();
}
@@ -1772,6 +1812,16 @@ function listAllBindings(db) {
ORDER BY d.zone_name, s.name
`).map((row) => enrichServiceBindingView(db, row));
}
function hasWeightedBindings(db) {
const row = db.get(sql2`
SELECT (EXISTS (
SELECT 1 FROM service_bindings WHERE lb_mode = 'weighted'
) OR EXISTS (
SELECT 1 FROM service_groups WHERE lb_mode = 'weighted'
)) AS n
`);
return Boolean(row?.n);
}
function listBindingsByDomain(db, domainId) {
return db.all(sql2`
SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)}
@@ -1800,6 +1850,123 @@ function getBinding(db, id) {
if (!row) throw new NotFoundError(`service binding ${id}`);
return mapServiceBinding(row);
}
function listBindingsByServiceIds(db, serviceIds) {
const result = /* @__PURE__ */ new Map();
if (serviceIds.length === 0) return result;
const idList = sql2.join(
serviceIds.map((id) => sql2`${id}`),
sql2`, `
);
const rows = db.all(sql2`
SELECT sb.*, d.zone_name FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
WHERE sb.service_id IN (${idList})
ORDER BY sb.id
`);
for (const row of rows) {
const { service_id, ...binding } = row;
let list = result.get(service_id);
if (!list) {
list = [];
result.set(service_id, list);
}
list.push({
...mapServiceBinding({ ...binding, service_id }),
zone_name: row.zone_name
});
}
return result;
}
function listBindingIpsWithMetaByBindingIds(db, bindingIds) {
const result = /* @__PURE__ */ new Map();
if (bindingIds.length === 0) return result;
const idList = sql2.join(
bindingIds.map((id) => sql2`${id}`),
sql2`, `
);
const rows = db.all(sql2`
SELECT binding_id, ip, weight, priority FROM service_binding_ips
WHERE binding_id IN (${idList})
ORDER BY binding_id, rowid
`);
for (const row of rows) {
let list = result.get(row.binding_id);
if (!list) {
list = [];
result.set(row.binding_id, list);
}
list.push({ ip: row.ip, weight: row.weight, priority: row.priority });
}
return result;
}
function listRecordsByBindingIds(db, bindingIds) {
const result = /* @__PURE__ */ new Map();
if (bindingIds.length === 0) return result;
const idList = sql2.join(
bindingIds.map((id) => sql2`${id}`),
sql2`, `
);
const rows = db.all(sql2`
SELECT dr.*, sbr.binding_id AS binding_id FROM dns_records dr
INNER JOIN service_binding_records sbr ON sbr.dns_record_id = dr.id
WHERE sbr.binding_id IN (${idList})
ORDER BY sbr.binding_id, dr.id
`);
for (const row of rows) {
const { binding_id, ...record } = row;
let list = result.get(binding_id);
if (!list) {
list = [];
result.set(binding_id, list);
}
list.push(record);
}
return result;
}
function listBindingIpHealthByBindingIds(db, bindingIds) {
const result = /* @__PURE__ */ new Map();
if (bindingIds.length === 0) return result;
const idList = sql2.join(
bindingIds.map((id) => sql2`${id}`),
sql2`, `
);
const rows = db.all(sql2`
SELECT ref_id, ip, status, latency_ms
FROM ip_health_status
WHERE scope = 'binding' AND ref_id IN (${idList})
`);
for (const row of rows) {
let byIp = result.get(row.ref_id);
if (!byIp) {
byIp = /* @__PURE__ */ new Map();
result.set(row.ref_id, byIp);
}
byIp.set(row.ip, { status: row.status, latency_ms: row.latency_ms });
}
return result;
}
function listServiceIpRowsByServiceIds(db, serviceIds) {
const result = /* @__PURE__ */ new Map();
if (serviceIds.length === 0) return result;
const idList = sql2.join(
serviceIds.map((id) => sql2`${id}`),
sql2`, `
);
const rows = db.all(sql2`
SELECT service_id, ip, enabled FROM service_ips
WHERE service_id IN (${idList})
ORDER BY service_id, rowid
`);
for (const row of rows) {
let list = result.get(row.service_id);
if (!list) {
list = [];
result.set(row.service_id, list);
}
list.push({ ip: row.ip, enabled: Boolean(row.enabled) });
}
return result;
}
function getBindingView(db, id) {
const rows = db.all(sql2`
SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)}
@@ -1972,6 +2139,29 @@ function listIpHealthStatus(db, scope, refId) {
WHERE scope = ${scope} AND ref_id = ${refId}
`);
}
function listIpHealthStatusByBindingIds(db, bindingIds) {
const result = /* @__PURE__ */ new Map();
if (bindingIds.length === 0) return result;
const idList = sql2.join(
bindingIds.map((id) => sql2`${id}`),
sql2`, `
);
const rows = db.all(sql2`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error, colo, provider
FROM ip_health_status
WHERE scope = 'binding' AND ref_id IN (${idList})
`);
for (const row of rows) {
let list = result.get(row.ref_id);
if (!list) {
list = [];
result.set(row.ref_id, list);
}
list.push(row);
}
return result;
}
var UNKNOWN_HEALTH = {
health_status: "unknown",
health_latency_ms: null
@@ -2127,6 +2317,83 @@ function listIpHealthByServiceIds(db, serviceIds) {
}
return result;
}
function parseIpHealthState(status) {
if (status === "up" || status === "down" || status === "degraded" || status === "unknown") {
return status;
}
return void 0;
}
function bestAliveIpState(statuses) {
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";
}
function listLatestLiveHealthByServiceIds(db, serviceIds) {
const result = /* @__PURE__ */ new Map();
if (serviceIds.length === 0) return result;
const idList = sql2.join(
serviceIds.map((id) => sql2`${id}`),
sql2`, `
);
const rows = db.all(sql2`
SELECT sb.service_id AS service_id,
l.ip AS ip,
l.provider AS provider,
l.status AS status,
l.latency_ms AS latency_ms,
l.error AS last_error,
l.colo AS colo,
l.checked_at AS checked_at
FROM health_probe_log l
INNER JOIN service_bindings sb
ON l.scope = 'binding' AND l.ref_id = sb.id
INNER JOIN (
SELECT sb2.service_id AS service_id,
l2.ip AS ip,
l2.provider AS provider,
MAX(l2.id) AS max_id
FROM health_probe_log l2
INNER JOIN service_bindings sb2
ON l2.scope = 'binding' AND l2.ref_id = sb2.id
WHERE sb2.service_id IN (${idList})
GROUP BY sb2.service_id, l2.ip, l2.provider
) latest
ON latest.max_id = l.id
`);
const byServiceIp = /* @__PURE__ */ new Map();
for (const row of rows) {
const status = parseIpHealthState(row.status);
if (!status) continue;
const key = `${row.service_id}\0${row.ip}`;
const probe = {
ip: row.ip,
status,
latency_ms: row.latency_ms,
last_checked_at: row.checked_at,
last_error: row.last_error,
provider: normalizeStatusProvider(row.provider),
colo: row.colo
};
const bucket = byServiceIp.get(key);
if (bucket) bucket.probes.push(probe);
else {
byServiceIp.set(key, {
serviceId: row.service_id,
ip: row.ip,
probes: [probe]
});
}
}
for (const { serviceId, ip, probes } of byServiceIp.values()) {
const status = bestAliveIpState(probes.map((probe) => probe.status));
const preferred = probes.find((probe) => probe.status === status) ?? probes[0];
const list = result.get(serviceId) ?? [];
list.push({ ...preferred, ip, status });
result.set(serviceId, list);
}
return result;
}
function mergeHealthAggregates(parts) {
const rank = {
unknown: 0,
@@ -2563,6 +2830,51 @@ function listNotificationLog(db, limit = 50) {
LIMIT ${limit}
`);
}
var LOG_RETENTION_LIMITS = {
notification_log: 500,
failover_log: 1e3,
audit_log: 2e3,
sync_jobs: 500,
health_probe_log: 2e3
};
function pruneLogs(db) {
let deleted = 0;
const byId = [
{ table: "notification_log", limit: LOG_RETENTION_LIMITS.notification_log },
{ table: "failover_log", limit: LOG_RETENTION_LIMITS.failover_log },
{ table: "health_probe_log", limit: LOG_RETENTION_LIMITS.health_probe_log }
];
for (const { table, limit } of byId) {
const res = db.run(sql2`
DELETE FROM ${sql2.identifier(table)}
WHERE id NOT IN (
SELECT id FROM ${sql2.identifier(table)}
ORDER BY id DESC
LIMIT ${limit}
)
`);
deleted += res.changes;
}
const audit = db.run(sql2`
DELETE FROM audit_log
WHERE id NOT IN (
SELECT id FROM audit_log
ORDER BY created_at DESC, id DESC
LIMIT ${LOG_RETENTION_LIMITS.audit_log}
)
`);
deleted += audit.changes;
const jobs = db.run(sql2`
DELETE FROM sync_jobs
WHERE id NOT IN (
SELECT id FROM sync_jobs
ORDER BY created_at DESC, id DESC
LIMIT ${LOG_RETENTION_LIMITS.sync_jobs}
)
`);
deleted += jobs.changes;
return deleted;
}
function insertFailoverLog(db, input) {
for (const entry of input.entries) {
db.insert(failoverLog).values({
@@ -2623,5 +2935,6 @@ export {
subdomains,
syncJobs,
touchVpsTrackerSync,
updateAppSettings
updateAppSettings,
walCheckpointTruncate
};
+8
View File
@@ -23,6 +23,9 @@ export function createDb(databaseUrl: string): { db: Db; sqlite: Sqlite } {
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("synchronous = NORMAL");
sqlite.pragma("foreign_keys = ON");
sqlite.pragma("busy_timeout = 5000");
sqlite.pragma("cache_size = -16000");
sqlite.pragma("wal_autocheckpoint = 1000");
const db = drizzle(sqlite, { schema });
return { db, sqlite };
}
@@ -63,3 +66,8 @@ export function runMigrations(sqlite: Sqlite): void {
export function healthCheck(sqlite: Sqlite): void {
sqlite.prepare("SELECT 1").get();
}
/** Truncate the WAL back into the main DB file; safe to run periodically. */
export function walCheckpointTruncate(sqlite: Sqlite): void {
sqlite.pragma("wal_checkpoint(TRUNCATE)");
}
+278
View File
@@ -648,6 +648,34 @@ export function listServicesByGroup(db: Db, groupId: number): Service[] {
.all() as Service[];
}
/** Batch: services for many groups in one query (hot path: /service-groups). */
export function listServicesByGroupIds(
db: Db,
groupIds: number[],
): Map<number, Service[]> {
const result = new Map<number, Service[]>();
if (groupIds.length === 0) return result;
const idList = sql.join(
groupIds.map((id) => sql`${id}`),
sql`, `,
);
const rows = db.all<Service & { service_group_id: number }>(sql`
SELECT * FROM services
WHERE service_group_id IN (${idList})
ORDER BY service_group_id, sort_order, name
`);
for (const row of rows) {
const groupId = row.service_group_id;
let list = result.get(groupId);
if (!list) {
list = [];
result.set(groupId, list);
}
list.push(row);
}
return result;
}
export function listUngroupedServices(db: Db): Service[] {
return db
.select()
@@ -1762,6 +1790,18 @@ export function listAllBindings(db: Db): ServiceBindingView[] {
.map((row) => enrichServiceBindingView(db, row));
}
/** Cheap predicate for the weighted-dns scheduler: any weighted row at all? */
export function hasWeightedBindings(db: Db): boolean {
const row = db.get<{ n: number }>(sql`
SELECT (EXISTS (
SELECT 1 FROM service_bindings WHERE lb_mode = 'weighted'
) OR EXISTS (
SELECT 1 FROM service_groups WHERE lb_mode = 'weighted'
)) AS n
`);
return Boolean(row?.n);
}
export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingView[] {
return db
.all<Omit<ServiceBindingView, "target_ips" | "target_ip_weights" | "target_ip_priorities">>(sql`
@@ -1799,6 +1839,163 @@ export function getBinding(db: Db, id: number): ServiceBinding {
return mapServiceBinding(row);
}
/** Batch: bindings for many services in one query (hot path: /service-groups). */
export function listBindingsByServiceIds(
db: Db,
serviceIds: number[],
): Map<number, Array<ServiceBinding & { zone_name: string }>> {
const result = new Map<
number,
Array<ServiceBinding & { zone_name: string }>
>();
if (serviceIds.length === 0) return result;
const idList = sql.join(
serviceIds.map((id) => sql`${id}`),
sql`, `,
);
const rows = db.all<
typeof serviceBindings.$inferSelect & { service_id: number; zone_name: string }
>(sql`
SELECT sb.*, d.zone_name FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
WHERE sb.service_id IN (${idList})
ORDER BY sb.id
`);
for (const row of rows) {
const { service_id, ...binding } = row;
let list = result.get(service_id);
if (!list) {
list = [];
result.set(service_id, list);
}
list.push({
...mapServiceBinding({ ...binding, service_id }),
zone_name: row.zone_name,
});
}
return result;
}
/** Batch: binding IPs with metadata for many bindings in one query. */
export function listBindingIpsWithMetaByBindingIds(
db: Db,
bindingIds: number[],
): Map<number, BindingIpMeta[]> {
const result = new Map<number, BindingIpMeta[]>();
if (bindingIds.length === 0) return result;
const idList = sql.join(
bindingIds.map((id) => sql`${id}`),
sql`, `,
);
const rows = db.all<{
binding_id: number;
ip: string;
weight: number;
priority: number;
}>(sql`
SELECT binding_id, ip, weight, priority FROM service_binding_ips
WHERE binding_id IN (${idList})
ORDER BY binding_id, rowid
`);
for (const row of rows) {
let list = result.get(row.binding_id);
if (!list) {
list = [];
result.set(row.binding_id, list);
}
list.push({ ip: row.ip, weight: row.weight, priority: row.priority });
}
return result;
}
/** Batch: dns records linked to many bindings in one query. */
export function listRecordsByBindingIds(
db: Db,
bindingIds: number[],
): Map<number, DnsRecord[]> {
const result = new Map<number, DnsRecord[]>();
if (bindingIds.length === 0) return result;
const idList = sql.join(
bindingIds.map((id) => sql`${id}`),
sql`, `,
);
const rows = db.all<DnsRecord & { binding_id: number }>(sql`
SELECT dr.*, sbr.binding_id AS binding_id FROM dns_records dr
INNER JOIN service_binding_records sbr ON sbr.dns_record_id = dr.id
WHERE sbr.binding_id IN (${idList})
ORDER BY sbr.binding_id, dr.id
`);
for (const row of rows) {
const { binding_id, ...record } = row;
let list = result.get(binding_id);
if (!list) {
list = [];
result.set(binding_id, list);
}
list.push(record);
}
return result;
}
/** Batch: binding-scope IP health status for many bindings in one query. */
export function listBindingIpHealthByBindingIds(
db: Db,
bindingIds: number[],
): Map<number, Map<string, { status: string; latency_ms: number | null }>> {
const result = new Map<number, Map<string, { status: string; latency_ms: number | null }>>();
if (bindingIds.length === 0) return result;
const idList = sql.join(
bindingIds.map((id) => sql`${id}`),
sql`, `,
);
const rows = db.all<{
ref_id: number;
ip: string;
status: string;
latency_ms: number | null;
}>(sql`
SELECT ref_id, ip, status, latency_ms
FROM ip_health_status
WHERE scope = 'binding' AND ref_id IN (${idList})
`);
for (const row of rows) {
let byIp = result.get(row.ref_id);
if (!byIp) {
byIp = new Map();
result.set(row.ref_id, byIp);
}
byIp.set(row.ip, { status: row.status, latency_ms: row.latency_ms });
}
return result;
}
/** Batch: service IP rows for many services in one query. */
export function listServiceIpRowsByServiceIds(
db: Db,
serviceIds: number[],
): Map<number, ServiceIpRow[]> {
const result = new Map<number, ServiceIpRow[]>();
if (serviceIds.length === 0) return result;
const idList = sql.join(
serviceIds.map((id) => sql`${id}`),
sql`, `,
);
const rows = db.all<{ service_id: number; ip: string; enabled: number }>(sql`
SELECT service_id, ip, enabled FROM service_ips
WHERE service_id IN (${idList})
ORDER BY service_id, rowid
`);
for (const row of rows) {
let list = result.get(row.service_id);
if (!list) {
list = [];
result.set(row.service_id, list);
}
list.push({ ip: row.ip, enabled: Boolean(row.enabled) });
}
return result;
}
export function getBindingView(db: Db, id: number): ServiceBindingView {
const rows = db.all<Omit<ServiceBindingView, "target_ips" | "target_ip_weights" | "target_ip_priorities">>(sql`
SELECT ${sql.raw(SERVICE_BINDING_SELECT_COLUMNS)}
@@ -2113,6 +2310,34 @@ export function listIpHealthStatus(
`);
}
/** Batch: latest IP health rows for many bindings in one query. */
export function listIpHealthStatusByBindingIds(
db: Db,
bindingIds: number[],
): Map<number, IpHealthStatus[]> {
const result = new Map<number, IpHealthStatus[]>();
if (bindingIds.length === 0) return result;
const idList = sql.join(
bindingIds.map((id) => sql`${id}`),
sql`, `,
);
const rows = db.all<IpHealthStatus>(sql`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error, colo, provider
FROM ip_health_status
WHERE scope = 'binding' AND ref_id IN (${idList})
`);
for (const row of rows) {
let list = result.get(row.ref_id);
if (!list) {
list = [];
result.set(row.ref_id, list);
}
list.push(row);
}
return result;
}
export type HealthAggregate = {
health_status: IpHealthState;
health_latency_ms: number | null;
@@ -3171,6 +3396,59 @@ export function listNotificationLog(
`);
}
// --- Log retention ---
export const LOG_RETENTION_LIMITS = {
notification_log: 500,
failover_log: 1000,
audit_log: 2000,
sync_jobs: 500,
health_probe_log: 2000,
} as const;
/** Keep only the newest N rows per log table; returns total deleted rows. */
export function pruneLogs(db: Db): number {
let deleted = 0;
// Integer autoincrement PKs — order by id.
const byId: Array<{ table: string; limit: number }> = [
{ table: "notification_log", limit: LOG_RETENTION_LIMITS.notification_log },
{ table: "failover_log", limit: LOG_RETENTION_LIMITS.failover_log },
{ table: "health_probe_log", limit: LOG_RETENTION_LIMITS.health_probe_log },
];
for (const { table, limit } of byId) {
const res = db.run(sql`
DELETE FROM ${sql.identifier(table)}
WHERE id NOT IN (
SELECT id FROM ${sql.identifier(table)}
ORDER BY id DESC
LIMIT ${limit}
)
`);
deleted += res.changes;
}
// audit_log has TEXT (uuid) PK — order by created_at, id.
const audit = db.run(sql`
DELETE FROM audit_log
WHERE id NOT IN (
SELECT id FROM audit_log
ORDER BY created_at DESC, id DESC
LIMIT ${LOG_RETENTION_LIMITS.audit_log}
)
`);
deleted += audit.changes;
// sync_jobs has TEXT PK — order by created_at, id.
const jobs = db.run(sql`
DELETE FROM sync_jobs
WHERE id NOT IN (
SELECT id FROM sync_jobs
ORDER BY created_at DESC, id DESC
LIMIT ${LOG_RETENTION_LIMITS.sync_jobs}
)
`);
deleted += jobs.changes;
return deleted;
}
export type FailoverLogAction = "added" | "removed";
export type FailoverLogRow = {
-105
View File
@@ -63,9 +63,6 @@ importers:
'@fastify/rate-limit':
specifier: ^10.3.0
version: 10.3.0
'@fastify/schedule':
specifier: ^6.0.0
version: 6.0.0([email protected])
'@fastify/sensible':
specifier: ^6.0.3
version: 6.0.4
@@ -87,9 +84,6 @@ importers:
p-limit:
specifier: ^6.2.0
version: 6.2.0
p-queue:
specifier: ^8.1.0
version: 8.1.1
toad-scheduler:
specifier: ^4.0.1
version: 4.0.1
@@ -166,9 +160,6 @@ importers:
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
date-fns:
specifier: ^4.4.0
version: 4.4.0
lucide-react:
specifier: ^1.18.0
version: 1.18.0([email protected])
@@ -178,18 +169,12 @@ importers:
react:
specifier: ^19.2.6
version: 19.2.7
react-day-picker:
specifier: ^10.0.1
version: 10.0.1(@types/[email protected])([email protected])
react-dom:
specifier: ^19.2.6
version: 19.2.7([email protected])
react-hook-form:
specifier: ^7.79.0
version: 7.79.0([email protected])
react-phone-number-input:
specifier: ^3.4.17
version: 3.4.17([email protected]([email protected]))([email protected])
recharts:
specifier: ^3.8.0
version: 3.8.0(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
@@ -1277,11 +1262,6 @@ packages:
'@fastify/[email protected]':
resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==}
'@fastify/[email protected]':
resolution: {integrity: sha512-q4mPXUtqISb+dH2cB7HE7DxXqQmxPee9k0G29ydvGOoj5Aqb+uzUCLtkMVAzpnkPE6oOFGon6+JzWEmfJNS8ig==}
peerDependencies:
toad-scheduler: '>=2.0.0'
'@fastify/[email protected]':
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
@@ -2854,9 +2834,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
[email protected]:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
[email protected]:
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
engines: {node: '>=6'}
@@ -3002,9 +2979,6 @@ packages:
typescript:
optional: true
[email protected]:
resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==}
[email protected]:
resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
engines: {node: '>=18.0'}
@@ -3784,17 +3758,6 @@ packages:
resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
[email protected]:
resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==}
peerDependencies:
react: '>=18.1.0'
react-dom: '>=18.1.0'
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
[email protected]:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
@@ -3949,9 +3912,6 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
[email protected]:
resolution: {integrity: sha512-80xal1m93rADejw2pMp2MSzFhHCPLEspjHxnH2UtqI+DgAmElsbmLMiqk9niwH9NWAfjsRtaJI+qBrOEmRx9nQ==}
[email protected]:
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
@@ -4097,10 +4057,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
[email protected]:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -4442,10 +4398,6 @@ packages:
resolution: {integrity: sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==}
engines: {node: '>=12'}
@@ -4611,9 +4563,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
[email protected]:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
[email protected]:
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
@@ -4668,18 +4617,9 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
[email protected]:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
[email protected]:
resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==}
[email protected]:
resolution: {integrity: sha512-1wcjhBAWHgEBAGLi5/XbeZI7Q3aEHNb2z/dHY6R2Gz70TQvu0ZoOT28NTdwtZf4lyRKXWufnTzVhLPBUD8LfmQ==}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
[email protected]:
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
peerDependencies:
@@ -6371,11 +6311,6 @@ snapshots:
fastify-plugin: 5.1.0
toad-cache: 3.7.1
'@fastify/[email protected]([email protected])':
dependencies:
fastify-plugin: 5.1.0
toad-scheduler: 4.0.1
'@fastify/[email protected]':
dependencies:
'@lukeed/ms': 2.0.2
@@ -7952,8 +7887,6 @@ snapshots:
dependencies:
clsx: 2.1.1
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -8105,8 +8038,6 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -8920,13 +8851,6 @@ snapshots:
[email protected]: {}
[email protected]([email protected]([email protected]))([email protected]):
dependencies:
prop-types: 15.8.1
optionalDependencies:
react: 19.2.7
react-dom: 19.2.7([email protected])
[email protected]: {}
[email protected]: {}
@@ -9044,8 +8968,6 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
[email protected]: {}
[email protected]:
dependencies:
cookie: 1.1.1
@@ -9157,10 +9079,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
js-tokens: 4.0.0
[email protected]: {}
[email protected]: {}
@@ -9394,11 +9312,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
eventemitter3: 5.0.4
p-timeout: 6.1.4
[email protected]: {}
[email protected]: {}
@@ -9545,12 +9458,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
[email protected]: {}
[email protected]: {}
@@ -9592,20 +9499,8 @@ snapshots:
dependencies:
react: 19.2.7
[email protected]: {}
[email protected]: {}
[email protected]([email protected]([email protected]))([email protected]):
dependencies:
classnames: 2.5.1
country-flag-icons: 1.6.20
input-format: 0.3.14([email protected]([email protected]))([email protected])
libphonenumber-js: 1.13.8
prop-types: 15.8.1
react: 19.2.7
react-dom: 19.2.7([email protected])
[email protected](@types/[email protected])([email protected])([email protected]):
dependencies:
'@types/use-sync-external-store': 0.0.6
+10 -3
View File
@@ -3,15 +3,22 @@
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
"outputs": [
"dist/**",
"src/routeTree.gen.ts",
"node_modules/.tmp/*.tsbuildinfo"
]
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"dependsOn": ["^build"]
"dependsOn": ["^build"],
"inputs": ["src/**", "test/**", "package.json", "tsconfig*.json", "vitest.config.*"]
},
"lint": {}
"lint": {
"inputs": ["src/**", "test/**", "package.json", "eslint.config.*", "tsconfig*.json"]
}
}
}