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
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:
@@ -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 соответствует концепции проекта.
|
||||||
@@ -170,6 +170,12 @@ jobs:
|
|||||||
packages/ui/node_modules
|
packages/ui/node_modules
|
||||||
packages/shared/node_modules
|
packages/shared/node_modules
|
||||||
packages/db/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 }}
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
pnpm-${{ runner.os }}-
|
pnpm-${{ runner.os }}-
|
||||||
@@ -208,6 +214,12 @@ jobs:
|
|||||||
packages/ui/node_modules
|
packages/ui/node_modules
|
||||||
packages/shared/node_modules
|
packages/shared/node_modules
|
||||||
packages/db/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 }}
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
pnpm-${{ runner.os }}-
|
pnpm-${{ runner.os }}-
|
||||||
|
|||||||
+228
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+4484
-1093
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -16,7 +16,6 @@
|
|||||||
"@fastify/helmet": "^13.0.1",
|
"@fastify/helmet": "^13.0.1",
|
||||||
"@fastify/jwt": "^9.1.0",
|
"@fastify/jwt": "^9.1.0",
|
||||||
"@fastify/rate-limit": "^10.3.0",
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/schedule": "^6.0.0",
|
|
||||||
"@fastify/sensible": "^6.0.3",
|
"@fastify/sensible": "^6.0.3",
|
||||||
"@fastify/static": "^8.2.0",
|
"@fastify/static": "^8.2.0",
|
||||||
"@fastify/type-provider-zod": "^1.0.0",
|
"@fastify/type-provider-zod": "^1.0.0",
|
||||||
@@ -24,7 +23,6 @@
|
|||||||
"fastify": "^5.4.0",
|
"fastify": "^5.4.0",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
"p-limit": "^6.2.0",
|
"p-limit": "^6.2.0",
|
||||||
"p-queue": "^8.1.0",
|
|
||||||
"toad-scheduler": "^4.0.1",
|
"toad-scheduler": "^4.0.1",
|
||||||
"undici": "^8.5.0",
|
"undici": "^8.5.0",
|
||||||
"zod": "^4.2.0"
|
"zod": "^4.2.0"
|
||||||
|
|||||||
+13
-3
@@ -32,6 +32,7 @@ import {
|
|||||||
import { settingsRoutes } from "./routes/settings.js";
|
import { settingsRoutes } from "./routes/settings.js";
|
||||||
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
|
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
|
||||||
import { auditRoutes } from "./routes/audit.js";
|
import { auditRoutes } from "./routes/audit.js";
|
||||||
|
import { repos, walCheckpointTruncate } from "@cfdm/db";
|
||||||
import * as certificateService from "./services/certificate-service.js";
|
import * as certificateService from "./services/certificate-service.js";
|
||||||
import {
|
import {
|
||||||
createHealthCheckTask,
|
createHealthCheckTask,
|
||||||
@@ -43,7 +44,7 @@ import {
|
|||||||
scheduleWeightedDnsJob,
|
scheduleWeightedDnsJob,
|
||||||
} from "./services/weighted-dns-scheduler.js";
|
} from "./services/weighted-dns-scheduler.js";
|
||||||
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.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 {
|
export interface BuildAppOptions {
|
||||||
config?: AppConfig;
|
config?: AppConfig;
|
||||||
@@ -111,11 +112,20 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!opts.memory) {
|
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(
|
const certTask = new AsyncTask(
|
||||||
"certificate-check",
|
"certificate-check",
|
||||||
async () => {
|
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);
|
const n = await certificateService.runAllChecks(app.db);
|
||||||
app.log.info({ checked: n }, "certificate check completed");
|
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(
|
new CronJob(
|
||||||
{ cronExpression: config.certCheckCron },
|
{ cronExpression: config.certCheckCron },
|
||||||
certTask,
|
certTask,
|
||||||
|
|||||||
@@ -38,12 +38,19 @@ export class CloudflareClient {
|
|||||||
return this.zones.listZones();
|
return this.zones.listZones();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateZonesCache(): void {
|
||||||
|
this.zones.invalidateZonesCache();
|
||||||
|
}
|
||||||
|
|
||||||
getZone(zoneId: string): Promise<CfZone> {
|
getZone(zoneId: string): Promise<CfZone> {
|
||||||
return this.zones.getZone(zoneId);
|
return this.zones.getZone(zoneId);
|
||||||
}
|
}
|
||||||
|
|
||||||
listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
|
listDnsRecords(
|
||||||
return this.dns.listDnsRecords(zoneId);
|
zoneId: string,
|
||||||
|
cache?: Map<string, CfDnsRecord[]>,
|
||||||
|
): Promise<CfDnsRecord[]> {
|
||||||
|
return this.dns.listDnsRecords(zoneId, cache);
|
||||||
}
|
}
|
||||||
|
|
||||||
createDnsRecord(zoneId: string, payload: CreateDnsRecordPayload): Promise<CfDnsRecord> {
|
createDnsRecord(zoneId: string, payload: CreateDnsRecordPayload): Promise<CfDnsRecord> {
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js";
|
|||||||
|
|
||||||
export function createDnsAdapter(token: string) {
|
export function createDnsAdapter(token: string) {
|
||||||
return {
|
return {
|
||||||
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
|
async listDnsRecords(
|
||||||
return withRetry(async () => {
|
zoneId: string,
|
||||||
const all: CfDnsRecord[] = [];
|
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;
|
let page = 1;
|
||||||
while (page <= 50) {
|
while (page <= 50) {
|
||||||
const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`);
|
const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`);
|
||||||
@@ -28,11 +33,13 @@ export function createDnsAdapter(token: string) {
|
|||||||
"list_dns_records",
|
"list_dns_records",
|
||||||
);
|
);
|
||||||
if (batch.length === 0) break;
|
if (batch.length === 0) break;
|
||||||
all.push(...batch);
|
records.push(...batch);
|
||||||
page += 1;
|
page += 1;
|
||||||
}
|
}
|
||||||
return all;
|
return records;
|
||||||
});
|
});
|
||||||
|
cache?.set(zoneId, all);
|
||||||
|
return all;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createDnsRecord(
|
async createDnsRecord(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { CfDnsRecord } from "@cfdm/shared";
|
import type { CfDnsRecord } from "@cfdm/shared";
|
||||||
import { AppError } from "../../errors.js";
|
import { AppError } from "../../errors.js";
|
||||||
import { parseRetryAfter } from "../cf-retry.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";
|
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export function mapCloudflareFailure(
|
|||||||
): AppError {
|
): AppError {
|
||||||
const lower = message.toLowerCase();
|
const lower = message.toLowerCase();
|
||||||
if (status === 401 || status === 403 || lower.includes("authentication")) {
|
if (status === 401 || status === 403 || lower.includes("authentication")) {
|
||||||
|
notifyZoneCacheInvalidated();
|
||||||
if (
|
if (
|
||||||
operation.includes("workers") ||
|
operation.includes("workers") ||
|
||||||
operation.includes("kv_") ||
|
operation.includes("kv_") ||
|
||||||
@@ -31,6 +33,7 @@ export function mapCloudflareFailure(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (status === 429 || lower.includes("rate limit")) {
|
if (status === 429 || lower.includes("rate limit")) {
|
||||||
|
notifyZoneCacheInvalidated();
|
||||||
return AppError.rateLimited();
|
return AppError.rateLimited();
|
||||||
}
|
}
|
||||||
if (lower.includes("zone") && (lower.includes("not found") || status === 404)) {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,69 @@
|
|||||||
import type { CfZone } from "@cfdm/shared";
|
import type { CfZone } from "@cfdm/shared";
|
||||||
import { withRetry } from "../cf-retry.js";
|
import { withRetry } from "../cf-retry.js";
|
||||||
import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.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) {
|
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 {
|
return {
|
||||||
async listZones(): Promise<CfZone[]> {
|
async listZones(): Promise<CfZone[]> {
|
||||||
return withRetry(async () => {
|
const now = Date.now();
|
||||||
const all: CfZone[] = [];
|
if (cachedZones && now - cachedAt < ZONE_CACHE_TTL_MS) {
|
||||||
let page = 1;
|
return cachedZones;
|
||||||
while (true) {
|
}
|
||||||
const url = new URL(`${CF_API_BASE}/zones`);
|
if (!inflight) {
|
||||||
url.searchParams.set("per_page", "50");
|
inflight = withRetry(fetchZones)
|
||||||
url.searchParams.set("page", String(page));
|
.then((zones) => {
|
||||||
const response = await fetch(url, {
|
cachedAt = Date.now();
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
cachedZones = zones;
|
||||||
signal: AbortSignal.timeout(30_000),
|
return zones;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflight = null;
|
||||||
});
|
});
|
||||||
if (response.status >= 500 || response.status === 429) {
|
}
|
||||||
throw mapCloudflareFailure("list_zones", response.status, String(response.status));
|
return inflight;
|
||||||
}
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
invalidateZonesCache: invalidate,
|
||||||
|
|
||||||
async getZone(zoneId: string): Promise<CfZone> {
|
async getZone(zoneId: string): Promise<CfZone> {
|
||||||
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, {
|
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { IpHealthStatus } from "@cfdm/shared";
|
||||||
import { healthStatusQuerySchema } from "@cfdm/shared";
|
import { healthStatusQuerySchema } from "@cfdm/shared";
|
||||||
import { getAppSettings, repos } from "@cfdm/db";
|
import { getAppSettings, repos } from "@cfdm/db";
|
||||||
import * as healthCheckService from "../services/health-check-service.js";
|
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) => {
|
app.post("/health-check/run", async (request) => {
|
||||||
const config = request.server.config;
|
const config = request.server.config;
|
||||||
const fallbacks = healthEngineFallbacksFromConfig(config);
|
const fallbacks = healthEngineFallbacksFromConfig(config);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { connect } from "node:net";
|
import { connect } from "node:net";
|
||||||
import { connect as tlsConnect } from "node:tls";
|
import { connect as tlsConnect } from "node:tls";
|
||||||
|
import pLimit from "p-limit";
|
||||||
import type { Db } from "@cfdm/db";
|
import type { Db } from "@cfdm/db";
|
||||||
import { repos } from "@cfdm/db";
|
import { repos } from "@cfdm/db";
|
||||||
import type { Certificate, ServiceCertificateRow, Subdomain } from "@cfdm/shared";
|
import type { Certificate, ServiceCertificateRow, Subdomain } from "@cfdm/shared";
|
||||||
@@ -21,19 +22,10 @@ export interface CertificateTarget {
|
|||||||
hostname: string;
|
hostname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneStaleCertificates(db: Db): void {
|
|
||||||
const targets = resolveCertificateTargets(db);
|
|
||||||
repos.deleteCertificatesNotIn(
|
|
||||||
db,
|
|
||||||
targets.map((t) => t.hostname),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listCertificates(
|
export function listCertificates(
|
||||||
db: Db,
|
db: Db,
|
||||||
status?: string,
|
status?: string,
|
||||||
): Certificate[] {
|
): Certificate[] {
|
||||||
pruneStaleCertificates(db);
|
|
||||||
return repos.listCertificates(db, status);
|
return repos.listCertificates(db, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,15 +223,20 @@ export function resolveCertificateTargets(db: Db): CertificateTarget[] {
|
|||||||
|
|
||||||
export async function runAllChecks(db: Db): Promise<number> {
|
export async function runAllChecks(db: Db): Promise<number> {
|
||||||
const targets = resolveCertificateTargets(db);
|
const targets = resolveCertificateTargets(db);
|
||||||
for (const target of targets) {
|
const limit = pLimit(5);
|
||||||
await checkAndStore(
|
await Promise.all(
|
||||||
db,
|
targets.map((target) =>
|
||||||
target.domainId,
|
limit(() =>
|
||||||
target.subdomainId,
|
checkAndStore(
|
||||||
target.hostname,
|
db,
|
||||||
target.serviceId,
|
target.domainId,
|
||||||
);
|
target.subdomainId,
|
||||||
}
|
target.hostname,
|
||||||
|
target.serviceId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
repos.deleteCertificatesNotIn(
|
repos.deleteCertificatesNotIn(
|
||||||
db,
|
db,
|
||||||
targets.map((t) => t.hostname),
|
targets.map((t) => t.hostname),
|
||||||
@@ -268,6 +265,5 @@ export async function runServiceChecks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function statusSummary(db: Db): Array<[string, number]> {
|
export function statusSummary(db: Db): Array<[string, number]> {
|
||||||
pruneStaleCertificates(db);
|
|
||||||
return repos.countCertificatesByStatus(db);
|
return repos.countCertificatesByStatus(db);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
import { AsyncTask, CronJob, type ToadScheduler } from "toad-scheduler";
|
||||||
import {
|
import {
|
||||||
getAppSettings,
|
getAppSettings,
|
||||||
getAppSettingsSecrets,
|
getAppSettingsSecrets,
|
||||||
@@ -18,6 +18,7 @@ import { cronStaleAfterMs } from "./health/mailbox.js";
|
|||||||
|
|
||||||
declare module "fastify" {
|
declare module "fastify" {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
|
scheduler?: ToadScheduler;
|
||||||
reloadHealthCheckJob?: () => void;
|
reloadHealthCheckJob?: () => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ async function httpProbe(
|
|||||||
dispatcher,
|
dispatcher,
|
||||||
});
|
});
|
||||||
const latency = Date.now() - started;
|
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 (target.expected_status != null) {
|
||||||
if (response.status !== target.expected_status) {
|
if (response.status !== target.expected_status) {
|
||||||
return {
|
return {
|
||||||
@@ -192,6 +194,9 @@ async function httpProbe(
|
|||||||
latencyMs: Date.now() - started,
|
latencyMs: Date.now() - started,
|
||||||
error: err instanceof Error ? err.message : String(err),
|
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(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Db } from "@cfdm/db";
|
import type { Db } from "@cfdm/db";
|
||||||
import { repos } from "@cfdm/db";
|
import { repos } from "@cfdm/db";
|
||||||
import type {
|
import type {
|
||||||
|
CfDnsRecord,
|
||||||
DnsRecord,
|
DnsRecord,
|
||||||
HealthCheckAggregate,
|
HealthCheckAggregate,
|
||||||
HealthCheckProvider,
|
HealthCheckProvider,
|
||||||
@@ -9,6 +10,7 @@ import type {
|
|||||||
IpHealthState,
|
IpHealthState,
|
||||||
LbMode,
|
LbMode,
|
||||||
Service,
|
Service,
|
||||||
|
ServiceBinding,
|
||||||
ServiceGroup,
|
ServiceGroup,
|
||||||
ServiceGroupsResponse,
|
ServiceGroupsResponse,
|
||||||
ServiceView,
|
ServiceView,
|
||||||
@@ -341,101 +343,148 @@ async function collectKnownZones(
|
|||||||
return zones;
|
return zones;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
interface BindingLbContext {
|
||||||
const service = repos.getService(db, serviceId);
|
ipMetaByBinding: Map<number, repos.BindingIpMeta[]>;
|
||||||
const ipRows = repos.listServiceIpRows(db, serviceId);
|
healthByBinding: Map<number, Map<string, { status: string; latency_ms: number | null }>>;
|
||||||
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);
|
|
||||||
|
|
||||||
const domainViews = bindings.map((binding) => {
|
function bindingLbStateFromBatch(
|
||||||
const records = repos.listRecordsForBinding(db, binding.id);
|
binding: ServiceBinding,
|
||||||
const statuses = records.map((r) => r.sync_status);
|
ctx: BindingLbContext,
|
||||||
const targetIpsWithMeta = repos.listBindingIpsWithMeta(db, binding.id);
|
): { config: LbTargetConfig; rows: LbIpRow[] } {
|
||||||
const targetIps = targetIpsWithMeta.map((entry) => entry.ip);
|
const ipMetas = ctx.ipMetaByBinding.get(binding.id) ?? [];
|
||||||
const linkedCname = records.find(
|
const healthByIp = ctx.healthByBinding.get(binding.id);
|
||||||
(record) => record.record_type.toUpperCase() === "CNAME",
|
const rows: LbIpRow[] = ipMetas.map((entry) => ({
|
||||||
);
|
ip: entry.ip,
|
||||||
const targetCname =
|
weight: entry.weight,
|
||||||
binding.cname_target?.trim() || linkedCname?.content?.trim() || null;
|
priority: entry.priority,
|
||||||
|
health: (healthByIp?.get(entry.ip)?.status as IpHealthState) ?? "unknown",
|
||||||
const target_ip_weights: Record<string, number> = {};
|
}));
|
||||||
const target_ip_priorities: Record<string, number> = {};
|
return {
|
||||||
for (const entry of targetIpsWithMeta) {
|
config: {
|
||||||
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,
|
|
||||||
lb_mode: binding.lb_mode,
|
lb_mode: binding.lb_mode,
|
||||||
health_check_enabled: binding.health_check_enabled,
|
health_check_enabled: binding.health_check_enabled,
|
||||||
health_check_type: binding.health_check_type,
|
},
|
||||||
health_check_port: binding.health_check_port,
|
rows,
|
||||||
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,
|
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||||
health_check_verify_tls: binding.health_check_verify_tls,
|
const [view] = await buildViews(db, [serviceId]);
|
||||||
health_check_provider: binding.health_check_provider ?? "local",
|
return view!;
|
||||||
health_check_providers: binding.health_check_providers ?? [
|
}
|
||||||
binding.health_check_provider ?? "local",
|
|
||||||
],
|
/** Batch view builder: 6 fixed queries for N services instead of O(services×bindings×ips). */
|
||||||
health_check_aggregate: binding.health_check_aggregate ?? "majority",
|
async function buildViews(db: Db, serviceIds: number[]): Promise<ServiceView[]> {
|
||||||
cert_monitoring: binding.cert_monitoring ?? "auto",
|
const services = serviceIds.map((id) => repos.getService(db, id));
|
||||||
sync_status: aggregateSyncStatus(statuses),
|
|
||||||
active_ips: bindingActiveIps,
|
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> = {
|
const HEALTH_RANK: Record<string, number> = {
|
||||||
@@ -566,9 +615,8 @@ function attachServiceHealth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listViews(db: Db): Promise<ServiceView[]> {
|
export async function listViews(db: Db): Promise<ServiceView[]> {
|
||||||
const views = await Promise.all(
|
const ids = repos.listServices(db).map((s) => s.id);
|
||||||
repos.listServices(db).map((s) => buildView(db, s.id)),
|
const views = await buildViews(db, ids);
|
||||||
);
|
|
||||||
return attachServiceHealth(db, views);
|
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> {
|
export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
|
||||||
const groups = repos.listServiceGroups(db);
|
const groups = repos.listServiceGroups(db);
|
||||||
const groupViewsRaw = await Promise.all(
|
const servicesByGroup = repos.listServicesByGroupIds(db, groups.map((g) => g.id));
|
||||||
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 ungroupedServices = repos.listUngroupedServices(db);
|
const ungroupedServices = repos.listUngroupedServices(db);
|
||||||
const ungroupedRaw = await Promise.all(
|
|
||||||
ungroupedServices.map((s) => buildView(db, s.id)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const allServiceViews = [
|
const allIds = [
|
||||||
...groupViewsRaw.flatMap((g) => g.services),
|
...[...servicesByGroup.values()].flat().map((s) => s.id),
|
||||||
...ungroupedRaw,
|
...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 withHealth = attachServiceHealth(db, allServiceViews);
|
||||||
const healthById = new Map(withHealth.map((v) => [v.id, v]));
|
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(
|
const ungrouped = ungroupedServices.map((s) => {
|
||||||
(s) =>
|
const view = viewsById.get(s.id);
|
||||||
healthById.get(s.id) ?? {
|
return (
|
||||||
...s,
|
healthById.get(s.id) ??
|
||||||
health_status: "unknown" as const,
|
(view
|
||||||
health_latency_ms: null,
|
? {
|
||||||
ip_health: [],
|
...view,
|
||||||
ip_enabled: {},
|
health_status: "unknown" as const,
|
||||||
},
|
health_latency_ms: null,
|
||||||
);
|
ip_health: [],
|
||||||
|
ip_enabled: {},
|
||||||
|
}
|
||||||
|
: view!)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return { groups: groupViews, ungrouped };
|
return { groups: groupViews, ungrouped };
|
||||||
}
|
}
|
||||||
@@ -661,6 +712,7 @@ async function syncBindingDns(
|
|||||||
hostname: string,
|
hostname: string,
|
||||||
desiredIps: string[],
|
desiredIps: string[],
|
||||||
cnameTarget: string | null,
|
cnameTarget: string | null,
|
||||||
|
listingCache?: ZoneDnsListingCache,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const domain = repos.getDomain(db, domainId);
|
const domain = repos.getDomain(db, domainId);
|
||||||
const zoneName = domain.zone_name;
|
const zoneName = domain.zone_name;
|
||||||
@@ -674,6 +726,8 @@ async function syncBindingDns(
|
|||||||
zoneName,
|
zoneName,
|
||||||
hostname,
|
hostname,
|
||||||
"CNAME",
|
"CNAME",
|
||||||
|
undefined,
|
||||||
|
listingCache,
|
||||||
);
|
);
|
||||||
if (existingCname) {
|
if (existingCname) {
|
||||||
effectiveCname = existingCname.content;
|
effectiveCname = existingCname.content;
|
||||||
@@ -690,6 +744,7 @@ async function syncBindingDns(
|
|||||||
domainId,
|
domainId,
|
||||||
hostname,
|
hostname,
|
||||||
effectiveCname,
|
effectiveCname,
|
||||||
|
listingCache,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -704,6 +759,7 @@ async function syncBindingDns(
|
|||||||
hostname,
|
hostname,
|
||||||
desiredIps,
|
desiredIps,
|
||||||
ttlForBinding(binding.lb_mode, configuredIps),
|
ttlForBinding(binding.lb_mode, configuredIps),
|
||||||
|
listingCache,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,6 +770,7 @@ async function syncBindingCnameDns(
|
|||||||
domainId: number,
|
domainId: number,
|
||||||
hostname: string,
|
hostname: string,
|
||||||
cnameTarget: string,
|
cnameTarget: string,
|
||||||
|
listingCache?: ZoneDnsListingCache,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const domain = repos.getDomain(db, domainId);
|
const domain = repos.getDomain(db, domainId);
|
||||||
const zoneName = domain.zone_name;
|
const zoneName = domain.zone_name;
|
||||||
@@ -755,6 +812,7 @@ async function syncBindingCnameDns(
|
|||||||
hostname,
|
hostname,
|
||||||
"CNAME",
|
"CNAME",
|
||||||
normalized,
|
normalized,
|
||||||
|
listingCache,
|
||||||
);
|
);
|
||||||
if (adopted) {
|
if (adopted) {
|
||||||
repos.linkBindingRecord(db, bindingId, adopted.id);
|
repos.linkBindingRecord(db, bindingId, adopted.id);
|
||||||
@@ -792,6 +850,7 @@ async function syncBindingADns(
|
|||||||
hostname: string,
|
hostname: string,
|
||||||
desiredIps: string[],
|
desiredIps: string[],
|
||||||
ttl: number,
|
ttl: number,
|
||||||
|
listingCache?: ZoneDnsListingCache,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const domain = repos.getDomain(db, domainId);
|
const domain = repos.getDomain(db, domainId);
|
||||||
const zoneName = domain.zone_name;
|
const zoneName = domain.zone_name;
|
||||||
@@ -849,6 +908,7 @@ async function syncBindingADns(
|
|||||||
hostname,
|
hostname,
|
||||||
"A",
|
"A",
|
||||||
ip,
|
ip,
|
||||||
|
listingCache,
|
||||||
);
|
);
|
||||||
if (adopted) {
|
if (adopted) {
|
||||||
repos.linkBindingRecord(db, bindingId, adopted.id);
|
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(
|
async function findOrImportDnsRecord(
|
||||||
db: Db,
|
db: Db,
|
||||||
cf: CloudflareClient,
|
cf: CloudflareClient,
|
||||||
@@ -999,6 +1062,7 @@ async function findOrImportDnsRecord(
|
|||||||
hostname: string,
|
hostname: string,
|
||||||
recordType: "A" | "CNAME",
|
recordType: "A" | "CNAME",
|
||||||
content?: string,
|
content?: string,
|
||||||
|
listingCache?: ZoneDnsListingCache,
|
||||||
): Promise<DnsRecord | null> {
|
): Promise<DnsRecord | null> {
|
||||||
const local = findLocalDnsRecord(
|
const local = findLocalDnsRecord(
|
||||||
db,
|
db,
|
||||||
@@ -1011,7 +1075,7 @@ async function findOrImportDnsRecord(
|
|||||||
if (local) return local;
|
if (local) return local;
|
||||||
|
|
||||||
const domain = repos.getDomain(db, domainId);
|
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) {
|
for (const cfRec of remote) {
|
||||||
if (cfRec.type.toUpperCase() !== recordType) continue;
|
if (cfRec.type.toUpperCase() !== recordType) continue;
|
||||||
if (content != null) {
|
if (content != null) {
|
||||||
@@ -1052,6 +1116,7 @@ async function findOrImportDnsARecord(
|
|||||||
zoneName: string,
|
zoneName: string,
|
||||||
hostname: string,
|
hostname: string,
|
||||||
content: string,
|
content: string,
|
||||||
|
listingCache?: ZoneDnsListingCache,
|
||||||
): Promise<DnsRecord | null> {
|
): Promise<DnsRecord | null> {
|
||||||
return findOrImportDnsRecord(
|
return findOrImportDnsRecord(
|
||||||
db,
|
db,
|
||||||
@@ -1061,6 +1126,7 @@ async function findOrImportDnsARecord(
|
|||||||
hostname,
|
hostname,
|
||||||
"A",
|
"A",
|
||||||
content,
|
content,
|
||||||
|
listingCache,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1084,6 +1150,7 @@ async function syncServiceBindingsToDns(
|
|||||||
throw AppError.validation("добавьте IP-адреса в пул сервиса");
|
throw AppError.validation("добавьте IP-адреса в пул сервиса");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listingCache: ZoneDnsListingCache = new Map();
|
||||||
for (const binding of bindings) {
|
for (const binding of bindings) {
|
||||||
const cnameTarget = binding.cname_target?.trim() || null;
|
const cnameTarget = binding.cname_target?.trim() || null;
|
||||||
if (cnameTarget) {
|
if (cnameTarget) {
|
||||||
@@ -1095,6 +1162,7 @@ async function syncServiceBindingsToDns(
|
|||||||
binding.hostname,
|
binding.hostname,
|
||||||
[],
|
[],
|
||||||
cnameTarget,
|
cnameTarget,
|
||||||
|
listingCache,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1117,6 +1185,7 @@ async function syncServiceBindingsToDns(
|
|||||||
binding.hostname,
|
binding.hostname,
|
||||||
desiredIps,
|
desiredIps,
|
||||||
null,
|
null,
|
||||||
|
listingCache,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1148,6 +1217,7 @@ async function syncGroupDomainDnsRecords(
|
|||||||
hostname: string,
|
hostname: string,
|
||||||
desiredIps: string[],
|
desiredIps: string[],
|
||||||
ttl: number = AUTO_DNS_TTL,
|
ttl: number = AUTO_DNS_TTL,
|
||||||
|
listingCache?: ZoneDnsListingCache,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const domain = repos.getDomain(db, domainId);
|
const domain = repos.getDomain(db, domainId);
|
||||||
const zoneName = domain.zone_name;
|
const zoneName = domain.zone_name;
|
||||||
@@ -1185,6 +1255,7 @@ async function syncGroupDomainDnsRecords(
|
|||||||
zoneName,
|
zoneName,
|
||||||
hostname,
|
hostname,
|
||||||
ip,
|
ip,
|
||||||
|
listingCache,
|
||||||
);
|
);
|
||||||
if (adopted) {
|
if (adopted) {
|
||||||
repos.linkGroupDnsRecord(db, groupId, adopted.id);
|
repos.linkGroupDnsRecord(db, groupId, adopted.id);
|
||||||
@@ -1245,6 +1316,7 @@ async function syncGroupDomainDns(
|
|||||||
db: Db,
|
db: Db,
|
||||||
cf: CloudflareClient,
|
cf: CloudflareClient,
|
||||||
groupId: number,
|
groupId: number,
|
||||||
|
listingCache?: ZoneDnsListingCache,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const group = repos.getServiceGroup(db, groupId);
|
const group = repos.getServiceGroup(db, groupId);
|
||||||
if (!group.enabled) {
|
if (!group.enabled) {
|
||||||
@@ -1267,6 +1339,7 @@ async function syncGroupDomainDns(
|
|||||||
hostname,
|
hostname,
|
||||||
desiredIps,
|
desiredIps,
|
||||||
ttlForBinding(group.lb_mode, fallbackIps),
|
ttlForBinding(group.lb_mode, fallbackIps),
|
||||||
|
listingCache,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1792,7 +1865,10 @@ export async function reconcileWeightedDns(
|
|||||||
db: Db,
|
db: Db,
|
||||||
cf: CloudflareClient,
|
cf: CloudflareClient,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
|
// Cheap predicate first: skip the full binding walk when nothing is weighted.
|
||||||
|
if (!repos.hasWeightedBindings(db)) return 0;
|
||||||
let n = 0;
|
let n = 0;
|
||||||
|
const listingCache: ZoneDnsListingCache = new Map();
|
||||||
for (const binding of repos.listAllBindings(db)) {
|
for (const binding of repos.listAllBindings(db)) {
|
||||||
if (binding.lb_mode !== "weighted") continue;
|
if (binding.lb_mode !== "weighted") continue;
|
||||||
if (binding.cname_target?.trim()) continue;
|
if (binding.cname_target?.trim()) continue;
|
||||||
@@ -1818,6 +1894,7 @@ export async function reconcileWeightedDns(
|
|||||||
latest.hostname,
|
latest.hostname,
|
||||||
desiredIps,
|
desiredIps,
|
||||||
null,
|
null,
|
||||||
|
listingCache,
|
||||||
);
|
);
|
||||||
n += 1;
|
n += 1;
|
||||||
});
|
});
|
||||||
@@ -1829,7 +1906,7 @@ export async function reconcileWeightedDns(
|
|||||||
if (group.lb_mode !== "weighted") continue;
|
if (group.lb_mode !== "weighted") continue;
|
||||||
if (!group.enabled || !group.domain?.trim()) continue;
|
if (!group.enabled || !group.domain?.trim()) continue;
|
||||||
try {
|
try {
|
||||||
await syncGroupDomainDns(db, cf, group.id);
|
await syncGroupDomainDns(db, cf, group.id, listingCache);
|
||||||
n += 1;
|
n += 1;
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -513,7 +513,7 @@ describe("certificates", () => {
|
|||||||
await testApp.close();
|
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({
|
const testApp = await buildApp({
|
||||||
config: { ...loadConfig(), staticDir: null },
|
config: { ...loadConfig(), staticDir: null },
|
||||||
memory: true,
|
memory: true,
|
||||||
@@ -563,13 +563,21 @@ describe("certificates", () => {
|
|||||||
|
|
||||||
expect(repos.listCertificates(testApp.db)).toHaveLength(2);
|
expect(repos.listCertificates(testApp.db)).toHaveLength(2);
|
||||||
|
|
||||||
|
// Read path must not mutate: stale rows survive until the cron prune.
|
||||||
const listRes = await testApp.inject({
|
const listRes = await testApp.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/api/v1/certificates",
|
url: "/api/v1/certificates",
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
expect(listRes.statusCode).toBe(200);
|
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();
|
await testApp.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
|||||||
entry: ["src/server.ts"],
|
entry: ["src/server.ts"],
|
||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
dts: true,
|
dts: true,
|
||||||
|
sourcemap: true,
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
copyFileSync(
|
copyFileSync(
|
||||||
|
|||||||
@@ -27,14 +27,11 @@
|
|||||||
"@tanstack/router-vite-plugin": "^1.167.18",
|
"@tanstack/router-vite-plugin": "^1.167.18",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.4.0",
|
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-day-picker": "^10.0.1",
|
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-hook-form": "^7.79.0",
|
"react-hook-form": "^7.79.0",
|
||||||
"react-phone-number-input": "^3.4.17",
|
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.1",
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ import {
|
|||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { changeServiceDomain, domainsListQueryOptions } from '@/queries'
|
import {
|
||||||
|
changeServiceDomain,
|
||||||
|
domainKeys,
|
||||||
|
domainsListQueryOptions,
|
||||||
|
serviceBindingKeys,
|
||||||
|
serviceGroupKeys,
|
||||||
|
serviceKeys,
|
||||||
|
} from '@/queries'
|
||||||
|
|
||||||
interface ChangeDomainSheetProps {
|
interface ChangeDomainSheetProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -70,7 +77,12 @@ export function ChangeDomainSheet({
|
|||||||
}),
|
}),
|
||||||
onSuccess: async (result: { message?: string }) => {
|
onSuccess: async (result: { message?: string }) => {
|
||||||
toast.success(result.message ?? 'Привязки перенесены')
|
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)
|
setConfirmOpen(false)
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
import { changeBindingIp, serviceNodesQueryOptions } from '@/queries'
|
import { changeBindingIp, serviceBindingKeys, serviceGroupKeys, serviceKeys, serviceNodesQueryOptions } from '@/queries'
|
||||||
|
|
||||||
interface ChangeIpSheetProps {
|
interface ChangeIpSheetProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -82,7 +82,11 @@ export function ChangeIpSheet({
|
|||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
setPreview(result.message)
|
setPreview(result.message)
|
||||||
toast.success(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)
|
onOpenChange(false)
|
||||||
},
|
},
|
||||||
onError: (e: unknown) =>
|
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 { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis } from 'recharts'
|
||||||
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
@@ -18,34 +18,6 @@ import {
|
|||||||
} from '@cfdm/ui/components/chart'
|
} from '@cfdm/ui/components/chart'
|
||||||
import { Separator } from '@cfdm/ui/components/separator'
|
import { Separator } from '@cfdm/ui/components/separator'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
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 = {
|
const statusChartConfig = {
|
||||||
count: { label: 'Сертификаты' },
|
count: { label: 'Сертификаты' },
|
||||||
@@ -74,7 +46,7 @@ interface CertStatusChartProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CertStatusChart({ data }: CertStatusChartProps) {
|
export function CertStatusChart({ data }: CertStatusChartProps) {
|
||||||
const chartRef = useChartSizeLog('cert-status', data.length)
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
const total = useMemo(
|
const total = useMemo(
|
||||||
() => data.reduce((sum, entry) => sum + entry.count, 0),
|
() => data.reduce((sum, entry) => sum + entry.count, 0),
|
||||||
[data],
|
[data],
|
||||||
@@ -166,7 +138,7 @@ interface GroupDomainsChartProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GroupDomainsChart({ data }: GroupDomainsChartProps) {
|
export function GroupDomainsChart({ data }: GroupDomainsChartProps) {
|
||||||
const chartRef = useChartSizeLog('group-domains', data.length)
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className="w-full">
|
<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 { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
|
||||||
import { Area, ComposedChart, Line, XAxis, YAxis } from 'recharts'
|
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: {
|
function syncHover(state: {
|
||||||
activeTooltipIndex?: unknown
|
activeTooltipIndex?: unknown
|
||||||
activeIndex?: unknown
|
activeIndex?: unknown
|
||||||
}) {
|
}) {
|
||||||
const index = Number(state.activeTooltipIndex ?? state.activeIndex)
|
const index = Number(state.activeTooltipIndex ?? state.activeIndex)
|
||||||
if (!Number.isFinite(index)) return
|
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 = (
|
const panel = (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,29 @@
|
|||||||
import { useQueries } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { healthStatusQueryOptions } from '@/queries'
|
import { bindingsHealthBatchQueryOptions } from '@/queries'
|
||||||
import type { IpHealthStatus, ServiceBinding } from '@/lib/schemas'
|
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) {
|
export function useDomainHealthByIp(bindings: ServiceBinding[] | undefined) {
|
||||||
const enabledBindings = useMemo(
|
const enabledBindings = useMemo(
|
||||||
() => (bindings ?? []).filter((b) => b.health_check_enabled),
|
() => (bindings ?? []).filter((b) => b.health_check_enabled),
|
||||||
[bindings],
|
[bindings],
|
||||||
)
|
)
|
||||||
|
const bindingIds = useMemo(
|
||||||
|
() => enabledBindings.map((b) => b.id),
|
||||||
|
[enabledBindings],
|
||||||
|
)
|
||||||
|
|
||||||
const queries = useQueries({
|
const batch = useQuery({
|
||||||
queries: enabledBindings.map((b) => ({
|
...bindingsHealthBatchQueryOptions(bindingIds),
|
||||||
...healthStatusQueryOptions('binding', b.id),
|
enabled: bindingIds.length > 0,
|
||||||
})),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const map: Record<string, IpHealthStatus> = {}
|
const map: Record<string, IpHealthStatus> = {}
|
||||||
const rank = { up: 0, unknown: 1, degraded: 2, down: 3 } as const
|
const rank = { up: 0, unknown: 1, degraded: 2, down: 3 } as const
|
||||||
for (const q of queries) {
|
for (const rows of batch.data?.values() ?? []) {
|
||||||
for (const row of q.data ?? []) {
|
for (const row of rows) {
|
||||||
const prev = map[row.ip]
|
const prev = map[row.ip]
|
||||||
if (!prev || rank[row.status] > rank[prev.status]) {
|
if (!prev || rank[row.status] > rank[prev.status]) {
|
||||||
map[row.ip] = row
|
map[row.ip] = row
|
||||||
@@ -28,5 +31,5 @@ export function useDomainHealthByIp(bindings: ServiceBinding[] | undefined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}, [queries])
|
}, [batch.data])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import type { ServiceView } from '@/lib/schemas'
|
import type { ServiceView } from '@/lib/schemas'
|
||||||
import {
|
import {
|
||||||
boardColumnsToKanbanValue,
|
boardColumnsToKanbanValue,
|
||||||
@@ -15,11 +15,16 @@ interface UseServicesKanbanOptions {
|
|||||||
export function useServicesKanban(options: UseServicesKanbanOptions) {
|
export function useServicesKanban(options: UseServicesKanbanOptions) {
|
||||||
const boardHook = useServicesBoard(options)
|
const boardHook = useServicesBoard(options)
|
||||||
|
|
||||||
const kanbanValue = boardColumnsToKanbanValue(
|
// Memoized: a 10s polling tick must not rebuild kanban value identity.
|
||||||
boardHook.board.columns.map((column) => ({
|
const kanbanValue = useMemo(
|
||||||
id: column.id,
|
() =>
|
||||||
items: column.items,
|
boardColumnsToKanbanValue(
|
||||||
})),
|
boardHook.board.columns.map((column) => ({
|
||||||
|
id: column.id,
|
||||||
|
items: column.items,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
[boardHook.board.columns],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleKanbanValueChange = useCallback(
|
const handleKanbanValueChange = useCallback(
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,8 @@ import { ApiError } from './api-client'
|
|||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
|
// Polling queries carry staleTime 5s; a refetch on every alt-tab is waste.
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
staleTime: 1000 * 60,
|
staleTime: 1000 * 60,
|
||||||
retry: (count, error) => {
|
retry: (count, error) => {
|
||||||
if (error instanceof ApiError && error.status === 404) return false
|
if (error instanceof ApiError && error.status === 404) return false
|
||||||
|
|||||||
@@ -8,12 +8,6 @@ import { Toaster } from '@cfdm/ui/components/sonner'
|
|||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
import { queryClient } from './lib/queryClient'
|
import { queryClient } from './lib/queryClient'
|
||||||
import '@cfdm/ui/globals.css'
|
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({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
|
|||||||
@@ -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() {
|
export async function runHealthCheck() {
|
||||||
return api.post<{ checked: number }>('/api/v1/health-check/run', {})
|
return api.post<{ checked: number }>('/api/v1/health-check/run', {})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useMemo, useState, useEffect } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ActivityIcon,
|
ActivityIcon,
|
||||||
AlertTriangleIcon,
|
AlertTriangleIcon,
|
||||||
@@ -41,7 +41,6 @@ import {
|
|||||||
} from '@cfdm/ui/components/item'
|
} from '@cfdm/ui/components/item'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { formatRelative } from '@/lib/format'
|
import { formatRelative } from '@/lib/format'
|
||||||
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
|
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/')({
|
export const Route = createFileRoute('/_auth/')({
|
||||||
@@ -201,25 +200,6 @@ function DashboardPage() {
|
|||||||
|
|
||||||
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
|
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[] = [
|
const kpiCards: KpiStatCard[] = [
|
||||||
{
|
{
|
||||||
id: 'domains',
|
id: 'domains',
|
||||||
|
|||||||
@@ -42,10 +42,8 @@ import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
|||||||
import {
|
import {
|
||||||
createServiceNode,
|
createServiceNode,
|
||||||
deleteServiceNode,
|
deleteServiceNode,
|
||||||
domainKeys,
|
|
||||||
domainsListQueryOptions,
|
domainsListQueryOptions,
|
||||||
serviceBindingKeys,
|
serviceBindingKeys,
|
||||||
serviceDetailKeys,
|
|
||||||
serviceFailoverLogQueryOptions,
|
serviceFailoverLogQueryOptions,
|
||||||
serviceGroupKeys,
|
serviceGroupKeys,
|
||||||
serviceGroupsQueryOptions,
|
serviceGroupsQueryOptions,
|
||||||
@@ -153,16 +151,11 @@ function ServiceDetailPage() {
|
|||||||
: []
|
: []
|
||||||
|
|
||||||
async function invalidateService() {
|
async function invalidateService() {
|
||||||
|
// Targeted invalidation: only service-scoped data the mutation touched.
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
|
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
|
||||||
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
|
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
|
||||||
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.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) }),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,51 @@ export default defineConfig({
|
|||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
|||||||
@@ -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/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/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 \
|
&& 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
|
FROM ${BASE_NODE} AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache ca-certificates
|
RUN apk add --no-cache ca-certificates wget
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
ARG GIT_SHA=unknown
|
ARG GIT_SHA=unknown
|
||||||
ARG BUILD_TIME=
|
ARG BUILD_TIME=
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
NODE_OPTIONS=--enable-source-maps \
|
||||||
STATIC_DIR=/app/static \
|
STATIC_DIR=/app/static \
|
||||||
DATABASE_URL=sqlite:/data/app.db \
|
DATABASE_URL=sqlite:/data/app.db \
|
||||||
SERVER_PORT=8080 \
|
SERVER_PORT=8080 \
|
||||||
APP_VERSION=${VERSION} \
|
APP_VERSION=${VERSION} \
|
||||||
GIT_SHA=${GIT_SHA} \
|
GIT_SHA=${GIT_SHA} \
|
||||||
BUILD_TIME=${BUILD_TIME}
|
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
|
EXPOSE 8080
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
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 ["wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"]
|
||||||
CMD ["node", "dist/server.js"]
|
CMD ["node", "--max-old-space-size=384", "dist/server.js"]
|
||||||
|
|||||||
+4
-2
@@ -19,9 +19,11 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
mem_limit: 512m
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 15s
|
start_period: 10s
|
||||||
|
|||||||
Vendored
+50
-2
File diff suppressed because one or more lines are too long
Vendored
+314
-1
@@ -425,6 +425,9 @@ function createDb(databaseUrl) {
|
|||||||
sqlite.pragma("journal_mode = WAL");
|
sqlite.pragma("journal_mode = WAL");
|
||||||
sqlite.pragma("synchronous = NORMAL");
|
sqlite.pragma("synchronous = NORMAL");
|
||||||
sqlite.pragma("foreign_keys = ON");
|
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 });
|
const db = drizzle(sqlite, { schema });
|
||||||
return { db, sqlite };
|
return { db, sqlite };
|
||||||
}
|
}
|
||||||
@@ -455,6 +458,9 @@ function runMigrations(sqlite) {
|
|||||||
function healthCheck(sqlite) {
|
function healthCheck(sqlite) {
|
||||||
sqlite.prepare("SELECT 1").get();
|
sqlite.prepare("SELECT 1").get();
|
||||||
}
|
}
|
||||||
|
function walCheckpointTruncate(sqlite) {
|
||||||
|
sqlite.pragma("wal_checkpoint(TRUNCATE)");
|
||||||
|
}
|
||||||
|
|
||||||
// src/errors.ts
|
// src/errors.ts
|
||||||
var NotFoundError = class extends Error {
|
var NotFoundError = class extends Error {
|
||||||
@@ -669,6 +675,7 @@ function touchVpsTrackerSync(db) {
|
|||||||
// src/repos.ts
|
// src/repos.ts
|
||||||
var repos_exports = {};
|
var repos_exports = {};
|
||||||
__export(repos_exports, {
|
__export(repos_exports, {
|
||||||
|
LOG_RETENTION_LIMITS: () => LOG_RETENTION_LIMITS,
|
||||||
addDomainTags: () => addDomainTags,
|
addDomainTags: () => addDomainTags,
|
||||||
aggregateGroupScopeHealthByIds: () => aggregateGroupScopeHealthByIds,
|
aggregateGroupScopeHealthByIds: () => aggregateGroupScopeHealthByIds,
|
||||||
aggregateIpHealthByRefs: () => aggregateIpHealthByRefs,
|
aggregateIpHealthByRefs: () => aggregateIpHealthByRefs,
|
||||||
@@ -723,6 +730,7 @@ __export(repos_exports, {
|
|||||||
getServiceGroup: () => getServiceGroup,
|
getServiceGroup: () => getServiceGroup,
|
||||||
getSubdomain: () => getSubdomain,
|
getSubdomain: () => getSubdomain,
|
||||||
getSyncJob: () => getSyncJob,
|
getSyncJob: () => getSyncJob,
|
||||||
|
hasWeightedBindings: () => hasWeightedBindings,
|
||||||
insertBinding: () => insertBinding,
|
insertBinding: () => insertBinding,
|
||||||
insertDnsRecord: () => insertDnsRecord,
|
insertDnsRecord: () => insertDnsRecord,
|
||||||
insertFailoverLog: () => insertFailoverLog,
|
insertFailoverLog: () => insertFailoverLog,
|
||||||
@@ -734,11 +742,14 @@ __export(repos_exports, {
|
|||||||
listAllDomains: () => listAllDomains,
|
listAllDomains: () => listAllDomains,
|
||||||
listAllNodes: () => listAllNodes,
|
listAllNodes: () => listAllNodes,
|
||||||
listAllSubdomains: () => listAllSubdomains,
|
listAllSubdomains: () => listAllSubdomains,
|
||||||
|
listBindingIpHealthByBindingIds: () => listBindingIpHealthByBindingIds,
|
||||||
listBindingIps: () => listBindingIps,
|
listBindingIps: () => listBindingIps,
|
||||||
listBindingIpsWithMeta: () => listBindingIpsWithMeta,
|
listBindingIpsWithMeta: () => listBindingIpsWithMeta,
|
||||||
|
listBindingIpsWithMetaByBindingIds: () => listBindingIpsWithMetaByBindingIds,
|
||||||
listBindingNodes: () => listBindingNodes,
|
listBindingNodes: () => listBindingNodes,
|
||||||
listBindingsByDomain: () => listBindingsByDomain,
|
listBindingsByDomain: () => listBindingsByDomain,
|
||||||
listBindingsByService: () => listBindingsByService,
|
listBindingsByService: () => listBindingsByService,
|
||||||
|
listBindingsByServiceIds: () => listBindingsByServiceIds,
|
||||||
listCertificates: () => listCertificates,
|
listCertificates: () => listCertificates,
|
||||||
listDnsByDomain: () => listDnsByDomain,
|
listDnsByDomain: () => listDnsByDomain,
|
||||||
listDnsRecords: () => listDnsRecords,
|
listDnsRecords: () => listDnsRecords,
|
||||||
@@ -757,19 +768,25 @@ __export(repos_exports, {
|
|||||||
listHealthProbeLogForService: () => listHealthProbeLogForService,
|
listHealthProbeLogForService: () => listHealthProbeLogForService,
|
||||||
listIpHealthByServiceIds: () => listIpHealthByServiceIds,
|
listIpHealthByServiceIds: () => listIpHealthByServiceIds,
|
||||||
listIpHealthStatus: () => listIpHealthStatus,
|
listIpHealthStatus: () => listIpHealthStatus,
|
||||||
|
listIpHealthStatusByBindingIds: () => listIpHealthStatusByBindingIds,
|
||||||
|
listLatestLiveHealthByServiceIds: () => listLatestLiveHealthByServiceIds,
|
||||||
listNodes: () => listNodes,
|
listNodes: () => listNodes,
|
||||||
listNotificationLog: () => listNotificationLog,
|
listNotificationLog: () => listNotificationLog,
|
||||||
listOriginIpsForFqdn: () => listOriginIpsForFqdn,
|
listOriginIpsForFqdn: () => listOriginIpsForFqdn,
|
||||||
|
listRecordsByBindingIds: () => listRecordsByBindingIds,
|
||||||
listRecordsForBinding: () => listRecordsForBinding,
|
listRecordsForBinding: () => listRecordsForBinding,
|
||||||
listServiceGroups: () => listServiceGroups,
|
listServiceGroups: () => listServiceGroups,
|
||||||
listServiceIpRows: () => listServiceIpRows,
|
listServiceIpRows: () => listServiceIpRows,
|
||||||
|
listServiceIpRowsByServiceIds: () => listServiceIpRowsByServiceIds,
|
||||||
listServiceIps: () => listServiceIps,
|
listServiceIps: () => listServiceIps,
|
||||||
listServices: () => listServices,
|
listServices: () => listServices,
|
||||||
listServicesByGroup: () => listServicesByGroup,
|
listServicesByGroup: () => listServicesByGroup,
|
||||||
|
listServicesByGroupIds: () => listServicesByGroupIds,
|
||||||
listSubdomainsByDomain: () => listSubdomainsByDomain,
|
listSubdomainsByDomain: () => listSubdomainsByDomain,
|
||||||
listUngroupedServices: () => listUngroupedServices,
|
listUngroupedServices: () => listUngroupedServices,
|
||||||
markDnsPendingDelete: () => markDnsPendingDelete,
|
markDnsPendingDelete: () => markDnsPendingDelete,
|
||||||
mergeHealthAggregates: () => mergeHealthAggregates,
|
mergeHealthAggregates: () => mergeHealthAggregates,
|
||||||
|
pruneLogs: () => pruneLogs,
|
||||||
pruneStaleIpHealthStatus: () => pruneStaleIpHealthStatus,
|
pruneStaleIpHealthStatus: () => pruneStaleIpHealthStatus,
|
||||||
reorderServices: () => reorderServices,
|
reorderServices: () => reorderServices,
|
||||||
replaceBindingIps: () => replaceBindingIps,
|
replaceBindingIps: () => replaceBindingIps,
|
||||||
@@ -1135,6 +1152,29 @@ function listServices(db) {
|
|||||||
function listServicesByGroup(db, groupId) {
|
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();
|
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) {
|
function listUngroupedServices(db) {
|
||||||
return db.select().from(services).where(isNull(services.service_group_id)).orderBy(asc(services.sort_order), asc(services.name)).all();
|
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
|
ORDER BY d.zone_name, s.name
|
||||||
`).map((row) => enrichServiceBindingView(db, row));
|
`).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) {
|
function listBindingsByDomain(db, domainId) {
|
||||||
return db.all(sql2`
|
return db.all(sql2`
|
||||||
SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)}
|
SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)}
|
||||||
@@ -1800,6 +1850,123 @@ function getBinding(db, id) {
|
|||||||
if (!row) throw new NotFoundError(`service binding ${id}`);
|
if (!row) throw new NotFoundError(`service binding ${id}`);
|
||||||
return mapServiceBinding(row);
|
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) {
|
function getBindingView(db, id) {
|
||||||
const rows = db.all(sql2`
|
const rows = db.all(sql2`
|
||||||
SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)}
|
SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)}
|
||||||
@@ -1972,6 +2139,29 @@ function listIpHealthStatus(db, scope, refId) {
|
|||||||
WHERE scope = ${scope} AND ref_id = ${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 = {
|
var UNKNOWN_HEALTH = {
|
||||||
health_status: "unknown",
|
health_status: "unknown",
|
||||||
health_latency_ms: null
|
health_latency_ms: null
|
||||||
@@ -2127,6 +2317,83 @@ function listIpHealthByServiceIds(db, serviceIds) {
|
|||||||
}
|
}
|
||||||
return result;
|
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) {
|
function mergeHealthAggregates(parts) {
|
||||||
const rank = {
|
const rank = {
|
||||||
unknown: 0,
|
unknown: 0,
|
||||||
@@ -2563,6 +2830,51 @@ function listNotificationLog(db, limit = 50) {
|
|||||||
LIMIT ${limit}
|
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) {
|
function insertFailoverLog(db, input) {
|
||||||
for (const entry of input.entries) {
|
for (const entry of input.entries) {
|
||||||
db.insert(failoverLog).values({
|
db.insert(failoverLog).values({
|
||||||
@@ -2623,5 +2935,6 @@ export {
|
|||||||
subdomains,
|
subdomains,
|
||||||
syncJobs,
|
syncJobs,
|
||||||
touchVpsTrackerSync,
|
touchVpsTrackerSync,
|
||||||
updateAppSettings
|
updateAppSettings,
|
||||||
|
walCheckpointTruncate
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export function createDb(databaseUrl: string): { db: Db; sqlite: Sqlite } {
|
|||||||
sqlite.pragma("journal_mode = WAL");
|
sqlite.pragma("journal_mode = WAL");
|
||||||
sqlite.pragma("synchronous = NORMAL");
|
sqlite.pragma("synchronous = NORMAL");
|
||||||
sqlite.pragma("foreign_keys = ON");
|
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 });
|
const db = drizzle(sqlite, { schema });
|
||||||
return { db, sqlite };
|
return { db, sqlite };
|
||||||
}
|
}
|
||||||
@@ -63,3 +66,8 @@ export function runMigrations(sqlite: Sqlite): void {
|
|||||||
export function healthCheck(sqlite: Sqlite): void {
|
export function healthCheck(sqlite: Sqlite): void {
|
||||||
sqlite.prepare("SELECT 1").get();
|
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)");
|
||||||
|
}
|
||||||
|
|||||||
@@ -648,6 +648,34 @@ export function listServicesByGroup(db: Db, groupId: number): Service[] {
|
|||||||
.all() as 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[] {
|
export function listUngroupedServices(db: Db): Service[] {
|
||||||
return db
|
return db
|
||||||
.select()
|
.select()
|
||||||
@@ -1762,6 +1790,18 @@ export function listAllBindings(db: Db): ServiceBindingView[] {
|
|||||||
.map((row) => enrichServiceBindingView(db, row));
|
.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[] {
|
export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingView[] {
|
||||||
return db
|
return db
|
||||||
.all<Omit<ServiceBindingView, "target_ips" | "target_ip_weights" | "target_ip_priorities">>(sql`
|
.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);
|
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 {
|
export function getBindingView(db: Db, id: number): ServiceBindingView {
|
||||||
const rows = db.all<Omit<ServiceBindingView, "target_ips" | "target_ip_weights" | "target_ip_priorities">>(sql`
|
const rows = db.all<Omit<ServiceBindingView, "target_ips" | "target_ip_weights" | "target_ip_priorities">>(sql`
|
||||||
SELECT ${sql.raw(SERVICE_BINDING_SELECT_COLUMNS)}
|
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 = {
|
export type HealthAggregate = {
|
||||||
health_status: IpHealthState;
|
health_status: IpHealthState;
|
||||||
health_latency_ms: number | null;
|
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 FailoverLogAction = "added" | "removed";
|
||||||
|
|
||||||
export type FailoverLogRow = {
|
export type FailoverLogRow = {
|
||||||
|
|||||||
Generated
-105
@@ -63,9 +63,6 @@ importers:
|
|||||||
'@fastify/rate-limit':
|
'@fastify/rate-limit':
|
||||||
specifier: ^10.3.0
|
specifier: ^10.3.0
|
||||||
version: 10.3.0
|
version: 10.3.0
|
||||||
'@fastify/schedule':
|
|
||||||
specifier: ^6.0.0
|
|
||||||
version: 6.0.0([email protected])
|
|
||||||
'@fastify/sensible':
|
'@fastify/sensible':
|
||||||
specifier: ^6.0.3
|
specifier: ^6.0.3
|
||||||
version: 6.0.4
|
version: 6.0.4
|
||||||
@@ -87,9 +84,6 @@ importers:
|
|||||||
p-limit:
|
p-limit:
|
||||||
specifier: ^6.2.0
|
specifier: ^6.2.0
|
||||||
version: 6.2.0
|
version: 6.2.0
|
||||||
p-queue:
|
|
||||||
specifier: ^8.1.0
|
|
||||||
version: 8.1.1
|
|
||||||
toad-scheduler:
|
toad-scheduler:
|
||||||
specifier: ^4.0.1
|
specifier: ^4.0.1
|
||||||
version: 4.0.1
|
version: 4.0.1
|
||||||
@@ -166,9 +160,6 @@ importers:
|
|||||||
cmdk:
|
cmdk:
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
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:
|
lucide-react:
|
||||||
specifier: ^1.18.0
|
specifier: ^1.18.0
|
||||||
version: 1.18.0([email protected])
|
version: 1.18.0([email protected])
|
||||||
@@ -178,18 +169,12 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^19.2.6
|
specifier: ^19.2.6
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
react-day-picker:
|
|
||||||
specifier: ^10.0.1
|
|
||||||
version: 10.0.1(@types/[email protected])([email protected])
|
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.6
|
specifier: ^19.2.6
|
||||||
version: 19.2.7([email protected])
|
version: 19.2.7([email protected])
|
||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.79.0
|
specifier: ^7.79.0
|
||||||
version: 7.79.0([email protected])
|
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:
|
recharts:
|
||||||
specifier: ^3.8.0
|
specifier: ^3.8.0
|
||||||
version: 3.8.0(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
|
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]':
|
'@fastify/[email protected]':
|
||||||
resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==}
|
resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==}
|
||||||
|
|
||||||
'@fastify/[email protected]':
|
|
||||||
resolution: {integrity: sha512-q4mPXUtqISb+dH2cB7HE7DxXqQmxPee9k0G29ydvGOoj5Aqb+uzUCLtkMVAzpnkPE6oOFGon6+JzWEmfJNS8ig==}
|
|
||||||
peerDependencies:
|
|
||||||
toad-scheduler: '>=2.0.0'
|
|
||||||
|
|
||||||
'@fastify/[email protected]':
|
'@fastify/[email protected]':
|
||||||
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
|
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
|
||||||
|
|
||||||
@@ -2854,9 +2834,6 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
|
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3002,9 +2979,6 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==}
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
|
resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
|
||||||
engines: {node: '>=18.0'}
|
engines: {node: '>=18.0'}
|
||||||
@@ -3784,17 +3758,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==}
|
resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==}
|
||||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
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]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -3949,9 +3912,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
resolution: {integrity: sha512-80xal1m93rADejw2pMp2MSzFhHCPLEspjHxnH2UtqI+DgAmElsbmLMiqk9niwH9NWAfjsRtaJI+qBrOEmRx9nQ==}
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
|
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
|
||||||
|
|
||||||
@@ -4097,10 +4057,6 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||||
|
|
||||||
@@ -4442,10 +4398,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==}
|
resolution: {integrity: sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==}
|
resolution: {integrity: sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -4611,9 +4563,6 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
|
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
|
||||||
|
|
||||||
@@ -4668,18 +4617,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==}
|
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]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
|
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -6371,11 +6311,6 @@ snapshots:
|
|||||||
fastify-plugin: 5.1.0
|
fastify-plugin: 5.1.0
|
||||||
toad-cache: 3.7.1
|
toad-cache: 3.7.1
|
||||||
|
|
||||||
'@fastify/[email protected]([email protected])':
|
|
||||||
dependencies:
|
|
||||||
fastify-plugin: 5.1.0
|
|
||||||
toad-scheduler: 4.0.1
|
|
||||||
|
|
||||||
'@fastify/[email protected]':
|
'@fastify/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@lukeed/ms': 2.0.2
|
'@lukeed/ms': 2.0.2
|
||||||
@@ -7952,8 +7887,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
|
|
||||||
[email protected]: {}
|
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -8105,8 +8038,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
|
|
||||||
[email protected]: {}
|
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -8920,13 +8851,6 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[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]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -9044,8 +8968,6 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
[email protected]: {}
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
cookie: 1.1.1
|
cookie: 1.1.1
|
||||||
@@ -9157,10 +9079,6 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
dependencies:
|
|
||||||
js-tokens: 4.0.0
|
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -9394,11 +9312,6 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
dependencies:
|
|
||||||
eventemitter3: 5.0.4
|
|
||||||
p-timeout: 6.1.4
|
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -9545,12 +9458,6 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
dependencies:
|
|
||||||
loose-envify: 1.4.0
|
|
||||||
object-assign: 4.1.1
|
|
||||||
react-is: 16.13.1
|
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -9592,20 +9499,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
|
|
||||||
[email protected]: {}
|
|
||||||
|
|
||||||
[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]):
|
[email protected](@types/[email protected])([email protected])([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/use-sync-external-store': 0.0.6
|
'@types/use-sync-external-store': 0.0.6
|
||||||
|
|||||||
+10
-3
@@ -3,15 +3,22 @@
|
|||||||
"tasks": {
|
"tasks": {
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
"outputs": ["dist/**"]
|
"outputs": [
|
||||||
|
"dist/**",
|
||||||
|
"src/routeTree.gen.ts",
|
||||||
|
"node_modules/.tmp/*.tsbuildinfo"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"dev": {
|
"dev": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": true
|
"persistent": true
|
||||||
},
|
},
|
||||||
"test": {
|
"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"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user