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

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

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-03 16:16:41 +07:00
co-authored by Cursor
parent de3dbe8521
commit 1e9cc04a59
42 changed files with 6036 additions and 2836 deletions
+228
View File
@@ -0,0 +1,228 @@
/**
* CFDM health-probe Worker. Cron Trigger reads KV `targets`, probes TCP/HTTP
* from the edge (UptimeFlare-style), writes KV `results`. CFDM is SoT in SQLite.
*/
const TARGETS_KEY = "targets";
const RESULTS_KEY = "results";
const CURSOR_KEY = "cursor";
const BATCH = 48;
const CONCURRENCY = 5;
const COOLDOWN_MS = 3 * 60 * 1000;
const UA = "CFDM-health-probe/1.0";
export default {
async fetch() {
return new Response(JSON.stringify({ ok: true, service: "cfdm-health-probe" }), {
headers: { "content-type": "application/json" },
});
},
async scheduled(_event, env) {
await probeBatch(env);
},
};
async function probeBatch(env) {
const raw = await env.HEALTH_KV.get(TARGETS_KEY);
if (!raw) return;
let doc;
try {
doc = JSON.parse(raw);
} catch {
return;
}
const items = Array.isArray(doc.items) ? doc.items : [];
if (items.length === 0) return;
let offset = 0;
const cursorRaw = await env.HEALTH_KV.get(CURSOR_KEY);
if (cursorRaw) {
try {
const cursor = JSON.parse(cursorRaw);
if (Number.isFinite(cursor.offset) && cursor.offset >= 0) {
offset = cursor.offset % items.length;
}
} catch {
offset = 0;
}
}
const slice = items.slice(offset, offset + BATCH);
const nextOffset = offset + slice.length >= items.length ? 0 : offset + slice.length;
const colo = await readColo();
const probed = await mapPool(slice, CONCURRENCY, async (target) => {
const type = target.type === "http" ? "http" : "tcp";
const port = Number(target.port) || (type === "http" ? 80 : 80);
const timeoutMs = Math.min(Math.max(Number(target.timeoutMs) || 3000, 100), 25_000);
const hostname = String(target.hostname ?? "").trim() || target.ip;
try {
const result =
type === "http"
? await httpProbe({
ip: target.ip,
hostname,
port,
path: target.path || "/",
expectedStatus: target.expectedStatus ?? 200,
timeoutMs,
verifyTls: Boolean(target.verifyTls),
})
: await tcpProbe(target.ip, port, timeoutMs);
return { key: target.key, ...result };
} catch (err) {
return {
key: target.key,
ok: false,
latencyMs: 0,
error: err instanceof Error ? err.message : "probe failed",
};
}
});
const fingerprint = resultFingerprint(probed);
const previousRaw = await env.HEALTH_KV.get(RESULTS_KEY);
let skipWrite = false;
if (previousRaw) {
try {
const prev = JSON.parse(previousRaw);
const age = Date.now() - Date.parse(prev.probedAt);
if (prev.fingerprint === fingerprint && Number.isFinite(age) && age < COOLDOWN_MS) {
skipWrite = true;
}
} catch {
skipWrite = false;
}
}
if (!skipWrite) {
const results = {
probedAt: new Date().toISOString(),
colo,
fingerprint,
items: probed,
};
await env.HEALTH_KV.put(RESULTS_KEY, JSON.stringify(results));
}
if (items.length > BATCH || offset !== 0) {
await env.HEALTH_KV.put(CURSOR_KEY, JSON.stringify({ offset: nextOffset }));
}
}
function resultFingerprint(items) {
return items
.map((item) => `${item.key}:${item.ok ? "1" : "0"}:${item.error ?? ""}`)
.sort()
.join("|");
}
async function mapPool(items, concurrency, fn) {
if (items.length === 0) return [];
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const idx = next;
next += 1;
results[idx] = await fn(items[idx]);
}
}
const n = Math.min(concurrency, items.length);
await Promise.all(Array.from({ length: n }, () => worker()));
return results;
}
async function readColo() {
try {
const res = await fetch("https://www.cloudflare.com/cdn-cgi/trace", {
cf: { cacheTtlByStatus: { "100-599": -1 } },
});
const text = await res.text();
const line = text.split("\n").find((row) => row.startsWith("colo="));
return line ? line.slice(5).trim() || null : null;
} catch {
return null;
}
}
function withTimeout(promise, timeoutMs, label) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${label} timeout`)), timeoutMs);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(err) => {
clearTimeout(timer);
reject(err);
},
);
});
}
async function tcpProbe(ip, port, timeoutMs) {
const started = Date.now();
const { connect } = await import("cloudflare:sockets");
const socket = connect({ hostname: ip, port });
try {
await withTimeout(socket.opened, timeoutMs, "tcp");
return { ok: true, latencyMs: Date.now() - started, error: null };
} catch (err) {
const message = err instanceof Error ? err.message : "tcp failed";
return { ok: false, latencyMs: Date.now() - started, error: message };
} finally {
try {
socket.close();
} catch {
// ignore
}
}
}
async function httpProbe(opts) {
const started = Date.now();
const useTls = opts.verifyTls || opts.port === 443;
const host = opts.ip.includes(":") ? `[${opts.ip}]` : opts.ip;
const path = opts.path.startsWith("/") ? opts.path : `/${opts.path}`;
const url = `${useTls ? "https" : "http"}://${host}:${opts.port}${path}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
try {
const res = await fetch(url, {
method: "GET",
headers: {
Host: opts.hostname,
"User-Agent": UA,
},
signal: controller.signal,
redirect: "manual",
cf: { cacheTtlByStatus: { "100-599": -1 } },
});
try {
await res.body?.cancel();
} catch {
// ignore
}
const latencyMs = Date.now() - started;
if (res.status !== opts.expectedStatus) {
return {
ok: false,
latencyMs,
error: `HTTP ${res.status} (ожидали ${opts.expectedStatus})`,
};
}
return { ok: true, latencyMs, error: null };
} catch (err) {
const message =
err instanceof Error
? err.name === "AbortError"
? "http timeout"
: err.message
: "http failed";
return { ok: false, latencyMs: Date.now() - started, error: message };
} finally {
clearTimeout(timer);
}
}
+4484 -1093
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
-2
View File
@@ -16,7 +16,6 @@
"@fastify/helmet": "^13.0.1",
"@fastify/jwt": "^9.1.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/schedule": "^6.0.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"@fastify/type-provider-zod": "^1.0.0",
@@ -24,7 +23,6 @@
"fastify": "^5.4.0",
"fastify-plugin": "^5.0.1",
"p-limit": "^6.2.0",
"p-queue": "^8.1.0",
"toad-scheduler": "^4.0.1",
"undici": "^8.5.0",
"zod": "^4.2.0"
+13 -3
View File
@@ -32,6 +32,7 @@ import {
import { settingsRoutes } from "./routes/settings.js";
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
import { auditRoutes } from "./routes/audit.js";
import { repos, walCheckpointTruncate } from "@cfdm/db";
import * as certificateService from "./services/certificate-service.js";
import {
createHealthCheckTask,
@@ -43,7 +44,7 @@ import {
scheduleWeightedDnsJob,
} from "./services/weighted-dns-scheduler.js";
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
import { AsyncTask, CronJob } from "toad-scheduler";
import { AsyncTask, CronJob, ToadScheduler } from "toad-scheduler";
export interface BuildAppOptions {
config?: AppConfig;
@@ -111,11 +112,20 @@ export async function buildApp(opts: BuildAppOptions = {}) {
}
if (!opts.memory) {
await app.register(import("@fastify/schedule"));
const scheduler = new ToadScheduler();
app.decorate("scheduler", scheduler);
app.addHook("onClose", async () => {
scheduler.stop();
});
const certTask = new AsyncTask(
"certificate-check",
async () => {
const pruned = repos.pruneLogs(app.db);
if (pruned > 0) {
app.log.info({ pruned }, "log retention pruned");
}
walCheckpointTruncate(app.sqlite);
const n = await certificateService.runAllChecks(app.db);
app.log.info({ checked: n }, "certificate check completed");
},
@@ -124,7 +134,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
},
);
app.scheduler.addCronJob(
scheduler.addCronJob(
new CronJob(
{ cronExpression: config.certCheckCron },
certTask,
+9 -2
View File
@@ -38,12 +38,19 @@ export class CloudflareClient {
return this.zones.listZones();
}
invalidateZonesCache(): void {
this.zones.invalidateZonesCache();
}
getZone(zoneId: string): Promise<CfZone> {
return this.zones.getZone(zoneId);
}
listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return this.dns.listDnsRecords(zoneId);
listDnsRecords(
zoneId: string,
cache?: Map<string, CfDnsRecord[]>,
): Promise<CfDnsRecord[]> {
return this.dns.listDnsRecords(zoneId, cache);
}
createDnsRecord(zoneId: string, payload: CreateDnsRecordPayload): Promise<CfDnsRecord> {
+12 -5
View File
@@ -4,9 +4,14 @@ import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js";
export function createDnsAdapter(token: string) {
return {
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return withRetry(async () => {
const all: CfDnsRecord[] = [];
async listDnsRecords(
zoneId: string,
cache?: Map<string, CfDnsRecord[]>,
): Promise<CfDnsRecord[]> {
const cached = cache?.get(zoneId);
if (cached) return cached;
const all = await withRetry(async () => {
const records: CfDnsRecord[] = [];
let page = 1;
while (page <= 50) {
const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`);
@@ -28,11 +33,13 @@ export function createDnsAdapter(token: string) {
"list_dns_records",
);
if (batch.length === 0) break;
all.push(...batch);
records.push(...batch);
page += 1;
}
return all;
return records;
});
cache?.set(zoneId, all);
return all;
},
async createDnsRecord(
+3
View File
@@ -1,6 +1,7 @@
import type { CfDnsRecord } from "@cfdm/shared";
import { AppError } from "../../errors.js";
import { parseRetryAfter } from "../cf-retry.js";
import { notifyZoneCacheInvalidated } from "./zone-cache-events.js";
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
@@ -17,6 +18,7 @@ export function mapCloudflareFailure(
): AppError {
const lower = message.toLowerCase();
if (status === 401 || status === 403 || lower.includes("authentication")) {
notifyZoneCacheInvalidated();
if (
operation.includes("workers") ||
operation.includes("kv_") ||
@@ -31,6 +33,7 @@ export function mapCloudflareFailure(
);
}
if (status === 429 || lower.includes("rate limit")) {
notifyZoneCacheInvalidated();
return AppError.rateLimited();
}
if (lower.includes("zone") && (lower.includes("not found") || status === 404)) {
@@ -0,0 +1,21 @@
/**
* Tiny module-level hook registry: http.ts signals CF auth/rate-limit failures,
* zone-service subscribes to drop its TTL cache. Kept separate to avoid a
* circular import between http.ts and zone-service.ts.
*/
const subscribers = new Set<() => void>();
export function subscribeZoneCacheInvalidation(cb: () => void): () => void {
subscribers.add(cb);
return () => subscribers.delete(cb);
}
export function notifyZoneCacheInvalidated(): void {
for (const cb of subscribers) {
try {
cb();
} catch {
// subscriber cleanup must never break the CF response path
}
}
}
+56 -21
View File
@@ -1,34 +1,69 @@
import type { CfZone } from "@cfdm/shared";
import { withRetry } from "../cf-retry.js";
import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js";
import { subscribeZoneCacheInvalidation } from "./zone-cache-events.js";
const ZONE_CACHE_TTL_MS = 5 * 60_000;
/** TTL cache for the zones list: /ready, collectKnownZones, weighted-reconcile
* poll it on a hot schedule; zones rarely change, so one CF API call per 5 min. */
export function createZoneAdapter(token: string) {
let cachedAt = 0;
let cachedZones: CfZone[] | null = null;
let inflight: Promise<CfZone[]> | null = null;
async function fetchZones(): Promise<CfZone[]> {
const all: CfZone[] = [];
let page = 1;
while (true) {
const url = new URL(`${CF_API_BASE}/zones`);
url.searchParams.set("per_page", "50");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("list_zones", response.status, String(response.status));
}
const batch = await handleCfResponse<CfZone[]>(response, "list_zones");
if (batch.length === 0) break;
all.push(...batch);
if (batch.length < 50) break;
page += 1;
}
return all;
}
function invalidate(): void {
cachedAt = 0;
cachedZones = null;
}
subscribeZoneCacheInvalidation(invalidate);
return {
async listZones(): Promise<CfZone[]> {
return withRetry(async () => {
const all: CfZone[] = [];
let page = 1;
while (true) {
const url = new URL(`${CF_API_BASE}/zones`);
url.searchParams.set("per_page", "50");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
const now = Date.now();
if (cachedZones && now - cachedAt < ZONE_CACHE_TTL_MS) {
return cachedZones;
}
if (!inflight) {
inflight = withRetry(fetchZones)
.then((zones) => {
cachedAt = Date.now();
cachedZones = zones;
return zones;
})
.finally(() => {
inflight = null;
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("list_zones", response.status, String(response.status));
}
const batch = await handleCfResponse<CfZone[]>(response, "list_zones");
if (batch.length === 0) break;
all.push(...batch);
if (batch.length < 50) break;
page += 1;
}
return all;
});
}
return inflight;
},
invalidateZonesCache: invalidate,
async getZone(zoneId: string): Promise<CfZone> {
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, {
headers: { Authorization: `Bearer ${token}` },
+26
View File
@@ -1,4 +1,6 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { IpHealthStatus } from "@cfdm/shared";
import { healthStatusQuerySchema } from "@cfdm/shared";
import { getAppSettings, repos } from "@cfdm/db";
import * as healthCheckService from "../services/health-check-service.js";
@@ -19,6 +21,30 @@ export async function healthCheckRoutes(app: FastifyInstance) {
);
});
// Batch endpoint for lists: one request instead of N per-binding polls.
app.get("/health-status/batch", async (request) => {
const raw = (request.query as Record<string, unknown>) ?? {};
const idsParam = typeof raw.ref_ids === "string" ? raw.ref_ids : "";
const refIds = z
.array(z.coerce.number().int().positive())
.max(200)
.parse(
idsParam
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0),
);
const grouped = repos.listIpHealthStatusByBindingIds(
request.server.db,
refIds,
);
const items: { ref_id: number; rows: IpHealthStatus[] }[] = [];
for (const id of refIds) {
items.push({ ref_id: id, rows: grouped.get(id) ?? [] });
}
return { items };
});
app.post("/health-check/run", async (request) => {
const config = request.server.config;
const fallbacks = healthEngineFallbacksFromConfig(config);
+15 -19
View File
@@ -1,5 +1,6 @@
import { connect } from "node:net";
import { connect as tlsConnect } from "node:tls";
import pLimit from "p-limit";
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { Certificate, ServiceCertificateRow, Subdomain } from "@cfdm/shared";
@@ -21,19 +22,10 @@ export interface CertificateTarget {
hostname: string;
}
function pruneStaleCertificates(db: Db): void {
const targets = resolveCertificateTargets(db);
repos.deleteCertificatesNotIn(
db,
targets.map((t) => t.hostname),
);
}
export function listCertificates(
db: Db,
status?: string,
): Certificate[] {
pruneStaleCertificates(db);
return repos.listCertificates(db, status);
}
@@ -231,15 +223,20 @@ export function resolveCertificateTargets(db: Db): CertificateTarget[] {
export async function runAllChecks(db: Db): Promise<number> {
const targets = resolveCertificateTargets(db);
for (const target of targets) {
await checkAndStore(
db,
target.domainId,
target.subdomainId,
target.hostname,
target.serviceId,
);
}
const limit = pLimit(5);
await Promise.all(
targets.map((target) =>
limit(() =>
checkAndStore(
db,
target.domainId,
target.subdomainId,
target.hostname,
target.serviceId,
),
),
),
);
repos.deleteCertificatesNotIn(
db,
targets.map((t) => t.hostname),
@@ -268,6 +265,5 @@ export async function runServiceChecks(
}
export function statusSummary(db: Db): Array<[string, number]> {
pruneStaleCertificates(db);
return repos.countCertificatesByStatus(db);
}
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify";
import { AsyncTask, CronJob } from "toad-scheduler";
import { AsyncTask, CronJob, type ToadScheduler } from "toad-scheduler";
import {
getAppSettings,
getAppSettingsSecrets,
@@ -18,6 +18,7 @@ import { cronStaleAfterMs } from "./health/mailbox.js";
declare module "fastify" {
interface FastifyInstance {
scheduler?: ToadScheduler;
reloadHealthCheckJob?: () => void;
}
}
@@ -168,6 +168,8 @@ async function httpProbe(
dispatcher,
});
const latency = Date.now() - started;
// Consume body so the socket is released before agent teardown.
await response.body?.cancel().catch(() => {});
if (target.expected_status != null) {
if (response.status !== target.expected_status) {
return {
@@ -192,6 +194,9 @@ async function httpProbe(
latencyMs: Date.now() - started,
error: err instanceof Error ? err.message : String(err),
};
} finally {
// Per-probe agents must not leak sockets/fds; probes run every ~2 min × N targets.
await dispatcher?.destroy().catch(() => {});
}
}
+198 -121
View File
@@ -1,6 +1,7 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type {
CfDnsRecord,
DnsRecord,
HealthCheckAggregate,
HealthCheckProvider,
@@ -9,6 +10,7 @@ import type {
IpHealthState,
LbMode,
Service,
ServiceBinding,
ServiceGroup,
ServiceGroupsResponse,
ServiceView,
@@ -341,101 +343,148 @@ async function collectKnownZones(
return zones;
}
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
const service = repos.getService(db, serviceId);
const ipRows = repos.listServiceIpRows(db, serviceId);
const ips = ipRows.map((row) => row.ip);
const ip_enabled = Object.fromEntries(
ipRows.map((row) => [row.ip, row.enabled]),
);
const bindings = repos.listBindingsByService(db, serviceId);
interface BindingLbContext {
ipMetaByBinding: Map<number, repos.BindingIpMeta[]>;
healthByBinding: Map<number, Map<string, { status: string; latency_ms: number | null }>>;
}
const domainViews = bindings.map((binding) => {
const records = repos.listRecordsForBinding(db, binding.id);
const statuses = records.map((r) => r.sync_status);
const targetIpsWithMeta = repos.listBindingIpsWithMeta(db, binding.id);
const targetIps = targetIpsWithMeta.map((entry) => entry.ip);
const linkedCname = records.find(
(record) => record.record_type.toUpperCase() === "CNAME",
);
const targetCname =
binding.cname_target?.trim() || linkedCname?.content?.trim() || null;
const target_ip_weights: Record<string, number> = {};
const target_ip_priorities: Record<string, number> = {};
for (const entry of targetIpsWithMeta) {
target_ip_weights[entry.ip] = entry.weight;
target_ip_priorities[entry.ip] = entry.priority;
}
for (const ip of targetIps) {
if (target_ip_weights[ip] === undefined) target_ip_weights[ip] = 1;
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
}
const { config, rows } = getBindingLbState(db, binding.id);
const bindingActiveIps = targetCname
? []
: resolveDesiredAIps(config, rows, targetIps, Date.now(), ips);
return {
binding_id: binding.id,
domain_id: binding.domain_id,
zone_name: binding.zone_name,
hostname: binding.hostname,
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
record_type: targetCname ? ("CNAME" as const) : ("A" as const),
target_ips: targetCname ? [] : targetIps,
target_ip_weights,
target_ip_priorities,
target_cname: targetCname,
function bindingLbStateFromBatch(
binding: ServiceBinding,
ctx: BindingLbContext,
): { config: LbTargetConfig; rows: LbIpRow[] } {
const ipMetas = ctx.ipMetaByBinding.get(binding.id) ?? [];
const healthByIp = ctx.healthByBinding.get(binding.id);
const rows: LbIpRow[] = ipMetas.map((entry) => ({
ip: entry.ip,
weight: entry.weight,
priority: entry.priority,
health: (healthByIp?.get(entry.ip)?.status as IpHealthState) ?? "unknown",
}));
return {
config: {
lb_mode: binding.lb_mode,
health_check_enabled: binding.health_check_enabled,
health_check_type: binding.health_check_type,
health_check_port: binding.health_check_port,
health_check_path: binding.health_check_path,
health_check_expected_status: binding.health_check_expected_status,
health_check_interval_sec: binding.health_check_interval_sec,
health_check_timeout_ms: binding.health_check_timeout_ms,
health_check_verify_tls: binding.health_check_verify_tls,
health_check_provider: binding.health_check_provider ?? "local",
health_check_providers: binding.health_check_providers ?? [
binding.health_check_provider ?? "local",
],
health_check_aggregate: binding.health_check_aggregate ?? "majority",
cert_monitoring: binding.cert_monitoring ?? "auto",
sync_status: aggregateSyncStatus(statuses),
active_ips: bindingActiveIps,
},
rows,
};
}
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
const [view] = await buildViews(db, [serviceId]);
return view!;
}
/** Batch view builder: 6 fixed queries for N services instead of O(services×bindings×ips). */
async function buildViews(db: Db, serviceIds: number[]): Promise<ServiceView[]> {
const services = serviceIds.map((id) => repos.getService(db, id));
const ipRowsByService = repos.listServiceIpRowsByServiceIds(db, serviceIds);
const bindingsByService = repos.listBindingsByServiceIds(db, serviceIds);
const allBindings = [...bindingsByService.values()].flat();
const bindingIds = allBindings.map((b) => b.id);
const recordsByBinding = repos.listRecordsByBindingIds(db, bindingIds);
const ipMetaByBinding = repos.listBindingIpsWithMetaByBindingIds(db, bindingIds);
const healthByBinding = repos.listBindingIpHealthByBindingIds(db, bindingIds);
const lbCtx: BindingLbContext = { ipMetaByBinding, healthByBinding };
const now = Date.now();
return services.map((service) => {
const ipRows = ipRowsByService.get(service.id) ?? [];
const ips = ipRows.map((row) => row.ip);
const ip_enabled = Object.fromEntries(
ipRows.map((row) => [row.ip, row.enabled]),
);
const bindings = bindingsByService.get(service.id) ?? [];
const domainViews = bindings.map((binding) => {
const records = recordsByBinding.get(binding.id) ?? [];
const statuses = records.map((r) => r.sync_status);
const targetIpsWithMeta = ipMetaByBinding.get(binding.id) ?? [];
const targetIps = targetIpsWithMeta.map((entry) => entry.ip);
const linkedCname = records.find(
(record) => record.record_type.toUpperCase() === "CNAME",
);
const targetCname =
binding.cname_target?.trim() || linkedCname?.content?.trim() || null;
const target_ip_weights: Record<string, number> = {};
const target_ip_priorities: Record<string, number> = {};
for (const entry of targetIpsWithMeta) {
target_ip_weights[entry.ip] = entry.weight;
target_ip_priorities[entry.ip] = entry.priority;
}
for (const ip of targetIps) {
if (target_ip_weights[ip] === undefined) target_ip_weights[ip] = 1;
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
}
const { config, rows } = bindingLbStateFromBatch(binding, lbCtx);
const bindingActiveIps = targetCname
? []
: resolveDesiredAIps(config, rows, targetIps, now, ips);
return {
binding_id: binding.id,
domain_id: binding.domain_id,
zone_name: binding.zone_name,
hostname: binding.hostname,
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
record_type: targetCname ? ("CNAME" as const) : ("A" as const),
target_ips: targetCname ? [] : targetIps,
target_ip_weights,
target_ip_priorities,
target_cname: targetCname,
lb_mode: binding.lb_mode,
health_check_enabled: binding.health_check_enabled,
health_check_type: binding.health_check_type,
health_check_port: binding.health_check_port,
health_check_path: binding.health_check_path,
health_check_expected_status: binding.health_check_expected_status,
health_check_interval_sec: binding.health_check_interval_sec,
health_check_timeout_ms: binding.health_check_timeout_ms,
health_check_verify_tls: binding.health_check_verify_tls,
health_check_provider: binding.health_check_provider ?? "local",
health_check_providers: binding.health_check_providers ?? [
binding.health_check_provider ?? "local",
],
health_check_aggregate: binding.health_check_aggregate ?? "majority",
cert_monitoring: binding.cert_monitoring ?? "auto",
sync_status: aggregateSyncStatus(statuses),
active_ips: bindingActiveIps,
};
});
const activeIps = new Set<string>();
for (const domain of domainViews) {
for (const ip of domain.active_ips) {
activeIps.add(ip);
}
}
return {
id: service.id,
name: service.name,
slug: service.slug,
service_group_id: service.service_group_id ?? null,
subdomain: service.subdomain ?? "",
enabled: Boolean(service.enabled),
computed_fqdn: null,
lb_weight: service.lb_weight,
lb_priority: service.lb_priority,
created_at: service.created_at,
updated_at: service.updated_at,
ips,
ip_enabled,
domains: domainViews,
health_status: "unknown",
health_latency_ms: null,
ip_health: [],
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
active_ips: [...activeIps],
};
});
const activeIps = new Set<string>();
for (const domain of domainViews) {
for (const ip of domain.active_ips) {
activeIps.add(ip);
}
}
return {
id: service.id,
name: service.name,
slug: service.slug,
service_group_id: service.service_group_id ?? null,
subdomain: service.subdomain ?? "",
enabled: Boolean(service.enabled),
computed_fqdn: null,
lb_weight: service.lb_weight,
lb_priority: service.lb_priority,
created_at: service.created_at,
updated_at: service.updated_at,
ips,
ip_enabled,
domains: domainViews,
health_status: "unknown",
health_latency_ms: null,
ip_health: [],
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
active_ips: [...activeIps],
};
}
const HEALTH_RANK: Record<string, number> = {
@@ -566,9 +615,8 @@ function attachServiceHealth(
}
export async function listViews(db: Db): Promise<ServiceView[]> {
const views = await Promise.all(
repos.listServices(db).map((s) => buildView(db, s.id)),
);
const ids = repos.listServices(db).map((s) => s.id);
const views = await buildViews(db, ids);
return attachServiceHealth(db, views);
}
@@ -580,25 +628,23 @@ export async function getView(db: Db, id: number): Promise<ServiceView> {
export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
const groups = repos.listServiceGroups(db);
const groupViewsRaw = await Promise.all(
groups.map(async (group) => {
const services = repos.listServicesByGroup(db, group.id);
const serviceViews = await Promise.all(
services.map((s) => buildView(db, s.id)),
);
return { ...group, services: serviceViews };
}),
);
const servicesByGroup = repos.listServicesByGroupIds(db, groups.map((g) => g.id));
const ungroupedServices = repos.listUngroupedServices(db);
const ungroupedRaw = await Promise.all(
ungroupedServices.map((s) => buildView(db, s.id)),
);
const allServiceViews = [
...groupViewsRaw.flatMap((g) => g.services),
...ungroupedRaw,
const allIds = [
...[...servicesByGroup.values()].flat().map((s) => s.id),
...ungroupedServices.map((s) => s.id),
];
const allServiceViews = await buildViews(db, allIds);
const viewsById = new Map(allServiceViews.map((v) => [v.id, v]));
const groupViewsRaw = groups.map((group) => ({
...group,
services: (servicesByGroup.get(group.id) ?? [])
.map((s) => viewsById.get(s.id))
.filter((v): v is ServiceView => v !== undefined),
}));
const withHealth = attachServiceHealth(db, allServiceViews);
const healthById = new Map(withHealth.map((v) => [v.id, v]));
@@ -632,16 +678,21 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
};
});
const ungrouped = ungroupedRaw.map(
(s) =>
healthById.get(s.id) ?? {
...s,
health_status: "unknown" as const,
health_latency_ms: null,
ip_health: [],
ip_enabled: {},
},
);
const ungrouped = ungroupedServices.map((s) => {
const view = viewsById.get(s.id);
return (
healthById.get(s.id) ??
(view
? {
...view,
health_status: "unknown" as const,
health_latency_ms: null,
ip_health: [],
ip_enabled: {},
}
: view!)
);
});
return { groups: groupViews, ungrouped };
}
@@ -661,6 +712,7 @@ async function syncBindingDns(
hostname: string,
desiredIps: string[],
cnameTarget: string | null,
listingCache?: ZoneDnsListingCache,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
@@ -674,6 +726,8 @@ async function syncBindingDns(
zoneName,
hostname,
"CNAME",
undefined,
listingCache,
);
if (existingCname) {
effectiveCname = existingCname.content;
@@ -690,6 +744,7 @@ async function syncBindingDns(
domainId,
hostname,
effectiveCname,
listingCache,
);
return;
}
@@ -704,6 +759,7 @@ async function syncBindingDns(
hostname,
desiredIps,
ttlForBinding(binding.lb_mode, configuredIps),
listingCache,
);
}
@@ -714,6 +770,7 @@ async function syncBindingCnameDns(
domainId: number,
hostname: string,
cnameTarget: string,
listingCache?: ZoneDnsListingCache,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
@@ -755,6 +812,7 @@ async function syncBindingCnameDns(
hostname,
"CNAME",
normalized,
listingCache,
);
if (adopted) {
repos.linkBindingRecord(db, bindingId, adopted.id);
@@ -792,6 +850,7 @@ async function syncBindingADns(
hostname: string,
desiredIps: string[],
ttl: number,
listingCache?: ZoneDnsListingCache,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
@@ -849,6 +908,7 @@ async function syncBindingADns(
hostname,
"A",
ip,
listingCache,
);
if (adopted) {
repos.linkBindingRecord(db, bindingId, adopted.id);
@@ -991,6 +1051,9 @@ function findLocalDnsRecord(
);
}
/** Per-reconcile DNS listing cache: one zone = one CF API listing per pass. */
export type ZoneDnsListingCache = Map<string, CfDnsRecord[]>;
async function findOrImportDnsRecord(
db: Db,
cf: CloudflareClient,
@@ -999,6 +1062,7 @@ async function findOrImportDnsRecord(
hostname: string,
recordType: "A" | "CNAME",
content?: string,
listingCache?: ZoneDnsListingCache,
): Promise<DnsRecord | null> {
const local = findLocalDnsRecord(
db,
@@ -1011,7 +1075,7 @@ async function findOrImportDnsRecord(
if (local) return local;
const domain = repos.getDomain(db, domainId);
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const remote = await cf.listDnsRecords(domain.cf_zone_id, listingCache);
for (const cfRec of remote) {
if (cfRec.type.toUpperCase() !== recordType) continue;
if (content != null) {
@@ -1052,6 +1116,7 @@ async function findOrImportDnsARecord(
zoneName: string,
hostname: string,
content: string,
listingCache?: ZoneDnsListingCache,
): Promise<DnsRecord | null> {
return findOrImportDnsRecord(
db,
@@ -1061,6 +1126,7 @@ async function findOrImportDnsARecord(
hostname,
"A",
content,
listingCache,
);
}
@@ -1084,6 +1150,7 @@ async function syncServiceBindingsToDns(
throw AppError.validation("добавьте IP-адреса в пул сервиса");
}
const listingCache: ZoneDnsListingCache = new Map();
for (const binding of bindings) {
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) {
@@ -1095,6 +1162,7 @@ async function syncServiceBindingsToDns(
binding.hostname,
[],
cnameTarget,
listingCache,
);
continue;
}
@@ -1117,6 +1185,7 @@ async function syncServiceBindingsToDns(
binding.hostname,
desiredIps,
null,
listingCache,
);
}
}
@@ -1148,6 +1217,7 @@ async function syncGroupDomainDnsRecords(
hostname: string,
desiredIps: string[],
ttl: number = AUTO_DNS_TTL,
listingCache?: ZoneDnsListingCache,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
@@ -1185,6 +1255,7 @@ async function syncGroupDomainDnsRecords(
zoneName,
hostname,
ip,
listingCache,
);
if (adopted) {
repos.linkGroupDnsRecord(db, groupId, adopted.id);
@@ -1245,6 +1316,7 @@ async function syncGroupDomainDns(
db: Db,
cf: CloudflareClient,
groupId: number,
listingCache?: ZoneDnsListingCache,
): Promise<void> {
const group = repos.getServiceGroup(db, groupId);
if (!group.enabled) {
@@ -1267,6 +1339,7 @@ async function syncGroupDomainDns(
hostname,
desiredIps,
ttlForBinding(group.lb_mode, fallbackIps),
listingCache,
);
}
@@ -1792,7 +1865,10 @@ export async function reconcileWeightedDns(
db: Db,
cf: CloudflareClient,
): Promise<number> {
// Cheap predicate first: skip the full binding walk when nothing is weighted.
if (!repos.hasWeightedBindings(db)) return 0;
let n = 0;
const listingCache: ZoneDnsListingCache = new Map();
for (const binding of repos.listAllBindings(db)) {
if (binding.lb_mode !== "weighted") continue;
if (binding.cname_target?.trim()) continue;
@@ -1818,6 +1894,7 @@ export async function reconcileWeightedDns(
latest.hostname,
desiredIps,
null,
listingCache,
);
n += 1;
});
@@ -1829,7 +1906,7 @@ export async function reconcileWeightedDns(
if (group.lb_mode !== "weighted") continue;
if (!group.enabled || !group.domain?.trim()) continue;
try {
await syncGroupDomainDns(db, cf, group.id);
await syncGroupDomainDns(db, cf, group.id, listingCache);
n += 1;
} catch {
continue;
+10 -2
View File
@@ -513,7 +513,7 @@ describe("certificates", () => {
await testApp.close();
});
it("GET /certificates prunes stale rows without running check", async () => {
it("runAllChecks prunes stale rows; GET /certificates is a plain read", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
@@ -563,13 +563,21 @@ describe("certificates", () => {
expect(repos.listCertificates(testApp.db)).toHaveLength(2);
// Read path must not mutate: stale rows survive until the cron prune.
const listRes = await testApp.inject({
method: "GET",
url: "/api/v1/certificates",
headers,
});
expect(listRes.statusCode).toBe(200);
expect(listRes.json()).toEqual([]);
expect(listRes.json()).toHaveLength(2);
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: null,
error: "network unreachable",
});
await certificateService.runAllChecks(testApp.db);
expect(repos.listCertificates(testApp.db)).toEqual([]);
await testApp.close();
});
+1
View File
@@ -7,6 +7,7 @@ export default defineConfig({
entry: ["src/server.ts"],
format: ["esm"],
dts: true,
sourcemap: true,
async onSuccess() {
const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
copyFileSync(
-3
View File
@@ -27,14 +27,11 @@
"@tanstack/router-vite-plugin": "^1.167.18",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.18.0",
"next-themes": "^0.4.6",
"react": "^19.2.6",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.6",
"react-hook-form": "^7.79.0",
"react-phone-number-input": "^3.4.17",
"recharts": "^3.8.0",
"sonner": "^2.0.7",
"tailwindcss": "^4.3.1",
@@ -14,7 +14,14 @@ import {
} from '@cfdm/ui/components/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { changeServiceDomain, domainsListQueryOptions } from '@/queries'
import {
changeServiceDomain,
domainKeys,
domainsListQueryOptions,
serviceBindingKeys,
serviceGroupKeys,
serviceKeys,
} from '@/queries'
interface ChangeDomainSheetProps {
open: boolean
@@ -70,7 +77,12 @@ export function ChangeDomainSheet({
}),
onSuccess: async (result: { message?: string }) => {
toast.success(result.message ?? 'Привязки перенесены')
await queryClient.invalidateQueries()
await Promise.all([
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
])
setConfirmOpen(false)
onOpenChange(false)
},
+6 -2
View File
@@ -15,7 +15,7 @@ import {
SelectValue,
} from '@cfdm/ui/components/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { changeBindingIp, serviceNodesQueryOptions } from '@/queries'
import { changeBindingIp, serviceBindingKeys, serviceGroupKeys, serviceKeys, serviceNodesQueryOptions } from '@/queries'
interface ChangeIpSheetProps {
open: boolean
@@ -82,7 +82,11 @@ export function ChangeIpSheet({
onSuccess: async (result) => {
setPreview(result.message)
toast.success(result.message)
await queryClient.invalidateQueries()
await Promise.all([
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
])
onOpenChange(false)
},
onError: (e: unknown) =>
@@ -1,4 +1,4 @@
import { useMemo, useEffect, useRef } from 'react'
import { useMemo, useRef } from 'react'
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis } from 'recharts'
import { StatusBadge } from '@/components/status-badge'
@@ -18,34 +18,6 @@ import {
} from '@cfdm/ui/components/chart'
import { Separator } from '@cfdm/ui/components/separator'
import { cn } from '@cfdm/ui/lib/utils'
import { debugAgentLog } from '@/lib/debug-agent-log'
function useChartSizeLog(chartId: string, dataLen: number) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const svg = el.querySelector('svg.recharts-surface')
const chartSlot = el.querySelector('[data-slot=chart]') as HTMLElement | null
debugAgentLog(
'dashboard-analytics.tsx:chart-mount',
'chart container dimensions',
{
chartId,
dataLen,
containerW: el.clientWidth,
containerH: el.clientHeight,
chartSlotW: chartSlot?.clientWidth ?? 0,
chartSlotH: chartSlot?.clientHeight ?? 0,
svgW: svg?.getAttribute('width') ?? null,
svgH: svg?.getAttribute('height') ?? null,
hasSvg: Boolean(svg),
},
'D',
)
}, [chartId, dataLen])
return ref
}
const statusChartConfig = {
count: { label: 'Сертификаты' },
@@ -74,7 +46,7 @@ interface CertStatusChartProps {
}
export function CertStatusChart({ data }: CertStatusChartProps) {
const chartRef = useChartSizeLog('cert-status', data.length)
const chartRef = useRef<HTMLDivElement>(null)
const total = useMemo(
() => data.reduce((sum, entry) => sum + entry.count, 0),
[data],
@@ -166,7 +138,7 @@ interface GroupDomainsChartProps {
}
export function GroupDomainsChart({ data }: GroupDomainsChartProps) {
const chartRef = useChartSizeLog('group-domains', data.length)
const chartRef = useRef<HTMLDivElement>(null)
return (
<Frame dense spacing="sm" className="w-full">
@@ -1,4 +1,4 @@
import { useId, useMemo, useState } from 'react'
import { useId, useMemo, useRef, useState } from 'react'
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
import { Area, ComposedChart, Line, XAxis, YAxis } from 'recharts'
@@ -259,13 +259,20 @@ export function UptimeChart({
})
: []
// rAF-throttled: mouse-move must not re-render the card on every pixel.
const rafRef = useRef(0)
function syncHover(state: {
activeTooltipIndex?: unknown
activeIndex?: unknown
}) {
const index = Number(state.activeTooltipIndex ?? state.activeIndex)
if (!Number.isFinite(index)) return
setHovered(points[index] ?? null)
const next = points[index] ?? null
if (rafRef.current) cancelAnimationFrame(rafRef.current)
rafRef.current = requestAnimationFrame(() => {
rafRef.current = 0
setHovered(next)
})
}
const panel = (
File diff suppressed because it is too large Load Diff
+13 -10
View File
@@ -1,26 +1,29 @@
import { useQueries } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { healthStatusQueryOptions } from '@/queries'
import { bindingsHealthBatchQueryOptions } from '@/queries'
import type { IpHealthStatus, ServiceBinding } from '@/lib/schemas'
/** Map IP → worst health status across enabled bindings. */
/** Map IP → worst health status across enabled bindings (single batch request). */
export function useDomainHealthByIp(bindings: ServiceBinding[] | undefined) {
const enabledBindings = useMemo(
() => (bindings ?? []).filter((b) => b.health_check_enabled),
[bindings],
)
const bindingIds = useMemo(
() => enabledBindings.map((b) => b.id),
[enabledBindings],
)
const queries = useQueries({
queries: enabledBindings.map((b) => ({
...healthStatusQueryOptions('binding', b.id),
})),
const batch = useQuery({
...bindingsHealthBatchQueryOptions(bindingIds),
enabled: bindingIds.length > 0,
})
return useMemo(() => {
const map: Record<string, IpHealthStatus> = {}
const rank = { up: 0, unknown: 1, degraded: 2, down: 3 } as const
for (const q of queries) {
for (const row of q.data ?? []) {
for (const rows of batch.data?.values() ?? []) {
for (const row of rows) {
const prev = map[row.ip]
if (!prev || rank[row.status] > rank[prev.status]) {
map[row.ip] = row
@@ -28,5 +31,5 @@ export function useDomainHealthByIp(bindings: ServiceBinding[] | undefined) {
}
}
return map
}, [queries])
}, [batch.data])
}
+11 -6
View File
@@ -1,4 +1,4 @@
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import type { ServiceView } from '@/lib/schemas'
import {
boardColumnsToKanbanValue,
@@ -15,11 +15,16 @@ interface UseServicesKanbanOptions {
export function useServicesKanban(options: UseServicesKanbanOptions) {
const boardHook = useServicesBoard(options)
const kanbanValue = boardColumnsToKanbanValue(
boardHook.board.columns.map((column) => ({
id: column.id,
items: column.items,
})),
// Memoized: a 10s polling tick must not rebuild kanban value identity.
const kanbanValue = useMemo(
() =>
boardColumnsToKanbanValue(
boardHook.board.columns.map((column) => ({
id: column.id,
items: column.items,
})),
),
[boardHook.board.columns],
)
const handleKanbanValueChange = useCallback(
-28
View File
@@ -1,28 +0,0 @@
/** Debug session 943716 — remove after layout/chart investigation */
export const DEBUG_BUILD_STAMP = 'layout-charts-v1'
export function debugAgentLog(
location: string,
message: string,
data: Record<string, unknown>,
hypothesisId: string,
) {
// #region agent log
fetch('http://127.0.0.1:7580/ingest/5c1b60ca-3f59-41ce-8435-d25bcc12c3cf', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Debug-Session-Id': '943716',
},
body: JSON.stringify({
sessionId: '943716',
location,
message,
data,
hypothesisId,
timestamp: Date.now(),
runId: 'pre-fix',
}),
}).catch(() => {})
// #endregion
}
+2
View File
@@ -4,6 +4,8 @@ import { ApiError } from './api-client'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Polling queries carry staleTime 5s; a refetch on every alt-tab is waste.
refetchOnWindowFocus: false,
staleTime: 1000 * 60,
retry: (count, error) => {
if (error instanceof ApiError && error.status === 404) return false
-6
View File
@@ -8,12 +8,6 @@ import { Toaster } from '@cfdm/ui/components/sonner'
import { routeTree } from './routeTree.gen'
import { queryClient } from './lib/queryClient'
import '@cfdm/ui/globals.css'
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
debugAgentLog('main.tsx:boot', 'app boot', {
buildStamp: DEBUG_BUILD_STAMP,
href: typeof window !== 'undefined' ? window.location.href : '',
}, 'B')
const router = createRouter({
routeTree,
+21
View File
@@ -34,6 +34,27 @@ export function healthStatusQueryOptions(
})
}
/** Batch: IP health for many bindings in one request (lists avoid N+1 polls). */
export function bindingsHealthBatchQueryOptions(bindingIds: number[]) {
const ids = [...bindingIds].sort((a, b) => a - b)
return queryOptions({
queryKey: [...healthStatusKeys.all, 'batch', ids] as const,
queryFn: async () => {
const data = await api.get<{ items: { ref_id: number; rows: unknown[] }[] }>(
`/api/v1/health-status/batch?ref_ids=${ids.join(',')}`,
)
const byId = new Map<number, z.infer<typeof ipHealthStatusSchema>[]>()
for (const item of data.items) {
byId.set(item.ref_id, z.array(ipHealthStatusSchema).parse(item.rows))
}
return byId
},
// Polling pauses when the tab is hidden (refetchIntervalInBackground=false default).
refetchInterval: 10_000,
staleTime: 5_000,
})
}
export async function runHealthCheck() {
return api.post<{ checked: number }>('/api/v1/health-check/run', {})
}
+1 -21
View File
@@ -1,6 +1,6 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState, useEffect } from 'react'
import { useMemo, useState } from 'react'
import {
ActivityIcon,
AlertTriangleIcon,
@@ -41,7 +41,6 @@ import {
} from '@cfdm/ui/components/item'
import { Button } from '@cfdm/ui/components/button'
import { formatRelative } from '@/lib/format'
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
import { api } from '@/lib/api-client'
export const Route = createFileRoute('/_auth/')({
@@ -201,25 +200,6 @@ function DashboardPage() {
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
useEffect(() => {
if (isLoading) return
debugAgentLog(
'index.tsx:dashboard-data',
'dashboard chart inputs',
{
buildStamp: DEBUG_BUILD_STAMP,
summaryRaw: summary ?? null,
statusChartLen: statusChartData.length,
statusChartData,
groupChartLen: groupChartData.length,
groupChartData,
domainsLen: domains?.length ?? 0,
groupsLen: groups?.length ?? 0,
},
'C',
)
}, [isLoading, summary, statusChartData, groupChartData, domains, groups])
const kpiCards: KpiStatCard[] = [
{
id: 'domains',
@@ -42,10 +42,8 @@ import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
import {
createServiceNode,
deleteServiceNode,
domainKeys,
domainsListQueryOptions,
serviceBindingKeys,
serviceDetailKeys,
serviceFailoverLogQueryOptions,
serviceGroupKeys,
serviceGroupsQueryOptions,
@@ -153,16 +151,11 @@ function ServiceDetailPage() {
: []
async function invalidateService() {
// Targeted invalidation: only service-scoped data the mutation touched.
await Promise.all([
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.failoverLog(id) }),
])
}
+45
View File
@@ -10,6 +10,51 @@ export default defineConfig({
react(),
tailwindcss(),
],
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{
name: 'react-vendor',
test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/,
priority: 20,
},
{
name: 'router-vendor',
test: /node_modules[\\/]@tanstack[\\/](react-router|router-core|history|store|react-store)[\\/]/,
priority: 19,
},
{
name: 'query-vendor',
test: /node_modules[\\/]@tanstack[\\/](react-query|query-core|query-devtools|mutation-core)[\\/]/,
priority: 18,
},
{
name: 'charts-vendor',
test: /node_modules[\\/](recharts|d3-[a-z]+|victory-vendor|internmap)[\\/]/,
priority: 17,
},
{
name: 'ui-vendor',
test: /node_modules[\\/](@base-ui|@dnd-kit|lucide-react|cmdk|sonner|next-themes|class-variance-authority)[\\/]/,
priority: 16,
},
{
name: 'forms-vendor',
test: /node_modules[\\/](react-hook-form|@hookform|zod)[\\/]/,
priority: 15,
},
{
name: 'vendor',
test: /node_modules/,
priority: 1,
},
],
},
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),