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