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:
DenozordecandCursor committed 2026-09-03 16:16:41 +07:00
1 parent de3dbe8521
commit 1e9cc04a59
42 files changed
+6036 -2836

No files matched your search

@@ -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
@@ -0,0 +1 @@
{"version":3,"sources":["../src/server.ts","../src/app.ts","../src/config.ts","../src/plugins/auth.ts","../src/errors.ts","../src/lib/permissions.ts","../src/plugins/cf-client.ts","../src/lib/cf-retry.ts","../src/lib/cloudflare/zone-cache-events.ts","../src/lib/cloudflare/http.ts","../src/lib/cloudflare/dns-service.ts","../src/lib/cloudflare/healthcheck-service.ts","../src/lib/cloudflare/kv-service.ts","../src/lib/cloudflare/workers-service.ts","../src/lib/cloudflare/zone-service.ts","../src/lib/cf-client.ts","../src/plugins/cors.ts","../src/plugins/db.ts","../src/plugins/error-handler.ts","../src/routes/health.ts","../src/services/auth.ts","../src/routes/groups.ts","../src/services/group-service.ts","../src/lib/audit.ts","../src/services/audit-portal-push.ts","../src/routes/services.ts","../src/services/service-config-service.ts","../src/lib/validators.ts","../src/services/dns-service.ts","../src/services/domain-service.ts","../src/services/binding-service.ts","../src/services/sync-service.ts","../src/services/vps-tracker-sync.ts","../src/services/routing/pool.ts","../src/services/health/health-worker-deploy.ts","../src/services/health/health-probe-script.ts","../src/services/health/mailbox.ts","../src/services/routing/health.ts","../src/services/routing/failover.ts","../src/services/routing/round-robin.ts","../src/services/routing/weighted.ts","../src/services/routing/binding-lock.ts","../src/services/routing/index.ts","../src/services/node-service.ts","../src/services/change-domain-service.ts","../src/services/certificate-service.ts","../src/routes/service-groups.ts","../src/routes/service-bindings.ts","../src/services/change-ip-service.ts","../src/routes/domains.ts","../src/routes/dns.ts","../src/routes/subdomains.ts","../src/routes/certificates.ts","../src/routes/sync.ts","../src/routes/origin-health-checks.ts","../src/services/origin-health-check-service.ts","../src/services/health/cloudflare.ts","../src/routes/health-check.ts","../src/services/health-check-service.ts","../src/services/health/state-machine.ts","../src/services/health/local.ts","../src/services/health/worker.ts","../src/lib/globalping-client.ts","../src/services/health/globalping.ts","../src/services/health-check-scheduler.ts","../src/routes/domain-monitors.ts","../src/routes/settings.ts","../src/routes/integrations-vps-tracker.ts","../src/services/vps-tracker-events.ts","../src/routes/audit.ts","../src/services/weighted-dns-scheduler.ts"],"sourcesContent":["import { readFileSync, existsSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { buildApp } from \"./app.js\";\nimport { loadConfig } from \"./config.js\";\n\nfor (const path of [\n resolve(import.meta.dirname, \"../../../.env\"),\n \".env\",\n \"../.env\",\n]) {\n if (!existsSync(path)) continue;\n const content = readFileSync(path, \"utf-8\");\n for (const line of content.split(\"\\n\")) {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(\"#\")) continue;\n const eq = trimmed.indexOf(\"=\");\n if (eq === -1) continue;\n const key = trimmed.slice(0, eq).trim();\n let value = trimmed.slice(eq + 1).trim();\n if (\n (value.startsWith('\"') && value.endsWith('\"')) ||\n (value.startsWith(\"'\") && value.endsWith(\"'\"))\n ) {\n value = value.slice(1, -1);\n }\n if (!(key in process.env)) process.env[key] = value;\n }\n break;\n}\n\nconst config = loadConfig();\n\nif (!config.cloudflareApiToken) {\n console.warn(\n \"CLOUDFLARE_API_TOKEN не задан — импорт доменов из Cloudflare недоступен\",\n );\n}\n\nconst app = await buildApp({ config });\n\ntry {\n await app.listen({ port: config.serverPort, host: \"0.0.0.0\" });\n app.log.info(`listening on ${config.serverPort}`);\n} catch (err) {\n app.log.error(err);\n process.exit(1);\n}\n","import { resolve } from \"node:path\";\r\nimport Fastify from \"fastify\";\r\nimport {\r\n serializerCompiler,\r\n validatorCompiler,\r\n type ZodTypeProvider,\r\n} from \"@fastify/type-provider-zod\";\r\nimport type { AppConfig } from \"./config.js\";\r\nimport { loadConfig } from \"./config.js\";\r\nimport authPlugin from \"./plugins/auth.js\";\r\nimport cfClientPlugin from \"./plugins/cf-client.js\";\r\nimport { requireAuth } from \"./plugins/auth.js\";\r\nimport corsPlugin from \"./plugins/cors.js\";\r\nimport dbPlugin from \"./plugins/db.js\";\r\nimport errorHandlerPlugin from \"./plugins/error-handler.js\";\r\nimport { authRoutes, healthRoutes } from \"./routes/health.js\";\r\nimport { groupRoutes } from \"./routes/groups.js\";\r\nimport { serviceRoutes } from \"./routes/services.js\";\r\nimport { serviceGroupRoutes } from \"./routes/service-groups.js\";\r\nimport { serviceBindingRoutes } from \"./routes/service-bindings.js\";\r\nimport { domainRoutes } from \"./routes/domains.js\";\r\nimport { dnsRoutes } from \"./routes/dns.js\";\r\nimport { subdomainRoutes } from \"./routes/subdomains.js\";\r\nimport { certifiLine truncated
-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
@@ -9723,6 +9723,8 @@ declare function createMemoryDb(): {
};
declare function runMigrations(sqlite: Sqlite): void;
declare function healthCheck(sqlite: Sqlite): void;
/** Truncate the WAL back into the main DB file; safe to run periodically. */
declare function walCheckpointTruncate(sqlite: Sqlite): void;
declare class NotFoundError extends Error {
constructor(message: string);
@@ -9883,6 +9885,8 @@ declare function findDnsByCfId(db: Db, domainId: number, cfRecordId: string): Dn
declare function markDnsPendingDelete(db: Db, id: number): void;
declare function listServices(db: Db): Service[];
declare function listServicesByGroup(db: Db, groupId: number): Service[];
/** Batch: services for many groups in one query (hot path: /service-groups). */
declare function listServicesByGroupIds(db: Db, groupIds: number[]): Map<number, Service[]>;
declare function listUngroupedServices(db: Db): Service[];
declare function getService(db: Db, id: number): Service;
declare function createService(db: Db, name: string, slug: string): Service;
@@ -10028,11 +10032,28 @@ declare function listGroupDnsRecords(db: Db, groupId: number): DnsRecord[];
declare function linkGroupDnsRecord(db: Db, groupId: number, dnsRecordId: number): void;
declare function unlinkGroupDnsRecord(db: Db, groupId: number, dnsRecordId: number): void;
declare function listAllBindings(db: Db): ServiceBindingView[];
/** Cheap predicate for the weighted-dns scheduler: any weighted row at all? */
declare function hasWeightedBindings(db: Db): boolean;
declare function listBindingsByDomain(db: Db, domainId: number): ServiceBindingView[];
declare function listBindingsByService(db: Db, serviceId: number): Array<ServiceBinding & {
zone_name: string;
}>;
declare function getBinding(db: Db, id: number): ServiceBinding;
/** Batch: bindings for many services in one query (hot path: /service-groups). */
declare function listBindingsByServiceIds(db: Db, serviceIds: number[]): Map<number, Array<ServiceBinding & {
zone_name: string;
}>>;
/** Batch: binding IPs with metadata for many bindings in one query. */
declare function listBindingIpsWithMetaByBindingIds(db: Db, bindingIds: number[]): Map<number, BindingIpMeta[]>;
/** Batch: dns records linked to many bindings in one query. */
declare function listRecordsByBindingIds(db: Db, bindingIds: number[]): Map<number, DnsRecord[]>;
/** Batch: binding-scope IP health status for many bindings in one query. */
declare function listBindingIpHealthByBindingIds(db: Db, bindingIds: number[]): Map<number, Map<string, {
status: string;
latency_ms: number | null;
}>>;
/** Batch: service IP rows for many services in one query. */
declare function listServiceIpRowsByServiceIds(db: Db, serviceIds: number[]): Map<number, ServiceIpRow[]>;
declare function getBindingView(db: Db, id: number): ServiceBindingView;
declare function findBinding(db: Db, serviceId: number, domainId: number, hostname: string): ServiceBinding | null;
declare function insertBinding(db: Db, domainId: number, serviceId: number, hostname: string, dnsRecordId: number | null): ServiceBinding;
@@ -10050,6 +10071,8 @@ declare function createSyncJob(db: Db, id: string, domainId: number | null): voi
declare function getSyncJob(db: Db, id: string): SyncJob;
declare function finishSyncJob(db: Db, id: string, status: string, message: string | null): void;
declare function listIpHealthStatus(db: Db, scope: HealthCheckScope, refId: number): IpHealthStatus[];
/** Batch: latest IP health rows for many bindings in one query. */
declare function listIpHealthStatusByBindingIds(db: Db, bindingIds: number[]): Map<number, IpHealthStatus[]>;
type HealthAggregate = {
health_status: IpHealthState;
health_latency_ms: number | null;
@@ -10074,6 +10097,11 @@ type ServiceIpHealthRow = {
};
/** Per-IP binding-scope health, worst status if the same IP is on several bindings. */
declare function listIpHealthByServiceIds(db: Db, serviceIds: number[]): Map<number, ServiceIpHealthRow[]>;
/**
* Latest probe per service+IP+provider from health_probe_log, then any-up per IP.
* Display overlay — hysteresis in ip_health_status is unchanged (DNS).
*/
declare function listLatestLiveHealthByServiceIds(db: Db, serviceIds: number[]): Map<number, ServiceIpHealthRow[]>;
declare function mergeHealthAggregates(parts: Array<HealthAggregate | undefined | null>): HealthAggregate;
declare function getIpHealthStatusRow(db: Db, scope: HealthCheckScope, refId: number, ip: string): IpHealthStatus | null;
declare function upsertIpHealthStatus(db: Db, scope: HealthCheckScope, refId: number, ip: string, status: string, latencyMs: number | null, consecutiveFailures: number, lastError: string | null, consecutiveSuccesses?: number, extras?: {
@@ -10178,6 +10206,15 @@ declare function listNotificationLog(db: Db, limit?: number): {
message: string;
created_at: string;
}[];
declare const LOG_RETENTION_LIMITS: {
readonly notification_log: 500;
readonly failover_log: 1000;
readonly audit_log: 2000;
readonly sync_jobs: 500;
readonly health_probe_log: 2000;
};
/** Keep only the newest N rows per log table; returns total deleted rows. */
declare function pruneLogs(db: Db): number;
type FailoverLogAction = "added" | "removed";
type FailoverLogRow = {
id: number;
@@ -10206,6 +10243,7 @@ type repos_DomainMonitorRow = DomainMonitorRow;
type repos_FailoverLogAction = FailoverLogAction;
type repos_FailoverLogRow = FailoverLogRow;
type repos_HealthAggregate = HealthAggregate;
declare const repos_LOG_RETENTION_LIMITS: typeof LOG_RETENTION_LIMITS;
type repos_ServiceGroupLbPatch = ServiceGroupLbPatch;
type repos_ServiceIpHealthRow = ServiceIpHealthRow;
type repos_ServiceIpRow = ServiceIpRow;
@@ -10264,6 +10302,7 @@ declare const repos_getService: typeof getService;
declare const repos_getServiceGroup: typeof getServiceGroup;
declare const repos_getSubdomain: typeof getSubdomain;
declare const repos_getSyncJob: typeof getSyncJob;
declare const repos_hasWeightedBindings: typeof hasWeightedBindings;
declare const repos_insertBinding: typeof insertBinding;
declare const repos_insertDnsRecord: typeof insertDnsRecord;
declare const repos_insertFailoverLog: typeof insertFailoverLog;
@@ -10275,11 +10314,14 @@ declare const repos_listAllBindings: typeof listAllBindings;
declare const repos_listAllDomains: typeof listAllDomains;
declare const repos_listAllNodes: typeof listAllNodes;
declare const repos_listAllSubdomains: typeof listAllSubdomains;
declare const repos_listBindingIpHealthByBindingIds: typeof listBindingIpHealthByBindingIds;
declare const repos_listBindingIps: typeof listBindingIps;
declare const repos_listBindingIpsWithMeta: typeof listBindingIpsWithMeta;
declare const repos_listBindingIpsWithMetaByBindingIds: typeof listBindingIpsWithMetaByBindingIds;
declare const repos_listBindingNodes: typeof listBindingNodes;
declare const repos_listBindingsByDomain: typeof listBindingsByDomain;
declare const repos_listBindingsByService: typeof listBindingsByService;
declare const repos_listBindingsByServiceIds: typeof listBindingsByServiceIds;
declare const repos_listCertificates: typeof listCertificates;
declare const repos_listDnsByDomain: typeof listDnsByDomain;
declare const repos_listDnsRecords: typeof listDnsRecords;
@@ -10298,19 +10340,25 @@ declare const repos_listHealthChecks: typeof listHealthChecks;
declare const repos_listHealthProbeLogForService: typeof listHealthProbeLogForService;
declare const repos_listIpHealthByServiceIds: typeof listIpHealthByServiceIds;
declare const repos_listIpHealthStatus: typeof listIpHealthStatus;
declare const repos_listIpHealthStatusByBindingIds: typeof listIpHealthStatusByBindingIds;
declare const repos_listLatestLiveHealthByServiceIds: typeof listLatestLiveHealthByServiceIds;
declare const repos_listNodes: typeof listNodes;
declare const repos_listNotificationLog: typeof listNotificationLog;
declare const repos_listOriginIpsForFqdn: typeof listOriginIpsForFqdn;
declare const repos_listRecordsByBindingIds: typeof listRecordsByBindingIds;
declare const repos_listRecordsForBinding: typeof listRecordsForBinding;
declare const repos_listServiceGroups: typeof listServiceGroups;
declare const repos_listServiceIpRows: typeof listServiceIpRows;
declare const repos_listServiceIpRowsByServiceIds: typeof listServiceIpRowsByServiceIds;
declare const repos_listServiceIps: typeof listServiceIps;
declare const repos_listServices: typeof listServices;
declare const repos_listServicesByGroup: typeof listServicesByGroup;
declare const repos_listServicesByGroupIds: typeof listServicesByGroupIds;
declare const repos_listSubdomainsByDomain: typeof listSubdomainsByDomain;
declare const repos_listUngroupedServices: typeof listUngroupedServices;
declare const repos_markDnsPendingDelete: typeof markDnsPendingDelete;
declare const repos_mergeHealthAggregates: typeof mergeHealthAggregates;
declare const repos_pruneLogs: typeof pruneLogs;
declare const repos_pruneStaleIpHealthStatus: typeof pruneStaleIpHealthStatus;
declare const repos_reorderServices: typeof reorderServices;
declare const repos_replaceBindingIps: typeof replaceBindingIps;
@@ -10345,7 +10393,7 @@ declare const repos_upsertCertificateCheck: typeof upsertCertificateCheck;
declare const repos_upsertIpHealthStatus: typeof upsertIpHealthStatus;
declare const repos_upsertSubdomain: typeof upsertSubdomain;
declare namespace repos {
export { type repos_BindingIpMeta as BindingIpMeta, type repos_BindingLbPatch as BindingLbPatch, type repos_DnsListFilter as DnsListFilter, type repos_DomainMonitorRow as DomainMonitorRow, type repos_FailoverLogAction as FailoverLogAction, type repos_FailoverLogRow as FailoverLogRow, type repos_HealthAggregate as HealthAggregate, type repos_ServiceGroupLbPatch as ServiceGroupLbPatch, type repos_ServiceIpHealthRow as ServiceIpHealthRow, type repos_ServiceIpRow as ServiceIpRow, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_addDomainTags as addDomainTags, repos_aggregateGroupScopeHealthByIds as aggregateGroupScopeHealthByIds, repos_aggregateIpHealthByRefs as aggregateIpHealthByRefs, repos_aggregateIpHealthByServiceIds as aggregateIpHealthByServiceIds, repos_bindingsToRemove as bindingsToRemove, repos_bumpBindingVersion as bumpBindingVersion, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createDomainMonitor as createDomainMonitor, repos_createGroup as createGroup, repos_createHealthCheck as createHealthCheck, repos_createNode as createNode, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteCertificatesNotIn as deleteCertificatesNotIn, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteDomainMonitor as deleteDomainMonitor, repos_deleteGroup as deleteGroup, repos_deleteHealthCheck as deleteHealthCheck, repos_deleteIpHealthStatusForIp as deleteIpHealthStatusForIp, repos_deleteIpHealthStatusForRef as deleteIpHealthStatusForRef, repos_deleteNode as deleteNode, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_ensureNode as ensureNode, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_findHealthCheckByCfId as findHealthCheckByCfId, repos_findNodeByAddress as findNodeByAddress, repos_findNodeByIp as findNodeByIp, repos_findSubdomainByDomainAndName as findSubdomainByDomainAndName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getDomainMonitor as getDomainMonitor, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getHealthCheck as getHealthCheck, repos_getIpHealthStatusRow as getIpHealthStatusRow, repos_getNode as getNode, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_insertFailoverLog as insertFailoverLog, repos_insertHealthProbeLog as insertHealthProbeLog, repos_insertNotificationLog as insertNotificationLog, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllNodes as listAllNodes, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingIpsWithMeta as listBindingIpsWithMeta, repos_listBindingNodes as listBindingNodes, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomainMonitorResults as listDomainMonitorResults, repos_listDomainMonitorResultsForDomain as listDomainMonitorResultsForDomain, repos_listDomainMonitors as listDomainMonitors, repos_listDomainTags as listDomainTags, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listEnabledDomainMonitors as listEnabledDomainMonitors, repos_listFailoverLogForService as listFailoverLogForService, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listHealthCheckTargets as listHealthCheckTargets, repos_listHealthChecks as listHealthChecks, repos_listHealthProbeLogForService as listHealthProbeLogForService, repos_listIpHealthByServiceIds as listIpHealthByServiceIds, repos_listIpHealthStatus as listIpHealthStatus, repos_listNodes as listNodes, repos_listNotificationLog as listNotificationLog, repos_listOriginIpsForFqdn as listOriginIpsForFqdn, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIpRows as listServiceIpRows, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroup as listServicesByGroup, repos_listSubdomainsByDomain as listSubdomainsByDLine truncated
export { type repos_BindingIpMeta as BindingIpMeta, type repos_BindingLbPatch as BindingLbPatch, type repos_DnsListFilter as DnsListFilter, type repos_DomainMonitorRow as DomainMonitorRow, type repos_FailoverLogAction as FailoverLogAction, type repos_FailoverLogRow as FailoverLogRow, type repos_HealthAggregate as HealthAggregate, repos_LOG_RETENTION_LIMITS as LOG_RETENTION_LIMITS, type repos_ServiceGroupLbPatch as ServiceGroupLbPatch, type repos_ServiceIpHealthRow as ServiceIpHealthRow, type repos_ServiceIpRow as ServiceIpRow, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_addDomainTags as addDomainTags, repos_aggregateGroupScopeHealthByIds as aggregateGroupScopeHealthByIds, repos_aggregateIpHealthByRefs as aggregateIpHealthByRefs, repos_aggregateIpHealthByServiceIds as aggregateIpHealthByServiceIds, repos_bindingsToRemove as bindingsToRemove, repos_bumpBindingVersion as bumpBindingVersion, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createDomainMonitor as createDomainMonitor, repos_createGroup as createGroup, repos_createHealthCheck as createHealthCheck, repos_createNode as createNode, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteCertificatesNotIn as deleteCertificatesNotIn, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteDomainMonitor as deleteDomainMonitor, repos_deleteGroup as deleteGroup, repos_deleteHealthCheck as deleteHealthCheck, repos_deleteIpHealthStatusForIp as deleteIpHealthStatusForIp, repos_deleteIpHealthStatusForRef as deleteIpHealthStatusForRef, repos_deleteNode as deleteNode, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_ensureNode as ensureNode, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_findHealthCheckByCfId as findHealthCheckByCfId, repos_findNodeByAddress as findNodeByAddress, repos_findNodeByIp as findNodeByIp, repos_findSubdomainByDomainAndName as findSubdomainByDomainAndName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getDomainMonitor as getDomainMonitor, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getHealthCheck as getHealthCheck, repos_getIpHealthStatusRow as getIpHealthStatusRow, repos_getNode as getNode, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_hasWeightedBindings as hasWeightedBindings, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_insertFailoverLog as insertFailoverLog, repos_insertHealthProbeLog as insertHealthProbeLog, repos_insertNotificationLog as insertNotificationLog, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllNodes as listAllNodes, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIpHealthByBindingIds as listBindingIpHealthByBindingIds, repos_listBindingIps as listBindingIps, repos_listBindingIpsWithMeta as listBindingIpsWithMeta, repos_listBindingIpsWithMetaByBindingIds as listBindingIpsWithMetaByBindingIds, repos_listBindingNodes as listBindingNodes, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listBindingsByServiceIds as listBindingsByServiceIds, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomainMonitorResults as listDomainMonitorResults, repos_listDomainMonitorResultsForDomain as listDomainMonitorResultsForDomain, repos_listDomainMonitors as listDomainMonitors, repos_listDomainTags as listDomainTags, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listEnabledDomainMonitors as listEnabledDomainMonitors, repos_listFailoverLogForService as listFailoverLogForService, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listHealthCheckTargets as listHealthCheckTargets, repos_listHealthChecks as listHealthChecks, repos_listHealthProbeLogForService as listHealthProbeLogForService, repos_listIpHealthByServiceIds as listIpHealthByServiceIds, repos_listIpHealthStatus as listIpHealthStatus, repos_listIpHealthStatusByBindingIds as listIpHealthStatusByBindingIds, repos_listLatestLiveHealthByServiceIds as listLatestLiveHealthBySLine truncated
}
export { type AppSettingsDto, type AppSettingsPatch, type AppendAuditInput, ConflictError, type Db, type DnsListFilter, type HealthEngineFallbacks, type HealthEngineSettings, NotFoundError, type Sqlite, type UpdateSubdomainPatch, appSettings, appendAudit, auditLog, bindingNodes, certificates, createDb, createMemoryDb, dnsRecords, domainMonitorResults, domainMonitors, domainTags, domains, failoverLog, getAppSettings, getAppSettingsSecrets, groups, healthCheck, healthChecks, healthProbeLog, ipHealthStatus, listAudit, nodes, notificationLog, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs, touchVpsTrackerSync, updateAppSettings };
export { type AppSettingsDto, type AppSettingsPatch, type AppendAuditInput, ConflictError, type Db, type DnsListFilter, type HealthEngineFallbacks, type HealthEngineSettings, NotFoundError, type Sqlite, type UpdateSubdomainPatch, appSettings, appendAudit, auditLog, bindingNodes, certificates, createDb, createMemoryDb, dnsRecords, domainMonitorResults, domainMonitors, domainTags, domains, failoverLog, getAppSettings, getAppSettingsSecrets, groups, healthCheck, healthChecks, healthProbeLog, ipHealthStatus, listAudit, nodes, notificationLog, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs, touchVpsTrackerSync, updateAppSettings, walCheckpointTruncate };
+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"]
}
}
}