Compare commits

...
10 Commits
Author SHA1 Message Date
Denozordec 2c92e78b24 feat(health-checks): enhance health check configuration and logging
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 51s
quality / api (push) Successful in 44s
CD / quality (push) Successful in 1m44s
CD / publish (push) Successful in 1m35s
- Added new health worker URL and token fields to the AppConfig interface, allowing for Cloudflare Worker integration.
- Updated health check routes to utilize the new worker configuration, enabling dynamic health checks via Cloudflare Workers.
- Introduced a health log endpoint for services, providing detailed logs of health probe results.
- Enhanced health check service logic to support both local and Cloudflare Worker providers, improving flexibility in health monitoring.
- Updated UI components to reflect changes in health check provider settings and display relevant health information.

This commit significantly improves the health check management capabilities, allowing for better integration with Cloudflare Workers and enhanced logging features.
2026-08-19 16:27:34 +07:00
Denozordec d63c86065c feat(health-checks): implement health check IP toggling and configuration updates
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 51s
quality / api (push) Successful in 43s
CD / quality (push) Successful in 1m43s
CD / publish (push) Successful in 1m38s
- Added functionality to toggle individual IPs for services, allowing for dynamic management of IP health status.
- Enhanced the health check configuration in the UI, enabling users to set parameters directly from the settings page.
- Updated service views to include IP health tracking, improving visibility into the status of each IP associated with a service.
- Refactored relevant components to support the new IP toggling feature, ensuring a seamless user experience.

This commit significantly enhances the health management capabilities of services, providing users with more control over IP configurations and health monitoring.
2026-08-19 15:33:47 +07:00
Denozordec 4224db8eb3 feat(services): enhance service health management with IP health tracking
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
quality / api (push) Successful in 41s
CD / quality (push) Successful in 1m45s
CD / publish (push) Successful in 1m33s
- Introduced IP health tracking in service views, allowing for detailed monitoring of individual IP statuses and latencies.
- Updated the service configuration to include an `ip_health` array, providing structured health data for each IP.
- Enhanced the `attachServiceHealth` function to aggregate IP health data alongside overall service health.
- Modified relevant components to display IP health information, improving visibility and user experience in service management interfaces.

This commit significantly improves the health monitoring capabilities of services, enabling better insights into the status of individual IPs associated with each service.
2026-08-19 14:57:19 +07:00
Denozordec 4ca948292d refactor(services): streamline service edit functionality and improve component structure
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
CD / quality (push) Successful in 1m2s
CD / publish (push) Successful in 1m35s
- Removed unused imports and refactored the ServiceEditSheet component to enhance readability and maintainability.
- Introduced new utility functions for managing service binding drafts and handling common FQDN changes.
- Updated the ServiceCatalogSection to improve layout responsiveness and enhance the display of service units.
- Simplified the ServiceUnitCard component by removing unnecessary elements and optimizing the layout for better user experience.

This commit improves the overall structure and functionality of service management components, making them more efficient and user-friendly.
2026-08-19 14:27:02 +07:00
Denozordec 50c5c21c18 feat(services): enhance service edit and display components with common domain support
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 52s
CD / quality (push) Successful in 1m1s
CD / publish (push) Successful in 1m36s
- Added a common FQDN input field in the ServiceEditSheet to streamline domain management for services.
- Updated binding handling to synchronize the common domain across service bindings.
- Improved the ServiceKanbanCard to display both common domain and IP addresses for better visibility.
- Refactored the ServiceFqdnList to support customizable text classes and added a new ServiceIpList component for IP display.

This commit enhances the user experience by simplifying domain management and improving the display of service information.
2026-08-19 13:29:15 +07:00
Denozordec ab4ccbd7a1 feat(services): add copyable FQDN functionality and improve service display
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 52s
CD / quality (push) Successful in 59s
CD / publish (push) Successful in 1m41s
- Enhanced the ServiceFqdnList component to support a copyable feature, allowing users to easily copy FQDNs to the clipboard.
- Updated the ServiceKanbanCard to utilize the new copyable functionality in the FQDN list.
- Refined the services grouped catalog logic to improve data handling and display.
- Removed the obsolete services-grouped-columns file to streamline the codebase.

This commit improves user experience by facilitating easier access to FQDNs and optimizing service management components.
2026-08-19 13:03:52 +07:00
Denozordec b2dbb4ad98 feat(health-checks): enhance health check configuration and UI components
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
quality / api (push) Successful in 52s
CD / quality (push) Successful in 1m55s
CD / publish (push) Successful in 1m54s
- Introduced a new HealthProviderToggle component to manage health check providers (local/cloudflare) in the UI.
- Updated health check configuration to include additional parameters such as retries and consecutive success/failure counts.
- Improved the service edit and group edit sheets to support the new health check provider options.
- Enhanced documentation to clarify the use of ACTIONS_PAT and GITEA_TOKEN for wiki updates.

This commit improves the health check management experience and expands the configuration options for better service monitoring.
2026-08-19 12:39:33 +07:00
Denozordec 3f6f402872 feat(health-checks): enhance health check functionality and add new routes
quality / commitlint (push) Skipped
CD / update-wiki (push) Failing after 8s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 50s
quality / api (push) Successful in 41s
CD / quality (push) Successful in 1m40s
CD / publish (push) Successful in 1m33s
- Introduced origin health check routes and integrated them into the application.
- Updated health check configuration to include success recovery thresholds.
- Expanded error handling with new error codes for health check failures.
- Added new service routes for managing health checks, including creation and listing.
- Improved health check service logic to track consecutive successes and failures.

This commit enhances the health check capabilities, providing better monitoring and management of service health.
2026-08-19 12:26:12 +07:00
DenozordecandCursor 9c00b268dc fix(ui): выровнять очередь внимания дашборда под Frame и IconTile
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / web (push) Successful in 1m6s
CD / quality (push) Successful in 1m21s
CD / publish (push) Successful in 2m36s
Соседние Frame-колонки как в EvoBGP вместо вложенной оболочки; compact empty через IconTile elevated; счётчики ReUI Badge.

Co-authored-by: Cursor <[email protected]>
2026-08-19 01:11:36 +07:00
Denozordec a185b3a2cb chore: update dependencies and improve wiki update process
CD / update-wiki (push) Successful in 11s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m34s
quality / api (push) Successful in 1m4s
CD / quality (push) Successful in 2m52s
CD / publish (push) Successful in 54s
- Added new dependencies for @tanstack/router-cli and updated related packages in pnpm-lock.yaml.
- Modified the build script in package.json to include `tsr generate` for route generation.
- Enhanced the wiki update job in Gitea Actions to use a fallback for the GITEA_TOKEN and improved the authorization method for pushing changes.
- Updated .dockerignore to clarify generated files.

This commit improves the build process and ensures smoother updates to the wiki documentation.
2026-08-19 01:00:24 +07:00
107 changed files with 11321 additions and 1572 deletions
+1
View File
@@ -22,6 +22,7 @@ node_modules
**/node_modules
**/dist
**/.turbo
# Генерируется `tsr generate` в `web` build (tsc идёт до Vite-плагина).
apps/web/src/routeTree.gen.ts
apps/web/playwright-report
apps/web/test-results
+7
View File
@@ -36,3 +36,10 @@ RUST_LOG=info
# Certificate scheduler (cron)
CERT_CHECK_CRON=0 0 */6 * * *
# Local health-check engine (override in UI: Настройки → Health-check)
# HEALTH_CHECK_CRON=0 */2 * * * *
# HEALTH_DEGRADED_FAILURES=1
# HEALTH_DOWN_FAILURES=2
# HEALTH_SUCCESS_RECOVERIES=2
# HEALTH_LATENCY_WARN_MS=1000
+2 -2
View File
@@ -35,13 +35,13 @@ Runner: `ubuntu-latest`, Docker для **docker-check** (PR) и **publish** (CD)
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
Job **update-wiki** идёт **параллельно** publish (не блокирует образы): при diff `docs/Home.md` копирует файл в wiki-репозиторий (`GITEA_TOKEN`).
Job **update-wiki** идёт **параллельно** publish (не блокирует образы): при diff `docs/Home.md` копирует файл в wiki-репозиторий. Clone/push идут на публичный **`https://git.shx.one`** (не внутренний `gitea.server_url` / `192.168.x.x:3000`): Gitea `ROOT_URL` совпадает с Host, иначе `git-receive-pack` wiki отвечает `Repository not found`. Токен в URL `https://oauth2:<PAT>@…/*.wiki.git` — Gitea на неаутентифицированный wiki push даёт **404, не 401**, поэтому `http.extraHeader` / ASKPASS не срабатывают. Секрет: **`ACTIONS_PAT`**, fallback **`GITEA_TOKEN`**.
### Секреты
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `gitea.token`. Push OCI — **только PAT** (у job token Gitea нет права packages).
**`GITEA_TOKEN`**: clone/push wiki.
**`GITEA_TOKEN`**: опциональный wiki-only PAT (fallback, если нет `ACTIONS_PAT`).
### Теги образов
+26 -7
View File
@@ -37,21 +37,40 @@ jobs:
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Clone Wiki repository
if: steps.check_changes.outputs.changed == 'true'
run: |
WIKI_URL=$(echo "${{ github.server_url }}/${{ github.repository }}.wiki.git" | sed -e "s|://|://gitea-actions:${{ secrets.GITEA_TOKEN }}@|")
git clone "${WIKI_URL}" cfdm.wiki
- name: Update and push Wiki content
if: steps.check_changes.outputs.changed == 'true'
env:
# ACTIONS_PAT уже пишет git (tags/releases). GITEA_TOKEN — опциональный
# wiki-only PAT; если он задан без write, Gitea отвечает 404, не 403.
WIKI_TOKEN: ${{ secrets.ACTIONS_PAT || secrets.GITEA_TOKEN }}
# Не gitea.server_url: на runner это внутренний http://192.168.x.x:3000,
# а ROOT_URL = git.shx.one — git-receive-pack wiki тогда даёт 404.
GITEA_PUBLIC_URL: https://git.shx.one
REPO: ${{ gitea.repository }}
run: |
set -euo pipefail
if [ -z "${WIKI_TOKEN:-}" ]; then
echo "ACTIONS_PAT / GITEA_TOKEN is empty — cannot push wiki"
exit 1
fi
PUBLIC_URL="${GITEA_PUBLIC_URL%/}"
TOKEN_ENC="$(python3 -c 'import urllib.parse,os; print(urllib.parse.quote(os.environ["WIKI_TOKEN"], safe=""))')"
WIKI_URL="${PUBLIC_URL}/${REPO}.wiki.git"
# Gitea на неаутентифицированный wiki push отвечает 404, не 401 —
# extraHeader/ASKPASS не помогают: токен должен быть в URL с первого запроса.
AUTH_INSTEAD="url.https://oauth2:${TOKEN_ENC}@${PUBLIC_URL#https://}/.insteadOf=${PUBLIC_URL}/"
GIT_TERMINAL_PROMPT=0 git -c "${AUTH_INSTEAD}" clone "${WIKI_URL}" cfdm.wiki
cp docs/Home.md cfdm.wiki/Home.md
cd cfdm.wiki
git config user.name "Gitea Actions"
git config user.email "actions@gitea"
git add Home.md
git diff --staged --quiet || git commit -m "docs: Update Wiki from main repository"
git push
if git diff --staged --quiet; then
echo "Wiki Home.md already up to date"
exit 0
fi
git commit -m "docs: Update Wiki from main repository"
GIT_TERMINAL_PROMPT=0 git -c "${AUTH_INSTEAD}" push origin HEAD
publish:
needs: [quality]
+11 -67
View File
@@ -7,7 +7,6 @@ import {
} from "@fastify/type-provider-zod";
import type { AppConfig } from "./config.js";
import { loadConfig } from "./config.js";
import { repos } from "@cfdm/db";
import authPlugin from "./plugins/auth.js";
import cfClientPlugin from "./plugins/cf-client.js";
import { requireAuth } from "./plugins/auth.js";
@@ -24,6 +23,7 @@ import { dnsRoutes } from "./routes/dns.js";
import { subdomainRoutes } from "./routes/subdomains.js";
import { certificateRoutes } from "./routes/certificates.js";
import { syncRoutes } from "./routes/sync.js";
import { originHealthCheckRoutes } from "./routes/origin-health-checks.js";
import { healthCheckRoutes } from "./routes/health-check.js";
import {
domainMonitorRoutes,
@@ -33,8 +33,10 @@ import { settingsRoutes } from "./routes/settings.js";
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
import { auditRoutes } from "./routes/audit.js";
import * as certificateService from "./services/certificate-service.js";
import * as healthCheckService from "./services/health-check-service.js";
import * as serviceConfigService from "./services/service-config-service.js";
import {
createHealthCheckTask,
scheduleHealthCheckJob,
} from "./services/health-check-scheduler.js";
import { AsyncTask, CronJob } from "toad-scheduler";
export interface BuildAppOptions {
@@ -81,6 +83,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await protectedApi.register(certificateRoutes);
await protectedApi.register(syncRoutes);
await protectedApi.register(healthCheckRoutes);
await protectedApi.register(originHealthCheckRoutes);
await protectedApi.register(domainMonitorRoutes);
await protectedApi.register(notificationRoutes);
await protectedApi.register(settingsRoutes);
@@ -123,70 +126,11 @@ export async function buildApp(opts: BuildAppOptions = {}) {
),
);
const healthTask = new AsyncTask(
"health-check",
async () => {
const thresholds = {
degradedFailures: config.healthDegradedFailures,
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
};
const n = await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: config.healthProbeGapMs,
onStatusChange: async (target, prev, next) => {
try {
const label =
next === "up"
? "OK"
: next === "degraded"
? "Slow"
: next === "down"
? "Down"
: "—";
repos.insertNotificationLog(
app.db,
"ip_health",
target.scope,
target.ref_id,
`${target.hostname || target.ip}: ${label}`,
`IP ${target.ip}: ${prev ?? "—"}${label}`,
);
await serviceConfigService.reconcileDnsForTarget(
app.db,
app.cf,
target.scope,
target.ref_id,
);
} catch (err) {
app.log.warn(
{ err, scope: target.scope, refId: target.ref_id },
"health-check reconcile failed",
);
}
},
});
const monitors = await healthCheckService.runDomainMonitors(
app.db,
thresholds,
);
app.log.info(
{ checked: n, monitors },
"health check completed",
);
},
(err) => {
app.log.warn({ err }, "health check failed");
},
);
app.scheduler.addCronJob(
new CronJob(
{ cronExpression: config.healthCheckCron },
healthTask,
{ preventOverrun: true },
),
);
const healthTask = createHealthCheckTask(app, config);
scheduleHealthCheckJob(app, config, healthTask);
app.decorate("reloadHealthCheckJob", () => {
scheduleHealthCheckJob(app, config, healthTask);
});
}
return app;
+7
View File
@@ -13,9 +13,12 @@ export interface AppConfig {
healthCheckCron: string;
healthDegradedFailures: number;
healthDownFailures: number;
healthSuccessRecoveries: number;
healthLatencyWarnMs: number;
/** Min pause between probes to different physical targets (same IP is probed once). */
healthProbeGapMs: number;
healthWorkerUrl: string;
healthWorkerToken: string;
logLevel: string;
/** Portal SSO — when true, require portal JWT with apps includes cfdm */
authRequired: boolean;
@@ -55,9 +58,13 @@ export function loadConfig(): AppConfig {
healthDegradedFailures:
Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1,
healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2,
healthSuccessRecoveries:
Number(process.env.HEALTH_SUCCESS_RECOVERIES ?? "2") || 2,
healthLatencyWarnMs:
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
healthProbeGapMs: Number(process.env.HEALTH_PROBE_GAP_MS ?? "2000") || 2000,
healthWorkerUrl: (process.env.HEALTH_WORKER_URL ?? "").trim(),
healthWorkerToken: (process.env.HEALTH_WORKER_TOKEN ?? "").trim(),
logLevel: process.env.LOG_LEVEL ?? "info",
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
authIssuer:
+44
View File
@@ -8,6 +8,14 @@ export type ErrorCode =
| "FORBIDDEN"
| "CONFLICT"
| "CLOUDFLARE_ERROR"
| "DNS_UPDATE_FAILED"
| "HEALTHCHECK_CREATE_FAILED"
| "ZONE_NOT_FOUND"
| "INVALID_IP"
| "INVALID_HOSTNAME"
| "RATE_LIMITED"
| "CLOUDFLARE_AUTH_FAILED"
| "SYNC_FAILED"
| "INTERNAL_ERROR";
export class AppError extends Error {
@@ -44,6 +52,42 @@ export class AppError extends Error {
return new AppError("CLOUDFLARE_ERROR", message, 502);
}
static dnsUpdateFailed(message: string) {
return new AppError(
"DNS_UPDATE_FAILED",
message,
502,
);
}
static healthcheckCreateFailed(message: string) {
return new AppError("HEALTHCHECK_CREATE_FAILED", message, 502);
}
static zoneNotFound(message = "зона Cloudflare не найдена") {
return new AppError("ZONE_NOT_FOUND", message, 404);
}
static invalidIp(message = "Некорректный IP-адрес") {
return new AppError("INVALID_IP", message, 400);
}
static invalidHostname(message = "Некорректное имя хоста") {
return new AppError("INVALID_HOSTNAME", message, 400);
}
static rateLimited(message = "Cloudflare временно ограничил запросы. Повторите попытку.") {
return new AppError("RATE_LIMITED", message, 429);
}
static cloudflareAuthFailed(message = "Cloudflare отклонил токен доступа") {
return new AppError("CLOUDFLARE_AUTH_FAILED", message, 401);
}
static syncFailed(message: string) {
return new AppError("SYNC_FAILED", message, 502);
}
static internal(message: string) {
return new AppError("INTERNAL_ERROR", message, 500);
}
+57 -125
View File
@@ -1,152 +1,84 @@
import type {
CfDnsRecord,
CfHealthCheck,
CfZone,
CreateDnsRecordPayload,
PatchDnsRecordPayload,
} from "@cfdm/shared";
import { AppError } from "../errors.js";
import { withRetry, parseRetryAfter } from "./cf-retry.js";
import { createDnsAdapter } from "./cloudflare/dns-service.js";
import { createHealthCheckAdapter, type CfHealthCheckPayload } from "./cloudflare/healthcheck-service.js";
import { createZoneAdapter } from "./cloudflare/zone-service.js";
const BASE_URL = "https://api.cloudflare.com/client/v4";
interface CfResponse<T> {
success: boolean;
result?: T;
errors?: Array<{ code: number; message: string }>;
}
export type { CfHealthCheckPayload };
export class CloudflareClient {
constructor(private readonly token: string) {}
private readonly zones;
private readonly dns;
private readonly healthchecks;
private async handleResponse<T>(
response: Response,
operation: string,
): Promise<T> {
if (response.status === 429) {
const wait = parseRetryAfter(response.headers) ?? 5000;
throw AppError.cloudflare(`rate limited, retry after ${wait}ms`);
}
const body = (await response.json()) as CfResponse<T>;
if (!body.success) {
const msg =
body.errors?.map((e) => e.message).join("; ") ??
"unknown cloudflare error";
throw AppError.cloudflare(`${operation}: ${msg}`);
}
if (body.result === undefined) {
throw AppError.cloudflare(`${operation}: empty result`);
}
return body.result;
constructor(token: string) {
this.zones = createZoneAdapter(token);
this.dns = createDnsAdapter(token);
this.healthchecks = createHealthCheckAdapter(token);
}
async listZones(): Promise<CfZone[]> {
return withRetry(async () => {
const all: CfZone[] = [];
let page = 1;
while (true) {
const url = new URL(`${BASE_URL}/zones`);
url.searchParams.set("per_page", "50");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${this.token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw AppError.cloudflare(String(response.status));
}
const batch = await this.handleResponse<CfZone[]>(
response,
"list_zones",
);
if (batch.length === 0) break;
all.push(...batch);
if (batch.length < 50) break;
page += 1;
}
return all;
});
listZones(): Promise<CfZone[]> {
return this.zones.listZones();
}
async getZone(zoneId: string): Promise<CfZone> {
const response = await fetch(`${BASE_URL}/zones/${zoneId}`, {
headers: { Authorization: `Bearer ${this.token}` },
signal: AbortSignal.timeout(30_000),
});
return this.handleResponse(response, "get_zone");
getZone(zoneId: string): Promise<CfZone> {
return this.zones.getZone(zoneId);
}
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return withRetry(async () => {
const all: CfDnsRecord[] = [];
let page = 1;
while (page <= 50) {
const url = new URL(`${BASE_URL}/zones/${zoneId}/dns_records`);
url.searchParams.set("per_page", "100");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${this.token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw AppError.cloudflare(String(response.status));
}
const batch = await this.handleResponse<CfDnsRecord[]>(
response,
"list_dns_records",
);
if (batch.length === 0) break;
all.push(...batch);
page += 1;
}
return all;
});
listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return this.dns.listDnsRecords(zoneId);
}
async createDnsRecord(
zoneId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(`${BASE_URL}/zones/${zoneId}/dns_records`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
});
return this.handleResponse(response, "create_dns_record");
createDnsRecord(zoneId: string, payload: CreateDnsRecordPayload): Promise<CfDnsRecord> {
return this.dns.createDnsRecord(zoneId, payload);
}
async updateDnsRecord(
updateDnsRecord(
zoneId: string,
recordId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(
`${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
},
);
return this.handleResponse(response, "update_dns_record");
return this.dns.updateDnsRecord(zoneId, recordId, payload);
}
async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
const response = await fetch(
`${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${this.token}` },
signal: AbortSignal.timeout(30_000),
},
);
await this.handleResponse(response, "delete_dns_record");
patchDnsRecord(
zoneId: string,
recordId: string,
payload: PatchDnsRecordPayload,
): Promise<CfDnsRecord> {
return this.dns.patchDnsRecord(zoneId, recordId, payload);
}
deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
return this.dns.deleteDnsRecord(zoneId, recordId);
}
listHealthChecks(zoneId: string): Promise<CfHealthCheck[]> {
return this.healthchecks.listHealthChecks(zoneId);
}
getHealthCheck(zoneId: string, id: string): Promise<CfHealthCheck> {
return this.healthchecks.getHealthCheck(zoneId, id);
}
createHealthCheck(zoneId: string, payload: CfHealthCheckPayload): Promise<CfHealthCheck> {
return this.healthchecks.createHealthCheck(zoneId, payload);
}
updateHealthCheck(
zoneId: string,
id: string,
payload: CfHealthCheckPayload,
): Promise<CfHealthCheck> {
return this.healthchecks.updateHealthCheck(zoneId, id, payload);
}
deleteHealthCheck(zoneId: string, id: string): Promise<void> {
return this.healthchecks.deleteHealthCheck(zoneId, id);
}
}
+106
View File
@@ -0,0 +1,106 @@
import type { CfDnsRecord, CreateDnsRecordPayload, PatchDnsRecordPayload } from "@cfdm/shared";
import { withRetry } from "../cf-retry.js";
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[] = [];
let page = 1;
while (page <= 50) {
const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`);
url.searchParams.set("per_page", "100");
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_dns_records",
response.status,
String(response.status),
);
}
const batch = await handleCfResponse<CfDnsRecord[]>(
response,
"list_dns_records",
);
if (batch.length === 0) break;
all.push(...batch);
page += 1;
}
return all;
});
},
async createDnsRecord(
zoneId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}/dns_records`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
});
return handleCfResponse(response, "create_dns_record");
},
async updateDnsRecord(
zoneId: string,
recordId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
},
);
return handleCfResponse(response, "update_dns_record");
},
async patchDnsRecord(
zoneId: string,
recordId: string,
payload: PatchDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
},
);
return handleCfResponse(response, "patch_dns_record");
},
async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
},
);
await handleCfResponse(response, "delete_dns_record");
},
};
}
@@ -0,0 +1,110 @@
import type { CfHealthCheck } from "@cfdm/shared";
import { CF_API_BASE, handleCfResponse } from "./http.js";
export interface CfHealthCheckHttpConfig {
method?: string;
path?: string;
expected_codes?: string[];
header?: Record<string, string[]>;
port?: number;
follow_redirects?: boolean;
allow_insecure?: boolean;
}
export interface CfHealthCheckTcpConfig {
method?: "connection_established";
port?: number;
}
export interface CfHealthCheckPayload {
address: string;
name: string;
type?: "HTTP" | "HTTPS" | "TCP";
description?: string;
interval?: number;
timeout?: number;
retries?: number;
consecutive_fails?: number;
consecutive_successes?: number;
suspended?: boolean;
check_regions?: string[];
http_config?: CfHealthCheckHttpConfig;
tcp_config?: CfHealthCheckTcpConfig;
}
export function createHealthCheckAdapter(token: string) {
return {
async listHealthChecks(zoneId: string): Promise<CfHealthCheck[]> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/healthchecks`,
{
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
},
);
return handleCfResponse(response, "list_healthchecks");
},
async getHealthCheck(zoneId: string, id: string): Promise<CfHealthCheck> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,
{
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
},
);
return handleCfResponse(response, "get_healthcheck");
},
async createHealthCheck(
zoneId: string,
payload: CfHealthCheckPayload,
): Promise<CfHealthCheck> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/healthchecks`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
},
);
return handleCfResponse(response, "create_healthcheck");
},
async updateHealthCheck(
zoneId: string,
id: string,
payload: CfHealthCheckPayload,
): Promise<CfHealthCheck> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
},
);
return handleCfResponse(response, "update_healthcheck");
},
async deleteHealthCheck(zoneId: string, id: string): Promise<void> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
},
);
await handleCfResponse(response, "delete_healthcheck");
},
};
}
+91
View File
@@ -0,0 +1,91 @@
import type { CfDnsRecord } from "@cfdm/shared";
import { AppError } from "../../errors.js";
import { parseRetryAfter } from "../cf-retry.js";
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
export interface CfResponse<T> {
success: boolean;
result?: T;
errors?: Array<{ code: number; message: string }>;
}
export function mapCloudflareFailure(
operation: string,
status: number,
message: string,
): AppError {
const lower = message.toLowerCase();
if (status === 401 || status === 403 || lower.includes("authentication")) {
return AppError.cloudflareAuthFailed(
"Cloudflare отклонил токен. Проверьте CLOUDFLARE_API_TOKEN.",
);
}
if (status === 429 || lower.includes("rate limit")) {
return AppError.rateLimited();
}
if (lower.includes("zone") && (lower.includes("not found") || status === 404)) {
return AppError.zoneNotFound();
}
if (
operation.includes("healthcheck") &&
(lower.includes("plan") ||
lower.includes("not entitled") ||
lower.includes("not allowed") ||
lower.includes("permission"))
) {
return AppError.healthcheckCreateFailed(
"Cloudflare Health Checks недоступны для этой зоны. Используйте локальные проверки.",
);
}
if (operation.includes("dns") || operation.includes("dns_record")) {
return AppError.dnsUpdateFailed(`Не удалось обновить DNS в Cloudflare: ${message}`);
}
return AppError.cloudflare(`${operation}: ${message}`);
}
export async function handleCfResponse<T>(
response: Response,
operation: string,
): Promise<T> {
if (response.status === 429) {
const wait = parseRetryAfter(response.headers) ?? 5000;
throw AppError.rateLimited(
`Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`,
);
}
const body = (await response.json()) as CfResponse<T>;
if (!body.success) {
const msg =
body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
throw mapCloudflareFailure(operation, response.status, msg);
}
if (body.result === undefined) {
throw mapCloudflareFailure(operation, response.status, "empty result");
}
return body.result;
}
export async function cfRequest<T>(
token: string,
path: string,
operation: string,
init: RequestInit = {},
): Promise<T> {
const response = await fetch(`${CF_API_BASE}${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
signal: init.signal ?? AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure(operation, response.status, String(response.status));
}
return handleCfResponse<T>(response, operation);
}
export type DnsRecordResult = CfDnsRecord;
@@ -0,0 +1,40 @@
import type { CfZone } from "@cfdm/shared";
import { withRetry } from "../cf-retry.js";
import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js";
export function createZoneAdapter(token: string) {
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),
});
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;
});
},
async getZone(zoneId: string): Promise<CfZone> {
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
return handleCfResponse(response, "get_zone");
},
};
}
+6 -4
View File
@@ -94,14 +94,17 @@ const RULES: Rule[] = [
methods: ["GET"],
match: (p) =>
p.startsWith("/api/v1/services") ||
p.startsWith("/api/v1/service-bindings"),
p.startsWith("/api/v1/service-bindings") ||
p.startsWith("/api/v1/health-checks") ||
p === "/api/v1/ops-summary",
permission: "cfdm:services:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/v1/services") ||
p.startsWith("/api/v1/service-bindings"),
p.startsWith("/api/v1/service-bindings") ||
p.startsWith("/api/v1/health-checks"),
permission: "cfdm:services:write",
},
{
@@ -114,8 +117,7 @@ const RULES: Rule[] = [
match: (p) =>
p.startsWith("/api/v1/settings") ||
p.startsWith("/api/v1/notifications") ||
p.startsWith("/api/v1/health-check") ||
p.startsWith("/api/v1/health-checks"),
p.startsWith("/api/v1/health-check"),
permission: "cfdm:settings:admin",
},
{
+14 -4
View File
@@ -1,8 +1,12 @@
import type { FastifyInstance } from "fastify";
import { healthStatusQuerySchema } from "@cfdm/shared";
import { repos } from "@cfdm/db";
import { getAppSettings, repos } from "@cfdm/db";
import * as healthCheckService from "../services/health-check-service.js";
import * as serviceConfigService from "../services/service-config-service.js";
import {
healthEngineFallbacksFromConfig,
resolveWorkerProbeConfig,
} from "../services/health-check-scheduler.js";
export async function healthCheckRoutes(app: FastifyInstance) {
app.get("/health-status", async (request) => {
@@ -16,14 +20,20 @@ export async function healthCheckRoutes(app: FastifyInstance) {
app.post("/health-check/run", async (request) => {
const config = request.server.config;
const settings = getAppSettings(
request.server.db,
healthEngineFallbacksFromConfig(config),
);
const thresholds = {
degradedFailures: config.healthDegradedFailures,
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
degradedFailures: settings.healthDegradedFailures,
downFailures: settings.healthDownFailures,
latencyWarnMs: settings.healthLatencyWarnMs,
successRecoveries: settings.healthSuccessRecoveries,
};
const checked = await healthCheckService.runAllChecks(request.server.db, {
thresholds,
probeGapMs: config.healthProbeGapMs,
worker: resolveWorkerProbeConfig(request.server.db, config),
onStatusChange: async (target, prev, next) => {
try {
const label =
@@ -0,0 +1,77 @@
import type { FastifyInstance } from "fastify";
import {
createOriginHealthCheckSchema,
} from "@cfdm/shared";
import * as originHealth from "../services/origin-health-check-service.js";
import { recordAudit } from "../lib/audit.js";
export async function originHealthCheckRoutes(app: FastifyInstance) {
app.get("/health-checks", async (request) => {
return originHealth.listOriginHealthChecks(request.server.db);
});
app.post("/health-checks", async (request) => {
const body = createOriginHealthCheckSchema.parse(request.body);
const check = await originHealth.createOriginHealthCheck(
request.server.db,
request.server.cf,
body,
);
recordAudit(request.server, request, {
action: "healthcheck.create",
targetType: "app_resource",
targetId: String(check.id),
summary: `Создан health check «${check.name}» (${check.provider})`,
});
return check;
});
app.get("/health-checks/:id", async (request) => {
const { id } = request.params as { id: string };
return originHealth.getOriginHealthCheck(request.server.db, Number(id));
});
app.patch("/health-checks/:id", async (request) => {
const { id } = request.params as { id: string };
const body = createOriginHealthCheckSchema.partial().parse(request.body);
const check = await originHealth.updateOriginHealthCheck(
request.server.db,
request.server.cf,
Number(id),
body,
);
recordAudit(request.server, request, {
action: "healthcheck.update",
targetType: "app_resource",
targetId: id,
summary: `Обновлён health check «${check.name}»`,
});
return check;
});
app.delete("/health-checks/:id", async (request) => {
const { id } = request.params as { id: string };
await originHealth.deleteOriginHealthCheck(
request.server.db,
request.server.cf,
Number(id),
);
recordAudit(request.server, request, {
action: "healthcheck.delete",
severity: "warning",
targetType: "app_resource",
targetId: id,
summary: `Удалён health check ${id}`,
});
return { deleted: true };
});
app.post("/health-checks/:id/sync", async (request) => {
const { id } = request.params as { id: string };
return originHealth.syncOriginHealthCheck(
request.server.db,
request.server.cf,
Number(id),
);
});
}
+24
View File
@@ -1,6 +1,9 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { changeIpSchema } from "@cfdm/shared";
import * as bindingService from "../services/binding-service.js";
import * as changeIp from "../services/change-ip-service.js";
import { recordAudit } from "../lib/audit.js";
export async function serviceBindingRoutes(app: FastifyInstance) {
const createSchema = z.object({
@@ -52,6 +55,27 @@ export async function serviceBindingRoutes(app: FastifyInstance) {
return { deleted: true };
});
app.post("/service-bindings/:id/change-ip", async (request) => {
const { id } = request.params as { id: string };
const body = changeIpSchema.parse(request.body);
const result = await changeIp.changeBindingIp(
request.server.db,
request.server.cf,
Number(id),
body,
);
if (result.applied) {
recordAudit(request.server, request, {
action: "binding.change_ip",
targetType: "app_resource",
targetId: id,
summary: `Сменён IP: ${result.message}`,
details: result,
});
}
return result;
});
app.get("/domains/:id/service-bindings", async (request) => {
const { id } = request.params as { id: string };
return bindingService.listByDomain(request.server.db, Number(id));
+122 -1
View File
@@ -1,8 +1,17 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { reorderServicesSchema, updateServiceConfigSchema } from "@cfdm/shared";
import {
changeDomainSchema,
createServiceNodeSchema,
reorderServicesSchema,
toggleServiceIpSchema,
updateServiceConfigSchema,
updateServiceNodeSchema,
} from "@cfdm/shared";
import { repos } from "@cfdm/db";
import * as serviceConfig from "../services/service-config-service.js";
import * as nodeService from "../services/node-service.js";
import * as changeDomain from "../services/change-domain-service.js";
import { recordAudit } from "../lib/audit.js";
export async function serviceRoutes(app: FastifyInstance) {
@@ -56,6 +65,96 @@ export async function serviceRoutes(app: FastifyInstance) {
return serviceConfig.getView(request.server.db, Number(id));
});
app.get("/services/:id/health-log", async (request) => {
const { id } = request.params as { id: string };
repos.getService(request.server.db, Number(id));
return {
items: repos.listHealthProbeLogForService(request.server.db, Number(id)),
};
});
app.get("/services/:id/overview", async (request) => {
const { id } = request.params as { id: string };
return nodeService.getOverview(request.server.db, Number(id));
});
app.get("/services/:id/nodes", async (request) => {
const { id } = request.params as { id: string };
return nodeService.listNodes(request.server.db, Number(id));
});
app.post("/services/:id/nodes", async (request) => {
const { id } = request.params as { id: string };
const body = createServiceNodeSchema.parse(request.body);
const node = nodeService.createNode(request.server.db, Number(id), body);
recordAudit(request.server, request, {
action: "node.create",
targetType: "app_resource",
targetId: String(node.id),
summary: `Добавлена нода ${node.address}`,
details: { service_id: Number(id), address: node.address },
});
return node;
});
app.patch("/services/:id/nodes/:nodeId", async (request) => {
const { id, nodeId } = request.params as { id: string; nodeId: string };
const body = updateServiceNodeSchema.parse(request.body);
const node = nodeService.updateNode(
request.server.db,
Number(id),
Number(nodeId),
body,
);
recordAudit(request.server, request, {
action: "node.update",
targetType: "app_resource",
targetId: String(node.id),
summary: `Обновлена нода ${node.address}`,
details: body,
});
return node;
});
app.delete("/services/:id/nodes/:nodeId", async (request) => {
const { id, nodeId } = request.params as { id: string; nodeId: string };
const node = repos.getNode(request.server.db, Number(nodeId));
nodeService.deleteNode(request.server.db, Number(id), Number(nodeId));
recordAudit(request.server, request, {
action: "node.delete",
severity: "warning",
targetType: "app_resource",
targetId: nodeId,
summary: `Удалена нода ${node.address}`,
});
return { deleted: true };
});
app.post("/services/:id/change-domain", async (request) => {
const { id } = request.params as { id: string };
const body = changeDomainSchema.parse(request.body);
const result = await changeDomain.changeServiceDomain(
request.server.db,
request.server.cf,
Number(id),
body,
);
if (result.applied) {
recordAudit(request.server, request, {
action: "service.change_domain",
targetType: "app_resource",
targetId: id,
summary: result.message,
details: result,
});
}
return result;
});
app.get("/ops-summary", async (request) => {
return nodeService.opsSummary(request.server.db);
});
app.patch("/services/:id", async (request) => {
const { id } = request.params as { id: string };
@@ -101,4 +200,26 @@ export async function serviceRoutes(app: FastifyInstance) {
body.enabled,
);
});
app.patch("/services/:id/ips/toggle", async (request) => {
const { id } = request.params as { id: string };
const body = toggleServiceIpSchema.parse(request.body);
const view = await serviceConfig.toggleServiceIp(
request.server.db,
request.server.cf,
Number(id),
body.ip,
body.enabled,
);
recordAudit(request.server, request, {
action: "service.ip.toggle",
targetType: "app_resource",
targetId: String(id),
summary: body.enabled
? `Включён IP ${body.ip} сервиса «${view.name}»`
: `Выключен IP ${body.ip} сервиса «${view.name}»`,
details: { ip: body.ip, enabled: body.enabled },
});
return view;
});
}
+34 -3
View File
@@ -5,15 +5,46 @@ import {
updateAppSettings,
} from "@cfdm/db";
import { pingVpsTracker } from "../services/vps-tracker-sync.js";
import { AppError } from "../errors.js";
import {
assertValidHealthCron,
healthEngineFallbacksFromConfig,
} from "../services/health-check-scheduler.js";
export async function settingsRoutes(app: FastifyInstance) {
app.get("/settings", async (request) => {
return getAppSettings(request.server.db);
return getAppSettings(
request.server.db,
healthEngineFallbacksFromConfig(request.server.config),
);
});
app.patch("/settings", async (request) => {
const body = appSettingsPatchSchema.parse(request.body);
return updateAppSettings(request.server.db, body);
const parsed = appSettingsPatchSchema.safeParse(request.body);
if (!parsed.success) {
throw AppError.validation(
parsed.error.issues[0]?.message ?? "некорректные настройки",
);
}
const body = parsed.data;
if (body.healthCheckCron) {
assertValidHealthCron(body.healthCheckCron);
}
const fallbacks = healthEngineFallbacksFromConfig(request.server.config);
const current = getAppSettings(request.server.db, fallbacks);
const nextDegraded =
body.healthDegradedFailures ?? current.healthDegradedFailures;
const nextDown = body.healthDownFailures ?? current.healthDownFailures;
if (nextDown < nextDegraded) {
throw AppError.validation(
"ошибок до down не меньше, чем до degraded",
);
}
const next = updateAppSettings(request.server.db, body, fallbacks);
if (body.healthCheckCron !== undefined) {
request.server.reloadHealthCheckJob?.();
}
return next;
});
app.post("/settings/vps-tracker/test", async (request) => {
@@ -0,0 +1,118 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { ChangeDomainInput } from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js";
import * as dnsService from "./dns-service.js";
import { withBindingLock } from "./routing/index.js";
import { applyBindingDesiredDns, fqdnToDisplay } from "./service-config-service.js";
export interface ChangeDomainItem {
binding_id: number;
hostname: string;
from_fqdn: string;
to_fqdn: string;
}
export interface ChangeDomainPreview {
from_domain_id: number;
to_domain_id: number;
from_zone: string;
to_zone: string;
items: ChangeDomainItem[];
dry_run: boolean;
applied: boolean;
message: string;
}
export async function changeServiceDomain(
db: Db,
cf: CloudflareClient,
serviceId: number,
input: ChangeDomainInput,
): Promise<ChangeDomainPreview> {
repos.getService(db, serviceId);
if (input.from_domain_id === input.to_domain_id) {
throw AppError.validation("укажите другой целевой домен");
}
const fromDomain = repos.getDomain(db, input.from_domain_id);
const toDomain = repos.getDomain(db, input.to_domain_id);
const bindings = repos
.listBindingsByService(db, serviceId)
.filter((b) => b.domain_id === input.from_domain_id);
const selected = input.hostnames?.length
? bindings.filter((b) => input.hostnames!.includes(b.hostname))
: bindings;
if (selected.length === 0) {
throw AppError.validation("нет привязок для переноса");
}
const items: ChangeDomainItem[] = selected.map((binding) => ({
binding_id: binding.id,
hostname: binding.hostname,
from_fqdn: fqdnToDisplay(binding.hostname, fromDomain.zone_name),
to_fqdn: fqdnToDisplay(binding.hostname, toDomain.zone_name),
}));
const preview: ChangeDomainPreview = {
from_domain_id: fromDomain.id,
to_domain_id: toDomain.id,
from_zone: fromDomain.zone_name,
to_zone: toDomain.zone_name,
items,
dry_run: Boolean(input.dry_run),
applied: false,
message: `Перенос ${items.length} привязок ${fromDomain.zone_name}${toDomain.zone_name}`,
};
if (input.dry_run) return preview;
const createdRecordIds: number[] = [];
try {
for (const binding of selected) {
const existing = repos.findBinding(
db,
serviceId,
toDomain.id,
binding.hostname,
);
if (existing) {
throw AppError.conflict(
`привязка ${fqdnToDisplay(binding.hostname, toDomain.zone_name)} уже существует`,
);
}
await withBindingLock(binding.id, async () => {
repos.bumpBindingVersion(db, binding.id);
const ips = repos.listBindingIps(db, binding.id);
repos.updateBindingDomain(db, binding.id, toDomain.id, binding.hostname);
await applyBindingDesiredDns(db, cf, binding.id, ips);
const newRecords = repos.listRecordsForBinding(db, binding.id);
createdRecordIds.push(...newRecords.map((r) => r.id));
const oldRecords = newRecords.filter((r) => r.domain_id === fromDomain.id);
for (const record of oldRecords) {
repos.unlinkBindingRecord(db, binding.id, record.id);
try {
await dnsService.deleteRecord(db, cf, fromDomain.id, record.id);
} catch {
// best-effort cleanup of old zone
}
}
});
}
} catch (err) {
throw err instanceof AppError
? err
: AppError.syncFailed(
err instanceof Error ? err.message : "не удалось перенести привязки",
);
}
void createdRecordIds;
return {
...preview,
dry_run: false,
applied: true,
message: `Привязки перенесены в ${toDomain.zone_name}. Старые записи зоны удалены.`,
};
}
+142
View File
@@ -0,0 +1,142 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { ChangeIpInput } from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js";
import { isValidIpv4 } from "../lib/validators.js";
import { withBindingLock } from "./routing/index.js";
import { applyBindingDesiredDns } from "./service-config-service.js";
export interface ChangeIpPreview {
binding_id: number;
hostname: string;
zone_name: string;
from_ip: string;
to_ip: string;
dry_run: boolean;
applied: boolean;
message: string;
}
async function patchRecordContent(
db: Db,
cf: CloudflareClient,
domainId: number,
recordId: number,
content: string,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const record = repos.getDnsRecord(db, domainId, recordId);
if (!record.cf_record_id) {
throw AppError.dnsUpdateFailed("у DNS-записи нет идентификатора Cloudflare");
}
try {
const patched = await cf.patchDnsRecord(domain.cf_zone_id, record.cf_record_id, {
content,
});
repos.updateDnsFields(
db,
record.id,
patched.type ?? record.record_type,
patched.name ?? record.name,
patched.content ?? content,
patched.ttl ?? record.ttl,
patched.proxied ?? record.proxied,
patched.priority ?? record.priority,
"synced",
patched.id ?? record.cf_record_id,
null,
);
} catch (err) {
if (err instanceof AppError) throw err;
throw AppError.dnsUpdateFailed(
err instanceof Error ? err.message : "не удалось обновить запись в Cloudflare",
);
}
}
export async function changeBindingIp(
db: Db,
cf: CloudflareClient,
bindingId: number,
input: ChangeIpInput,
): Promise<ChangeIpPreview> {
const binding = repos.getBinding(db, bindingId);
const domain = repos.getDomain(db, binding.domain_id);
const current = repos.listBindingIpsWithMeta(db, bindingId);
if (current.length === 0) {
throw AppError.validation("у привязки нет IP для замены");
}
let fromIp = input.from_ip?.trim();
let toIp = input.to_ip?.trim();
if (input.node_id) {
const node = repos.getNode(db, input.node_id);
if (node.service_id !== binding.service_id) {
throw AppError.validation("нода не принадлежит сервису этой привязки");
}
toIp = node.address;
}
if (!fromIp) {
fromIp = current[0]!.ip;
}
if (!toIp) {
throw AppError.invalidIp("укажите новый IP или ноду");
}
if (!isValidIpv4(toIp)) {
throw AppError.invalidIp(`Некорректный IP-адрес: ${toIp}`);
}
if (!current.some((row) => row.ip === fromIp)) {
throw AppError.validation(`IP ${fromIp} нет в привязке`);
}
const preview: ChangeIpPreview = {
binding_id: bindingId,
hostname: binding.hostname,
zone_name: domain.zone_name,
from_ip: fromIp,
to_ip: toIp,
dry_run: Boolean(input.dry_run),
applied: false,
message: `${fromIp}${toIp}`,
};
if (input.dry_run || fromIp === toIp) {
return preview;
}
return withBindingLock(bindingId, async () => {
repos.bumpBindingVersion(db, bindingId);
const next = current.map((row) =>
row.ip === fromIp ? { ...row, ip: toIp } : row,
);
repos.replaceBindingIpsWithMeta(db, bindingId, next);
const records = repos.listRecordsForBinding(db, bindingId);
const match = records.find(
(record) =>
record.content === fromIp &&
(record.record_type.toUpperCase() === "A" ||
record.record_type.toUpperCase() === "AAAA"),
);
if (match) {
await patchRecordContent(db, cf, binding.domain_id, match.id, toIp);
} else {
await applyBindingDesiredDns(
db,
cf,
bindingId,
next.map((row) => row.ip),
);
}
return {
...preview,
dry_run: false,
applied: true,
message: `Запись обновлена в Cloudflare (${fromIp}${toIp}). Распространение зависит от TTL.`,
};
});
}
+47 -1
View File
@@ -1,6 +1,6 @@
import type { Db } from "@cfdm/db";
import { repos, type DnsListFilter } from "@cfdm/db";
import type { CreateDnsRecordPayload, DnsRecord } from "@cfdm/shared";
import type { CreateDnsRecordPayload, DnsRecord, PatchDnsRecordPayload } from "@cfdm/shared";
import {
SYNC_CONFLICT,
SYNC_ERROR,
@@ -179,6 +179,52 @@ export async function update(
return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);
}
export async function patchContent(
db: Db,
cf: CloudflareClient,
domainId: number,
recordId: number,
payload: PatchDnsRecordPayload,
): Promise<DnsRecord> {
const domain = repos.getDomain(db, domainId);
const existing = repos.getDnsRecord(db, domainId, recordId);
if (!existing.cf_record_id) {
throw AppError.dnsUpdateFailed("у DNS-записи нет идентификатора Cloudflare");
}
try {
const cfRec = await cf.patchDnsRecord(
domain.cf_zone_id,
existing.cf_record_id,
payload,
);
repos.updateDnsFields(
db,
existing.id,
cfRec.type ?? existing.record_type,
cfRec.name ?? existing.name,
cfRec.content ?? existing.content,
cfRec.ttl ?? existing.ttl,
cfRec.proxied ?? existing.proxied,
cfRec.priority ?? existing.priority,
SYNC_SYNCED,
cfRec.id ?? existing.cf_record_id,
null,
);
return repos.getDnsRecord(db, domainId, existing.id);
} catch (e) {
repos.setDnsSyncStatus(
db,
existing.id,
SYNC_ERROR,
existing.cf_record_id,
e instanceof Error ? e.message : String(e),
);
throw e instanceof AppError
? e
: AppError.dnsUpdateFailed(e instanceof Error ? e.message : String(e));
}
}
export async function deleteRecord(
db: Db,
cf: CloudflareClient,
@@ -0,0 +1,150 @@
import type { FastifyInstance } from "fastify";
import { AsyncTask, CronJob } from "toad-scheduler";
import {
getAppSettings,
getAppSettingsSecrets,
type HealthEngineFallbacks,
} from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { AppConfig } from "../config.js";
import { AppError } from "../errors.js";
import * as healthCheckService from "./health-check-service.js";
import * as serviceConfigService from "./service-config-service.js";
declare module "fastify" {
interface FastifyInstance {
reloadHealthCheckJob?: () => void;
}
}
export const HEALTH_CHECK_JOB_ID = "health-check";
export function healthEngineFallbacksFromConfig(
config: AppConfig,
): HealthEngineFallbacks {
return {
healthCheckCron: config.healthCheckCron,
healthDegradedFailures: config.healthDegradedFailures,
healthDownFailures: config.healthDownFailures,
healthLatencyWarnMs: config.healthLatencyWarnMs,
healthSuccessRecoveries: config.healthSuccessRecoveries,
healthWorkerUrl: config.healthWorkerUrl,
healthWorkerTokenSet: Boolean(config.healthWorkerToken),
};
}
export function resolveWorkerProbeConfig(
db: import("@cfdm/db").Db,
config: AppConfig,
): { url: string; token: string } | null {
const settings = getAppSettings(db, healthEngineFallbacksFromConfig(config));
const secrets = getAppSettingsSecrets(db);
const url = settings.healthWorkerUrl.trim();
const token = (secrets.healthWorkerToken || config.healthWorkerToken).trim();
if (!url || !token) return null;
return { url, token };
}
export function assertValidHealthCron(expr: string): void {
const cronExpression = expr.trim();
const parts = cronExpression.split(/\s+/).filter(Boolean);
if (parts.length < 5 || parts.length > 6) {
throw AppError.validation("некорректное cron-выражение");
}
try {
const job = new CronJob(
{ cronExpression },
new AsyncTask("validate-cron", async () => undefined),
{ id: "validate-cron" },
);
job.stop();
} catch {
throw AppError.validation("некорректное cron-выражение");
}
}
export function createHealthCheckTask(
app: FastifyInstance,
config: AppConfig,
): AsyncTask {
const fallbacks = healthEngineFallbacksFromConfig(config);
return new AsyncTask(
HEALTH_CHECK_JOB_ID,
async () => {
const settings = getAppSettings(app.db, fallbacks);
const thresholds = {
degradedFailures: settings.healthDegradedFailures,
downFailures: settings.healthDownFailures,
latencyWarnMs: settings.healthLatencyWarnMs,
successRecoveries: settings.healthSuccessRecoveries,
};
const n = await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: config.healthProbeGapMs,
worker: resolveWorkerProbeConfig(app.db, config),
onStatusChange: async (target, prev, next) => {
try {
const label =
next === "up"
? "OK"
: next === "degraded"
? "Slow"
: next === "down"
? "Down"
: "—";
repos.insertNotificationLog(
app.db,
"ip_health",
target.scope,
target.ref_id,
`${target.hostname || target.ip}: ${label}`,
`IP ${target.ip}: ${prev ?? "—"}${label}`,
);
await serviceConfigService.reconcileDnsForTarget(
app.db,
app.cf,
target.scope,
target.ref_id,
);
} catch (err) {
app.log.warn(
{ err, scope: target.scope, refId: target.ref_id },
"health-check reconcile failed",
);
}
},
});
const monitors = await healthCheckService.runDomainMonitors(
app.db,
thresholds,
);
app.log.info({ checked: n, monitors }, "health check completed");
},
(err) => {
app.log.warn({ err }, "health check failed");
},
);
}
export function scheduleHealthCheckJob(
app: FastifyInstance,
config: AppConfig,
task: AsyncTask,
): void {
const scheduler = app.scheduler;
if (!scheduler) return;
if (scheduler.existsById(HEALTH_CHECK_JOB_ID)) {
scheduler.removeById(HEALTH_CHECK_JOB_ID);
}
const settings = getAppSettings(
app.db,
healthEngineFallbacksFromConfig(config),
);
scheduler.addCronJob(
new CronJob(
{ cronExpression: settings.healthCheckCron },
task,
{ preventOverrun: true, id: HEALTH_CHECK_JOB_ID },
),
);
}
+80 -23
View File
@@ -5,17 +5,25 @@ import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
import { AppError } from "../errors.js";
import { nextHealthState } from "./health/state-machine.js";
import { LocalHealthCheckProvider } from "./health/local.js";
import {
CloudflareWorkerHealthCheckProvider,
workerNotConfiguredResult,
} from "./health/worker.js";
export interface HealthCheckThresholds {
degradedFailures: number;
downFailures: number;
latencyWarnMs: number;
successRecoveries?: number;
}
export interface ProbeResult {
ok: boolean;
latencyMs: number;
error: string | null;
colo?: string | null;
}
/** Bracket IPv6 for URL authority; leave IPv4/hostname as-is. */
@@ -234,29 +242,33 @@ export async function probeTarget(
function deriveState(
ok: boolean,
latencyMs: number,
prev: { consecutive_failures: number; status: string } | null,
prev: {
consecutive_failures: number;
consecutive_successes?: number;
status: string;
} | null,
thresholds: HealthCheckThresholds,
): { state: IpHealthState; failures: number } {
if (!ok) {
const failures = (prev?.consecutive_failures ?? 0) + 1;
if (failures >= thresholds.downFailures) {
return { state: "down", failures };
}
if (failures >= thresholds.degradedFailures) {
return { state: "degraded", failures };
}
return { state: "degraded", failures };
}
if (latencyMs > thresholds.latencyWarnMs) {
return { state: "degraded", failures: 0 };
}
return { state: "up", failures: 0 };
): { state: IpHealthState; failures: number; successes: number; node: string } {
const next = nextHealthState(ok, latencyMs, prev, {
degradedFailures: thresholds.degradedFailures,
downFailures: thresholds.downFailures,
latencyWarnMs: thresholds.latencyWarnMs,
successRecoveries: thresholds.successRecoveries ?? 2,
});
return {
state: next.legacy,
failures: next.failures,
successes: next.successes,
node: next.node,
};
}
export interface RunAllChecksOptions {
thresholds: HealthCheckThresholds;
/** Pause between unique physical probes (default 2000). Same IP is only probed once. */
probeGapMs?: number;
/** Cloudflare Worker URL+token. Missing → cloudflare targets fail, never Local fallback. */
worker?: { url: string; token: string } | null;
onStatusChange?: (
target: HealthCheckTarget,
prevState: IpHealthState | null,
@@ -273,21 +285,34 @@ function sleep(ms: number): Promise<void> {
* so anti-bot / rate-limit on the origin is not tripped by back-to-back checks.
*/
export function physicalProbeKey(target: HealthCheckTarget): string {
const kind = target.provider === "cloudflare" ? "cloudflare" : "local";
const port = target.port ?? (target.type === "http" ? 80 : 80);
const ip = String(target.ip || "").trim().toLowerCase();
if (target.type === "http") {
const path = (target.path?.trim() || "/") || "/";
const expected = target.expected_status ?? "";
return `http|${ip}|${port}|${path}|${expected}`;
return `${kind}|http|${ip}|${port}|${path}|${expected}`;
}
if (target.type === "tcp") return `tcp|${ip}|${port}`;
if (target.type === "tcp") return `${kind}|tcp|${ip}|${port}`;
if (target.type === "ping") {
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
return `${kind}|ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
}
if (target.type === "dns") {
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
return `${kind}|dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
}
return `${target.type}|${ip}|${port}`;
return `${kind}|${target.type}|${ip}|${port}`;
}
async function executeProbe(
target: HealthCheckTarget,
local: LocalHealthCheckProvider,
worker: CloudflareWorkerHealthCheckProvider | null,
): Promise<ProbeResult> {
if (target.provider === "cloudflare") {
if (!worker) return workerNotConfiguredResult();
return worker.probe(target);
}
return local.probe(target);
}
export async function runAllChecks(
@@ -296,6 +321,11 @@ export async function runAllChecks(
): Promise<number> {
const targets = repos.listHealthCheckTargets(db);
const gapMs = Math.max(0, options.probeGapMs ?? 2000);
const local = new LocalHealthCheckProvider();
const worker =
options.worker?.url && options.worker.token
? new CloudflareWorkerHealthCheckProvider(options.worker)
: null;
const byPhysical = new Map<string, HealthCheckTarget[]>();
for (const target of targets) {
@@ -315,7 +345,7 @@ export async function runAllChecks(
// Prefer binding hostname for SNI when several scopes share one IP.
const representative =
group.find((t) => t.scope === "binding") ?? group[0]!;
const result = await probeTarget(representative);
const result = await executeProbe(representative, local, worker);
for (const target of group) {
const prev = repos.getIpHealthStatusRow(
@@ -324,12 +354,13 @@ export async function runAllChecks(
target.ref_id,
target.ip,
);
const { state, failures } = deriveState(
const { state, failures, successes, node } = deriveState(
result.ok,
result.latencyMs,
prev
? {
consecutive_failures: prev.consecutive_failures,
consecutive_successes: prev.consecutive_successes,
status: prev.status,
}
: null,
@@ -338,6 +369,7 @@ export async function runAllChecks(
const prevState: IpHealthState | null = prev
? (prev.status as IpHealthState)
: null;
const provider = target.provider === "cloudflare" ? "cloudflare" : "local";
repos.upsertIpHealthStatus(
db,
target.scope,
@@ -347,7 +379,30 @@ export async function runAllChecks(
result.latencyMs,
failures,
result.error,
successes,
{ colo: result.colo ?? null, provider },
);
repos.insertHealthProbeLog(db, {
scope: target.scope,
refId: target.ref_id,
ip: target.ip,
provider,
status: state,
ok: result.ok,
latencyMs: result.latencyMs,
colo: result.colo ?? null,
error: result.error,
});
const matchedNode = repos.findNodeByIp(db, target.ip);
if (matchedNode && matchedNode.enabled) {
repos.updateNode(db, matchedNode.id, {
health_status: node,
consecutive_failures: failures,
consecutive_successes: successes,
last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19),
last_failure_reason: result.error,
});
}
if (prevState !== state) {
options.onStatusChange?.(target, prevState, state);
}
@@ -376,6 +431,7 @@ export async function runDomainMonitors(
expected_status: monitor.expected_status,
timeout_ms: monitor.timeout_ms,
verify_tls: false,
provider: "local",
};
let result: ProbeResult;
if (monitor.type === "http") {
@@ -402,6 +458,7 @@ export async function runDomainMonitors(
result.latencyMs,
{
consecutive_failures: result.ok ? 0 : 1,
consecutive_successes: result.ok ? 1 : 0,
status: prevStatus,
},
thresholds,
+112
View File
@@ -0,0 +1,112 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { CfHealthCheck, HealthCheckTarget, OriginHealthCheck } from "@cfdm/shared";
import type { CloudflareClient } from "../../lib/cf-client.js";
import type { CfHealthCheckPayload } from "../../lib/cloudflare/healthcheck-service.js";
import { AppError } from "../../errors.js";
import type { ProbeResult } from "../health-check-service.js";
import type { HealthCheckProvider } from "./provider.js";
function toPayload(
check: OriginHealthCheck,
address: string,
): CfHealthCheckPayload {
const type = (check.protocol || "TCP").toUpperCase() as "HTTP" | "HTTPS" | "TCP";
const payload: CfHealthCheckPayload = {
address,
name: check.name,
type,
interval: check.interval_sec,
timeout: check.timeout,
retries: check.retries,
consecutive_fails: check.consecutive_fails,
consecutive_successes: check.consecutive_successes,
suspended: check.suspended,
};
if (type === "HTTP" || type === "HTTPS") {
payload.http_config = {
method: check.method ?? "GET",
path: check.path ?? "/",
expected_codes: check.expected_status != null ? [String(check.expected_status)] : ["200"],
};
} else {
payload.tcp_config = { method: "connection_established" };
}
return payload;
}
export class CloudflareHealthCheckProvider implements HealthCheckProvider {
readonly kind = "cloudflare" as const;
constructor(
private readonly db: Db,
private readonly cf: CloudflareClient,
) {}
async probe(target: HealthCheckTarget): Promise<ProbeResult> {
const node = repos.findNodeByIp(this.db, target.ip);
if (!node?.health_check_id) {
return { ok: false, latencyMs: 0, error: "нет Cloudflare Health Check" };
}
const check = repos.getHealthCheck(this.db, node.health_check_id);
if (!check.cf_zone_id || !check.cf_healthcheck_id) {
return { ok: false, latencyMs: 0, error: "Cloudflare Health Check не синхронизирован" };
}
try {
const remote = await this.cf.getHealthCheck(check.cf_zone_id, check.cf_healthcheck_id);
const status = (remote.status ?? "").toLowerCase();
const ok = status === "healthy" || status === "ok";
return {
ok,
latencyMs: 0,
error: ok ? null : remote.status ?? "unhealthy",
};
} catch (err) {
return {
ok: false,
latencyMs: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
async syncCreate(
check: OriginHealthCheck,
zoneId: string,
address: string,
): Promise<CfHealthCheck> {
try {
const remote = await this.cf.createHealthCheck(zoneId, toPayload(check, address));
repos.updateHealthCheck(this.db, check.id, {
cf_healthcheck_id: remote.id,
cf_zone_id: zoneId,
provider: "cloudflare",
});
return remote;
} catch (err) {
if (err instanceof AppError) throw err;
throw AppError.healthcheckCreateFailed(
err instanceof Error ? err.message : "не удалось создать Cloudflare Health Check",
);
}
}
async syncUpdate(
check: OriginHealthCheck,
address: string,
): Promise<CfHealthCheck> {
if (!check.cf_zone_id || !check.cf_healthcheck_id) {
throw AppError.healthcheckCreateFailed("Cloudflare Health Check не привязан к зоне");
}
return this.cf.updateHealthCheck(
check.cf_zone_id,
check.cf_healthcheck_id,
toPayload(check, address),
);
}
async syncDelete(check: OriginHealthCheck): Promise<void> {
if (!check.cf_zone_id || !check.cf_healthcheck_id) return;
await this.cf.deleteHealthCheck(check.cf_zone_id, check.cf_healthcheck_id);
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { HealthCheckTarget } from "@cfdm/shared";
import { probeTarget, type ProbeResult } from "../health-check-service.js";
import type { HealthCheckProvider } from "./provider.js";
export class LocalHealthCheckProvider implements HealthCheckProvider {
readonly kind = "local" as const;
probe(target: HealthCheckTarget): Promise<ProbeResult> {
return probeTarget(target);
}
}
+7
View File
@@ -0,0 +1,7 @@
import type { HealthCheckTarget } from "@cfdm/shared";
import type { ProbeResult } from "../health-check-service.js";
export interface HealthCheckProvider {
readonly kind: "local" | "cloudflare";
probe(target: HealthCheckTarget): Promise<ProbeResult>;
}
@@ -0,0 +1,75 @@
import type { IpHealthState, NodeHealthState } from "@cfdm/shared";
export interface HealthThresholds {
degradedFailures: number;
downFailures: number;
successRecoveries: number;
latencyWarnMs: number;
}
export interface HealthCounters {
status: string;
consecutive_failures: number;
consecutive_successes?: number;
}
export interface NextHealth {
legacy: IpHealthState;
node: NodeHealthState;
failures: number;
successes: number;
}
function wasHealthy(status: string | undefined): boolean {
return status === "up" || status === "healthy";
}
function wasUnhealthy(status: string | undefined): boolean {
return (
status === "down" ||
status === "unhealthy" ||
status === "checking" ||
status === "degraded"
);
}
export function nextHealthState(
ok: boolean,
latencyMs: number,
prev: HealthCounters | null,
thresholds: HealthThresholds,
): NextHealth {
if (!ok) {
const failures = (prev?.consecutive_failures ?? 0) + 1;
if (failures >= thresholds.downFailures) {
return { legacy: "down", node: "unhealthy", failures, successes: 0 };
}
return { legacy: "degraded", node: "degraded", failures, successes: 0 };
}
if (latencyMs > thresholds.latencyWarnMs) {
return { legacy: "degraded", node: "degraded", failures: 0, successes: 0 };
}
if (!prev || wasHealthy(prev.status) || !wasUnhealthy(prev.status)) {
return {
legacy: "up",
node: "healthy",
failures: 0,
successes: (prev?.consecutive_successes ?? 0) + 1,
};
}
const successes = (prev.consecutive_successes ?? 0) + 1;
if (successes >= thresholds.successRecoveries) {
return { legacy: "up", node: "healthy", failures: 0, successes };
}
return { legacy: "unknown", node: "checking", failures: 0, successes };
}
export function toLegacyHealth(status: NodeHealthState | IpHealthState): IpHealthState {
if (status === "healthy" || status === "up") return "up";
if (status === "unhealthy" || status === "down") return "down";
if (status === "degraded") return "degraded";
return "unknown";
}
+93
View File
@@ -0,0 +1,93 @@
import type { HealthCheckTarget } from "@cfdm/shared";
import type { ProbeResult } from "../health-check-service.js";
import type { HealthCheckProvider } from "./provider.js";
export interface WorkerProbeConfig {
url: string;
token: string;
}
const WORKER_NOT_CONFIGURED = "Cloudflare Worker не настроен (URL и токен)";
export class CloudflareWorkerHealthCheckProvider implements HealthCheckProvider {
readonly kind = "cloudflare" as const;
constructor(private readonly config: WorkerProbeConfig) {}
async probe(target: HealthCheckTarget): Promise<ProbeResult> {
const base = this.config.url.replace(/\/$/, "");
if (!base || !this.config.token) {
return {
ok: false,
latencyMs: 0,
error: WORKER_NOT_CONFIGURED,
colo: null,
};
}
const timeoutMs = Math.max(100, target.timeout_ms ?? 3000);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs + 2500);
try {
const res = await fetch(`${base}/probe`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.config.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: target.type === "http" ? "http" : "tcp",
ip: target.ip,
hostname: target.hostname,
port: target.port ?? (target.type === "http" ? 80 : 80),
path: target.path ?? "/",
expected_status: target.expected_status,
timeout_ms: timeoutMs,
verify_tls: Boolean(target.verify_tls),
method: "GET",
}),
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
return {
ok: false,
latencyMs: 0,
error: `Worker HTTP ${res.status}${text ? `: ${text.slice(0, 180)}` : ""}`,
colo: null,
};
}
const body = (await res.json()) as {
ok?: boolean;
latencyMs?: number;
error?: string | null;
colo?: string | null;
};
const ok = Boolean(body.ok);
return {
ok,
latencyMs: typeof body.latencyMs === "number" ? body.latencyMs : 0,
error: ok ? null : (body.error ?? "probe failed"),
colo: body.colo ?? null,
};
} catch (err) {
const message =
err instanceof Error
? err.name === "AbortError"
? "Worker timeout"
: err.message
: "Worker probe failed";
return { ok: false, latencyMs: 0, error: message, colo: null };
} finally {
clearTimeout(timer);
}
}
}
export function workerNotConfiguredResult(): ProbeResult {
return {
ok: false,
latencyMs: 0,
error: WORKER_NOT_CONFIGURED,
colo: null,
};
}
+150
View File
@@ -0,0 +1,150 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type {
CreateServiceNodeInput,
ServiceNode,
ServiceOverview,
UpdateServiceNodeInput,
} from "@cfdm/shared";
import { AppError } from "../errors.js";
import { isValidIpv4 } from "../lib/validators.js";
import { getView } from "./service-config-service.js";
import { selectActiveIpsByMode } from "./routing/index.js";
function assertAddress(address: string): void {
if (!isValidIpv4(address)) {
throw AppError.invalidIp(`Некорректный IP-адрес: ${address}`);
}
}
export function listNodes(db: Db, serviceId: number): ServiceNode[] {
repos.getService(db, serviceId);
return repos.listNodes(db, serviceId);
}
export function createNode(
db: Db,
serviceId: number,
input: CreateServiceNodeInput,
): ServiceNode {
repos.getService(db, serviceId);
assertAddress(input.address);
try {
return repos.createNode(db, serviceId, {
address: input.address,
protocol: input.protocol,
port: input.port,
enabled: input.enabled,
priority: input.priority,
weight: input.weight,
health_check_id: input.health_check_id,
});
} catch (err) {
if (err instanceof Error && err.name === "ConflictError") {
throw AppError.conflict(err.message);
}
throw err;
}
}
export function updateNode(
db: Db,
serviceId: number,
nodeId: number,
patch: UpdateServiceNodeInput,
): ServiceNode {
const node = repos.getNode(db, nodeId);
if (node.service_id !== serviceId) {
throw AppError.notFound(`node ${nodeId}`);
}
if (patch.address) assertAddress(patch.address);
return repos.updateNode(db, nodeId, patch);
}
export function deleteNode(db: Db, serviceId: number, nodeId: number): void {
const node = repos.getNode(db, nodeId);
if (node.service_id !== serviceId) {
throw AppError.notFound(`node ${nodeId}`);
}
repos.deleteNode(db, nodeId);
}
export async function getOverview(
db: Db,
serviceId: number,
): Promise<ServiceOverview> {
const service = await getView(db, serviceId);
const nodes = repos.listNodes(db, serviceId);
const bindings = repos.listBindingsByService(db, serviceId);
const first = bindings[0];
const routing = first?.routing_strategy ?? first?.lb_mode ?? "round_robin";
const healthCheck =
nodes
.map((n) => n.health_check_id)
.find((id): id is number => id != null) != null
? repos.getHealthCheck(
db,
nodes.find((n) => n.health_check_id != null)!.health_check_id!,
)
: null;
const active = new Set<string>();
for (const binding of bindings) {
const metas = repos.listBindingIpsWithMeta(db, binding.id);
const rows = metas.map((entry) => {
const status = repos.getIpHealthStatusRow(db, "binding", binding.id, entry.ip);
return {
ip: entry.ip,
weight: entry.weight,
priority: entry.priority,
health: status ? status.status : ("unknown" as const),
};
});
for (const ip of selectActiveIpsByMode(
{
lb_mode: binding.lb_mode,
health_check_enabled: binding.health_check_enabled,
},
rows,
)) {
active.add(ip);
}
}
return {
service,
nodes,
health_check: healthCheck,
routing_strategy: routing,
active_addresses: [...active],
};
}
export function opsSummary(db: Db) {
const allNodes = repos.listAllNodes(db);
const services = repos.listServices(db);
const domains = repos.listDomains(db);
const healthy = allNodes.filter(
(n) => n.health_status === "healthy" || n.health_status === "up",
).length;
const unhealthy = allNodes.filter(
(n) => n.health_status === "unhealthy" || n.health_status === "down",
).length;
const failoverActive = repos.listAllBindings(db).filter((binding) => {
if (binding.lb_mode !== "failover" || !binding.health_check_enabled) {
return false;
}
return binding.target_ips.some((ip) => {
const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip);
return row?.status === "down";
});
}).length;
return {
domains: domains.length,
services: services.length,
nodes: allNodes.length,
healthy,
unhealthy,
active_failovers: failoverActive,
};
}
@@ -0,0 +1,126 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type {
CreateOriginHealthCheckInput,
OriginHealthCheck,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js";
import { CloudflareHealthCheckProvider } from "./health/cloudflare.js";
export function listOriginHealthChecks(db: Db): OriginHealthCheck[] {
return repos.listHealthChecks(db);
}
export function getOriginHealthCheck(db: Db, id: number): OriginHealthCheck {
return repos.getHealthCheck(db, id);
}
export async function createOriginHealthCheck(
db: Db,
cf: CloudflareClient,
input: CreateOriginHealthCheckInput,
): Promise<OriginHealthCheck> {
const protocol = (input.protocol ?? "tcp").toLowerCase();
const check = repos.createHealthCheck(db, {
provider: input.provider,
name: input.name,
cf_zone_id: input.cf_zone_id ?? null,
protocol,
path: input.path,
method: input.method,
timeout: input.timeout,
interval_sec: input.interval_sec,
retries: input.retries,
expected_status: input.expected_status,
consecutive_fails: input.consecutive_fails,
consecutive_successes: input.consecutive_successes,
suspended: input.suspended,
});
if (input.node_id) {
const node = repos.getNode(db, input.node_id);
repos.updateNode(db, node.id, { health_check_id: check.id });
}
if (input.provider === "cloudflare") {
const zoneId = input.cf_zone_id;
if (!zoneId) {
throw AppError.zoneNotFound("укажите зону Cloudflare для Health Check");
}
const address = input.node_id
? repos.getNode(db, input.node_id).address
: check.name;
const provider = new CloudflareHealthCheckProvider(db, cf);
await provider.syncCreate(check, zoneId, address);
return repos.getHealthCheck(db, check.id);
}
return check;
}
export async function updateOriginHealthCheck(
db: Db,
cf: CloudflareClient,
id: number,
patch: Partial<CreateOriginHealthCheckInput>,
): Promise<OriginHealthCheck> {
const current = repos.getHealthCheck(db, id);
const updated = repos.updateHealthCheck(db, id, {
provider: patch.provider,
name: patch.name,
cf_zone_id: patch.cf_zone_id,
protocol: patch.protocol?.toLowerCase(),
path: patch.path,
method: patch.method,
timeout: patch.timeout,
interval_sec: patch.interval_sec,
retries: patch.retries,
expected_status: patch.expected_status,
consecutive_fails: patch.consecutive_fails,
consecutive_successes: patch.consecutive_successes,
suspended: patch.suspended,
});
if (updated.provider === "cloudflare" && updated.cf_healthcheck_id) {
const address = repos.findNodeByIp(db, updated.name)?.address ?? updated.name;
const provider = new CloudflareHealthCheckProvider(db, cf);
await provider.syncUpdate(updated, address);
}
void current;
return repos.getHealthCheck(db, id);
}
export async function deleteOriginHealthCheck(
db: Db,
cf: CloudflareClient,
id: number,
): Promise<void> {
const check = repos.getHealthCheck(db, id);
if (check.provider === "cloudflare") {
const provider = new CloudflareHealthCheckProvider(db, cf);
await provider.syncDelete(check);
}
repos.deleteHealthCheck(db, id);
}
export async function syncOriginHealthCheck(
db: Db,
cf: CloudflareClient,
id: number,
): Promise<OriginHealthCheck> {
const check = repos.getHealthCheck(db, id);
if (check.provider !== "cloudflare") {
throw AppError.validation("синхронизация доступна только для Cloudflare Health Checks");
}
if (!check.cf_zone_id) {
throw AppError.zoneNotFound();
}
const provider = new CloudflareHealthCheckProvider(db, cf);
const address = repos.findNodeByIp(db, check.name)?.address ?? check.name;
if (check.cf_healthcheck_id) {
await provider.syncUpdate(check, address);
} else {
await provider.syncCreate(check, check.cf_zone_id, address);
}
return repos.getHealthCheck(db, id);
}
@@ -0,0 +1,25 @@
const locks = new Map<number, Promise<void>>();
export async function withBindingLock<T>(
bindingId: number,
fn: () => Promise<T>,
): Promise<T> {
const previous = locks.get(bindingId) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((resolve) => {
release = resolve;
});
locks.set(
bindingId,
previous.then(() => current).catch(() => current),
);
await previous.catch(() => undefined);
try {
return await fn();
} finally {
release();
if (locks.get(bindingId) === current) {
locks.delete(bindingId);
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { LbIpRow } from "./types.js";
import { isHealthy } from "./health.js";
export function failoverDesired(rows: LbIpRow[]): string[] {
if (rows.length === 0) return [];
const healthy = rows.filter((r) => isHealthy(r.health));
const pool = healthy.length > 0 ? healthy : rows;
const sorted = [...pool].sort(
(a, b) => a.priority - b.priority || a.weight - b.weight,
);
const minPriority = sorted[0]!.priority;
const primaries = sorted.filter((r) => r.priority === minPriority);
if (healthy.length > 0) {
return primaries.map((r) => r.ip);
}
return [sorted[0]!.ip];
}
+5
View File
@@ -0,0 +1,5 @@
import type { IpHealthState, NodeHealthState } from "@cfdm/shared";
export function isHealthy(state: IpHealthState | NodeHealthState | string): boolean {
return state === "up" || state === "healthy";
}
+26
View File
@@ -0,0 +1,26 @@
import type { LbMode } from "@cfdm/shared";
import { failoverDesired } from "./failover.js";
import { roundRobinDesired } from "./round-robin.js";
import type { LbIpRow, LbTargetConfig } from "./types.js";
export type { LbIpRow, LbTargetConfig } from "./types.js";
export { isHealthy } from "./health.js";
export { withBindingLock } from "./binding-lock.js";
export function selectActiveIpsByMode(
config: LbTargetConfig,
rows: LbIpRow[],
): string[] {
if (rows.length === 0) return [];
if (config.lb_mode === "failover") {
return failoverDesired(rows);
}
// weighted = round_robin on DNS (one A per IP)
return roundRobinDesired(rows);
}
export function strategyLabel(mode: LbMode): string {
if (mode === "failover") return "Failover";
if (mode === "weighted") return "Round Robin (weighted alias)";
return "Round Robin";
}
@@ -0,0 +1,8 @@
import type { LbIpRow } from "./types.js";
import { isHealthy } from "./health.js";
export function roundRobinDesired(rows: LbIpRow[]): string[] {
const healthy = rows.filter((r) => isHealthy(r.health));
const pool = healthy.length > 0 ? healthy : rows;
return pool.map((r) => r.ip);
}
+13
View File
@@ -0,0 +1,13 @@
import type { IpHealthState, LbMode } from "@cfdm/shared";
export interface LbTargetConfig {
lb_mode: LbMode;
health_check_enabled: boolean;
}
export interface LbIpRow {
ip: string;
weight: number;
priority: number;
health: IpHealthState;
}
+141 -75
View File
@@ -24,6 +24,15 @@ import { isValidIpv4 } from "../lib/validators.js";
import * as dnsService from "./dns-service.js";
import * as domainService from "./domain-service.js";
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
import {
selectActiveIpsByMode,
withBindingLock,
type LbIpRow,
type LbTargetConfig,
} from "./routing/index.js";
export type { LbIpRow, LbTargetConfig };
export { selectActiveIpsByMode };
export interface ServiceDomainInput {
fqdn: string;
@@ -41,6 +50,7 @@ export interface ServiceDomainInput {
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
health_check_verify_tls?: boolean;
health_check_provider?: "local" | "cloudflare";
}
export interface ToggleRequest {
@@ -61,6 +71,7 @@ export interface ServiceGroupBody {
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
health_check_verify_tls?: boolean;
health_check_provider?: "local" | "cloudflare";
}
export interface UpdateServiceGroupBody {
@@ -77,6 +88,7 @@ export interface UpdateServiceGroupBody {
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
health_check_verify_tls?: boolean;
health_check_provider?: "local" | "cloudflare";
}
export interface UpdateServiceConfigRequest {
@@ -137,54 +149,6 @@ function aggregateSyncStatus(statuses: string[]): string | null {
return statuses[0] ?? null;
}
function isHealthy(state: IpHealthState): boolean {
return state === "up" || state === "unknown";
}
export interface LbTargetConfig {
lb_mode: LbMode;
health_check_enabled: boolean;
}
export interface LbIpRow {
ip: string;
weight: number;
priority: number;
health: IpHealthState;
}
export function selectActiveIpsByMode(
config: LbTargetConfig,
rows: LbIpRow[],
): string[] {
if (rows.length === 0) return [];
const healthy = rows.filter((r) => isHealthy(r.health));
const pool = healthy.length > 0 ? healthy : rows;
if (config.lb_mode === "failover") {
const sorted = [...pool].sort(
(a, b) => a.priority - b.priority || a.weight - b.weight,
);
const minPriority = sorted[0]!.priority;
const primaries = sorted.filter((r) => r.priority === minPriority);
if (healthy.length > 0) {
return primaries.map((r) => r.ip);
}
return [sorted[0]!.ip];
}
if (config.lb_mode === "weighted") {
// Cloudflare не допускает дублирования A-записей с одинаковым name+content,
// поэтому weighted на уровне DNS реализован как RR по одному A на IP.
// Веса сохраняются в БД и используются для приоритизации/отображения;
// точное weighted-распределение требует CF Load Balancer (см. README).
return pool.map((r) => r.ip);
}
return pool.map((r) => r.ip);
}
function getBindingLbState(
db: Db,
bindingId: number,
@@ -281,7 +245,11 @@ async function collectKnownZones(
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
const service = repos.getService(db, serviceId);
const ips = repos.listServiceIps(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);
const domainViews = bindings.map((binding) => {
@@ -326,6 +294,7 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
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",
sync_status: aggregateSyncStatus(statuses),
};
});
@@ -343,9 +312,11 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
created_at: service.created_at,
updated_at: service.updated_at,
ips,
ip_enabled,
domains: domainViews,
health_status: "unknown",
health_latency_ms: null,
ip_health: [],
};
}
@@ -353,16 +324,31 @@ function attachServiceHealth(
db: Db,
views: ServiceView[],
): ServiceView[] {
const healthByService = repos.aggregateIpHealthByServiceIds(
db,
views.map((v) => v.id),
);
const ids = views.map((v) => v.id);
const healthByService = repos.aggregateIpHealthByServiceIds(db, ids);
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
return views.map((view) => {
const health = healthByService.get(view.id);
const byIp = new Map(
(ipHealthByService.get(view.id) ?? []).map((row) => [row.ip, row]),
);
const ip_health = (view.ips ?? []).map((ip) => {
const row = byIp.get(ip);
return {
ip,
status: row?.status ?? ("unknown" as const),
latency_ms: row?.latency_ms ?? null,
last_checked_at: row?.last_checked_at ?? null,
last_error: row?.last_error ?? null,
provider: row?.provider ?? "local",
colo: row?.colo ?? null,
};
});
return {
...view,
health_status: health?.health_status ?? "unknown",
health_latency_ms: health?.health_latency_ms ?? null,
ip_health,
};
});
}
@@ -411,7 +397,7 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
const groupViews = groupViewsRaw.map((group) => {
const services = group.services.map(
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null },
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null, ip_health: [], ip_enabled: {} },
);
const groupScopeHealth = groupHealthById.get(group.id);
// Only enabled services feed the group badge — a disabled service with a
@@ -440,6 +426,8 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
...s,
health_status: "unknown" as const,
health_latency_ms: null,
ip_health: [],
ip_enabled: {},
},
);
@@ -1174,7 +1162,8 @@ export async function updateConfig(
input.health_check_expected_status !== undefined ||
input.health_check_interval_sec !== undefined ||
input.health_check_timeout_ms !== undefined ||
input.health_check_verify_tls !== undefined
input.health_check_verify_tls !== undefined ||
input.health_check_provider !== undefined
) {
repos.updateBindingLbConfig(db, binding.id, {
lb_mode: input.lb_mode,
@@ -1186,6 +1175,7 @@ export async function updateConfig(
health_check_interval_sec: input.health_check_interval_sec,
health_check_timeout_ms: input.health_check_timeout_ms,
health_check_verify_tls: input.health_check_verify_tls,
health_check_provider: input.health_check_provider,
});
}
@@ -1287,6 +1277,7 @@ export async function createGroup(
health_check_interval_sec: body.health_check_interval_sec,
health_check_timeout_ms: body.health_check_timeout_ms,
health_check_verify_tls: body.health_check_verify_tls,
health_check_provider: body.health_check_provider,
},
);
}
@@ -1323,6 +1314,7 @@ export async function updateGroup(
health_check_interval_sec: body.health_check_interval_sec,
health_check_timeout_ms: body.health_check_timeout_ms,
health_check_verify_tls: body.health_check_verify_tls,
health_check_provider: body.health_check_provider,
},
);
if (!domain && group.enabled) {
@@ -1371,6 +1363,59 @@ export async function toggleService(
return enabledView!;
}
export async function toggleServiceIp(
db: Db,
cf: CloudflareClient,
serviceId: number,
ip: string,
enabled: boolean,
): Promise<ServiceView> {
repos.getService(db, serviceId);
const pool = repos.listServiceIps(db, serviceId);
if (!pool.includes(ip)) {
throw AppError.validation(`IP ${ip} не входит в пул адресов сервиса`);
}
repos.setServiceIpEnabled(db, serviceId, ip, enabled);
const node = repos
.listNodes(db, serviceId)
.find((entry) => entry.address === ip);
if (node) {
repos.updateNode(db, node.id, { enabled });
}
const bindings = repos.listBindingsByService(db, serviceId);
for (const binding of bindings) {
if (binding.cname_target?.trim()) continue;
const current = repos.listBindingIpsWithMeta(db, binding.id);
const hasIp = current.some((entry) => entry.ip === ip);
if (enabled && !hasIp) {
repos.replaceBindingIpsWithMeta(db, binding.id, [
...current,
{ ip, weight: 1, priority: 1 },
]);
continue;
}
if (!enabled && hasIp) {
repos.replaceBindingIpsWithMeta(
db,
binding.id,
current.filter((entry) => entry.ip !== ip),
);
}
}
const service = repos.getService(db, serviceId);
if (shouldPushDns(db, service)) {
await syncServiceBindingsToDns(db, cf, serviceId);
await syncGroupDomainForService(db, cf, serviceId);
}
void syncServiceToVpsTracker(db, serviceId);
const [view] = attachServiceHealth(db, [await buildView(db, serviceId)]);
return view!;
}
export async function toggleGroup(
db: Db,
cf: CloudflareClient,
@@ -1411,6 +1456,25 @@ export function reorderServices(
repos.reorderServices(db, groupId, serviceIds);
}
export async function applyBindingDesiredDns(
db: Db,
cf: CloudflareClient,
bindingId: number,
desiredIps: string[],
): Promise<void> {
const binding = repos.getBinding(db, bindingId);
const cnameTarget = binding.cname_target?.trim() || null;
await syncBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
desiredIps,
cnameTarget,
);
}
export async function reconcileDnsForTarget(
db: Db,
cf: CloudflareClient,
@@ -1418,26 +1482,28 @@ export async function reconcileDnsForTarget(
refId: number,
): Promise<void> {
if (scope === "binding") {
const binding = repos.getBinding(db, refId);
if (!binding.health_check_enabled) return;
const service = repos.getService(db, binding.service_id);
if (!shouldPushDns(db, service)) return;
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) return;
const ips = repos.listServiceIps(db, service.id);
const targetIps = repos.listBindingIps(db, binding.id);
validateTargetIpsInPool(targetIps, ips);
const activeIps = computeActiveIps(db, "binding", refId);
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
await syncBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
desiredIps,
null,
);
await withBindingLock(refId, async () => {
const binding = repos.getBinding(db, refId);
if (!binding.health_check_enabled) return;
const service = repos.getService(db, binding.service_id);
if (!shouldPushDns(db, service)) return;
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) return;
const ips = repos.listServiceIps(db, service.id);
const targetIps = repos.listBindingIps(db, binding.id);
validateTargetIpsInPool(targetIps, ips);
const activeIps = computeActiveIps(db, "binding", refId);
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
await syncBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
desiredIps,
null,
);
});
return;
}
+5 -4
View File
@@ -43,8 +43,9 @@ function resolveIpsLocally(
const binding = index.byFqdn.get(key);
if (!binding) return [];
if (binding.target_ips.some(isIpLiteral)) {
return binding.target_ips.filter(isIpLiteral);
const ips = (binding.target_ips ?? []).filter(isIpLiteral);
if (ips.length > 0) {
return ips;
}
const cname = binding.cname_target?.trim();
@@ -79,7 +80,7 @@ export async function resolveBindingIpsForSync(
index: BindingIpIndex,
db?: Db,
): Promise<string[]> {
const directIps = binding.target_ips.filter(isIpLiteral);
const directIps = (binding.target_ips ?? []).filter(isIpLiteral);
if (directIps.length > 0) {
return [...directIps];
}
@@ -124,7 +125,7 @@ export async function buildServiceSyncBindingsAsync(
const serviceIps = repos.listServiceIps(db, serviceId);
const allBindings = repos.listAllBindings(db);
const index = buildBindingIndex(allBindings);
const bindings = repos.listBindingsByService(db, serviceId);
const bindings = allBindings.filter((row) => row.service_id === serviceId);
const items: CfdmBindingSyncItem[] = [];
for (const binding of bindings) {
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { repos } from "@cfdm/db";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
const config = loadConfig();
const res = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: config.adminUsername, password: "admin" },
});
expect(res.statusCode).toBe(200);
const { token } = res.json() as { token: string };
return { authorization: `Bearer ${token}` };
}
describe("origin health checks", () => {
it("creates a local health check", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const res = await app.inject({
method: "POST",
url: "/api/v1/health-checks",
headers,
payload: { provider: "local", name: "origin-1", protocol: "tcp" },
});
expect(res.statusCode).toBe(200);
const body = res.json() as { provider: string; name: string };
expect(body.provider).toBe("local");
expect(body.name).toBe("origin-1");
expect(repos.listHealthChecks(app.db)).toHaveLength(1);
await app.close();
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { repos } from "@cfdm/db";
import type { CloudflareClient } from "../src/lib/cf-client.js";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { changeServiceDomain } from "../src/services/change-domain-service.js";
import { updateConfig } from "../src/services/service-config-service.js";
function mockCf(): CloudflareClient {
return {
listDnsRecords: async () => [],
createDnsRecord: async (_zoneId: string, payload: { type: string; name: string; content: string }) => ({
id: `cf-${payload.name}-${payload.content}`,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
}),
updateDnsRecord: async (
_zoneId: string,
id: string,
payload: { type: string; name: string; content: string },
) => ({
id,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
}),
patchDnsRecord: async (
_zoneId: string,
id: string,
payload: { content?: string },
) => ({
id,
type: "A",
name: "app.example.com",
content: payload.content ?? "1.1.1.1",
ttl: 1,
proxied: false,
}),
deleteDnsRecord: async () => undefined,
listZones: async () => [
{ id: "zone-1", name: "example.com", status: "active" },
{ id: "zone-2", name: "other.com", status: "active" },
],
} as unknown as CloudflareClient;
}
describe("change-domain", () => {
it("dry-run lists FQDN from → to", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const cf = mockCf();
const from = repos.createDomain(app.db, null, "example.com", "zone-1");
const to = repos.createDomain(app.db, null, "other.com", "zone-2");
const service = repos.createService(app.db, "App", "app");
await updateConfig(app.db, cf, service.id, {
ips: ["1.1.1.1"],
domains: [{ fqdn: "app.example.com", target_ips: ["1.1.1.1"] }],
});
const preview = await changeServiceDomain(app.db, cf, service.id, {
from_domain_id: from.id,
to_domain_id: to.id,
dry_run: true,
});
expect(preview.applied).toBe(false);
expect(preview.items[0]?.from_fqdn).toBe("app.example.com");
expect(preview.items[0]?.to_fqdn).toBe("app.other.com");
await app.close();
});
});
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { repos } from "@cfdm/db";
import type { CloudflareClient } from "../src/lib/cf-client.js";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { changeBindingIp } from "../src/services/change-ip-service.js";
import { withBindingLock } from "../src/services/routing/index.js";
import { updateConfig } from "../src/services/service-config-service.js";
function mockCf(): CloudflareClient {
return {
listDnsRecords: async () => [],
createDnsRecord: async (_zoneId: string, payload: { type: string; name: string; content: string }) => ({
id: `cf-${payload.name}-${payload.content}`,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
}),
updateDnsRecord: async (
_zoneId: string,
id: string,
payload: { type: string; name: string; content: string },
) => ({
id,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
}),
patchDnsRecord: async (
_zoneId: string,
id: string,
payload: { content?: string },
) => ({
id,
type: "A",
name: "panel.example.com",
content: payload.content ?? "0.0.0.0",
ttl: 1,
proxied: false,
}),
deleteDnsRecord: async () => undefined,
listZones: async () => [{ id: "zone-1", name: "example.com", status: "active" }],
} as unknown as CloudflareClient;
}
describe("change-ip", () => {
it("dry-run previews from → to without writing", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const cf = mockCf();
repos.createDomain(app.db, null, "example.com", "zone-1");
const service = repos.createService(app.db, "Panel", "panel");
await updateConfig(app.db, cf, service.id, {
ips: ["10.0.0.10"],
domains: [{ fqdn: "panel.example.com", target_ips: ["10.0.0.10"] }],
});
const bindings = repos.listBindingsByService(app.db, service.id);
const preview = await changeBindingIp(app.db, cf, bindings[0]!.id, {
from_ip: "10.0.0.10",
to_ip: "10.0.0.20",
dry_run: true,
});
expect(preview.applied).toBe(false);
expect(preview.message).toBe("10.0.0.10 → 10.0.0.20");
expect(repos.listBindingIps(app.db, bindings[0]!.id)).toEqual(["10.0.0.10"]);
await app.close();
});
it("apply patches binding IP", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const cf = mockCf();
repos.createDomain(app.db, null, "example.com", "zone-1");
const service = repos.createService(app.db, "Panel", "panel");
await updateConfig(app.db, cf, service.id, {
ips: ["10.0.0.10"],
domains: [{ fqdn: "panel.example.com", target_ips: ["10.0.0.10"] }],
});
const binding = repos.listBindingsByService(app.db, service.id)[0]!;
const result = await changeBindingIp(app.db, cf, binding.id, {
from_ip: "10.0.0.10",
to_ip: "10.0.0.20",
});
expect(result.applied).toBe(true);
expect(repos.listBindingIps(app.db, binding.id)).toEqual(["10.0.0.20"]);
await app.close();
});
it("serializes concurrent binding locks", async () => {
const order: number[] = [];
await Promise.all([
withBindingLock(1, async () => {
await new Promise((r) => setTimeout(r, 20));
order.push(1);
}),
withBindingLock(1, async () => {
order.push(2);
}),
]);
expect(order).toEqual([1, 2]);
});
});
+4
View File
@@ -56,6 +56,7 @@ describe("health-check probeTarget", () => {
expected_status: null,
timeout_ms: 1000,
verify_tls: false,
provider: "local",
};
const result = await healthCheckService.probeTarget(target);
expect(result.ok).toBe(true);
@@ -75,6 +76,7 @@ describe("health-check probeTarget", () => {
expected_status: null,
timeout_ms: 500,
verify_tls: false,
provider: "local",
};
const result = await healthCheckService.probeTarget(target);
expect(result.ok).toBe(false);
@@ -107,6 +109,7 @@ describe("health-check probeTarget", () => {
expected_status: 200,
timeout_ms: 1000,
verify_tls: false,
provider: "local",
};
const bindingTarget: HealthCheckTarget = {
...groupTarget,
@@ -142,6 +145,7 @@ describe("health-check probeTarget", () => {
expected_status: null,
timeout_ms: 3000,
verify_tls: false,
provider: "local",
};
const binding: HealthCheckTarget = {
...group,
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { nextHealthState } from "../src/services/health/state-machine.js";
const thresholds = {
degradedFailures: 1,
downFailures: 2,
successRecoveries: 2,
latencyWarnMs: 1000,
};
describe("health state machine", () => {
it("first success from unknown is healthy immediately", () => {
const next = nextHealthState(true, 20, null, thresholds);
expect(next.legacy).toBe("up");
expect(next.node).toBe("healthy");
});
it("recovery from down goes checking until consecutive successes", () => {
const first = nextHealthState(
true,
10,
{ status: "down", consecutive_failures: 3, consecutive_successes: 0 },
thresholds,
);
expect(first.node).toBe("checking");
expect(first.legacy).toBe("unknown");
const second = nextHealthState(
true,
10,
{
status: "checking",
consecutive_failures: 0,
consecutive_successes: first.successes,
},
thresholds,
);
expect(second.node).toBe("healthy");
expect(second.legacy).toBe("up");
});
it("two failures mark unhealthy", () => {
const first = nextHealthState(
false,
5,
{ status: "up", consecutive_failures: 0, consecutive_successes: 1 },
thresholds,
);
expect(first.node).toBe("degraded");
const second = nextHealthState(
false,
5,
{
status: "degraded",
consecutive_failures: first.failures,
consecutive_successes: 0,
},
thresholds,
);
expect(second.node).toBe("unhealthy");
expect(second.legacy).toBe("down");
});
});
+215
View File
@@ -0,0 +1,215 @@
import { createServer, type Server as HttpServer } from "node:http";
import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { repos, type Db } from "@cfdm/db";
import * as healthCheckService from "../src/services/health-check-service.js";
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
const res = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: "admin", password: "admin" },
});
expect(res.statusCode).toBe(200);
const { token } = res.json() as { token: string };
return { authorization: `Bearer ${token}` };
}
function startWorkerMock(handler: (req: {
url?: string;
headers: Record<string, string | string[] | undefined>;
body: string;
}) => { status: number; json: unknown } | "hang"): Promise<{
server: HttpServer;
url: string;
}> {
return new Promise((resolve) => {
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(chunk as Buffer));
req.on("end", () => {
const result = handler({
url: req.url,
headers: req.headers,
body: Buffer.concat(chunks).toString("utf8"),
});
if (result === "hang") return;
res.writeHead(result.status, { "content-type": "application/json" });
res.end(JSON.stringify(result.json));
});
});
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
resolve({ server, url: `http://127.0.0.1:${port}` });
});
});
}
async function seedBinding(
db: Db,
opts: { provider: "local" | "cloudflare"; ip: string },
) {
const domain = repos.createDomain(db, null, "example.com", "zone-1");
const service = repos.createService(db, "Panel", "panel");
repos.setServiceEnabled(db, service.id, true);
repos.replaceServiceIps(db, service.id, [opts.ip]);
const binding = repos.insertBinding(db, domain.id, service.id, "panel", null);
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: opts.ip, weight: 1, priority: 1 },
]);
repos.updateBindingLbConfig(db, binding.id, {
health_check_enabled: true,
health_check_type: "tcp",
health_check_port: 1,
health_check_timeout_ms: 400,
health_check_provider: opts.provider,
});
return { service, binding, domain };
}
const thresholds = {
degradedFailures: 1,
downFailures: 2,
latencyWarnMs: 1000,
successRecoveries: 2,
};
describe("health-check XOR worker", () => {
it("lists only local providers when no cloudflare bindings", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
await seedBinding(app.db, {
provider: "local",
ip: "10.0.0.1",
});
const targets = repos.listHealthCheckTargets(app.db);
expect(targets.length).toBeGreaterThan(0);
expect(targets.every((t) => t.provider === "local")).toBe(true);
expect(targets.some((t) => t.provider === "cloudflare")).toBe(false);
await app.close();
});
it("cloudflare without worker URL does not fall back to local", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const { binding } = await seedBinding(app.db, {
provider: "cloudflare",
ip: "127.0.0.1",
});
await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: 0,
worker: null,
});
const row = repos.getIpHealthStatusRow(
app.db,
"binding",
binding.id,
"127.0.0.1",
);
expect(row?.last_error).toMatch(/Worker не настроен/i);
expect(row?.provider).toBe("cloudflare");
await app.close();
});
it("worker mock 200 writes colo and last_checked_at", async () => {
const mock = await startWorkerMock((req) => {
const auth = String(req.headers.authorization ?? "");
if (auth !== "Bearer secret") {
return { status: 401, json: { ok: false, error: "unauthorized" } };
}
return {
status: 200,
json: { ok: true, latencyMs: 42, error: null, colo: "AMS" },
};
});
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const { service, binding } = await seedBinding(app.db, {
provider: "cloudflare",
ip: "203.0.113.10",
});
await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: 0,
worker: { url: mock.url, token: "secret" },
});
const row = repos.getIpHealthStatusRow(
app.db,
"binding",
binding.id,
"203.0.113.10",
);
expect(row?.status).toBe("up");
expect(row?.colo).toBe("AMS");
expect(row?.last_checked_at).toBeTruthy();
expect(row?.provider).toBe("cloudflare");
const res = await app.inject({
method: "GET",
url: `/api/v1/services/${service.id}`,
headers,
});
expect(res.statusCode).toBe(200);
const body = res.json() as {
ip_health: Array<{
ip: string;
colo: string | null;
last_checked_at: string | null;
provider: string;
}>;
};
const ipRow = body.ip_health.find((item) => item.ip === "203.0.113.10");
expect(ipRow?.colo).toBe("AMS");
expect(ipRow?.last_checked_at).toBeTruthy();
expect(ipRow?.provider).toBe("cloudflare");
const logRes = await app.inject({
method: "GET",
url: `/api/v1/services/${service.id}/health-log`,
headers,
});
expect(logRes.statusCode).toBe(200);
const logBody = logRes.json() as { items: Array<{ colo: string | null }> };
expect(logBody.items[0]?.colo).toBe("AMS");
await new Promise<void>((resolve) => mock.server.close(() => resolve()));
await app.close();
});
it("worker timeout is recorded, not local probe", async () => {
const mock = await startWorkerMock(() => "hang");
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const { binding } = await seedBinding(app.db, {
provider: "cloudflare",
ip: "203.0.113.20",
});
await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: 0,
worker: { url: mock.url, token: "secret" },
});
const row = repos.getIpHealthStatusRow(
app.db,
"binding",
binding.id,
"203.0.113.20",
);
expect(row?.last_error).toMatch(/timeout|Worker/i);
expect(row?.provider).toBe("cloudflare");
await new Promise<void>((resolve) => mock.server.close(() => resolve()));
await app.close();
}, 15_000);
});
+16 -1
View File
@@ -39,7 +39,10 @@ describe("selectActiveIpsByMode", () => {
lb_mode: "round_robin",
health_check_enabled: true,
};
const rows = [row("1.1.1.1", { health: "unknown" }), row("2.2.2.2")];
const rows = [
row("1.1.1.1", { health: "unknown" }),
row("2.2.2.2", { health: "unknown" }),
];
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
"1.1.1.1",
"2.2.2.2",
@@ -88,6 +91,18 @@ describe("selectActiveIpsByMode", () => {
]);
});
it("round_robin excludes unknown when another ip is up", () => {
const config: LbTargetConfig = {
lb_mode: "round_robin",
health_check_enabled: true,
};
const rows = [
row("1.1.1.1", { health: "up" }),
row("2.2.2.2", { health: "unknown" }),
];
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
});
it("returns empty array for no rows", () => {
const config: LbTargetConfig = {
lb_mode: "round_robin",
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { repos } from "@cfdm/db";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
const config = loadConfig();
const res = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: config.adminUsername, password: "admin" },
});
expect(res.statusCode).toBe(200);
const { token } = res.json() as { token: string };
return { authorization: `Bearer ${token}` };
}
describe("service nodes API", () => {
it("creates and lists nodes without changing empty DNS pool until bound", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const service = repos.createService(app.db, "Panel", "panel");
const created = await app.inject({
method: "POST",
url: `/api/v1/services/${service.id}/nodes`,
headers,
payload: { address: "10.0.0.8", protocol: "tcp" },
});
expect(created.statusCode).toBe(200);
const node = created.json() as { address: string };
expect(node.address).toBe("10.0.0.8");
expect(repos.listServiceIps(app.db, service.id)).toContain("10.0.0.8");
const listed = await app.inject({
method: "GET",
url: `/api/v1/services/${service.id}/nodes`,
headers,
});
expect(listed.statusCode).toBe(200);
expect(listed.json()).toHaveLength(1);
const overview = await app.inject({
method: "GET",
url: `/api/v1/services/${service.id}/overview`,
headers,
});
expect(overview.statusCode).toBe(200);
await app.close();
});
});
@@ -39,6 +39,7 @@ describe("service groups health enrichment", () => {
const service = repos.createService(app.db, "Panel", "panel");
repos.setServiceGroup(app.db, service.id, group.id);
repos.setServiceEnabled(app.db, service.id, true);
repos.replaceServiceIps(app.db, service.id, ["1.2.3.4"]);
const binding = repos.insertBinding(
app.db,
domain.id,
@@ -85,6 +86,11 @@ describe("service groups health enrichment", () => {
id: number;
health_status: string;
health_latency_ms: number | null;
ip_health: Array<{
ip: string;
status: string;
latency_ms: number | null;
}>;
}>;
}>;
};
@@ -92,6 +98,17 @@ describe("service groups health enrichment", () => {
expect(groupView).toBeDefined();
expect(groupView!.services[0]?.health_status).toBe("degraded");
expect(groupView!.services[0]?.health_latency_ms).toBe(120);
expect(groupView!.services[0]?.ip_health).toEqual([
{
ip: "1.2.3.4",
status: "degraded",
latency_ms: 120,
last_checked_at: expect.any(String),
last_error: null,
provider: "local",
colo: null,
},
]);
// group worst = degraded (from service) over up (group scope)
expect(groupView!.health_status).toBe("degraded");
@@ -165,4 +165,94 @@ describe("create service then list groups", () => {
await app.close();
});
it("PATCH /services/:id/ips/toggle keeps IP in pool and removes it from A-binding", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const cf = mockCf();
repos.createDomain(app.db, null, "example.com", "zone-1");
const group = repos.createServiceGroup(
app.db,
"VPN",
"vpn",
null,
"vpn.example.com",
);
const createRes = await app.inject({
method: "POST",
url: "/api/v1/services",
headers,
payload: {
name: "Panel",
slug: "panel-ip-toggle",
service_group_id: group.id,
},
});
expect(createRes.statusCode).toBe(200);
const created = createRes.json() as { id: number };
await updateConfig(app.db, cf, created.id, {
ips: ["1.2.3.4", "5.6.7.8"],
service_group_id: group.id,
domains: [
{
fqdn: "panel.example.com",
target_ips: ["1.2.3.4", "5.6.7.8"],
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1 },
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1 },
lb_mode: "round_robin",
health_check_enabled: false,
health_check_type: "tcp",
health_check_port: 443,
health_check_path: null,
health_check_expected_status: null,
health_check_interval_sec: 30,
health_check_timeout_ms: 3000,
health_check_verify_tls: false,
},
],
});
// HTTP toggle uses request.server.cf; disable DNS push so the test
// does not call the real Cloudflare client.
repos.setServiceEnabled(app.db, created.id, false);
const offRes = await app.inject({
method: "PATCH",
url: `/api/v1/services/${created.id}/ips/toggle`,
headers,
payload: { ip: "1.2.3.4", enabled: false },
});
expect(offRes.statusCode, JSON.stringify(offRes.json())).toBe(200);
const offView = offRes.json() as {
ips: string[];
ip_enabled: Record<string, boolean>;
};
expect(offView.ips).toEqual(expect.arrayContaining(["1.2.3.4", "5.6.7.8"]));
expect(offView.ip_enabled["1.2.3.4"]).toBe(false);
expect(offView.ip_enabled["5.6.7.8"]).toBe(true);
const binding = repos.listBindingsByService(app.db, created.id)[0]!;
expect(repos.listBindingIps(app.db, binding.id)).toEqual(["5.6.7.8"]);
const onRes = await app.inject({
method: "PATCH",
url: `/api/v1/services/${created.id}/ips/toggle`,
headers,
payload: { ip: "1.2.3.4", enabled: true },
});
expect(onRes.statusCode).toBe(200);
const onView = onRes.json() as { ip_enabled: Record<string, boolean> };
expect(onView.ip_enabled["1.2.3.4"]).toBe(true);
expect(repos.listBindingIps(app.db, binding.id)).toEqual(
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
);
await app.close();
});
});
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
const res = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: "admin", password: "admin" },
});
expect(res.statusCode).toBe(200);
const { token } = res.json() as { token: string };
return { authorization: `Bearer ${token}` };
}
describe("settings health engine", () => {
it("GET /api/v1/settings returns env fallbacks for health fields", async () => {
const app = await buildApp({
config: {
...loadConfig(),
staticDir: null,
healthCheckCron: "*/30 * * * * *",
healthDegradedFailures: 3,
healthDownFailures: 4,
healthLatencyWarnMs: 1500,
healthSuccessRecoveries: 5,
},
memory: true,
});
const headers = await authHeaders(app);
const res = await app.inject({
method: "GET",
url: "/api/v1/settings",
headers,
});
expect(res.statusCode).toBe(200);
const body = res.json() as {
healthCheckCron: string;
healthDegradedFailures: number;
healthDownFailures: number;
healthLatencyWarnMs: number;
healthSuccessRecoveries: number;
};
expect(body.healthCheckCron).toBe("*/30 * * * * *");
expect(body.healthDegradedFailures).toBe(3);
expect(body.healthDownFailures).toBe(4);
expect(body.healthLatencyWarnMs).toBe(1500);
expect(body.healthSuccessRecoveries).toBe(5);
await app.close();
});
it("PATCH persists health engine settings", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const res = await app.inject({
method: "PATCH",
url: "/api/v1/settings",
headers,
payload: {
healthCheckCron: "0 */5 * * * *",
healthDegradedFailures: 2,
healthDownFailures: 4,
healthLatencyWarnMs: 800,
healthSuccessRecoveries: 3,
},
});
expect(res.statusCode).toBe(200);
const body = res.json() as {
healthCheckCron: string;
healthDegradedFailures: number;
healthDownFailures: number;
healthLatencyWarnMs: number;
healthSuccessRecoveries: number;
};
expect(body.healthCheckCron).toBe("0 */5 * * * *");
expect(body.healthDegradedFailures).toBe(2);
expect(body.healthDownFailures).toBe(4);
expect(body.healthLatencyWarnMs).toBe(800);
expect(body.healthSuccessRecoveries).toBe(3);
const again = await app.inject({
method: "GET",
url: "/api/v1/settings",
headers,
});
expect(again.json()).toMatchObject({
healthCheckCron: "0 */5 * * * *",
healthDegradedFailures: 2,
healthDownFailures: 4,
healthLatencyWarnMs: 800,
healthSuccessRecoveries: 3,
});
await app.close();
});
it("PATCH rejects invalid cron", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const res = await app.inject({
method: "PATCH",
url: "/api/v1/settings",
headers,
payload: { healthCheckCron: "not-a-cron" },
});
expect(res.statusCode).toBe(400);
expect(res.json()).toMatchObject({
error: { code: "VALIDATION_ERROR" },
});
await app.close();
});
it("PATCH rejects down < degraded", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const res = await app.inject({
method: "PATCH",
url: "/api/v1/settings",
headers,
payload: {
healthDegradedFailures: 5,
healthDownFailures: 2,
},
});
expect(res.statusCode).toBe(400);
await app.close();
});
it("PATCH worker URL; token is not returned in GET", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const res = await app.inject({
method: "PATCH",
url: "/api/v1/settings",
headers,
payload: {
healthWorkerUrl: "https://cfdm-health-probe.example.workers.dev",
healthWorkerToken: "super-secret",
},
});
expect(res.statusCode).toBe(200);
const body = res.json() as {
healthWorkerUrl: string;
healthWorkerTokenSet: boolean;
healthWorkerToken?: string;
};
expect(body.healthWorkerUrl).toBe(
"https://cfdm-health-probe.example.workers.dev",
);
expect(body.healthWorkerTokenSet).toBe(true);
expect(body.healthWorkerToken).toBeUndefined();
await app.close();
});
});
+13
View File
@@ -134,6 +134,19 @@ describe("resolveBindingIpsForSync", () => {
expect(ips).toEqual(["203.0.113.10"]);
});
it("treats missing target_ips as empty instead of throwing", async () => {
const cname = binding({
id: 2,
hostname: "imsk",
zone_name: "rkns.top",
cname_target: "ihome.rkns.top",
});
delete (cname as { target_ips?: string[] }).target_ips;
const index = { byFqdn: new Map([["imsk.rkns.top", cname]]) };
const ips = await resolveBindingIpsForSync(cname, ["198.51.100.9"], index);
expect(ips).toEqual(["198.51.100.9"]);
});
it("prefers service IPs over empty CNAME resolution chain", async () => {
const cname = binding({
id: 2,
+2 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build": "tsr generate && tsc -b && vite build",
"test": "vitest run",
"lint": "eslint .",
"preview": "vite preview"
@@ -42,6 +42,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tanstack/router-cli": "^1.167.31",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+6 -2
View File
@@ -25,15 +25,18 @@ import {
const infrastructureNav = [
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
{ to: '/domains', label: 'Домены', icon: GlobeIcon, exact: false },
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
] as const
const operationsNav = [
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
{ to: '/settings/appearance', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' },
] as const
const secondaryNav = [
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
] as const
function isNavActive(
pathname: string,
to: string,
@@ -106,6 +109,7 @@ export function AppSidebar() {
<SidebarContent>
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
<NavSection label="Операции" items={operationsNav} pathname={pathname} />
<NavSection label="Прочее" items={secondaryNav} pathname={pathname} />
</SidebarContent>
<SidebarFooter>
<NavUser />
@@ -0,0 +1,162 @@
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { Button } from '@cfdm/ui/components/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { changeServiceDomain, domainsListQueryOptions } from '@/queries'
interface ChangeDomainSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
serviceId: number
fromDomainId?: number | null
}
interface FormValues {
from_domain_id: string
to_domain_id: string
}
export function ChangeDomainSheet({
open,
onOpenChange,
serviceId,
fromDomainId,
}: ChangeDomainSheetProps) {
const queryClient = useQueryClient()
const domainsQuery = useQuery(domainsListQueryOptions())
const form = useForm<FormValues>({
defaultValues: {
from_domain_id: fromDomainId ? String(fromDomainId) : '',
to_domain_id: '',
},
})
const [confirmOpen, setConfirmOpen] = useState(false)
const [preview, setPreview] = useState<string | null>(null)
useEffect(() => {
if (open) {
form.reset({
from_domain_id: fromDomainId ? String(fromDomainId) : '',
to_domain_id: '',
})
setPreview(null)
}
}, [open, fromDomainId, form])
const domains = domainsQuery.data ?? []
const fromId = form.watch('from_domain_id')
const toId = form.watch('to_domain_id')
const fromZone = domains.find((d) => String(d.id) === fromId)?.zone_name
const toZone = domains.find((d) => String(d.id) === toId)?.zone_name
const mutate = useMutation({
mutationFn: () =>
changeServiceDomain(serviceId, {
from_domain_id: Number(fromId),
to_domain_id: Number(toId),
dry_run: false,
}),
onSuccess: async (result: { message?: string }) => {
toast.success(result.message ?? 'Привязки перенесены')
await queryClient.invalidateQueries()
setConfirmOpen(false)
onOpenChange(false)
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось перенести домен'),
})
return (
<>
<FormSheet
open={open}
onOpenChange={onOpenChange}
title="Сменить домен"
description="Перенос привязок между зонами Cloudflare без orphan-записей."
form={form}
onSubmit={() => {
setPreview(
fromZone && toZone
? `${fromZone}${toZone}`
: 'Проверьте выбранные зоны',
)
setConfirmOpen(true)
}}
footer={
<>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button type="submit" disabled={!fromId || !toId || fromId === toId}>
Предпросмотр
</Button>
</>
}
>
<FormFieldSimple label="Исходная зона" htmlFor="from_domain_id">
<Select
value={fromId || null}
onValueChange={(value) => form.setValue('from_domain_id', value ?? '')}
>
<SelectTrigger id="from_domain_id">
<SelectValue placeholder="Откуда" />
</SelectTrigger>
<SelectContent>
{domains.map((domain) => (
<SelectItem key={domain.id} value={String(domain.id)}>
{domain.zone_name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormFieldSimple>
<FormFieldSimple label="Целевая зона" htmlFor="to_domain_id">
<Select
value={toId || null}
onValueChange={(value) => form.setValue('to_domain_id', value ?? '')}
>
<SelectTrigger id="to_domain_id">
<SelectValue placeholder="Куда" />
</SelectTrigger>
<SelectContent>
{domains.map((domain) => (
<SelectItem key={domain.id} value={String(domain.id)}>
{domain.zone_name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormFieldSimple>
{fromZone && toZone ? (
<Alert>
<AlertTitle>Предпросмотр FQDN</AlertTitle>
<AlertDescription>
Привязки будут перенесены из {fromZone} в {toZone}. Старые DNS-записи
исходной зоны будут удалены.
</AlertDescription>
</Alert>
) : null}
</FormSheet>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title="Подтвердить перенос"
description={preview ?? 'Перенести привязки в другую зону?'}
confirmLabel="Перенести"
onConfirm={() => mutate.mutate()}
/>
</>
)
}
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { changeBindingIp, serviceNodesQueryOptions } from '@/queries'
interface ChangeIpSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
bindingId: number | null
serviceId?: number | null
currentIp?: string | null
}
interface FormValues {
from_ip: string
to_ip: string
node_id: string
}
export function ChangeIpSheet({
open,
onOpenChange,
bindingId,
serviceId,
currentIp,
}: ChangeIpSheetProps) {
const queryClient = useQueryClient()
const form = useForm<FormValues>({
defaultValues: { from_ip: currentIp ?? '', to_ip: '', node_id: '' },
})
const [preview, setPreview] = useState<string | null>(null)
const nodesQuery = useQuery({
...serviceNodesQueryOptions(serviceId ?? 0),
enabled: open && serviceId != null,
})
useEffect(() => {
if (open) {
form.reset({ from_ip: currentIp ?? '', to_ip: '', node_id: '' })
setPreview(null)
}
}, [open, currentIp, form])
const fromIp = form.watch('from_ip')
const toIp = form.watch('to_ip')
const nodeId = form.watch('node_id')
const nodes = (nodesQuery.data ?? []) as Array<{ id: number; address: string }>
const previewText = useMemo(() => {
const next = nodeId
? nodes.find((n) => String(n.id) === nodeId)?.address
: toIp
if (!fromIp || !next) return null
return `${fromIp}${next}`
}, [fromIp, toIp, nodeId, nodes])
const mutate = useMutation({
mutationFn: async () => {
if (bindingId == null) throw new Error('нет привязки')
const selectedNode = nodeId ? Number(nodeId) : undefined
return changeBindingIp(bindingId, {
from_ip: fromIp || undefined,
to_ip: selectedNode ? undefined : toIp || undefined,
node_id: selectedNode,
dry_run: false,
})
},
onSuccess: async (result) => {
setPreview(result.message)
toast.success(result.message)
await queryClient.invalidateQueries()
onOpenChange(false)
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось сменить IP'),
})
return (
<FormSheet
open={open}
onOpenChange={onOpenChange}
title="Сменить IP"
description="Обновить A-запись в Cloudflare без перехода на страницу DNS."
form={form}
onSubmit={() => mutate.mutate()}
footer={
<>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton
type="submit"
isLoading={mutate.isPending}
loadingLabel="Updating…"
>
Сменить IP
</LoadingButton>
</>
}
>
<FormFieldSimple label="Текущий IP" htmlFor="from_ip">
<Input id="from_ip" {...form.register('from_ip')} />
</FormFieldSimple>
{nodes.length > 0 ? (
<FormFieldSimple label="Нода" htmlFor="node_id">
<Select
value={nodeId || null}
onValueChange={(value) => {
form.setValue('node_id', value ?? '')
const node = nodes.find((n) => String(n.id) === value)
if (node) form.setValue('to_ip', node.address)
}}
>
<SelectTrigger id="node_id">
<SelectValue placeholder="Выберите ноду" />
</SelectTrigger>
<SelectContent>
{nodes.map((node) => (
<SelectItem key={node.id} value={String(node.id)}>
{node.address}
</SelectItem>
))}
</SelectContent>
</Select>
</FormFieldSimple>
) : null}
<FormFieldSimple label="Новый IP" htmlFor="to_ip" hint="IPv4">
<Input id="to_ip" {...form.register('to_ip')} placeholder="10.0.0.20" />
</FormFieldSimple>
{previewText ? (
<Alert>
<AlertTitle>Предпросмотр</AlertTitle>
<AlertDescription>{preview ?? previewText}</AlertDescription>
</Alert>
) : null}
</FormSheet>
)
}
+15 -5
View File
@@ -1,5 +1,6 @@
import { InboxIcon, type LucideIcon } from 'lucide-react'
import { IconStack } from '@/components/reui/icon-stack'
import { IconTile } from '@/components/reui/icon-tile'
import {
Empty,
EmptyContent,
@@ -46,20 +47,29 @@ export function EmptyState({
!centered && className,
)}
>
<EmptyHeader className="gap-5 text-center">
<EmptyHeader className={cn('text-center', stackedIcon ? 'gap-5' : 'gap-3')}>
<EmptyMedia className="mb-0">
{stackedIcon ? (
<IconStack aria-hidden="true" className="h-14 w-12">
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
</IconStack>
) : (
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
<Icon aria-hidden="true" />
</span>
<IconTile
variant="elevated"
className="size-10.5 text-muted-foreground"
aria-hidden="true"
>
<Icon />
</IconTile>
)}
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">
<EmptyTitle
className={cn(
'font-semibold tracking-tight',
stackedIcon ? 'text-base' : 'text-sm',
)}
>
{title}
</EmptyTitle>
{description ? (
@@ -0,0 +1,40 @@
import {
Timeline,
TimelineContent,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
export interface FailoverEvent {
id: string
title: string
detail: string
}
export function FailoverTimeline({ events }: { events: FailoverEvent[] }) {
if (events.length === 0) {
return (
<p className="text-muted-foreground text-sm">
Событий failover пока нет.
</p>
)
}
return (
<Timeline defaultValue={events.length} className="w-full">
{events.map((event, index) => (
<TimelineItem key={event.id} step={index + 1}>
<TimelineSeparator />
<TimelineIndicator />
<TimelineHeader>
<TimelineTitle>{event.title}</TimelineTitle>
</TimelineHeader>
<TimelineContent>{event.detail}</TimelineContent>
</TimelineItem>
))}
</Timeline>
)
}
+33 -4
View File
@@ -7,6 +7,20 @@ import { formatDate } from '@/lib/format'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
type HealthStatus =
| IpHealthStatus['status']
| 'healthy'
| 'unhealthy'
| 'checking'
| 'disabled'
function normalizeHealth(status: HealthStatus): IpHealthStatus['status'] {
if (status === 'healthy') return 'up'
if (status === 'unhealthy' || status === 'disabled') return 'down'
if (status === 'checking') return 'unknown'
return status
}
const healthVariants: Record<IpHealthStatus['status'], BadgeVariant> = {
up: 'success-light',
degraded: 'warning-light',
@@ -21,6 +35,13 @@ const healthLabels: Record<IpHealthStatus['status'], string> = {
unknown: '—',
}
const extraLabels: Partial<Record<HealthStatus, string>> = {
healthy: 'Healthy',
unhealthy: 'Unhealthy',
checking: 'Checking',
disabled: 'Disabled',
}
const dotColor: Record<IpHealthStatus['status'], string> = {
up: 'bg-success',
degraded: 'bg-warning',
@@ -29,10 +50,12 @@ const dotColor: Record<IpHealthStatus['status'], string> = {
}
interface HealthCheckBadgeProps {
status: IpHealthStatus['status']
status: HealthStatus
latencyMs?: number | null
lastCheckedAt?: string | null
lastError?: string | null
colo?: string | null
provider?: 'local' | 'cloudflare' | string | null
title?: string
showLatency?: boolean
size?: 'xs' | 'sm'
@@ -44,19 +67,25 @@ export function HealthCheckBadge({
latencyMs,
lastCheckedAt,
lastError,
colo,
provider,
title,
showLatency = false,
size = 'sm',
className,
}: HealthCheckBadgeProps) {
const variant = healthVariants[status]
const label = healthLabels[status]
const normalized = normalizeHealth(status)
const variant = healthVariants[normalized]
const label = extraLabels[status] ?? healthLabels[normalized]
const tooltipParts: string[] = []
if (title) tooltipParts.push(title)
tooltipParts.push(`Статус: ${label}`)
if (latencyMs != null) tooltipParts.push(`Задержка: ${latencyMs} мс`)
if (lastCheckedAt) tooltipParts.push(`Проверка: ${formatDate(lastCheckedAt)}`)
if (colo) tooltipParts.push(`Colo: ${colo}`)
if (provider === 'cloudflare') tooltipParts.push('Провайдер: Cloudflare Worker')
if (provider === 'local') tooltipParts.push('Провайдер: Local')
if (lastError) tooltipParts.push(`Ошибка: ${lastError}`)
return (
@@ -74,7 +103,7 @@ export function HealthCheckBadge({
className={cn('gap-1.5', className)}
>
<span
className={cn('size-1.5 shrink-0 rounded-full', dotColor[status])}
className={cn('size-1.5 shrink-0 rounded-full', dotColor[normalized])}
aria-hidden
/>
{label}
@@ -17,11 +17,16 @@ import {
SelectValue,
} from '@cfdm/ui/components/select'
import { Switch } from '@cfdm/ui/components/switch'
import { Button } from '@cfdm/ui/components/button'
import { ButtonGroup } from '@cfdm/ui/components/button-group'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Link } from '@tanstack/react-router'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { cn } from '@cfdm/ui/lib/utils'
export type LbMode = 'round_robin' | 'failover' | 'weighted'
export type HealthCheckType = 'tcp' | 'http'
export type HealthProvider = 'local' | 'cloudflare'
export interface HealthCheckConfig {
enabled: boolean
@@ -32,6 +37,11 @@ export interface HealthCheckConfig {
interval_sec: number
timeout_ms: number
verify_tls: boolean
provider: HealthProvider
method?: string | null
retries?: number
consecutive_fails?: number
consecutive_successes?: number
}
export interface LbAndHealthConfig extends HealthCheckConfig {
@@ -49,6 +59,47 @@ const healthCheckTypes = [
{ value: 'http', label: 'HTTP' },
] as const
const cloudflareTypes = [
{ value: 'tcp', label: 'TCP' },
{ value: 'http', label: 'HTTP' },
] as const
export function HealthProviderToggle({
value,
onChange,
id,
}: {
value: HealthProvider
onChange: (next: HealthProvider) => void
id?: string
}) {
const provider = value || 'local'
return (
<ButtonGroup className="w-full" id={id}>
<Button
type="button"
size="sm"
className="flex-1"
variant={provider === 'local' ? 'secondary' : 'outline'}
aria-pressed={provider === 'local'}
onClick={() => onChange('local')}
>
Local
</Button>
<Button
type="button"
size="sm"
className="flex-1"
variant={provider === 'cloudflare' ? 'secondary' : 'outline'}
aria-pressed={provider === 'cloudflare'}
onClick={() => onChange('cloudflare')}
>
Cloudflare
</Button>
</ButtonGroup>
)
}
interface HealthCheckConfigFieldsProps {
value: LbAndHealthConfig
onChange: (next: LbAndHealthConfig) => void
@@ -119,6 +170,7 @@ export function HealthCheckConfigFields({
className={rowClass}
>
<Select
modal={false}
value={value.lb_mode}
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
>
@@ -136,6 +188,50 @@ export function HealthCheckConfigFields({
</SettingRow>
) : null}
<SettingRow
title="Провайдер health-check"
description="Откуда идёт проба: API CFDM или Cloudflare Worker (edge)"
labelFor={`${idPrefix}-provider`}
compact
className={rowClass}
>
<HealthProviderToggle
id={`${idPrefix}-provider`}
value={value.provider ?? 'local'}
onChange={(provider) =>
patch({
provider,
enabled: provider === 'cloudflare' ? true : value.enabled,
})
}
/>
</SettingRow>
{value.provider === 'cloudflare' ? (
<Alert>
<AlertTitle>Cloudflare Worker</AlertTitle>
<AlertDescription>
Проба с edge Cloudflare, не продукт Health Checks API (на Free его нет).
Регионы WNAM/WEU недоступны в результате будет colo ближайшего POP
(например AMS). URL и токен Worker в{' '}
<Link to="/settings/health" className="text-foreground underline">
Настройках Health-check
</Link>
. Если Worker не задан, цель не пробируется как Local.
</AlertDescription>
</Alert>
) : (
<Alert>
<AlertTitle>Local health-check</AlertTitle>
<AlertDescription>
Проба TCP/HTTP с сервера API. Cron и пороги Slow/Down в{' '}
<Link to="/settings/health" className="text-foreground underline">
Настройках Health-check
</Link>
. Интервал в карточке не используется.
</AlertDescription>
</Alert>
)}
<SettingRow
title="Health-check"
description="TCP/HTTP проверка цели DNS"
@@ -164,6 +260,7 @@ export function HealthCheckConfigFields({
<div className="grid grid-cols-2 gap-3">
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
<Select
modal={false}
value={value.type}
onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })}
>
@@ -171,7 +268,10 @@ export function HealthCheckConfigFields({
<SelectValue placeholder="Тип" />
</SelectTrigger>
<SelectContent>
{healthCheckTypes.map((item) => (
{(value.provider === 'cloudflare'
? cloudflareTypes
: healthCheckTypes
).map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@@ -239,32 +339,18 @@ export function HealthCheckConfigFields({
</>
) : null}
<div className="grid grid-cols-2 gap-3">
<FormFieldSimple label="Интервал, сек" htmlFor={`${idPrefix}-interval`}>
<CompactNumberField
id={`${idPrefix}-interval`}
value={value.interval_sec}
min={5}
max={3600}
placeholder="30"
onValueChange={(next) =>
patch({ interval_sec: next ?? 30 })
}
/>
</FormFieldSimple>
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
<CompactNumberField
id={`${idPrefix}-timeout`}
value={value.timeout_ms}
min={100}
max={30000}
placeholder="3000"
onValueChange={(next) =>
patch({ timeout_ms: next ?? 3000 })
}
/>
</FormFieldSimple>
</div>
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
<CompactNumberField
id={`${idPrefix}-timeout`}
value={value.timeout_ms}
min={100}
max={30000}
placeholder="3000"
onValueChange={(next) =>
patch({ timeout_ms: next ?? 3000 })
}
/>
</FormFieldSimple>
</div>
) : null}
</FieldGroup>
@@ -23,6 +23,8 @@ export interface HealthTimelineEvent {
latency_ms?: number | null
error?: string | null
checked_at: string
colo?: string | null
provider?: string | null
}
interface HealthTimelineProps {
@@ -63,6 +65,8 @@ export function HealthTimeline({ events }: HealthTimelineProps) {
<HealthCheckBadge
status={event.status}
latencyMs={event.latency_ms}
colo={event.colo}
provider={event.provider}
size="xs"
showLatency
/>
@@ -3,7 +3,7 @@ import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
import { ServiceFqdnList, ServiceIpList } from '@/components/services/service-fqdn-list'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
@@ -78,7 +78,22 @@ export function ServiceKanbanCard({
</ItemHeader>
<ItemContent className="min-w-0 gap-2">
<ServiceFqdnList service={service} />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-muted-foreground text-xs">Общий домен</span>
<ServiceFqdnList
copyable
service={service}
emptyLabel="Не задан"
/>
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-muted-foreground text-xs">IP</span>
<ServiceIpList
copyable
ips={service.ips ?? []}
ipHealth={service.ip_health ?? []}
/>
</div>
</ItemContent>
<ItemFooter className="min-w-0 justify-between gap-2">
@@ -3,6 +3,7 @@ import { Link, useNavigate } from '@tanstack/react-router'
import {
FolderTreeIcon,
GlobeIcon,
HeartPulseIcon,
LayoutDashboardIcon,
SearchIcon,
ServerIcon,
@@ -57,6 +58,12 @@ const NAV_ITEMS = [
keywords: ['certificates', 'ssl', 'tls'],
icon: ShieldCheckIcon,
},
{
to: '/settings/health',
label: 'Health-check',
keywords: ['health', 'health-check', 'cron', 'пороги', 'настройки'],
icon: HeartPulseIcon,
},
{
to: '/settings/integrations',
label: 'Настройки',
@@ -24,6 +24,7 @@ const routeTitles: Record<string, string> = {
'/services': 'Сервисы',
'/certificates': 'Сертификаты',
'/settings/appearance': 'Внешний вид',
'/settings/health': 'Health-check',
'/settings/integrations': 'Интеграции',
}
@@ -64,6 +65,8 @@ function getBreadcrumbs(
{ label: 'Настройки', href: '/settings/appearance' },
...(pathname === '/settings/integrations'
? [{ label: 'Интеграции', href: pathname }]
: pathname === '/settings/health'
? [{ label: 'Health-check', href: pathname }]
: pathname === '/settings/appearance'
? [{ label: 'Внешний вид', href: pathname }]
: []),
@@ -0,0 +1,95 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@cfdm/ui/lib/utils'
/**
* Sibling Frame columns for dashboard attention queue.
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
*/
export interface AttentionQueueColumn {
id: string
title: string
icon: LucideIcon
iconClassName?: string
count: number
countVariant?: 'destructive' | 'warning' | 'secondary' | 'destructive-light' | 'warning-light'
emptyTitle: string
emptyDescription: string
emptyAction?: ReactNode
children: ReactNode
}
interface AttentionQueueProps {
columns: AttentionQueueColumn[]
className?: string
}
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
export function AttentionQueue({ columns, className }: AttentionQueueProps) {
return (
<div
className={cn(
'grid min-w-0 items-start gap-2 @3xl:grid-cols-3',
className,
)}
>
{columns.map((column) => {
const Icon = column.icon
const isEmpty = column.count === 0
return (
<Frame key={column.id} dense spacing="sm" className="min-w-0 w-full">
<FrameHeader>
<div className="flex min-w-0 items-center gap-2">
<IconTile
variant="elevated"
size="sm"
className={cn(DEFAULT_ICON_CLASS, column.iconClassName)}
aria-hidden="true"
>
<Icon />
</IconTile>
<FrameTitle className="min-w-0 truncate">{column.title}</FrameTitle>
{column.count > 0 ? (
<Badge
size="sm"
variant={column.countVariant ?? 'secondary'}
className="tabular-nums"
>
{column.count}
</Badge>
) : null}
</div>
</FrameHeader>
<FramePanel className="min-w-0">
{isEmpty ? (
<div className="flex min-h-28 items-center justify-center py-4">
<EmptyState
icon={Icon}
title={column.emptyTitle}
description={column.emptyDescription}
action={column.emptyAction}
centered={false}
stackedIcon={false}
/>
</div>
) : (
column.children
)}
</FramePanel>
</Frame>
)
})}
</div>
)
}
@@ -13,6 +13,7 @@ export {
type OpsKpiCard,
} from './kpi-stat-grid'
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { AttentionQueue, type AttentionQueueColumn } from './attention-queue'
export { OpsDashboard } from './ops-dashboard'
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
@@ -1,11 +1,4 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
@@ -17,10 +10,18 @@ interface OpsDashboardProps {
afterKpi?: ReactNode
charts: ReactNode
queue: ReactNode
queueTitle?: string
queueDescription?: string
isLoading?: boolean
}
/** Denser stack for KPI / charts / queue. PageHeader lives outside via PageShell. */
/**
* Ops dashboard: KPI → optional Quick Actions → charts → queue.
* Queue is a sibling Frame grid — never wrap Frames inside another Frame.
* @see https://reui.io/preview/base/dashboard-1
* @see https://reui.io/preview/base/stats-12
* @see https://reui.io/docs/components/base/frame
*/
const rootClassName =
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
@@ -32,7 +33,11 @@ function OpsDashboardSkeleton() {
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
</div>
<Skeleton className="h-48 w-full rounded-xl" />
<div className="grid gap-2 @3xl:grid-cols-3">
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
</div>
</div>
)
}
@@ -42,6 +47,8 @@ export function OpsDashboard({
afterKpi,
charts,
queue,
queueTitle = 'Требуют внимания',
queueDescription = 'Проблемы health-check, истекающие сертификаты и домены без группы',
isLoading = false,
}: OpsDashboardProps) {
if (isLoading) {
@@ -63,16 +70,16 @@ export function OpsDashboard({
{charts}
</section>
<section aria-label="Требуют внимания">
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Требуют внимания</FrameTitle>
<FrameDescription>
Проблемы health-check, истекающие сертификаты и домены без группы
</FrameDescription>
</FrameHeader>
<FramePanel>{queue}</FramePanel>
</Frame>
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-2">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
{queueDescription ? (
<p className="text-muted-foreground max-w-prose text-sm">
{queueDescription}
</p>
) : null}
</div>
{queue}
</section>
</div>
)
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { PaletteIcon, SettingsIcon } from 'lucide-react'
import { HeartPulseIcon, PaletteIcon, SettingsIcon } from 'lucide-react'
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
import { cn } from '@cfdm/ui/lib/utils'
@@ -21,6 +21,12 @@ const DEFAULT_TABS: SettingsTabConfig[] = [
label: 'Внешний вид',
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
},
{
id: 'health',
to: '/settings/health',
label: 'Health-check',
icon: <HeartPulseIcon className="size-4" aria-hidden="true" />,
},
{
id: 'integrations',
to: '/settings/integrations',
@@ -37,7 +43,7 @@ interface SettingsShellProps {
export function SettingsShell({
title = 'Настройки',
description = 'Внешний вид и интеграции',
description = 'Внешний вид, health-check и интеграции',
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
+288 -321
View File
@@ -1,8 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { Link2Icon, PlusIcon, Trash2Icon } from 'lucide-react'
import { PlusIcon, Trash2Icon } from 'lucide-react'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { EmptyState } from '@/components/empty-state'
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
import {
@@ -38,7 +36,6 @@ import {
ItemGroup,
} from '@cfdm/ui/components/item'
import { LoadingButton } from '@/components/loading-button'
import { TabsContent } from '@cfdm/ui/components/tabs'
import {
Select,
SelectContent,
@@ -46,7 +43,6 @@ import {
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Separator } from '@cfdm/ui/components/separator'
interface BindingHealthConfig {
enabled: boolean
@@ -57,6 +53,7 @@ interface BindingHealthConfig {
interval_sec: number
timeout_ms: number
verify_tls: boolean
provider: 'local' | 'cloudflare'
}
export interface ServiceBindingDraft {
@@ -79,6 +76,7 @@ const defaultHealth: BindingHealthConfig = {
interval_sec: 30,
timeout_ms: 3000,
verify_tls: false,
provider: 'local',
}
interface ServiceEditSheetProps {
@@ -112,6 +110,7 @@ function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
interval_sec: binding.health_check_interval_sec,
timeout_ms: binding.health_check_timeout_ms,
verify_tls: binding.health_check_verify_tls ?? false,
provider: binding.health_check_provider === 'cloudflare' ? 'cloudflare' : 'local',
},
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
@@ -139,6 +138,7 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
health_check_interval_sec: binding.health.interval_sec,
health_check_timeout_ms: binding.health.timeout_ms,
health_check_verify_tls: binding.health.verify_tls,
health_check_provider: binding.health.provider,
}
: {
fqdn: binding.fqdn.trim(),
@@ -154,10 +154,38 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
health_check_interval_sec: binding.health.interval_sec,
health_check_timeout_ms: binding.health.timeout_ms,
health_check_verify_tls: binding.health.verify_tls,
health_check_provider: binding.health.provider,
},
)
}
function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
return {
fqdn,
record_type: 'A',
target_ips: [],
target_cname: '',
lb_mode: 'round_robin',
health: { ...defaultHealth },
target_ip_weights: {},
target_ip_priorities: {},
}
}
function withPoolIps(draft: ServiceBindingDraft, pool: string[]): ServiceBindingDraft {
if (draft.record_type !== 'A' || draft.target_ips.length > 0 || pool.length === 0) {
return draft
}
return {
...draft,
target_ips: pool,
target_ip_weights: Object.fromEntries(pool.map((ip) => [ip, draft.target_ip_weights[ip] ?? 1])),
target_ip_priorities: Object.fromEntries(
pool.map((ip) => [ip, draft.target_ip_priorities[ip] ?? 1]),
),
}
}
export function ServiceEditSheet({
mode,
service,
@@ -176,10 +204,10 @@ export function ServiceEditSheet({
const [slug, setSlug] = useState('')
const [serviceGroupId, setServiceGroupId] = useState('none')
const [ips, setIps] = useState<string[]>([])
const [commonFqdn, setCommonFqdn] = useState('')
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
const [lbWeight, setLbWeight] = useState(1)
const [lbPriority, setLbPriority] = useState(1)
const [activeTab, setActiveTab] = useState('general')
const groupItems = useMemo(
() => [
@@ -189,16 +217,8 @@ export function ServiceEditSheet({
[groups],
)
const selectedGroup = useMemo(() => {
if (serviceGroupId === 'none') return null
return groups.find((g) => String(g.id) === serviceGroupId) ?? null
}, [groups, serviceGroupId])
const groupHasDomain = Boolean(selectedGroup?.domain?.trim())
useEffect(() => {
if (!open) return
setActiveTab('general')
if (mode === 'edit' && service) {
setName(service.name)
setSlug(service.slug)
@@ -206,7 +226,9 @@ export function ServiceEditSheet({
service.service_group_id != null ? String(service.service_group_id) : 'none',
)
setIps(service.ips ?? [])
setBindings(toBindingDrafts(service))
const drafts = toBindingDrafts(service)
setBindings(drafts)
setCommonFqdn(drafts[0]?.fqdn ?? '')
setLbWeight(service.lb_weight ?? 1)
setLbPriority(service.lb_priority ?? 1)
return
@@ -218,6 +240,7 @@ export function ServiceEditSheet({
defaultGroupId != null ? String(defaultGroupId) : 'none',
)
setIps([])
setCommonFqdn('')
setBindings([])
setLbWeight(1)
setLbPriority(1)
@@ -229,23 +252,28 @@ export function ServiceEditSheet({
[knownDomains],
)
function handleAddBinding() {
setBindings((current) => [
...current,
{
fqdn: '',
record_type: 'A',
target_ips: [],
target_cname: '',
lb_mode: 'round_robin',
health: { ...defaultHealth },
target_ip_weights: {},
target_ip_priorities: {},
},
])
const extraBindings = bindings.slice(1)
function handleCommonFqdnChange(value: string) {
setCommonFqdn(value)
setBindings((current) => {
if (current.length === 0) return current
return current.map((item, i) => (i === 0 ? { ...item, fqdn: value } : item))
})
}
function handleRemoveBinding(index: number) {
function handleAddExtraBinding() {
setBindings((current) => {
const extra = withPoolIps(emptyBindingDraft(), ips)
if (current.length === 0) {
return [emptyBindingDraft(commonFqdn), extra]
}
return [...current, extra]
})
}
function handleRemoveExtraBinding(extraIndex: number) {
const index = extraIndex + 1
setBindings((current) => current.filter((_, i) => i !== index))
}
@@ -295,68 +323,75 @@ export function ServiceEditSheet({
)
}
function handleBindingMetaChange(
index: number,
ip: string,
meta: { weight?: number; priority?: number },
) {
setBindings((current) =>
current.map((item, i) => {
if (i !== index) return item
const weights = { ...item.target_ip_weights }
const priorities = { ...item.target_ip_priorities }
if (meta.weight !== undefined) weights[ip] = meta.weight
if (meta.priority !== undefined) priorities[ip] = meta.priority
return { ...item, target_ip_weights: weights, target_ip_priorities: priorities }
}),
)
function healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
return {
enabled: next.enabled,
type: next.type,
port: next.port,
path: next.path,
expected_status: next.expected_status,
interval_sec: next.interval_sec,
timeout_ms: next.timeout_ms,
verify_tls: next.verify_tls,
provider: next.provider,
}
}
function handleBindingHealthChange(index: number, next: LbAndHealthConfig) {
setBindings((current) =>
current.map((item, i) =>
i === index
? {
...item,
lb_mode: next.lb_mode,
health: {
enabled: next.enabled,
type: next.type,
port: next.port,
path: next.path,
expected_status: next.expected_status,
interval_sec: next.interval_sec,
timeout_ms: next.timeout_ms,
verify_tls: next.verify_tls,
},
}
: item,
),
)
function handlePrimaryHealthChange(next: LbAndHealthConfig) {
const health = healthFromConfig(next)
setBindings((current) => {
if (current.length === 0) {
return [
{
...withPoolIps(emptyBindingDraft(commonFqdn), ips),
lb_mode: next.lb_mode,
health,
},
]
}
return current.map((item, index) =>
index === 0 ? { ...item, lb_mode: next.lb_mode, health } : item,
)
})
}
const primaryHealthValue: LbAndHealthConfig = {
lb_mode: bindings[0]?.lb_mode ?? 'round_robin',
...(bindings[0]?.health ?? defaultHealth),
}
function resolveServiceGroupId(): number | null {
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
}
function syncCommonDomain(current: ServiceBindingDraft[]): ServiceBindingDraft[] {
const trimmed = commonFqdn.trim()
if (!trimmed) return current
if (current.length === 0) {
return [withPoolIps(emptyBindingDraft(trimmed), ips)]
}
return current.map((item, index) => {
if (index !== 0) return item
return withPoolIps({ ...item, fqdn: trimmed }, ips)
})
}
function handleSubmit() {
const domains = buildDomainsPayload(bindings)
const syncedBindings = syncCommonDomain(bindings)
const domains = buildDomainsPayload(syncedBindings)
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
const hasDuplicateFqdn =
new Set(normalizedFqdns).size !== normalizedFqdns.length
if (hasDuplicateFqdn) {
toast.error('Укажите уникальные FQDN — дубликаты привязок недопустимы')
setActiveTab('bindings')
return
}
const groupId = resolveServiceGroupId()
const lbFields = groupHasDomain
? { lb_weight: lbWeight, lb_priority: lbPriority }
: {}
const configPayload = {
ips,
domains,
...lbFields,
lb_weight: lbWeight,
lb_priority: lbPriority,
}
if (mode === 'create') {
onCreate?.({
@@ -364,7 +399,8 @@ export function ServiceEditSheet({
slug: slug.trim(),
service_group_id: groupId,
ips,
...lbFields,
lb_weight: lbWeight,
lb_priority: lbPriority,
domains,
})
return
@@ -394,29 +430,16 @@ export function ServiceEditSheet({
<SheetHeader className="shrink-0 border-b pb-4">
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
<SheetDescription>
Настройте параметры сервиса и привязки FQDN IP или CNAME. Один
сервис может иметь несколько FQDN в разных зонах; зона определяется
из FQDN автоматически.
Общий домен и IP задаются у сервиса. Дополнительные FQDN ниже, зона
определяется автоматически.
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
<CountedLineTabs
tabs={[
{ id: 'general', label: 'Основное' },
{
id: 'bindings',
label: 'Привязки',
count: bindings.length > 0 ? bindings.length : undefined,
},
]}
value={activeTab}
onValueChange={setActiveTab}
className="flex w-full flex-col gap-4"
listClassName="mb-0 w-full"
>
<TabsContent value="general" className="flex flex-col gap-4">
<FieldGroup className="flex flex-col gap-4">
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 py-4">
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Сервис</h3>
<FieldGroup className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
<Input
@@ -435,238 +458,182 @@ export function ServiceEditSheet({
onChange={(e) => setSlug(e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
<Select
items={groupItems}
value={serviceGroupId}
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
>
<SelectTrigger id="edit-service-group" className="w-full">
<SelectValue placeholder="Без группы" />
</SelectTrigger>
<SelectContent>
{groupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
<TaggedInput
id="edit-service-ips"
value={ips}
onChange={setIps}
placeholder="192.168.1.1"
validate={isValidIpv4}
/>
</Field>
</FieldGroup>
{groupHasDomain && (
<>
<Separator />
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Балансировка внутри группы «{selectedGroup?.name}»: вес и приоритет
сервиса для общего домена группы.
</p>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="service-lb-weight">Вес</FieldLabel>
<Input
id="service-lb-weight"
type="number"
inputMode="numeric"
min={1}
max={100}
value={lbWeight}
onChange={(e) =>
setLbWeight(Math.max(1, Number(e.target.value) || 1))
}
/>
</Field>
<Field>
<FieldLabel htmlFor="service-lb-priority">Приоритет</FieldLabel>
<Input
id="service-lb-priority"
type="number"
inputMode="numeric"
min={1}
max={100}
value={lbPriority}
onChange={(e) =>
setLbPriority(Math.max(1, Number(e.target.value) || 1))
}
/>
</Field>
</div>
</div>
</>
)}
</TabsContent>
<TabsContent value="bindings" className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
Несколько FQDN в разных зонах IP или CNAME для DNS Cloudflare
</p>
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" />
Добавить
</Button>
</div>
{bindings.length === 0 ? (
<EmptyState
icon={Link2Icon}
title="Нет привязок"
description="Необязательно. Можно добавить несколько FQDN: api.ivx.su и www.other.su — зоны определятся автоматически."
centered={false}
action={
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" />
Добавить привязку
</Button>
<Field>
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
<Select
items={groupItems}
value={serviceGroupId}
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
>
<SelectTrigger id="edit-service-group" className="w-full">
<SelectValue placeholder="Без группы" />
</SelectTrigger>
<SelectContent>
{groupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-common-domain">
Общий домен (FQDN)
</FieldLabel>
<Input
id="edit-service-common-domain"
className="font-mono"
value={commonFqdn}
placeholder={
zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'
}
onChange={(e) => handleCommonFqdnChange(e.target.value)}
/>
) : (
<ItemGroup className="gap-2">
{bindings.map((binding, index) => {
const showLbBlock =
(binding.record_type === 'A' && binding.target_ips.length > 0) ||
(binding.record_type === 'CNAME' && binding.target_cname.trim().length > 0)
const showMeta =
binding.record_type === 'A' &&
binding.target_ips.length > 1 &&
binding.lb_mode !== 'round_robin'
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
return (
<Item key={`binding-${index}`} variant="outline" className="items-stretch">
<ItemContent className="w-full flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="text-sm font-medium">
Привязка {index + 1}
</span>
{parsedZone ? (
<Badge variant="outline" size="xs" className="font-mono">
{parsedZone.zoneName}
</Badge>
) : binding.fqdn.trim() ? (
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
aria-label="Удалить привязку"
onClick={() => handleRemoveBinding(index)}
>
<Trash2Icon />
</Button>
</div>
<Field className="min-w-0">
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
<Input
id={`binding-fqdn-${index}`}
className="font-mono"
value={binding.fqdn}
onChange={(event) =>
handleFqdnChange(index, event.target.value)
}
placeholder={
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'
}
/>
</Field>
<Field>
<FieldLabel htmlFor={`binding-type-${index}`}>Тип записи</FieldLabel>
<Select
items={[
{ label: 'A (IP)', value: 'A' },
{ label: 'CNAME', value: 'CNAME' },
]}
value={binding.record_type}
onValueChange={(value) =>
handleRecordTypeChange(index, (value ?? 'A') as 'A' | 'CNAME')
}
>
<SelectTrigger id={`binding-type-${index}`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">A (IP)</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
</Field>
{binding.record_type === 'CNAME' ? (
<Field>
<FieldLabel htmlFor={`binding-cname-${index}`}>
CNAME-цель
</FieldLabel>
<Input
id={`binding-cname-${index}`}
value={binding.target_cname}
placeholder="mmsk.rkns.top"
onChange={(event) => handleCnameChange(index, event.target.value)}
/>
</Field>
) : (
<Field>
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
<ServiceBindingIpInput
id={`binding-ip-${index}`}
value={binding.target_ips}
pool={ips}
onChange={(targetIps) => handleIpsChange(index, targetIps)}
showMeta={showLbBlock && showMeta}
weights={binding.target_ip_weights}
priorities={binding.target_ip_priorities}
onMetaChange={(ip, meta) =>
handleBindingMetaChange(index, ip, meta)
}
/>
</Field>
)}
</Field>
<Field>
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
<TaggedInput
id="edit-service-ips"
value={ips}
onChange={setIps}
placeholder="192.168.1.1"
validate={isValidIpv4}
/>
</Field>
</FieldGroup>
</section>
{showLbBlock ? (
<HealthCheckConfigFields
value={{
lb_mode: binding.lb_mode,
enabled: binding.health.enabled,
type: binding.health.type,
port: binding.health.port,
path: binding.health.path,
expected_status: binding.health.expected_status,
interval_sec: binding.health.interval_sec,
timeout_ms: binding.health.timeout_ms,
verify_tls: binding.health.verify_tls,
}}
onChange={(next) => handleBindingHealthChange(index, next)}
lbModeLabel="Режим балансировки"
showLbMode={
binding.record_type === 'A' && binding.target_ips.length > 1
}
idPrefix={`binding-${index}-health`}
/>
) : null}
</ItemContent>
</Item>
)
})}
</ItemGroup>
)}
</TabsContent>
</CountedLineTabs>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Health check</h3>
<HealthCheckConfigFields
idPrefix="service-health"
value={primaryHealthValue}
onChange={handlePrimaryHealthChange}
/>
</section>
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">Доп. FQDN</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddExtraBinding}
>
<PlusIcon data-icon="inline-start" />
Добавить
</Button>
</div>
{extraBindings.length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет дополнительных FQDN
</p>
) : (
<ItemGroup className="gap-2">
{extraBindings.map((binding, extraIndex) => {
const index = extraIndex + 1
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
return (
<Item
key={`extra-binding-${index}`}
variant="outline"
size="sm"
className="items-stretch"
>
<ItemContent className="flex w-full min-w-0 flex-col gap-2">
<div className="flex items-center gap-2">
{parsedZone ? (
<Badge variant="outline" size="xs" className="font-mono">
{parsedZone.zoneName}
</Badge>
) : binding.fqdn.trim() ? (
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
) : (
<span className="text-muted-foreground text-xs">
FQDN
</span>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
className="ml-auto shrink-0"
aria-label="Удалить FQDN"
onClick={() => handleRemoveExtraBinding(extraIndex)}
>
<Trash2Icon />
</Button>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_7.5rem]">
<Input
id={`extra-fqdn-${index}`}
className="font-mono"
value={binding.fqdn}
onChange={(event) =>
handleFqdnChange(index, event.target.value)
}
placeholder={
zoneHints[0]
? `api.${zoneHints[0]}`
: 'api.ivx.su'
}
/>
<Select
items={[
{ label: 'A (IP)', value: 'A' },
{ label: 'CNAME', value: 'CNAME' },
]}
value={binding.record_type}
onValueChange={(value) =>
handleRecordTypeChange(
index,
(value ?? 'A') as 'A' | 'CNAME',
)
}
>
<SelectTrigger
id={`extra-type-${index}`}
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">A (IP)</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
</div>
{binding.record_type === 'CNAME' ? (
<Input
id={`extra-cname-${index}`}
value={binding.target_cname}
placeholder="mmsk.rkns.top"
onChange={(event) =>
handleCnameChange(index, event.target.value)
}
/>
) : (
<ServiceBindingIpInput
id={`extra-ip-${index}`}
value={binding.target_ips}
pool={ips}
onChange={(targetIps) =>
handleIpsChange(index, targetIps)
}
/>
)}
</ItemContent>
</Item>
)
})}
</ItemGroup>
)}
</section>
</div>
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import {
@@ -12,10 +12,6 @@ import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { AppFieldGroup } from '@/components/app-field'
import { AppInput } from '@/components/app-input'
import {
HealthCheckConfigFields,
type LbAndHealthConfig,
} from '@/components/health-check-config-fields'
import {
Select,
SelectContent,
@@ -44,18 +40,6 @@ interface ServiceGroupEditSheetProps {
onSave?: (id: number, body: CreateServiceGroupInput) => void
}
const defaultLbHealth: LbAndHealthConfig = {
lb_mode: 'round_robin',
enabled: false,
type: 'tcp',
port: null,
path: null,
expected_status: null,
interval_sec: 30,
timeout_ms: 3000,
verify_tls: false,
}
export function ServiceGroupEditSheet({
mode,
group,
@@ -73,7 +57,6 @@ export function ServiceGroupEditSheet({
domain: null,
},
})
const [lbHealth, setLbHealth] = useState<LbAndHealthConfig>(defaultLbHealth)
useEffect(() => {
if (!open) return
@@ -81,43 +64,19 @@ export function ServiceGroupEditSheet({
form.reset({
name: group.name,
type: group.type,
domain: group.domain ?? null,
})
setLbHealth({
lb_mode: group.lb_mode,
enabled: group.health_check_enabled,
type: group.health_check_type === 'http' ? 'http' : 'tcp',
port: group.health_check_port,
path: group.health_check_path,
expected_status: group.health_check_expected_status,
interval_sec: group.health_check_interval_sec,
timeout_ms: group.health_check_timeout_ms,
verify_tls: group.health_check_verify_tls,
domain: null,
})
} else {
form.reset({ name: '', type: 'custom', domain: null })
setLbHealth(defaultLbHealth)
}
}, [open, mode, group, form])
const domainValue = form.watch('domain')
const hasDomain = Boolean(domainValue?.trim())
function handleSubmit(values: ServiceGroupFormValues) {
const body: CreateServiceGroupInput = {
name: values.name,
type: values.type ?? 'custom',
icon: values.icon,
domain: values.domain?.trim() || null,
lb_mode: lbHealth.lb_mode,
health_check_enabled: lbHealth.enabled,
health_check_type: lbHealth.type,
health_check_port: lbHealth.port,
health_check_path: lbHealth.path,
health_check_expected_status: lbHealth.expected_status,
health_check_interval_sec: lbHealth.interval_sec,
health_check_timeout_ms: lbHealth.timeout_ms,
health_check_verify_tls: lbHealth.verify_tls,
domain: null,
}
if (mode === 'create') {
onCreate?.(body)
@@ -131,7 +90,7 @@ export function ServiceGroupEditSheet({
open={open}
onOpenChange={onOpenChange}
title={mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
description="Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов."
description="Группа нужна только для сортировки каталога. Общий домен и IP задаются у каждого сервиса."
form={form}
onSubmit={handleSubmit}
contentClassName="gap-6"
@@ -184,26 +143,7 @@ export function ServiceGroupEditSheet({
)}
/>
</FormFieldSimple>
<FormFieldSimple
label="Домен группы (FQDN, необязательно)"
htmlFor="group-domain"
>
<AppInput
id="group-domain"
placeholder="domain.new.ivx.su"
{...form.register('domain')}
/>
</FormFieldSimple>
</AppFieldGroup>
{hasDomain ? (
<HealthCheckConfigFields
value={lbHealth}
onChange={setLbHealth}
lbModeLabel="Режим балансировки общего домена"
idPrefix="group-lb-health"
/>
) : null}
</FormSheet>
)
}
@@ -0,0 +1,168 @@
import { FolderOpenIcon, MoreHorizontalIcon, PlusIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { ServiceGroupIcon } from '@/components/service-group-icon'
import { ServiceUnitCard } from '@/components/services/service-unit-card'
import type { ServiceGroup, ServiceGroupView, ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
const GROUP_TYPE_LABELS: Record<ServiceGroup['type'], string> = {
vpn: 'VPN',
network: 'Сеть',
internet: 'Интернет',
bgp: 'BGP',
custom: 'Другое',
}
interface ServiceCatalogSectionProps {
group: ServiceGroupView | null
services: ServiceView[]
togglingId: number | null
togglingIp: { serviceId: number; ip: string } | null
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup: (group: ServiceGroupView) => void
onAddServiceToGroup: (groupId: number | null) => void
}
export function ServiceCatalogSection({
group,
services,
togglingId,
togglingIp,
onEditService,
onDeleteService,
onToggleService,
onToggleServiceIp,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
}: ServiceCatalogSectionProps) {
const title = group?.name ?? 'Без группы'
const typeLabel = group ? GROUP_TYPE_LABELS[group.type] : null
const groupId = group?.id ?? null
return (
<section
className="@container flex w-full flex-col gap-2"
aria-labelledby={`group-${groupId ?? 'none'}`}
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<IconTile
variant="elevated"
size="sm"
className="text-muted-foreground"
aria-hidden="true"
>
{group ? (
<ServiceGroupIcon type={group.type} />
) : (
<FolderOpenIcon />
)}
</IconTile>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<h2
id={`group-${groupId ?? 'none'}`}
className="truncate text-sm font-medium"
>
{title}
</h2>
{typeLabel ? (
<span className="text-muted-foreground text-xs">{typeLabel}</span>
) : null}
<Badge variant="outline" size="xs" className="shrink-0 tabular-nums">
{services.length}
</Badge>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Добавить сервис в ${title}`}
onClick={() => onAddServiceToGroup(groupId)}
>
<PlusIcon aria-hidden />
</Button>
{group ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия группы ${group.name}`}
/>
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onAddServiceToGroup(group.id)}>
<PlusIcon aria-hidden />
Добавить сервис
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditGroup(group)}>
Изменить группу
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteGroup(group)}
>
Удалить группу
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
{services.length === 0 ? (
<div className="flex flex-wrap items-center justify-between gap-2 py-1">
<span className="text-muted-foreground text-sm">Нет сервисов</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onAddServiceToGroup(groupId)}
>
<PlusIcon data-icon="inline-start" aria-hidden />
Добавить сервис
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-2 @xl:grid-cols-2 @4xl:grid-cols-3">
{services.map((service) => (
<ServiceUnitCard
key={service.id}
service={service}
togglingId={togglingId}
togglingIp={
togglingIp?.serviceId === service.id ? togglingIp.ip : null
}
onEditService={onEditService}
onDeleteService={onDeleteService}
onToggleService={onToggleService}
onToggleServiceIp={onToggleServiceIp}
/>
))}
</div>
)}
</section>
)
}
@@ -1,7 +1,14 @@
import { CheckIcon, CopyIcon } from 'lucide-react'
import { toast } from 'sonner'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { Badge } from '@/components/reui/badge'
import { TruncatedText } from '@/components/truncated-text'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { serviceDisplayFqdns } from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import { Switch } from '@cfdm/ui/components/switch'
import {
Tooltip,
TooltipContent,
@@ -10,16 +17,53 @@ import {
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
export function CopyFqdnButton({
value,
className,
}: {
value: string
className?: string
}) {
const { isCopied, copyToClipboard } = useCopyToClipboard({
onCopy: () => toast.success('Скопировано'),
})
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
className={className}
aria-label={isCopied ? 'Скопировано' : `Скопировать ${value}`}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
copyToClipboard(value)
}}
>
{isCopied ? (
<CheckIcon className="text-success" aria-hidden />
) : (
<CopyIcon aria-hidden />
)}
</Button>
)
}
interface ServiceFqdnListProps {
service: ServiceView
className?: string
emptyLabel?: string
copyable?: boolean
textClassName?: string
}
export function ServiceFqdnList({
service,
className,
emptyLabel = 'FQDN не задан',
emptyLabel = 'Нет FQDN',
copyable = false,
textClassName,
}: ServiceFqdnListProps) {
const fqdns = serviceDisplayFqdns(service)
if (fqdns.length === 0) {
@@ -32,10 +76,16 @@ export function ServiceFqdnList({
const [first, ...rest] = fqdns
const extraCount = rest.length
const copyValue = fqdns.join('\n')
return (
<div className={cn('flex min-w-0 items-center gap-1.5', className)}>
<TruncatedText className="text-muted-foreground min-w-0 font-mono text-xs">
<TruncatedText
className={cn(
'text-muted-foreground min-w-0 font-mono text-xs',
textClassName,
)}
>
{first}
</TruncatedText>
{extraCount > 0 ? (
@@ -62,6 +112,118 @@ export function ServiceFqdnList({
</Tooltip>
</TooltipProvider>
) : null}
{copyable ? <CopyFqdnButton value={copyValue} /> : null}
</div>
)
}
const VISIBLE_IP_LIMIT = 6
interface ServiceIpListProps {
ips: string[]
ipHealth?: ServiceView['ip_health']
ipEnabled?: Record<string, boolean>
togglingIp?: string | null
ipToggleDisabled?: boolean
onToggleIp?: (ip: string, enabled: boolean) => void
className?: string
emptyLabel?: string
copyable?: boolean
textClassName?: string
}
export function ServiceIpList({
ips,
ipHealth = [],
ipEnabled = {},
togglingIp = null,
ipToggleDisabled = false,
onToggleIp,
className,
emptyLabel = 'Нет IP',
copyable = false,
textClassName,
}: ServiceIpListProps) {
if (ips.length === 0) {
return (
<span className={cn('text-muted-foreground text-xs', className)}>
{emptyLabel}
</span>
)
}
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
const extraCount = ips.length - visible.length
return (
<div className={cn('flex min-w-0 flex-col gap-1', className)}>
{visible.map((ip) => {
const health = healthByIp.get(ip)
const enabled = ipEnabled[ip] !== false
return (
<div key={ip} className="flex min-w-0 items-center gap-1.5">
<HealthCheckBadge
status={health?.status ?? 'unknown'}
latencyMs={health?.latency_ms}
lastCheckedAt={health?.last_checked_at}
lastError={health?.last_error}
colo={health?.colo}
provider={health?.provider}
size="xs"
/>
<TruncatedText
className={cn(
'min-w-0 font-mono text-xs',
enabled ? 'text-muted-foreground' : 'text-muted-foreground/60',
textClassName,
)}
>
{ip}
</TruncatedText>
{copyable ? <CopyFqdnButton value={ip} /> : null}
{onToggleIp ? (
<Switch
size="sm"
className="ml-auto shrink-0"
checked={enabled}
disabled={ipToggleDisabled || togglingIp === ip}
onClick={(event) => {
event.stopPropagation()
}}
onCheckedChange={(checked) => onToggleIp(ip, Boolean(checked))}
aria-label={
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
}
/>
) : null}
</div>
)
})}
{extraCount > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Badge
variant="outline"
size="xs"
className="w-fit shrink-0 tabular-nums"
/>
}
>
ещё {extraCount}
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<ul className="flex flex-col gap-0.5 font-mono text-xs">
{ips.slice(VISIBLE_IP_LIMIT).map((ip) => (
<li key={ip}>{ip}</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
)
}
@@ -0,0 +1,178 @@
import { Link } from '@tanstack/react-router'
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import {
CopyFqdnButton,
ServiceIpList,
} from '@/components/services/service-fqdn-list'
import { serviceDisplayFqdn, serviceDisplayFqdns } from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { Switch } from '@cfdm/ui/components/switch'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
interface ServiceUnitCardProps {
service: ServiceView
togglingId: number | null
togglingIp: string | null
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
}
export function ServiceUnitCard({
service,
togglingId,
togglingIp,
onEditService,
onDeleteService,
onToggleService,
onToggleServiceIp,
}: ServiceUnitCardProps) {
const fqdns = serviceDisplayFqdns(service)
const primaryDomain = serviceDisplayFqdn(service)
const extraCount = Math.max(0, fqdns.length - 1)
return (
<Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden">
<FrameHeader className="flex-row items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<IconTile
variant="elevated"
size="sm"
className="text-muted-foreground"
aria-hidden="true"
>
<ServerIcon />
</IconTile>
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle className="min-w-0 truncate text-sm">
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
className="hover:underline"
>
{service.name}
</Link>
</FrameTitle>
<div className="flex min-w-0 items-center gap-1">
<FrameDescription className="min-w-0 truncate font-mono text-xs">
{primaryDomain}
</FrameDescription>
{extraCount > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Badge
variant="outline"
size="xs"
className="shrink-0 tabular-nums"
/>
}
>
+{extraCount}
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<ul className="flex flex-col gap-0.5 font-mono text-xs">
{fqdns.map((fqdn) => (
<li key={fqdn}>{fqdn}</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
{primaryDomain !== '—' ? (
<CopyFqdnButton value={fqdns.join('\n')} />
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Switch
size="sm"
checked={service.enabled}
disabled={togglingId === service.id}
onCheckedChange={(checked) =>
onToggleService(service.id, Boolean(checked))
}
aria-label={
service.enabled ? 'Выключить сервис' : 'Включить сервис'
}
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия ${service.name}`}
/>
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
render={
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
/>
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditService(service)}>
Изменить
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteService(service)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</FrameHeader>
<FramePanel className="flex min-w-0 flex-col gap-1 pt-0 shadow-none!">
<ServiceIpList
copyable
ips={service.ips ?? []}
ipHealth={service.ip_health ?? []}
ipEnabled={service.ip_enabled ?? {}}
ipToggleDisabled={togglingId === service.id}
togglingIp={togglingIp}
onToggleIp={(ip, enabled) =>
onToggleServiceIp(service.id, ip, enabled)
}
/>
</FramePanel>
</Frame>
)
}
@@ -1,45 +1,26 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import {
getCoreRowModel,
getExpandedRowModel,
useReactTable,
type ExpandedState,
} from '@tanstack/react-table'
import { useMemo, useState, type ReactNode } from 'react'
import {
ChevronDownIcon,
FilterIcon,
FolderPlusIcon,
FunnelXIcon,
PlusIcon,
SearchIcon,
ServerIcon,
} from 'lucide-react'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { Filters, type Filter } from '@/components/reui/filters'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { EmptyState } from '@/components/empty-state'
import { applyFiltersToData } from '@/components/reui-kit/filter-utils'
import { createServicesGroupedColumns } from '@/components/services/services-grouped-columns'
import type { ServiceCatalogTreeRow } from '@/components/services/services-grouped-columns'
import { ServiceCatalogSection } from '@/components/services/service-catalog-section'
import {
SERVICE_TABS,
createDefaultServiceFilters,
serviceFilterFieldValue,
serviceTabFilter,
useServiceFilterFields,
type ServiceCatalogRow,
} from '@/components/columns/services-columns'
import { serviceDisplayFqdns } from '@/lib/service-utils'
import type {
ServiceGroupView,
ServiceGroupsResponse,
@@ -52,23 +33,29 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { Separator } from '@cfdm/ui/components/separator'
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from '@cfdm/ui/components/input-group'
import { Skeleton } from '@cfdm/ui/components/skeleton'
const HEALTH_TABS = [
{ id: 'health-ok', label: 'OK' },
{ id: 'health-slow', label: 'Slow' },
{ id: 'health-down', label: 'Down' },
{ id: 'health-unknown', label: '—' },
] as const
const ALL_TABS = [...SERVICE_TABS, ...HEALTH_TABS] as const
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
if (domainId == null) return true
return service.domains.some((d) => d.domain_id === domainId)
}
function serviceMatchesQuery(service: ServiceView, query: string) {
const needle = query.trim().toLowerCase()
if (!needle) return true
if (service.name.toLowerCase().includes(needle)) return true
if (service.slug.toLowerCase().includes(needle)) return true
return serviceDisplayFqdns(service).some((fqdn) =>
fqdn.toLowerCase().includes(needle),
)
}
function toCatalogRow(
service: ServiceView,
groupId: number | null,
@@ -86,72 +73,51 @@ function toCatalogRow(
}
}
function catalogTabFilter(row: ServiceCatalogRow, tabId: string) {
if (tabId.startsWith('health-')) {
const status = row.service.health_status ?? 'unknown'
if (tabId === 'health-ok') return status === 'up'
if (tabId === 'health-slow') return status === 'degraded'
if (tabId === 'health-down') return status === 'down'
if (tabId === 'health-unknown') return status === 'unknown'
return true
}
return serviceTabFilter(row, tabId)
interface GroupUnitData {
id: string
group: ServiceGroupView | null
services: ServiceView[]
}
function buildTreeRows(
function buildGroupUnits(
data: ServiceGroupsResponse,
filteredServiceIds: Set<number>,
domainId?: number,
): ServiceCatalogTreeRow[] {
const rows: ServiceCatalogTreeRow[] = []
domainId: number | undefined,
showEmptyGroups: boolean,
): GroupUnitData[] {
const units: GroupUnitData[] = []
for (const group of data.groups) {
const services = group.services
.filter((s) => serviceMatchesDomain(s, domainId))
.filter((s) => filteredServiceIds.has(s.id))
if (services.length === 0) continue
const matchingDomain = group.services.filter((service) =>
serviceMatchesDomain(service, domainId),
)
const services = matchingDomain.filter((service) =>
filteredServiceIds.has(service.id),
)
rows.push({
kind: 'group',
id: `group-${group.id}`,
group,
subRows: services.map((service) => ({
kind: 'service' as const,
id: `service-${service.id}`,
service,
groupId: group.id,
groupName: group.name,
})),
})
if (services.length === 0) {
if (showEmptyGroups && matchingDomain.length === 0) {
units.push({ id: `group-${group.id}`, group, services: [] })
}
continue
}
units.push({ id: `group-${group.id}`, group, services })
}
const ungrouped = data.ungrouped
.filter((s) => serviceMatchesDomain(s, domainId))
.filter((s) => filteredServiceIds.has(s.id))
.filter((service) => serviceMatchesDomain(service, domainId))
.filter((service) => filteredServiceIds.has(service.id))
if (ungrouped.length > 0) {
rows.push({
kind: 'group',
units.push({
id: 'group-ungrouped',
group: null,
subRows: ungrouped.map((service) => ({
kind: 'service' as const,
id: `service-${service.id}`,
service,
groupId: null,
groupName: null,
})),
services: ungrouped,
})
}
return rows
}
function defaultExpanded(rows: ServiceCatalogTreeRow[]): ExpandedState {
return rows.reduce<Record<string, boolean>>((acc, row) => {
acc[row.id] = true
return acc
}, {})
return units
}
export function ServicesAddMenu({
@@ -194,11 +160,13 @@ interface ServicesGroupedCatalogProps {
primaryAction?: ReactNode
hideHeader?: boolean
togglingId: number | null
togglingIp: { serviceId: number; ip: string } | null
activeTab?: string
onTabChange?: (tabId: string) => void
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup: (group: ServiceGroupView) => void
onAddServiceToGroup: (groupId: number | null) => void
@@ -213,24 +181,18 @@ export function ServicesGroupedCatalog({
primaryAction,
hideHeader = false,
togglingId,
activeTab: controlledTab,
onTabChange,
togglingIp,
activeTab = 'all',
onEditService,
onDeleteService,
onToggleService,
onToggleServiceIp,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
emptyAction,
}: ServicesGroupedCatalogProps) {
const [internalTab, setInternalTab] = useState('all')
const tab = controlledTab ?? internalTab
const setTab = onTabChange ?? setInternalTab
const [filters, setFilters] = useState<Filter[]>(() =>
createDefaultServiceFilters(),
)
const [expanded, setExpanded] = useState<ExpandedState>({})
const filterFields = useServiceFilterFields()
const [query, setQuery] = useState('')
const flatRows = useMemo(() => {
const rows: ServiceCatalogRow[] = []
@@ -247,76 +209,33 @@ export function ServicesGroupedCatalog({
return rows
}, [data, domainId])
const tabCounts = useMemo(() => {
const counts: Record<string, number> = {}
for (const t of ALL_TABS) {
counts[t.id] = flatRows.filter((row) => catalogTabFilter(row, t.id)).length
}
return counts
}, [flatRows])
const filteredIds = useMemo(() => {
const afterTab = flatRows.filter((row) => catalogTabFilter(row, tab))
const afterFilters = applyFiltersToData(afterTab, filters, (item, field) =>
serviceFilterFieldValue(item, field),
const afterTab = flatRows.filter((row) => serviceTabFilter(row, activeTab))
const afterQuery = afterTab.filter((row) =>
serviceMatchesQuery(row.service, query),
)
return new Set(afterFilters.map((r) => r.id))
}, [flatRows, tab, filters])
return new Set(afterQuery.map((r) => r.id))
}, [flatRows, activeTab, query])
const treeData = useMemo(
() => buildTreeRows(data, filteredIds, domainId),
[data, filteredIds, domainId],
const showEmptyGroups =
activeTab === 'all' && domainId == null && query.trim().length === 0
const units = useMemo(
() => buildGroupUnits(data, filteredIds, domainId, showEmptyGroups),
[data, filteredIds, domainId, showEmptyGroups],
)
const expandedKey = treeData.map((r) => r.id).join(',')
useEffect(() => {
setExpanded(defaultExpanded(treeData))
}, [expandedKey, treeData])
const columns = useMemo(
() =>
createServicesGroupedColumns({
onEditService,
onDeleteService,
onToggleService,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
togglingId,
}),
[
onEditService,
onDeleteService,
onToggleService,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
togglingId,
],
)
const table = useReactTable({
data: treeData,
columns,
state: { expanded },
onExpandedChange: setExpanded,
getSubRows: (row) => (row.kind === 'group' ? row.subRows : undefined),
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
})
if (isLoading) {
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="mt-1 h-4 w-72" />
<Skeleton className="h-4 w-72" />
</FrameHeader>
<FramePanel className="flex flex-col gap-3 p-4">
<Skeleton className="h-9 w-full max-w-md" />
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
<Skeleton className="h-8 w-full max-w-md" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-36 w-full rounded-xl" />
))}
</FramePanel>
</Frame>
@@ -342,112 +261,66 @@ export function ServicesGroupedCatalog({
}
return (
<DataGrid
table={table}
recordCount={filteredIds.size}
emptyMessage="Нет записей по выбранным фильтрам."
tableLayout={{ dense: true }}
>
<Frame dense spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle>Сервисы</FrameTitle>
<FrameDescription>
{domainLabel
? `Каталог сервисов с привязками к ${domainLabel}`
: 'Группы, FQDN и доступность сервисов'}
</FrameDescription>
<Frame dense spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle>Сервисы</FrameTitle>
<FrameDescription>
{domainLabel
? `Каталог сервисов с привязками к ${domainLabel}`
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'}
</FrameDescription>
</div>
{primaryAction ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{primaryAction}
</div>
{primaryAction ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{primaryAction}
</div>
) : null}
</FrameHeader>
) : null}
) : null}
</FrameHeader>
) : null}
<FramePanel className="p-0 shadow-none!">
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<CountedLineTabs
tabs={ALL_TABS.map((t) => ({
id: t.id,
label: t.label,
count: tabCounts[t.id] ?? 0,
}))}
value={tab}
onValueChange={setTab}
/>
</div>
<FramePanel className="flex flex-col gap-4">
<InputGroup className="max-w-md">
<InputGroupAddon>
<InputGroupText>
<SearchIcon aria-hidden />
</InputGroupText>
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Поиск по названию"
aria-label="Поиск по названию"
/>
</InputGroup>
<Separator />
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={setFilters}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden />
Фильтры
</Button>
}
/>
<Button
type="button"
variant="outline"
onClick={() => {
setTab('all')
setFilters(createDefaultServiceFilters())
}}
>
<FunnelXIcon className="size-4" aria-hidden />
Сбросить
</Button>
</div>
<Separator />
{treeData.length === 0 ? (
<div className="p-6">
<EmptyState
title="Нет совпадений"
description="Измените фильтры или вкладку."
action={
<Button
type="button"
variant="outline"
onClick={() => {
setTab('all')
setFilters(createDefaultServiceFilters())
}}
>
Сбросить
</Button>
}
{units.length === 0 ? (
<EmptyState
title="Нет совпадений"
description="Измените запрос поиска."
/>
) : (
<div className="flex flex-col gap-6">
{units.map((unit) => (
<ServiceCatalogSection
key={unit.id}
group={unit.group}
services={unit.services}
togglingId={togglingId}
togglingIp={togglingIp}
onEditService={onEditService}
onDeleteService={onDeleteService}
onToggleService={onToggleService}
onToggleServiceIp={onToggleServiceIp}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
onAddServiceToGroup={onAddServiceToGroup}
/>
</div>
) : (
<>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination
sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/>
</FrameFooter>
</>
)}
</FramePanel>
</Frame>
</DataGrid>
))}
</div>
)}
</FramePanel>
</Frame>
)
}
@@ -1,297 +0,0 @@
import { useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import {
ChevronRightIcon,
FolderIcon,
MoreHorizontalIcon,
PlusIcon,
} from 'lucide-react'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { Switch } from '@cfdm/ui/components/switch'
import { Badge } from '@/components/reui/badge'
import { cn } from '@cfdm/ui/lib/utils'
export type ServiceTreeServiceRow = {
kind: 'service'
id: string
service: ServiceView
groupId: number | null
groupName: string | null
}
export type ServiceTreeGroupRow = {
kind: 'group'
id: string
group: ServiceGroupView | null
subRows: ServiceTreeServiceRow[]
}
export type ServiceCatalogTreeRow = ServiceTreeGroupRow | ServiceTreeServiceRow
function isServiceRow(row: ServiceCatalogTreeRow): row is ServiceTreeServiceRow {
return row.kind === 'service'
}
export function createServicesGroupedColumns({
onEditService,
onDeleteService,
onToggleService,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
togglingId,
}: {
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup: (group: ServiceGroupView) => void
onAddServiceToGroup: (groupId: number | null) => void
togglingId: number | null
}): ColumnDef<ServiceCatalogTreeRow>[] {
return [
{
id: 'name',
accessorFn: (row) =>
isServiceRow(row) ? row.service.name : (row.group?.name ?? 'Без группы'),
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Группа / сервис" />
),
cell: ({ row }) => {
const original = row.original
if (!isServiceRow(original)) {
const title = original.group?.name ?? 'Без группы'
const domain = original.group?.domain
return (
<div className="flex min-w-0 items-center gap-2">
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={
row.getIsExpanded() ? `Свернуть ${title}` : `Развернуть ${title}`
}
aria-expanded={row.getIsExpanded()}
className="text-muted-foreground hover:text-foreground size-6 shrink-0 p-0 shadow-none"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
row.getToggleExpandedHandler()()
}}
>
<ChevronRightIcon
className={cn(
'size-3.5 shrink-0 transition-transform duration-150',
row.getIsExpanded() && 'rotate-90',
)}
aria-hidden
/>
</Button>
<FolderIcon
className="text-muted-foreground size-4 shrink-0"
aria-hidden
/>
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold">{title}</span>
<Badge variant="outline" size="xs" className="shrink-0">
{original.subRows.length}
</Badge>
</div>
{domain ? (
<span className="text-muted-foreground truncate font-mono text-xs">
{domain}
</span>
) : null}
</div>
</div>
)
}
return (
<div className="flex min-w-0 flex-col gap-0.5 pl-8">
<span className="truncate text-sm font-medium">
{original.service.name}
</span>
<ServiceFqdnList service={original.service} emptyLabel="—" />
</div>
)
},
enableSorting: false,
minSize: 260,
meta: { headerTitle: 'Группа / сервис', autoSize: true },
},
{
id: 'health',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Доступность" />
),
cell: ({ row }) => {
const original = row.original
if (!isServiceRow(original)) {
return (
<HealthCheckBadge
status={original.group?.health_status ?? 'unknown'}
latencyMs={original.group?.health_latency_ms}
size="xs"
/>
)
}
return (
<HealthCheckBadge
status={original.service.health_status ?? 'unknown'}
latencyMs={original.service.health_latency_ms}
size="xs"
/>
)
},
size: 120,
enableSorting: false,
},
{
id: 'enabled',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => {
const original = row.original
if (!isServiceRow(original)) return null
return (
<div className="flex items-center gap-2">
<Switch
checked={original.service.enabled}
disabled={togglingId === original.service.id}
onCheckedChange={(checked) =>
onToggleService(original.service.id, Boolean(checked))
}
aria-label={
original.service.enabled
? 'Выключить сервис'
: 'Включить сервис'
}
/>
<StatusBadge
status={original.service.enabled ? 'active' : 'disabled'}
label={original.service.enabled ? 'Вкл' : 'Выкл'}
/>
</div>
)
},
size: 140,
enableSorting: false,
},
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const original = row.original
if (!isServiceRow(original)) {
if (!original.group) {
return (
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="Добавить сервис без группы"
onClick={() => onAddServiceToGroup(null)}
>
<PlusIcon aria-hidden />
</Button>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия группы ${original.group.name}`}
/>
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => onAddServiceToGroup(original.group!.id)}
>
<PlusIcon aria-hidden />
Добавить сервис
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditGroup(original.group!)}>
Изменить группу
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteGroup(original.group!)}
>
Удалить группу
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия ${original.service.name}`}
/>
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEditService(original.service)}>
Изменить
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteService(original.service)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
size: 56,
},
]
}
export function useServicesGroupedColumns(
args: Parameters<typeof createServicesGroupedColumns>[0],
) {
return useMemo(() => createServicesGroupedColumns(args), [
args.onEditService,
args.onDeleteService,
args.onToggleService,
args.onEditGroup,
args.onDeleteGroup,
args.onAddServiceToGroup,
args.togglingId,
])
}
+30
View File
@@ -36,6 +36,7 @@ export const serviceGroupSchema = z.object({
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
health_check_verify_tls: z.coerce.boolean().default(false),
health_check_provider: z.enum(['local', 'cloudflare']).catch('local'),
created_at: z.string(),
updated_at: z.string(),
})
@@ -76,6 +77,7 @@ export const serviceDomainBindingSchema = z
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
health_check_verify_tls: z.coerce.boolean().default(false),
health_check_provider: z.enum(['local', 'cloudflare']).catch('local'),
sync_status: z.string().nullable().default(null),
})
.transform((binding) => ({
@@ -94,6 +96,30 @@ export const serviceDomainBindingSchema = z
: (binding.record_type ?? 'A'),
}))
export const serviceIpHealthSchema = z.object({
ip: z.string(),
status: z.enum(['up', 'down', 'degraded', 'unknown']),
latency_ms: z.number().nullable(),
last_checked_at: z.string().nullable().optional(),
last_error: z.string().nullable().optional(),
provider: z.enum(['local', 'cloudflare']).optional(),
colo: z.string().nullable().optional(),
})
export const healthProbeLogSchema = z.object({
id: z.number(),
scope: z.string(),
ref_id: z.number(),
ip: z.string(),
provider: z.enum(['local', 'cloudflare']),
status: z.enum(['up', 'down', 'degraded', 'unknown']),
ok: z.coerce.boolean(),
latency_ms: z.number().nullable(),
colo: z.string().nullable(),
error: z.string().nullable(),
checked_at: z.string(),
})
export const serviceViewSchema = serviceSchema.extend({
subdomain: z.string().default(''),
enabled: z.coerce.boolean().default(false),
@@ -101,6 +127,8 @@ export const serviceViewSchema = serviceSchema.extend({
domains: z.array(serviceDomainBindingSchema).default([]),
health_status: z.enum(['up', 'down', 'degraded', 'unknown']).default('unknown'),
health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]),
ip_enabled: z.record(z.string(), z.boolean()).default({}),
})
export const serviceGroupViewSchema = serviceGroupSchema.extend({
@@ -160,6 +188,7 @@ export const serviceBindingSchema = z
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
health_check_verify_tls: z.coerce.boolean().default(false),
health_check_provider: z.enum(['local', 'cloudflare']).catch('local'),
sync_status: z.string().nullable().default(null),
created_at: z.string(),
updated_at: z.string(),
@@ -244,6 +273,7 @@ const healthCheckConfigFields = {
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
health_check_verify_tls: z.boolean().optional(),
health_check_provider: z.enum(['local', 'cloudflare']).optional(),
}
const serviceDomainInputSchema = z
+110
View File
@@ -1,6 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import {
healthProbeLogSchema,
serviceBindingSchema,
serviceGroupsResponseSchema,
serviceViewSchema,
@@ -83,3 +84,112 @@ export async function createServiceBinding(body: CreateServiceBindingBody) {
export async function deleteServiceBinding(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/service-bindings/${id}`)
}
export const serviceDetailKeys = {
overview: (id: number) => [...serviceKeys.all, id, 'overview'] as const,
nodes: (id: number) => [...serviceKeys.all, id, 'nodes'] as const,
healthLog: (id: number) => [...serviceKeys.all, id, 'health-log'] as const,
view: (id: number) => [...serviceKeys.all, id, 'view'] as const,
}
export const serviceViewQueryOptions = (id: number) =>
queryOptions({
queryKey: serviceDetailKeys.view(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/services/${id}`)
return serviceViewSchema.parse(data)
},
})
export const serviceHealthLogQueryOptions = (id: number) =>
queryOptions({
queryKey: serviceDetailKeys.healthLog(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/services/${id}/health-log`)
return z.object({ items: z.array(healthProbeLogSchema) }).parse(data)
},
})
export const serviceOverviewQueryOptions = (id: number) =>
queryOptions({
queryKey: serviceDetailKeys.overview(id),
queryFn: () => api.get(`/api/v1/services/${id}/overview`),
})
export const serviceNodesQueryOptions = (id: number) =>
queryOptions({
queryKey: serviceDetailKeys.nodes(id),
queryFn: () => api.get(`/api/v1/services/${id}/nodes`),
})
export const opsSummaryQueryOptions = () =>
queryOptions({
queryKey: ['ops-summary'] as const,
queryFn: () =>
api.get<{
domains: number
services: number
nodes: number
healthy: number
unhealthy: number
active_failovers: number
}>('/api/v1/ops-summary'),
})
export async function createServiceNode(
serviceId: number,
body: {
address: string
protocol?: string
port?: number | null
priority?: number
weight?: number
},
) {
return api.post(`/api/v1/services/${serviceId}/nodes`, body)
}
export async function deleteServiceNode(serviceId: number, nodeId: number) {
return api.delete(`/api/v1/services/${serviceId}/nodes/${nodeId}`)
}
export async function changeBindingIp(
bindingId: number,
body: {
from_ip?: string
to_ip?: string
node_id?: number
dry_run?: boolean
},
) {
return api.post<{
from_ip: string
to_ip: string
applied: boolean
dry_run: boolean
message: string
}>(`/api/v1/service-bindings/${bindingId}/change-ip`, body)
}
export async function changeServiceDomain(
serviceId: number,
body: {
from_domain_id: number
to_domain_id: number
hostnames?: string[]
dry_run?: boolean
},
) {
return api.post<{ message?: string }>(
`/api/v1/services/${serviceId}/change-domain`,
body,
)
}
export async function createOriginHealthCheck(body: Record<string, unknown>) {
return api.post('/api/v1/health-checks', body)
}
export async function listOriginHealthChecks() {
return api.get('/api/v1/health-checks')
}
+184 -21
View File
@@ -13,16 +13,23 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
import { Route as AuthServicesRouteImport } from './routes/_auth/services'
import { Route as AuthGroupsRouteImport } from './routes/_auth/groups'
import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates'
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
import { Route as AuthServicesIndexRouteImport } from './routes/_auth/services/index'
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
import { Route as AuthSettingsHealthRouteImport } from './routes/_auth/settings/health'
import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
import { Route as AuthServicesServiceIdRouteRouteImport } from './routes/_auth/services/$serviceId/route'
import { Route as AuthServicesServiceIdIndexRouteImport } from './routes/_auth/services/$serviceId/index'
import { Route as AuthDomainsDomainIdIndexRouteImport } from './routes/_auth/domains/$domainId/index'
import { Route as AuthServicesServiceIdSubdomainsRouteImport } from './routes/_auth/services/$serviceId/subdomains'
import { Route as AuthServicesServiceIdRoutingRouteImport } from './routes/_auth/services/$serviceId/routing'
import { Route as AuthServicesServiceIdNodesRouteImport } from './routes/_auth/services/$serviceId/nodes'
import { Route as AuthServicesServiceIdHealthRouteImport } from './routes/_auth/services/$serviceId/health'
import { Route as AuthDomainsDomainIdDnsRouteImport } from './routes/_auth/domains/$domainId/dns'
const LoginRoute = LoginRouteImport.update({
@@ -44,11 +51,6 @@ const AuthCallbackRoute = AuthCallbackRouteImport.update({
path: '/auth/callback',
getParentRoute: () => rootRouteImport,
} as any)
const AuthServicesRoute = AuthServicesRouteImport.update({
id: '/services',
path: '/services',
getParentRoute: () => AuthRoute,
} as any)
const AuthGroupsRoute = AuthGroupsRouteImport.update({
id: '/groups',
path: '/groups',
@@ -69,6 +71,11 @@ const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({
path: '/',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthServicesIndexRoute = AuthServicesIndexRouteImport.update({
id: '/services/',
path: '/services/',
getParentRoute: () => AuthRoute,
} as any)
const AuthDomainsIndexRoute = AuthDomainsIndexRouteImport.update({
id: '/domains/',
path: '/domains/',
@@ -80,6 +87,11 @@ const AuthSettingsIntegrationsRoute =
path: '/integrations',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthSettingsHealthRoute = AuthSettingsHealthRouteImport.update({
id: '/health',
path: '/health',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
id: '/appearance',
path: '/appearance',
@@ -90,12 +102,48 @@ const AuthGroupsGroupIdRoute = AuthGroupsGroupIdRouteImport.update({
path: '/$groupId',
getParentRoute: () => AuthGroupsRoute,
} as any)
const AuthServicesServiceIdRouteRoute =
AuthServicesServiceIdRouteRouteImport.update({
id: '/services/$serviceId',
path: '/services/$serviceId',
getParentRoute: () => AuthRoute,
} as any)
const AuthServicesServiceIdIndexRoute =
AuthServicesServiceIdIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthServicesServiceIdRouteRoute,
} as any)
const AuthDomainsDomainIdIndexRoute =
AuthDomainsDomainIdIndexRouteImport.update({
id: '/domains/$domainId/',
path: '/domains/$domainId/',
getParentRoute: () => AuthRoute,
} as any)
const AuthServicesServiceIdSubdomainsRoute =
AuthServicesServiceIdSubdomainsRouteImport.update({
id: '/subdomains',
path: '/subdomains',
getParentRoute: () => AuthServicesServiceIdRouteRoute,
} as any)
const AuthServicesServiceIdRoutingRoute =
AuthServicesServiceIdRoutingRouteImport.update({
id: '/routing',
path: '/routing',
getParentRoute: () => AuthServicesServiceIdRouteRoute,
} as any)
const AuthServicesServiceIdNodesRoute =
AuthServicesServiceIdNodesRouteImport.update({
id: '/nodes',
path: '/nodes',
getParentRoute: () => AuthServicesServiceIdRouteRoute,
} as any)
const AuthServicesServiceIdHealthRoute =
AuthServicesServiceIdHealthRouteImport.update({
id: '/health',
path: '/health',
getParentRoute: () => AuthServicesServiceIdRouteRoute,
} as any)
const AuthDomainsDomainIdDnsRoute = AuthDomainsDomainIdDnsRouteImport.update({
id: '/domains/$domainId/dns',
path: '/domains/$domainId/dns',
@@ -108,30 +156,43 @@ export interface FileRoutesByFullPath {
'/settings': typeof AuthSettingsRouteRouteWithChildren
'/certificates': typeof AuthCertificatesRoute
'/groups': typeof AuthGroupsRouteWithChildren
'/services': typeof AuthServicesRoute
'/auth/callback': typeof AuthCallbackRoute
'/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
'/settings/appearance': typeof AuthSettingsAppearanceRoute
'/settings/health': typeof AuthSettingsHealthRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/domains/': typeof AuthDomainsIndexRoute
'/services/': typeof AuthServicesIndexRoute
'/settings/': typeof AuthSettingsIndexRoute
'/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
'/services/$serviceId/health': typeof AuthServicesServiceIdHealthRoute
'/services/$serviceId/nodes': typeof AuthServicesServiceIdNodesRoute
'/services/$serviceId/routing': typeof AuthServicesServiceIdRoutingRoute
'/services/$serviceId/subdomains': typeof AuthServicesServiceIdSubdomainsRoute
'/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute
'/services/$serviceId/': typeof AuthServicesServiceIdIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/certificates': typeof AuthCertificatesRoute
'/groups': typeof AuthGroupsRouteWithChildren
'/services': typeof AuthServicesRoute
'/auth/callback': typeof AuthCallbackRoute
'/': typeof AuthIndexRoute
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
'/settings/appearance': typeof AuthSettingsAppearanceRoute
'/settings/health': typeof AuthSettingsHealthRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/domains': typeof AuthDomainsIndexRoute
'/services': typeof AuthServicesIndexRoute
'/settings': typeof AuthSettingsIndexRoute
'/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
'/services/$serviceId/health': typeof AuthServicesServiceIdHealthRoute
'/services/$serviceId/nodes': typeof AuthServicesServiceIdNodesRoute
'/services/$serviceId/routing': typeof AuthServicesServiceIdRoutingRoute
'/services/$serviceId/subdomains': typeof AuthServicesServiceIdSubdomainsRoute
'/domains/$domainId': typeof AuthDomainsDomainIdIndexRoute
'/services/$serviceId': typeof AuthServicesServiceIdIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -140,16 +201,23 @@ export interface FileRoutesById {
'/_auth/settings': typeof AuthSettingsRouteRouteWithChildren
'/_auth/certificates': typeof AuthCertificatesRoute
'/_auth/groups': typeof AuthGroupsRouteWithChildren
'/_auth/services': typeof AuthServicesRoute
'/auth/callback': typeof AuthCallbackRoute
'/_auth/': typeof AuthIndexRoute
'/_auth/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
'/_auth/settings/health': typeof AuthSettingsHealthRoute
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/_auth/domains/': typeof AuthDomainsIndexRoute
'/_auth/services/': typeof AuthServicesIndexRoute
'/_auth/settings/': typeof AuthSettingsIndexRoute
'/_auth/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
'/_auth/services/$serviceId/health': typeof AuthServicesServiceIdHealthRoute
'/_auth/services/$serviceId/nodes': typeof AuthServicesServiceIdNodesRoute
'/_auth/services/$serviceId/routing': typeof AuthServicesServiceIdRoutingRoute
'/_auth/services/$serviceId/subdomains': typeof AuthServicesServiceIdSubdomainsRoute
'/_auth/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute
'/_auth/services/$serviceId/': typeof AuthServicesServiceIdIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -159,30 +227,43 @@ export interface FileRouteTypes {
| '/settings'
| '/certificates'
| '/groups'
| '/services'
| '/auth/callback'
| '/services/$serviceId'
| '/groups/$groupId'
| '/settings/appearance'
| '/settings/health'
| '/settings/integrations'
| '/domains/'
| '/services/'
| '/settings/'
| '/domains/$domainId/dns'
| '/services/$serviceId/health'
| '/services/$serviceId/nodes'
| '/services/$serviceId/routing'
| '/services/$serviceId/subdomains'
| '/domains/$domainId/'
| '/services/$serviceId/'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
| '/certificates'
| '/groups'
| '/services'
| '/auth/callback'
| '/'
| '/groups/$groupId'
| '/settings/appearance'
| '/settings/health'
| '/settings/integrations'
| '/domains'
| '/services'
| '/settings'
| '/domains/$domainId/dns'
| '/services/$serviceId/health'
| '/services/$serviceId/nodes'
| '/services/$serviceId/routing'
| '/services/$serviceId/subdomains'
| '/domains/$domainId'
| '/services/$serviceId'
id:
| '__root__'
| '/_auth'
@@ -190,16 +271,23 @@ export interface FileRouteTypes {
| '/_auth/settings'
| '/_auth/certificates'
| '/_auth/groups'
| '/_auth/services'
| '/auth/callback'
| '/_auth/'
| '/_auth/services/$serviceId'
| '/_auth/groups/$groupId'
| '/_auth/settings/appearance'
| '/_auth/settings/health'
| '/_auth/settings/integrations'
| '/_auth/domains/'
| '/_auth/services/'
| '/_auth/settings/'
| '/_auth/domains/$domainId/dns'
| '/_auth/services/$serviceId/health'
| '/_auth/services/$serviceId/nodes'
| '/_auth/services/$serviceId/routing'
| '/_auth/services/$serviceId/subdomains'
| '/_auth/domains/$domainId/'
| '/_auth/services/$serviceId/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -238,13 +326,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/services': {
id: '/_auth/services'
path: '/services'
fullPath: '/services'
preLoaderRoute: typeof AuthServicesRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/groups': {
id: '/_auth/groups'
path: '/groups'
@@ -273,6 +354,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSettingsIndexRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/services/': {
id: '/_auth/services/'
path: '/services'
fullPath: '/services/'
preLoaderRoute: typeof AuthServicesIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/domains/': {
id: '/_auth/domains/'
path: '/domains'
@@ -287,6 +375,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/settings/health': {
id: '/_auth/settings/health'
path: '/health'
fullPath: '/settings/health'
preLoaderRoute: typeof AuthSettingsHealthRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/settings/appearance': {
id: '/_auth/settings/appearance'
path: '/appearance'
@@ -301,6 +396,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthGroupsGroupIdRouteImport
parentRoute: typeof AuthGroupsRoute
}
'/_auth/services/$serviceId': {
id: '/_auth/services/$serviceId'
path: '/services/$serviceId'
fullPath: '/services/$serviceId'
preLoaderRoute: typeof AuthServicesServiceIdRouteRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/services/$serviceId/': {
id: '/_auth/services/$serviceId/'
path: '/'
fullPath: '/services/$serviceId/'
preLoaderRoute: typeof AuthServicesServiceIdIndexRouteImport
parentRoute: typeof AuthServicesServiceIdRouteRoute
}
'/_auth/domains/$domainId/': {
id: '/_auth/domains/$domainId/'
path: '/domains/$domainId'
@@ -308,6 +417,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthDomainsDomainIdIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/services/$serviceId/subdomains': {
id: '/_auth/services/$serviceId/subdomains'
path: '/subdomains'
fullPath: '/services/$serviceId/subdomains'
preLoaderRoute: typeof AuthServicesServiceIdSubdomainsRouteImport
parentRoute: typeof AuthServicesServiceIdRouteRoute
}
'/_auth/services/$serviceId/routing': {
id: '/_auth/services/$serviceId/routing'
path: '/routing'
fullPath: '/services/$serviceId/routing'
preLoaderRoute: typeof AuthServicesServiceIdRoutingRouteImport
parentRoute: typeof AuthServicesServiceIdRouteRoute
}
'/_auth/services/$serviceId/nodes': {
id: '/_auth/services/$serviceId/nodes'
path: '/nodes'
fullPath: '/services/$serviceId/nodes'
preLoaderRoute: typeof AuthServicesServiceIdNodesRouteImport
parentRoute: typeof AuthServicesServiceIdRouteRoute
}
'/_auth/services/$serviceId/health': {
id: '/_auth/services/$serviceId/health'
path: '/health'
fullPath: '/services/$serviceId/health'
preLoaderRoute: typeof AuthServicesServiceIdHealthRouteImport
parentRoute: typeof AuthServicesServiceIdRouteRoute
}
'/_auth/domains/$domainId/dns': {
id: '/_auth/domains/$domainId/dns'
path: '/domains/$domainId/dns'
@@ -320,12 +457,14 @@ declare module '@tanstack/react-router' {
interface AuthSettingsRouteRouteChildren {
AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute
AuthSettingsHealthRoute: typeof AuthSettingsHealthRoute
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
}
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute,
AuthSettingsHealthRoute: AuthSettingsHealthRoute,
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
}
@@ -345,13 +484,36 @@ const AuthGroupsRouteWithChildren = AuthGroupsRoute._addFileChildren(
AuthGroupsRouteChildren,
)
interface AuthServicesServiceIdRouteRouteChildren {
AuthServicesServiceIdHealthRoute: typeof AuthServicesServiceIdHealthRoute
AuthServicesServiceIdNodesRoute: typeof AuthServicesServiceIdNodesRoute
AuthServicesServiceIdRoutingRoute: typeof AuthServicesServiceIdRoutingRoute
AuthServicesServiceIdSubdomainsRoute: typeof AuthServicesServiceIdSubdomainsRoute
AuthServicesServiceIdIndexRoute: typeof AuthServicesServiceIdIndexRoute
}
const AuthServicesServiceIdRouteRouteChildren: AuthServicesServiceIdRouteRouteChildren =
{
AuthServicesServiceIdHealthRoute: AuthServicesServiceIdHealthRoute,
AuthServicesServiceIdNodesRoute: AuthServicesServiceIdNodesRoute,
AuthServicesServiceIdRoutingRoute: AuthServicesServiceIdRoutingRoute,
AuthServicesServiceIdSubdomainsRoute: AuthServicesServiceIdSubdomainsRoute,
AuthServicesServiceIdIndexRoute: AuthServicesServiceIdIndexRoute,
}
const AuthServicesServiceIdRouteRouteWithChildren =
AuthServicesServiceIdRouteRoute._addFileChildren(
AuthServicesServiceIdRouteRouteChildren,
)
interface AuthRouteChildren {
AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren
AuthCertificatesRoute: typeof AuthCertificatesRoute
AuthGroupsRoute: typeof AuthGroupsRouteWithChildren
AuthServicesRoute: typeof AuthServicesRoute
AuthIndexRoute: typeof AuthIndexRoute
AuthServicesServiceIdRouteRoute: typeof AuthServicesServiceIdRouteRouteWithChildren
AuthDomainsIndexRoute: typeof AuthDomainsIndexRoute
AuthServicesIndexRoute: typeof AuthServicesIndexRoute
AuthDomainsDomainIdDnsRoute: typeof AuthDomainsDomainIdDnsRoute
AuthDomainsDomainIdIndexRoute: typeof AuthDomainsDomainIdIndexRoute
}
@@ -360,9 +522,10 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthSettingsRouteRoute: AuthSettingsRouteRouteWithChildren,
AuthCertificatesRoute: AuthCertificatesRoute,
AuthGroupsRoute: AuthGroupsRouteWithChildren,
AuthServicesRoute: AuthServicesRoute,
AuthIndexRoute: AuthIndexRoute,
AuthServicesServiceIdRouteRoute: AuthServicesServiceIdRouteRouteWithChildren,
AuthDomainsIndexRoute: AuthDomainsIndexRoute,
AuthServicesIndexRoute: AuthServicesIndexRoute,
AuthDomainsDomainIdDnsRoute: AuthDomainsDomainIdDnsRoute,
AuthDomainsDomainIdIndexRoute: AuthDomainsDomainIdIndexRoute,
}
+193 -164
View File
@@ -7,6 +7,7 @@ import {
FolderTreeIcon,
GlobeIcon,
PlugIcon,
HeartPulseIcon,
ServerIcon,
ShieldCheckIcon,
} from 'lucide-react'
@@ -15,16 +16,17 @@ import {
certSummaryQueryOptions,
domainsListQueryOptions,
groupsQueryOptions,
opsSummaryQueryOptions,
serviceGroupsQueryOptions,
} from '@/queries'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { EmptyState } from '@/components/empty-state'
import {
CertStatusChart,
GroupDomainsChart,
OpsDashboard,
QuickActionGrid,
AttentionQueue,
type KpiStatCard,
type QuickActionItem,
} from '@/components/reui-kit'
@@ -32,10 +34,12 @@ import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemTitle,
} 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'
@@ -48,23 +52,18 @@ export const Route = createFileRoute('/_auth/')({
queryClient.ensureQueryData(groupsQueryOptions()),
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
queryClient.ensureQueryData(certificatesQueryOptions()),
queryClient.ensureQueryData(opsSummaryQueryOptions()),
]),
component: DashboardPage,
})
function countByStatus(summary: [string, number][] | undefined, statuses: string[]) {
if (!summary) return 0
return summary
.filter(([status]) => statuses.includes(status))
.reduce((sum, [, count]) => sum + count, 0)
}
function DashboardPage() {
const { data: domains, isLoading: domainsLoading } = useQuery(domainsListQueryOptions())
const { data: summary, isLoading: summaryLoading } = useQuery(certSummaryQueryOptions())
const { data: certs } = useQuery(certificatesQueryOptions())
const { data: groups } = useQuery(groupsQueryOptions())
const { data: serviceData } = useQuery(serviceGroupsQueryOptions())
const { data: ops } = useQuery(opsSummaryQueryOptions())
const { data: appSettings } = useQuery({
queryKey: ['app-settings'],
queryFn: () => api.get<{ showQuickActions?: boolean }>('/api/v1/settings'),
@@ -200,8 +199,7 @@ function DashboardPage() {
const attentionCount = attentionServices.length
const certWarnings = countByStatus(summary, ['warning', 'expired', 'error'])
const certOk = countByStatus(summary, ['active', 'ok'])
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
useEffect(() => {
if (isLoading) return
@@ -222,7 +220,6 @@ function DashboardPage() {
)
}, [isLoading, summary, statusChartData, groupChartData, domains, groups])
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
const kpiCards: KpiStatCard[] = [
{
id: 'domains',
@@ -237,19 +234,10 @@ function DashboardPage() {
variant: ungroupedCount > 0 ? 'warning' : 'default',
to: '/domains',
},
{
id: 'groups',
label: 'Группы',
value: groups?.length ?? 0,
hint: `${groupChartData.filter((g) => g.count > 0).length} с зонами`,
icon: <FolderTreeIcon aria-hidden />,
iconClassName: 'text-primary',
to: '/groups',
},
{
id: 'services',
label: 'Сервисы',
value: serviceCount,
value: ops?.services ?? serviceCount,
hint: attentionCount
? `${attentionCount} требуют внимания`
: ungroupedServiceCount
@@ -262,17 +250,37 @@ function DashboardPage() {
search: { domainId: undefined },
},
{
id: 'certs',
label: 'Сертификаты',
value: certs?.length ?? 0,
hint:
certWarnings > 0
? `${certOk} в норме · ${certWarnings} внимания`
: `${certOk} в норме`,
icon: <ShieldCheckIcon aria-hidden />,
id: 'nodes',
label: 'Ноды',
value: ops?.nodes ?? 0,
hint: `${ops?.healthy ?? 0} healthy`,
icon: <HeartPulseIcon aria-hidden />,
iconClassName: 'text-info',
to: '/services',
},
{
id: 'healthy',
label: 'Healthy',
value: ops?.healthy ?? 0,
icon: <ActivityIcon aria-hidden />,
iconClassName: 'text-success',
},
{
id: 'unhealthy',
label: 'Unhealthy',
value: ops?.unhealthy ?? 0,
variant: (ops?.unhealthy ?? 0) > 0 ? 'destructive' : 'default',
icon: <AlertTriangleIcon aria-hidden />,
iconClassName: 'text-destructive',
},
{
id: 'failovers',
label: 'Failover',
value: ops?.active_failovers ?? 0,
hint: 'активные переключения',
variant: (ops?.active_failovers ?? 0) > 0 ? 'warning' : 'default',
icon: <ActivityIcon aria-hidden />,
iconClassName: 'text-warning',
variant: certWarnings > 0 ? 'warning' : 'default',
to: '/certificates',
},
]
@@ -300,143 +308,164 @@ function DashboardPage() {
</>
}
queue={
<div className="grid gap-3 lg:grid-cols-3">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<ActivityIcon
className="text-destructive size-4 shrink-0"
aria-hidden="true"
/>
<h3 className="text-sm font-semibold">Проблемы health-check</h3>
{attentionCount > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
({attentionCount})
</span>
) : null}
</div>
{attentionServices.length === 0 ? (
<div className="flex min-h-24 items-center justify-center py-4">
<EmptyState
title="Нет проблем"
description="Нет сервисов со статусом Down или Slow"
centered={false}
stackedIcon={false}
/>
</div>
) : (
<ItemGroup className="gap-2">
{attentionServices.map((service) => (
<Item key={service.id} variant="outline" size="sm">
<ItemContent className="flex flex-row items-center justify-between gap-2">
<ItemTitle className="truncate font-medium">
<Link
to="/services"
className="hover:underline"
>
<AttentionQueue
columns={[
{
id: 'health',
title: 'Проблемы health-check',
icon: ActivityIcon,
iconClassName: 'text-destructive [&_svg]:text-current',
count: attentionCount,
countVariant: 'destructive-light',
emptyTitle: 'Нет проблем',
emptyDescription: 'Нет сервисов со статусом Down или Slow',
emptyAction: (
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link to="/services" />}
>
К сервисам
</Button>
),
children: (
<ItemGroup className="gap-2">
{attentionServices.map((service) => (
<Item key={service.id} variant="outline" size="sm">
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{service.name}
</Link>
</ItemTitle>
<HealthCheckBadge
status={
(service.health_status as
| 'up'
| 'down'
| 'degraded'
| 'unknown') ?? 'unknown'
}
latencyMs={service.health_latency_ms}
size="xs"
/>
</ItemContent>
</Item>
))}
</ItemGroup>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<AlertTriangleIcon
className="text-warning size-4 shrink-0"
aria-hidden="true"
/>
<h3 className="text-sm font-semibold">Истекающие сертификаты</h3>
{expiringCerts.length > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
({expiringCerts.length})
</span>
) : null}
</div>
{expiringCerts.length === 0 ? (
<div className="flex min-h-24 items-center justify-center py-4">
<EmptyState
title="Нет истечений"
description="В пределах 14 дней истечений нет"
centered={false}
stackedIcon={false}
/>
</div>
) : (
<ItemGroup className="gap-2">
{expiringCerts.map(({ cert, ts }) => (
<Item key={cert.id} variant="outline" size="sm">
<ItemContent className="flex flex-row items-center justify-between gap-2">
<ItemTitle className="truncate font-medium">{cert.hostname}</ItemTitle>
<div className="flex shrink-0 items-center gap-2">
<StatusBadge status={cert.status} />
</ItemTitle>
</ItemContent>
<ItemActions>
<HealthCheckBadge
status={
(service.health_status as
| 'up'
| 'down'
| 'degraded'
| 'unknown') ?? 'unknown'
}
latencyMs={service.health_latency_ms}
size="xs"
/>
<Button
variant="outline"
size="sm"
nativeButton={false}
render={
<Link
to="/services"
search={{ serviceId: service.id }}
/>
}
>
Открыть
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
),
},
{
id: 'certs',
title: 'Истекающие сертификаты',
icon: AlertTriangleIcon,
iconClassName: 'text-warning [&_svg]:text-current',
count: expiringCerts.length,
countVariant: 'warning-light',
emptyTitle: 'Нет истечений',
emptyDescription: 'В пределах 14 дней истечений нет',
emptyAction: (
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link to="/certificates" />}
>
К сертификатам
</Button>
),
children: (
<ItemGroup className="gap-2">
{expiringCerts.map(({ cert, ts }) => (
<Item key={cert.id} variant="outline" size="sm">
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{cert.hostname}
</ItemTitle>
<span className="text-muted-foreground text-xs tabular-nums">
{formatRelative(new Date(ts).toISOString())}
</span>
</div>
</ItemContent>
</Item>
))}
</ItemGroup>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<FolderTreeIcon
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
<h3 className="text-sm font-semibold">Домены без группы</h3>
{ungroupedCount > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
({ungroupedCount})
</span>
) : null}
</div>
{ungroupedDomains.length === 0 ? (
<div className="flex min-h-24 items-center justify-center py-4">
<EmptyState
title="Всё сгруппировано"
description="Все домены в группах"
centered={false}
stackedIcon={false}
/>
</div>
) : (
<ItemGroup className="gap-2">
{ungroupedDomains.map((domain) => (
<Item key={domain.id} variant="outline" size="sm">
<ItemContent className="flex flex-row items-center justify-between gap-2">
<ItemTitle className="truncate font-medium">
{domain.zone_name}
</ItemTitle>
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
className="text-primary text-xs font-medium hover:underline"
>
Открыть
</Link>
</ItemContent>
</Item>
))}
</ItemGroup>
)}
</div>
</div>
</ItemContent>
<ItemActions>
<StatusBadge status={cert.status} />
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link to="/certificates" />}
>
Открыть
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
),
},
{
id: 'ungrouped',
title: 'Домены без группы',
icon: FolderTreeIcon,
iconClassName: 'text-info [&_svg]:text-current',
count: ungroupedCount,
countVariant: 'warning-light',
emptyTitle: 'Всё сгруппировано',
emptyDescription: 'Все домены в группах',
emptyAction: (
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link to="/domains" />}
>
К доменам
</Button>
),
children: (
<ItemGroup className="gap-2">
{ungroupedDomains.map((domain) => (
<Item key={domain.id} variant="outline" size="sm">
<ItemContent className="min-w-0">
<ItemTitle className="truncate font-medium">
{domain.zone_name}
</ItemTitle>
</ItemContent>
<ItemActions>
<Button
variant="outline"
size="sm"
nativeButton={false}
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
/>
}
>
Открыть
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
),
},
]}
/>
}
/>
</PageShell>
@@ -0,0 +1,104 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
import { DetailPanel, KpiStatGrid } from '@/components/reui-kit'
import { EmptyState } from '@/components/empty-state'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { HealthTimeline } from '@/components/health/health-timeline'
import { HealthCheckBadge } from '@/components/health-check-badge'
import {
serviceHealthLogQueryOptions,
serviceViewQueryOptions,
} from '@/queries'
import { formatDate } from '@/lib/format'
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
component: ServiceHealthPage,
})
export function ServiceHealthPage() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const serviceQuery = useQuery(serviceViewQueryOptions(id))
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
const service = serviceQuery.data
const items = logQuery.data?.items ?? []
const ipHealth = service?.ip_health ?? []
const kpiCards = ipHealth.map((row) => {
const variant =
row.status === 'down'
? ('destructive' as const)
: row.status === 'degraded'
? ('warning' as const)
: ('default' as const)
return {
id: row.ip,
label: row.ip,
value: row.latency_ms != null ? `${row.latency_ms} мс` : '—',
hint: row.colo ? `colo ${row.colo}` : row.provider === 'cloudflare' ? 'Worker' : 'Local',
icon: row.provider === 'cloudflare' ? <GlobeIcon /> : <ServerIcon />,
variant,
footer: (
<HealthCheckBadge
status={row.status}
latencyMs={row.latency_ms}
lastCheckedAt={row.last_checked_at}
lastError={row.last_error}
colo={row.colo}
provider={row.provider}
size="xs"
/>
),
}
})
return (
<DetailPanel>
<DetailPanel.Header
title="Health"
description="Снимок проб этого сервиса. Cloudflare = Worker с edge, не Health Checks API."
/>
<Alert>
<AlertTitle>XOR провайдеров</AlertTitle>
<AlertDescription>
Local ходит с API CFDM; Cloudflare через Worker. Cron и пороги Slow/Down общие, в{' '}
<Link to="/settings/health" className="text-foreground underline">
Настройках Health-check
</Link>
. Если Worker не задан, цель не пробируется как Local.
</AlertDescription>
</Alert>
{kpiCards.length > 0 ? (
<KpiStatGrid cards={kpiCards} />
) : (
<EmptyState
icon={ActivityIcon}
title="Нет проб"
description="Включите health-check на привязке — статус IP появится после cron."
/>
)}
<DetailPanel.Header
title="Журнал проб"
description={
items[0]?.checked_at
? `Последняя: ${formatDate(items[0].checked_at)}`
: 'Последние пробы по IP этого сервиса'
}
/>
<HealthTimeline
events={items.map((row) => ({
id: row.id,
hostname: row.ip,
type: row.provider,
status: row.status,
latency_ms: row.latency_ms,
error: row.error,
checked_at: row.checked_at,
colo: row.colo,
provider: row.provider,
}))}
/>
</DetailPanel>
)
}
@@ -0,0 +1,94 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
import { DetailPanel } from '@/components/reui-kit'
import { EmptyState } from '@/components/empty-state'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { Badge } from '@/components/reui/badge'
import { serviceOverviewQueryOptions } from '@/queries'
export const Route = createFileRoute('/_auth/services/$serviceId/')({
component: ServiceOverviewPage,
})
function ServiceOverviewPage() {
const { serviceId } = Route.useParams()
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
const overview = data as {
service: {
name: string
enabled: boolean
health_status: 'up' | 'down' | 'degraded' | 'unknown'
domains: Array<{ fqdn: string; zone_name: string }>
}
nodes: Array<{ id: number; address: string; health_status: string }>
routing_strategy: string
active_addresses: string[]
} | undefined
if (!overview) {
return (
<EmptyState
title="Сервис не найден"
description="Вернитесь в каталог и выберите сервис."
/>
)
}
const nodes = overview.nodes ?? []
const domains = overview.service.domains ?? []
return (
<DetailPanel>
<DetailPanel.Header
title={overview.service.name}
description={`Маршрутизация: ${overview.routing_strategy}. Активные IP: ${
overview.active_addresses.join(', ') || '—'
}`}
actions={
<HealthCheckBadge status={overview.service.health_status} />
}
/>
<DetailPanel.Metrics
cards={[
{
id: 'subdomains',
icon: <GlobeIcon />,
label: 'Поддомены',
description:
domains.length > 0
? domains.map((d) => d.fqdn).join(', ')
: 'Нет привязанных FQDN',
footer: <Badge variant="outline">{domains.length}</Badge>,
},
{
id: 'nodes',
icon: <ServerIcon />,
label: 'Ноды',
description:
nodes.length > 0
? nodes.map((n) => n.address).join(', ')
: 'Добавьте ноду, чтобы публиковать DNS',
footer: <Badge variant="outline">{nodes.length}</Badge>,
},
{
id: 'health',
icon: <ActivityIcon />,
label: 'Пул',
description:
overview.active_addresses.length > 0
? 'Здоровые адреса участвуют в DNS'
: 'unknown не попадает в пул, пока не станет healthy',
},
]}
/>
{domains.length === 0 && nodes.length === 0 ? (
<EmptyState
title="Пустой сервис"
description="Добавьте поддомен и ноду, затем настройте health-check."
stackedIcon
/>
) : null}
</DetailPanel>
)
}
@@ -0,0 +1,143 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { PlusIcon } from 'lucide-react'
import { DetailPanel } from '@/components/reui-kit'
import { EmptyState } from '@/components/empty-state'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { createServiceNode, deleteServiceNode, serviceNodesQueryOptions } from '@/queries'
export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({
component: ServiceNodesPage,
})
interface NodeRow {
id: number
address: string
port: number | null
protocol: string
health_status: 'up' | 'down' | 'degraded' | 'unknown' | 'healthy' | 'unhealthy' | 'checking' | 'disabled'
weight: number
priority: number
}
function mapHealth(
status: NodeRow['health_status'],
): 'up' | 'down' | 'degraded' | 'unknown' {
if (status === 'healthy' || status === 'up') return 'up'
if (status === 'unhealthy' || status === 'down') return 'down'
if (status === 'degraded') return 'degraded'
return 'unknown'
}
export function ServiceNodesPage() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const queryClient = useQueryClient()
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
const nodes = (nodesQuery.data ?? []) as NodeRow[]
const [open, setOpen] = useState(false)
const form = useForm<{ address: string; port: string }>({
defaultValues: { address: '', port: '' },
})
const createMut = useMutation({
mutationFn: (values: { address: string; port: string }) =>
createServiceNode(id, {
address: values.address.trim(),
port: values.port ? Number(values.port) : null,
}),
onSuccess: async () => {
toast.success('Нода добавлена, статус CHECKING')
await queryClient.invalidateQueries({ queryKey: ['services'] })
setOpen(false)
form.reset()
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
})
const deleteMut = useMutation({
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
onSuccess: async () => {
toast.success('Нода удалена')
await queryClient.invalidateQueries({ queryKey: ['services'] })
},
})
return (
<DetailPanel>
<DetailPanel.Header
title="Ноды"
description="Адреса происхождения сервиса."
actions={
<Button size="sm" onClick={() => setOpen(true)}>
<PlusIcon className="size-4" aria-hidden />
Добавить ноду
</Button>
}
/>
{nodes.length === 0 ? (
<EmptyState
title="Нет нод"
description="Добавьте IP, затем настройте health-check."
/>
) : (
<div className="flex flex-col gap-2">
{nodes.map((node) => (
<div
key={node.id}
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
>
<div className="flex flex-col gap-1">
<span className="font-medium">{node.address}</span>
<span className="text-muted-foreground text-xs">
{node.protocol}
{node.port ? `:${node.port}` : ''} · вес {node.weight} · приоритет{' '}
{node.priority}
</span>
</div>
<div className="flex items-center gap-2">
<HealthCheckBadge status={mapHealth(node.health_status)} />
<Button
size="sm"
variant="outline"
onClick={() => deleteMut.mutate(node.id)}
>
Удалить
</Button>
</div>
</div>
))}
</div>
)}
<FormSheet
open={open}
onOpenChange={setOpen}
title="Добавить ноду"
description="IP станет CHECKING до порога успешных проверок."
form={form}
onSubmit={(values) => createMut.mutate(values)}
footer={
<LoadingButton type="submit" isLoading={createMut.isPending}>
Добавить
</LoadingButton>
}
>
<FormFieldSimple label="IP" htmlFor="address">
<Input id="address" {...form.register('address')} placeholder="10.0.0.10" />
</FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
<Input id="port" {...form.register('port')} placeholder="443" />
</FormFieldSimple>
</FormSheet>
</DetailPanel>
)
}
@@ -0,0 +1,79 @@
import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowLeftIcon } from 'lucide-react'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { Button } from '@cfdm/ui/components/button'
import { serviceOverviewQueryOptions } from '@/queries'
import { cn } from '@cfdm/ui/lib/utils'
export const Route = createFileRoute('/_auth/services/$serviceId')({
loader: ({ context: { queryClient }, params }) =>
queryClient.ensureQueryData(serviceOverviewQueryOptions(Number(params.serviceId))),
component: ServiceLayout,
})
const tabs = [
{ to: '/services/$serviceId', label: 'Обзор', exact: true },
{ to: '/services/$serviceId/subdomains', label: 'Поддомены', exact: false },
{ to: '/services/$serviceId/nodes', label: 'Ноды', exact: false },
{ to: '/services/$serviceId/health', label: 'Health', exact: false },
{ to: '/services/$serviceId/routing', label: 'Маршрутизация', exact: false },
] as const
function ServiceLayout() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const pathname = useRouterState({ select: (s) => s.location.pathname })
const overview = useQuery(serviceOverviewQueryOptions(id))
const name = (overview.data as { service?: { name?: string } } | undefined)?.service?.name
return (
<PageShell>
<PageHeader
title={name ?? 'Сервис'}
description="Domain → Service → Node → Health → Failover"
actions={
<Button
variant="outline"
size="sm"
render={<Link to="/services" />}
>
<ArrowLeftIcon className="size-4" aria-hidden />
К каталогу
</Button>
}
/>
<nav className="flex flex-wrap gap-4 border-b">
{tabs.map((tab) => {
const href = tab.to.replace('$serviceId', serviceId)
const active = tab.exact
? pathname === `/services/${serviceId}` || pathname === `/services/${serviceId}/`
: pathname.startsWith(href)
return (
<Link
key={tab.to}
to={tab.to}
params={{ serviceId }}
className={cn(
'text-muted-foreground hover:text-foreground pb-3 text-sm font-medium',
active && 'text-foreground border-b-2 border-primary',
)}
>
{tab.label}
</Link>
)
})}
</nav>
<QueryState
isLoading={overview.isLoading}
isError={overview.isError}
error={overview.error}
onRetry={() => void overview.refetch()}
>
<Outlet />
</QueryState>
</PageShell>
)
}
@@ -0,0 +1,59 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { DetailPanel } from '@/components/reui-kit'
import { FailoverTimeline } from '@/components/failover-timeline'
import { Badge } from '@/components/reui/badge'
import { serviceOverviewQueryOptions } from '@/queries'
export const Route = createFileRoute('/_auth/services/$serviceId/routing')({
component: ServiceRoutingPage,
})
export function ServiceRoutingPage() {
const { serviceId } = Route.useParams()
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
const overview = data as {
routing_strategy: string
active_addresses: string[]
nodes: Array<{
address: string
health_status: string
consecutive_failures: number
last_failure_reason: string | null
}>
} | undefined
const events =
overview?.nodes
.filter(
(node) =>
node.health_status === 'unhealthy' ||
node.health_status === 'down' ||
node.health_status === 'checking',
)
.map((node) => ({
id: node.address,
title: `${node.address}: ${node.health_status}`,
detail: node.last_failure_reason
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
: `fail ${node.consecutive_failures}`,
})) ?? []
return (
<DetailPanel>
<DetailPanel.Header
title="Маршрутизация"
description="Round Robin / Failover. Weighted на DNS = alias Round Robin."
actions={<Badge variant="outline">{overview?.routing_strategy ?? 'round_robin'}</Badge>}
/>
<p className="text-sm">
Активные адреса:{' '}
{overview?.active_addresses.join(', ') || 'нет (unknown не в пуле)'}
</p>
<p className="text-muted-foreground text-xs">
Запись обновляется в Cloudflare. Распространение зависит от TTL.
</p>
<FailoverTimeline events={events} />
</DetailPanel>
)
}
@@ -0,0 +1,114 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ArrowRightLeftIcon } from 'lucide-react'
import { DetailPanel } from '@/components/reui-kit'
import { EmptyState } from '@/components/empty-state'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@/components/reui/badge'
import { ChangeIpSheet } from '@/components/change-ip-sheet'
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
import { serviceOverviewQueryOptions } from '@/queries'
export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({
component: ServiceSubdomainsPage,
})
export function ServiceSubdomainsPage() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const { data } = useQuery(serviceOverviewQueryOptions(id))
const overview = data as {
service: {
domains: Array<{
binding_id: number
domain_id: number
fqdn: string
zone_name: string
target_ips: string[]
}>
}
} | undefined
const rows = overview?.service.domains ?? []
const [changeIp, setChangeIp] = useState<{
bindingId: number
ip?: string
} | null>(null)
const [changeDomain, setChangeDomain] = useState(false)
const fromDomainId = useMemo(
() => rows[0]?.domain_id ?? null,
[rows],
)
return (
<DetailPanel>
<DetailPanel.Header
title="Поддомены"
description="FQDN сервиса в одной или нескольких зонах Cloudflare."
actions={
<Button
variant="outline"
size="sm"
onClick={() => setChangeDomain(true)}
disabled={rows.length === 0}
>
<ArrowRightLeftIcon className="size-4" aria-hidden />
Сменить домен
</Button>
}
/>
{rows.length === 0 ? (
<EmptyState
title="Нет поддоменов"
description="Привяжите FQDN к сервису из карточки редактирования."
/>
) : (
<div className="flex flex-col gap-2">
{rows.map((row) => (
<div
key={row.binding_id}
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
>
<div className="flex min-w-0 flex-col gap-1">
<span className="font-medium">{row.fqdn}</span>
<span className="text-muted-foreground text-xs">
{row.zone_name} · {row.target_ips.join(', ') || 'нет IP'}
</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{row.target_ips.length} IP</Badge>
<Button
size="sm"
variant="outline"
onClick={() =>
setChangeIp({
bindingId: row.binding_id,
ip: row.target_ips[0],
})
}
>
Сменить IP
</Button>
</div>
</div>
))}
</div>
)}
<ChangeIpSheet
open={changeIp != null}
onOpenChange={(open) => {
if (!open) setChangeIp(null)
}}
bindingId={changeIp?.bindingId ?? null}
serviceId={id}
currentIp={changeIp?.ip}
/>
<ChangeDomainSheet
open={changeDomain}
onOpenChange={setChangeDomain}
serviceId={id}
fromDomainId={fromDomainId}
/>
</DetailPanel>
)
}
@@ -43,7 +43,7 @@ import {
import { ServiceKanbanCard } from '@/components/kanban/service-kanban-card'
import { Button } from '@cfdm/ui/components/button'
export const Route = createFileRoute('/_auth/services')({
export const Route = createFileRoute('/_auth/services/')({
validateSearch: (
search: Record<string, unknown>,
): { domainId?: number; serviceId?: number; view?: 'board' } => ({
@@ -81,6 +81,29 @@ function setServiceEnabled(
}
}
function setCatalogServiceIpEnabled(
data: ServiceGroupsResponse,
serviceId: number,
ip: string,
enabled: boolean,
): ServiceGroupsResponse {
const patch = (service: ServiceView): ServiceView =>
service.id === serviceId
? {
...service,
ip_enabled: { ...(service.ip_enabled ?? {}), [ip]: enabled },
}
: service
return {
groups: data.groups.map((group) => ({
...group,
services: group.services.map(patch),
})),
ungrouped: data.ungrouped.map(patch),
}
}
const GROUP_DOT_COLORS = [
'bg-chart-1',
'bg-chart-2',
@@ -150,6 +173,10 @@ function ServicesPage() {
const [deletingId, setDeletingId] = useState<number | null>(null)
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
const [togglingIp, setTogglingIp] = useState<{
serviceId: number
ip: string
} | null>(null)
const [bulkToggling, setBulkToggling] = useState(false)
const [activeTab, setActiveTab] = useState('all')
const queryClient = useQueryClient()
@@ -364,6 +391,54 @@ function ServicesPage() {
},
})
const toggleServiceIpMutation = useMutation({
mutationFn: ({
id,
ip,
enabled,
}: {
id: number
ip: string
enabled: boolean
}) =>
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, {
ip,
enabled,
}),
onMutate: async ({ id, ip, enabled }) => {
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
serviceGroupKeys.all,
)
if (previous) {
queryClient.setQueryData(
serviceGroupKeys.all,
setCatalogServiceIpEnabled(previous, id, ip, enabled),
)
}
return { previous }
},
onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(serviceGroupKeys.all, context.previous)
}
toast.error(
err instanceof Error ? err.message : 'Не удалось переключить IP',
)
},
onSuccess: (_data, { enabled }) => {
toast.success(
enabled
? 'IP включён и добавлен в DNS-привязки'
: 'IP выключен и снят с DNS-привязок',
)
},
onSettled: async () => {
setTogglingIp(null)
await invalidateAll()
},
})
const deleteGroupMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`),
onSuccess: async () => {
@@ -398,6 +473,15 @@ function ServicesPage() {
toggleServiceMutation.mutate({ id: serviceId, enabled })
}
function handleServiceIpToggle(
serviceId: number,
ip: string,
enabled: boolean,
) {
setTogglingIp({ serviceId, ip })
toggleServiceIpMutation.mutate({ id: serviceId, ip, enabled })
}
function handleOpenCreateService(groupId: number | null = null) {
setDefaultGroupId(groupId)
setCreateSheetOpen(true)
@@ -456,7 +540,6 @@ function ServicesPage() {
board.columns.map((column, index) => ({
id: column.id,
title: column.title,
description: column.domain ?? undefined,
healthStatus: column.group?.health_status,
healthLatencyMs: column.group?.health_latency_ms,
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
@@ -494,7 +577,7 @@ function ServicesPage() {
const pageDescription = filteredDomain
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
: 'Группы, FQDN и доступность сервисов'
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'
const sheets = (
<>
@@ -603,9 +686,11 @@ function ServicesPage() {
isLoading
hideHeader
togglingId={null}
togglingIp={null}
onEditService={() => {}}
onDeleteService={() => {}}
onToggleService={() => {}}
onToggleServiceIp={() => {}}
onEditGroup={() => {}}
onDeleteGroup={() => {}}
onAddServiceToGroup={() => {}}
@@ -703,12 +788,14 @@ function ServicesPage() {
domainId={domainId}
domainLabel={filteredDomain?.zone_name}
togglingId={togglingServiceId}
togglingIp={togglingIp}
activeTab={activeTab}
onTabChange={setActiveTab}
hideHeader
onEditService={setEditingService}
onDeleteService={setDeletingService}
onToggleService={handleServiceToggle}
onToggleServiceIp={handleServiceIpToggle}
onEditGroup={setEditingGroup}
onDeleteGroup={setDeletingGroup}
onAddServiceToGroup={handleOpenCreateService}
@@ -0,0 +1,348 @@
import { useEffect } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { HeartPulseIcon } from 'lucide-react'
import { api } from '@/lib/api-client'
import { SettingRow } from '@/components/setting-row'
import { LoadingButton } from '@/components/loading-button'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from '@/components/reui/number-field'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
const formSchema = z.object({
healthCheckCron: z.string().trim().min(1, 'Укажите cron').max(64),
healthDegradedFailures: z.number().int().min(1).max(20),
healthDownFailures: z.number().int().min(1).max(50),
healthLatencyWarnMs: z.number().int().min(50).max(60_000),
healthSuccessRecoveries: z.number().int().min(1).max(20),
healthWorkerUrl: z.string().trim().url('Некорректный URL').or(z.literal('')),
healthWorkerToken: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.healthDownFailures < data.healthDegradedFailures) {
ctx.addIssue({
code: 'custom',
message: 'Не меньше порога degraded',
path: ['healthDownFailures'],
})
}
})
type FormValues = z.infer<typeof formSchema>
type SettingsResponse = FormValues & {
id: string
healthWorkerTokenSet?: boolean
}
export const Route = createFileRoute('/_auth/settings/health')({
component: HealthSettingsPage,
})
function CompactNumberInput({
id,
value,
min,
max,
disabled,
onValueChange,
}: {
id: string
value: number
min: number
max: number
disabled?: boolean
onValueChange: (next: number) => void
}) {
return (
<NumberField
id={id}
size="sm"
value={value}
min={min}
max={max}
disabled={disabled}
onValueChange={(next) => {
if (next != null) onValueChange(next)
}}
>
<NumberFieldGroup className="w-36">
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
)
}
function HealthSettingsPage() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({
queryKey: ['app-settings'],
queryFn: () => api.get<SettingsResponse>('/api/v1/settings'),
})
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
healthCheckCron: '0 */2 * * * *',
healthDegradedFailures: 1,
healthDownFailures: 2,
healthLatencyWarnMs: 1000,
healthSuccessRecoveries: 2,
healthWorkerUrl: '',
healthWorkerToken: '',
},
})
useEffect(() => {
if (!data) return
form.reset({
healthCheckCron: data.healthCheckCron,
healthDegradedFailures: data.healthDegradedFailures,
healthDownFailures: data.healthDownFailures,
healthLatencyWarnMs: data.healthLatencyWarnMs,
healthSuccessRecoveries: data.healthSuccessRecoveries,
healthWorkerUrl: data.healthWorkerUrl ?? '',
healthWorkerToken: '',
})
}, [data, form])
const saveMut = useMutation({
mutationFn: (values: FormValues) => {
const payload: Record<string, unknown> = {
healthCheckCron: values.healthCheckCron,
healthDegradedFailures: values.healthDegradedFailures,
healthDownFailures: values.healthDownFailures,
healthLatencyWarnMs: values.healthLatencyWarnMs,
healthSuccessRecoveries: values.healthSuccessRecoveries,
healthWorkerUrl: values.healthWorkerUrl,
}
if (values.healthWorkerToken?.trim()) {
payload.healthWorkerToken = values.healthWorkerToken.trim()
}
return api.patch<SettingsResponse>('/api/v1/settings', payload)
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
toast.success('Настройки health-check сохранены')
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
return (
<form
className="flex w-full flex-col gap-4"
onSubmit={(event) =>
void form.handleSubmit((values) => saveMut.mutate(values))(event)
}
>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle className="flex items-center gap-2">
<HeartPulseIcon className="size-4" aria-hidden />
Local health-check
</FrameTitle>
<FrameDescription>
Расписание и пороги движка общие для Local и Cloudflare Worker.
Тип/порт/path задаются в карточке сервиса.
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<FieldGroup className="gap-0">
<SettingRow
title="Cron"
description="Расписание проб (6 полей: сек мин час день месяц день-недели). Env: HEALTH_CHECK_CRON."
labelFor="health-cron"
stacked
>
<Input
id="health-cron"
className="font-mono"
spellCheck={false}
autoComplete="off"
disabled={isLoading || saveMut.isPending}
{...form.register('healthCheckCron')}
/>
</SettingRow>
{form.formState.errors.healthCheckCron ? (
<p className="text-destructive px-5 pb-2 text-sm">
{form.formState.errors.healthCheckCron.message}
</p>
) : null}
<SettingRow
title="Ошибок до Slow"
description="Подряд неуспешных проб до статуса degraded. Env: HEALTH_DEGRADED_FAILURES."
labelFor="health-degraded"
compact
>
<Controller
control={form.control}
name="healthDegradedFailures"
render={({ field }) => (
<CompactNumberInput
id="health-degraded"
value={field.value}
min={1}
max={20}
disabled={isLoading || saveMut.isPending}
onValueChange={field.onChange}
/>
)}
/>
</SettingRow>
<SettingRow
title="Ошибок до Down"
description="Подряд неуспешных проб до статуса down. Env: HEALTH_DOWN_FAILURES."
labelFor="health-down"
compact
>
<Controller
control={form.control}
name="healthDownFailures"
render={({ field }) => (
<CompactNumberInput
id="health-down"
value={field.value}
min={1}
max={50}
disabled={isLoading || saveMut.isPending}
onValueChange={field.onChange}
/>
)}
/>
</SettingRow>
{form.formState.errors.healthDownFailures ? (
<p className="text-destructive px-5 pb-2 text-sm">
{form.formState.errors.healthDownFailures.message}
</p>
) : null}
<SettingRow
title="Латентность Slow, мс"
description="Порог задержки для degraded при успешной пробе. Env: HEALTH_LATENCY_WARN_MS."
labelFor="health-latency"
compact
>
<Controller
control={form.control}
name="healthLatencyWarnMs"
render={({ field }) => (
<CompactNumberInput
id="health-latency"
value={field.value}
min={50}
max={60_000}
disabled={isLoading || saveMut.isPending}
onValueChange={field.onChange}
/>
)}
/>
</SettingRow>
<SettingRow
title="Успехов для recovery"
description="Подряд успешных проб, чтобы выйти из Checking в Healthy. Env: HEALTH_SUCCESS_RECOVERIES."
labelFor="health-recoveries"
compact
>
<Controller
control={form.control}
name="healthSuccessRecoveries"
render={({ field }) => (
<CompactNumberInput
id="health-recoveries"
value={field.value}
min={1}
max={20}
disabled={isLoading || saveMut.isPending}
onValueChange={field.onChange}
/>
)}
/>
</SettingRow>
<SettingRow
title="URL Worker"
description="https://cfdm-health-probe.<account>.workers.dev. Env: HEALTH_WORKER_URL."
labelFor="health-worker-url"
compact
>
<Input
id="health-worker-url"
type="url"
placeholder="https://cfdm-health-probe.workers.dev"
disabled={isLoading || saveMut.isPending}
{...form.register('healthWorkerUrl')}
/>
</SettingRow>
{form.formState.errors.healthWorkerUrl ? (
<p className="text-destructive px-5 pb-2 text-sm">
{form.formState.errors.healthWorkerUrl.message}
</p>
) : null}
<SettingRow
title="Токен Worker"
description={
data?.healthWorkerTokenSet
? 'Токен задан. Оставьте пустым, чтобы не менять.'
: 'Authorization Bearer. Env: HEALTH_WORKER_TOKEN.'
}
labelFor="health-worker-token"
compact
last
>
<Input
id="health-worker-token"
type="password"
autoComplete="new-password"
placeholder={data?.healthWorkerTokenSet ? '••••••••' : 'секрет'}
disabled={isLoading || saveMut.isPending}
{...form.register('healthWorkerToken')}
/>
</SettingRow>
</FieldGroup>
<Alert>
<AlertTitle>Cloudflare Worker, не Health Checks API</AlertTitle>
<AlertDescription>
На Free-плане продукта Health Checks нет. CFDM вызывает Worker с edge;
cron остаётся здесь. Лимит Free Workers 100k запросов/сутки (cron × число IP).
</AlertDescription>
</Alert>
<FrameFooter className="flex flex-row justify-end">
<LoadingButton
type="submit"
isLoading={saveMut.isPending}
disabled={isLoading || !form.formState.isDirty}
>
Сохранить
</LoadingButton>
</FrameFooter>
</FramePanel>
</Frame>
</form>
)
}
+39 -7
View File
@@ -20,10 +20,13 @@ Manage Cloudflare zones, DNS records, domain groups, and TLS certificate expiry
| `ADMIN_USERNAME` | Admin username |
| `ADMIN_PASSWORD_HASH` | Argon2 hash (empty = dev `admin`/`admin`) |
| `LOG_LEVEL` | Уровень логов API (`info`, `debug`) |
| `HEALTH_CHECK_CRON` | Cron для health-check (default `*/30 * * * * *`) |
| `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`) |
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`) |
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`) |
| `HEALTH_CHECK_CRON` | Cron для health-check (default `0 */2 * * * *`). Переопределяется в **Настройки → Health-check**. |
| `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`). То же в UI. |
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`). То же в UI. |
| `HEALTH_SUCCESS_RECOVERIES` | Успехов подряд для recovery `CHECKING → HEALTHY` (default `2`). То же в UI. |
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`). То же в UI. |
| `HEALTH_WORKER_URL` | URL Worker health-probe (fallback). Переопределяется в **Настройки → Health-check**. |
| `HEALTH_WORKER_TOKEN` | Bearer-токен Worker (fallback). В GET `/settings` не отдаётся целиком. |
## Load balancing & health checks
@@ -37,11 +40,40 @@ health-check работают на двух уровнях:
`(domain_id, service_id, hostname)`).
- **Привязка сервиса с multi-A** — режим LB и health-check настраиваются в карточке
сервиса для каждой привязки с несколькими IP; для IP задаются вес/приоритет.
- **Ноды** — first-class адреса сервиса (`nodes` + `binding_nodes`); IP-пулы
`service_ips` / `service_binding_ips` пишутся dual-write.
- **Change IP** — `POST /api/v1/service-bindings/:id/change-ip` (preview + PATCH DNS).
- **Change Domain** — перенос привязок между зонами `POST /api/v1/services/:id/change-domain`.
Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted`
работает как `round_robin` (одна A на IP), веса хранятся в БД для будущих расширений
и отображения в UI. Reconcile DNS запускается cron-задачей `health-check` при смене
статуса IP (`up` / `degraded` / `down` / `unknown`); `down`-IP убирается из A-записей.
работает как `round_robin` (одна A на IP). `unknown` **не** считается healthy и
не попадает в пул, пока нет успешных проб; восстановление — `UNHEALTHY → CHECKING → HEALTHY`
после `HEALTH_SUCCESS_RECOVERIES` (default 2). Пороги и cron движка задаются в
**Настройки → Health-check** (env — fallback, пока значения не сохранены в UI).
### Local XOR Cloudflare Worker
Провайдер задаётся на привязке (`service_bindings.health_check_provider`): **local**
или **cloudflare**. Одновременно оба не работают.
| | Local | Cloudflare Worker |
|---|---|---|
| Кто пробирует | процесс API CFDM | Worker на edge Cloudflare |
| Планировщик | глобальный cron CFDM | тот же cron вызывает Worker |
| Пороги Slow/Down | Настройки → Health-check | те же |
| Результат | SQLite `ip_health_status` | та же SQLite + `colo` |
| Регионы Health Checks | нет | нет (на Free продукта нет) |
**Cloudflare в CFDM — это Worker**, не [Health Checks API](https://developers.cloudflare.com/api/resources/healthchecks).
Продукт Health Checks на Free-плане недоступен и **не используется**. Worker
stateless: конфиг и журнал (`health_probe_log`) живут в SQLite CFDM.
Деплой Worker: [`workers/health-probe/README.md`](../workers/health-probe/README.md)
(`wrangler deploy`). URL и токен — **Настройки → Health-check**. Если Worker не
задан, cloudflare-цели **не** пробируются как Local.
Reconcile DNS запускается cron-задачей `health-check`. Free Workers ≈ 100k
запросов/сутки; cron раз в 2 мин × число IP должен влезать.
## Docker
+1 -1
View File
@@ -51,7 +51,7 @@ Workflows: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml), [.gitea/work
Fallback для **git tag**: `gitea.token`, если PAT недоступен. Push образов в Container Registry — **только `ACTIONS_PAT`** (у job token Gitea нет права packages).
Wiki: секрет **`GITEA_TOKEN`** для clone/push `*.wiki.git`.
Wiki: секрет **`ACTIONS_PAT`** (fallback `GITEA_TOKEN`) для clone/push `*.wiki.git` на `https://git.shx.one` (не внутренний `GITEA_INSTANCE_URL` раннера). Токен передаётся в URL (`oauth2:<PAT>`): Gitea на неаутентифицированный wiki push отвечает 404, а не 401.
## Источник правды для версии
+2498 -7
View File
File diff suppressed because one or more lines are too long
+518 -24
View File
@@ -52,6 +52,7 @@ var serviceGroups = sqliteTable("service_groups", {
health_check_interval_sec: integer("health_check_interval_sec").notNull().default(30),
health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3),
health_check_verify_tls: integer("health_check_verify_tls", { mode: "boolean" }).notNull().default(false),
health_check_provider: text("health_check_provider").notNull().default("local"),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
});
@@ -117,6 +118,9 @@ var serviceBindings = sqliteTable(
health_check_verify_tls: integer("health_check_verify_tls", {
mode: "boolean"
}).notNull().default(false),
health_check_provider: text("health_check_provider").notNull().default("local"),
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
operation_version: integer("operation_version").notNull().default(0),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
},
@@ -128,10 +132,64 @@ var serviceBindings = sqliteTable(
)
]
);
var healthChecks = sqliteTable("health_checks", {
id: integer("id").primaryKey({ autoIncrement: true }),
provider: text("provider").notNull().default("local"),
cf_healthcheck_id: text("cf_healthcheck_id"),
cf_zone_id: text("cf_zone_id"),
name: text("name").notNull(),
protocol: text("protocol").notNull().default("tcp"),
path: text("path"),
method: text("method"),
timeout: integer("timeout").notNull().default(5),
interval_sec: integer("interval_sec").notNull().default(30),
retries: integer("retries").notNull().default(2),
expected_status: integer("expected_status"),
consecutive_fails: integer("consecutive_fails").notNull().default(2),
consecutive_successes: integer("consecutive_successes").notNull().default(2),
suspended: integer("suspended", { mode: "boolean" }).notNull().default(false),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
});
var nodes = sqliteTable(
"nodes",
{
id: integer("id").primaryKey({ autoIncrement: true }),
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
address: text("address").notNull(),
protocol: text("protocol").notNull().default("tcp"),
port: integer("port"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull().default(1),
weight: integer("weight").notNull().default(1),
health_status: text("health_status").notNull().default("unknown"),
health_check_id: integer("health_check_id").references(() => healthChecks.id, {
onDelete: "set null"
}),
consecutive_failures: integer("consecutive_failures").notNull().default(0),
consecutive_successes: integer("consecutive_successes").notNull().default(0),
last_check_at: text("last_check_at"),
last_failure_reason: text("last_failure_reason"),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
},
(t) => [unique("nodes_service_address").on(t.service_id, t.address)]
);
var bindingNodes = sqliteTable(
"binding_nodes",
{
binding_id: integer("binding_id").notNull().references(() => serviceBindings.id, { onDelete: "cascade" }),
node_id: integer("node_id").notNull().references(() => nodes.id, { onDelete: "cascade" }),
weight: integer("weight").notNull().default(1),
priority: integer("priority").notNull().default(1)
},
(t) => [primaryKey({ columns: [t.binding_id, t.node_id] })]
);
var serviceIps = sqliteTable("service_ips", {
id: integer("id").primaryKey({ autoIncrement: true }),
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
ip: text("ip").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
created_at: text("created_at").notNull().default(sql`datetime('now')`)
});
var serviceBindingRecords = sqliteTable(
@@ -193,8 +251,11 @@ var ipHealthStatus = sqliteTable(
status: text("status").notNull().default("unknown"),
latency_ms: integer("latency_ms"),
consecutive_failures: integer("consecutive_failures").notNull().default(0),
consecutive_successes: integer("consecutive_successes").notNull().default(0),
last_checked_at: text("last_checked_at"),
last_error: text("last_error"),
colo: text("colo"),
provider: text("provider").notNull().default("local"),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
},
@@ -213,6 +274,13 @@ var appSettings = sqliteTable("app_settings", {
show_quick_actions: integer("show_quick_actions", {
mode: "boolean"
}).notNull().default(true),
health_check_cron: text("health_check_cron"),
health_degraded_failures: integer("health_degraded_failures"),
health_down_failures: integer("health_down_failures"),
health_latency_warn_ms: integer("health_latency_warn_ms"),
health_success_recoveries: integer("health_success_recoveries"),
health_worker_url: text("health_worker_url"),
health_worker_token: text("health_worker_token"),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
});
@@ -249,6 +317,19 @@ var domainMonitorResults = sqliteTable("domain_monitor_results", {
error: text("error"),
checked_at: text("checked_at").notNull().default(sql`datetime('now')`)
});
var healthProbeLog = sqliteTable("health_probe_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
scope: text("scope").notNull(),
ref_id: integer("ref_id").notNull(),
ip: text("ip").notNull(),
provider: text("provider").notNull(),
status: text("status").notNull(),
ok: integer("ok", { mode: "boolean" }).notNull(),
latency_ms: integer("latency_ms"),
colo: text("colo"),
error: text("error"),
checked_at: text("checked_at").notNull().default(sql`datetime('now')`)
});
var notificationLog = sqliteTable("notification_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
kind: text("kind").notNull(),
@@ -282,6 +363,9 @@ var schema = {
subdomains,
dnsRecords,
serviceBindings,
healthChecks,
nodes,
bindingNodes,
serviceIps,
serviceBindingRecords,
serviceBindingIps,
@@ -293,6 +377,7 @@ var schema = {
domainTags,
domainMonitors,
domainMonitorResults,
healthProbeLog,
notificationLog,
auditLog
};
@@ -434,7 +519,19 @@ function listAudit(db, opts = {}) {
// src/settings-repo.ts
import { eq as eq2 } from "drizzle-orm";
var SETTINGS_ID = "settings-main";
function toDto(row) {
function coalesceInt(value, fallback) {
return value == null || Number.isNaN(value) || value < 1 ? fallback : value;
}
function toDto(row, fallbacks) {
const env = fallbacks ?? {
healthCheckCron: "0 */2 * * * *",
healthDegradedFailures: 1,
healthDownFailures: 2,
healthLatencyWarnMs: 1e3,
healthSuccessRecoveries: 2,
healthWorkerUrl: "",
healthWorkerTokenSet: false
};
return {
id: row.id,
vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "",
@@ -443,28 +540,50 @@ function toDto(row) {
),
vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled),
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at,
showQuickActions: row.show_quick_actions == null ? true : Boolean(row.show_quick_actions)
showQuickActions: row.show_quick_actions == null ? true : Boolean(row.show_quick_actions),
healthCheckCron: row.health_check_cron?.trim() || env.healthCheckCron,
healthDegradedFailures: coalesceInt(
row.health_degraded_failures,
env.healthDegradedFailures
),
healthDownFailures: coalesceInt(
row.health_down_failures,
env.healthDownFailures
),
healthLatencyWarnMs: coalesceInt(
row.health_latency_warn_ms,
env.healthLatencyWarnMs
),
healthSuccessRecoveries: coalesceInt(
row.health_success_recoveries,
env.healthSuccessRecoveries
),
healthWorkerUrl: row.health_worker_url?.trim() || env.healthWorkerUrl,
healthWorkerTokenSet: Boolean(row.health_worker_token?.trim()) || env.healthWorkerTokenSet
};
}
function getAppSettings(db) {
function getAppSettings(db, fallbacks) {
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
if (!row) {
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
return toDto(
db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get()
db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get(),
fallbacks
);
}
return toDto(row);
return toDto(row, fallbacks);
}
function getAppSettingsSecrets(db) {
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
return {
vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "",
vpsTrackerIntegrationToken: row?.vps_tracker_integration_token?.trim() ?? "",
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled)
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled),
healthWorkerUrl: row?.health_worker_url?.trim() ?? "",
healthWorkerToken: row?.health_worker_token?.trim() ?? ""
};
}
function updateAppSettings(db, patch) {
function updateAppSettings(db, patch, fallbacks) {
const existing = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
if (!existing) {
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
@@ -476,9 +595,16 @@ function updateAppSettings(db, patch) {
vps_tracker_integration_token: patch.vpsTrackerIntegrationToken !== void 0 && patch.vpsTrackerIntegrationToken.trim() !== "" ? patch.vpsTrackerIntegrationToken : current.vps_tracker_integration_token,
vps_tracker_sync_enabled: patch.vpsTrackerSyncEnabled !== void 0 ? patch.vpsTrackerSyncEnabled : current.vps_tracker_sync_enabled,
show_quick_actions: patch.showQuickActions !== void 0 ? patch.showQuickActions : current.show_quick_actions,
health_check_cron: patch.healthCheckCron !== void 0 ? patch.healthCheckCron.trim() : current.health_check_cron,
health_degraded_failures: patch.healthDegradedFailures !== void 0 ? patch.healthDegradedFailures : current.health_degraded_failures,
health_down_failures: patch.healthDownFailures !== void 0 ? patch.healthDownFailures : current.health_down_failures,
health_latency_warn_ms: patch.healthLatencyWarnMs !== void 0 ? patch.healthLatencyWarnMs : current.health_latency_warn_ms,
health_success_recoveries: patch.healthSuccessRecoveries !== void 0 ? patch.healthSuccessRecoveries : current.health_success_recoveries,
health_worker_url: patch.healthWorkerUrl !== void 0 ? patch.healthWorkerUrl.trim() || null : current.health_worker_url,
health_worker_token: patch.healthWorkerToken !== void 0 && patch.healthWorkerToken.trim() !== "" ? patch.healthWorkerToken : current.health_worker_token,
updated_at: (/* @__PURE__ */ new Date()).toISOString()
}).where(eq2(appSettings.id, SETTINGS_ID)).run();
return getAppSettings(db);
return getAppSettings(db, fallbacks);
}
function touchVpsTrackerSync(db) {
db.update(appSettings).set({
@@ -495,10 +621,13 @@ __export(repos_exports, {
aggregateIpHealthByRefs: () => aggregateIpHealthByRefs,
aggregateIpHealthByServiceIds: () => aggregateIpHealthByServiceIds,
bindingsToRemove: () => bindingsToRemove,
bumpBindingVersion: () => bumpBindingVersion,
countCertificatesByStatus: () => countCertificatesByStatus,
createDomain: () => createDomain,
createDomainMonitor: () => createDomainMonitor,
createGroup: () => createGroup,
createHealthCheck: () => createHealthCheck,
createNode: () => createNode,
createService: () => createService,
createServiceGroup: () => createServiceGroup,
createSubdomain: () => createSubdomain,
@@ -510,14 +639,20 @@ __export(repos_exports, {
deleteDomain: () => deleteDomain,
deleteDomainMonitor: () => deleteDomainMonitor,
deleteGroup: () => deleteGroup,
deleteHealthCheck: () => deleteHealthCheck,
deleteIpHealthStatusForIp: () => deleteIpHealthStatusForIp,
deleteIpHealthStatusForRef: () => deleteIpHealthStatusForRef,
deleteNode: () => deleteNode,
deleteService: () => deleteService,
deleteServiceGroup: () => deleteServiceGroup,
deleteSubdomain: () => deleteSubdomain,
ensureNode: () => ensureNode,
findBinding: () => findBinding,
findDnsByCfId: () => findDnsByCfId,
findDomainByZoneName: () => findDomainByZoneName,
findHealthCheckByCfId: () => findHealthCheckByCfId,
findNodeByAddress: () => findNodeByAddress,
findNodeByIp: () => findNodeByIp,
findSubdomainByDomainAndName: () => findSubdomainByDomainAndName,
finishSyncJob: () => finishSyncJob,
getBinding: () => getBinding,
@@ -528,21 +663,26 @@ __export(repos_exports, {
getDomainMonitor: () => getDomainMonitor,
getGroup: () => getGroup,
getGroupWithStats: () => getGroupWithStats,
getHealthCheck: () => getHealthCheck,
getIpHealthStatusRow: () => getIpHealthStatusRow,
getNode: () => getNode,
getService: () => getService,
getServiceGroup: () => getServiceGroup,
getSubdomain: () => getSubdomain,
getSyncJob: () => getSyncJob,
insertBinding: () => insertBinding,
insertDnsRecord: () => insertDnsRecord,
insertHealthProbeLog: () => insertHealthProbeLog,
insertNotificationLog: () => insertNotificationLog,
linkBindingRecord: () => linkBindingRecord,
linkGroupDnsRecord: () => linkGroupDnsRecord,
listAllBindings: () => listAllBindings,
listAllDomains: () => listAllDomains,
listAllNodes: () => listAllNodes,
listAllSubdomains: () => listAllSubdomains,
listBindingIps: () => listBindingIps,
listBindingIpsWithMeta: () => listBindingIpsWithMeta,
listBindingNodes: () => listBindingNodes,
listBindingsByDomain: () => listBindingsByDomain,
listBindingsByService: () => listBindingsByService,
listCertificates: () => listCertificates,
@@ -558,11 +698,16 @@ __export(repos_exports, {
listGroupDnsRecords: () => listGroupDnsRecords,
listGroups: () => listGroups,
listHealthCheckTargets: () => listHealthCheckTargets,
listHealthChecks: () => listHealthChecks,
listHealthProbeLogForService: () => listHealthProbeLogForService,
listIpHealthByServiceIds: () => listIpHealthByServiceIds,
listIpHealthStatus: () => listIpHealthStatus,
listNodes: () => listNodes,
listNotificationLog: () => listNotificationLog,
listOriginIpsForFqdn: () => listOriginIpsForFqdn,
listRecordsForBinding: () => listRecordsForBinding,
listServiceGroups: () => listServiceGroups,
listServiceIpRows: () => listServiceIpRows,
listServiceIps: () => listServiceIps,
listServices: () => listServices,
listServicesByGroup: () => listServicesByGroup,
@@ -577,21 +722,26 @@ __export(repos_exports, {
replaceServiceIps: () => replaceServiceIps,
setBindingCnameTarget: () => setBindingCnameTarget,
setBindingDnsRecordId: () => setBindingDnsRecordId,
setBindingRoutingStrategy: () => setBindingRoutingStrategy,
setDnsSyncStatus: () => setDnsSyncStatus,
setDomainLastSynced: () => setDomainLastSynced,
setDomainTags: () => setDomainTags,
setServiceEnabled: () => setServiceEnabled,
setServiceGroup: () => setServiceGroup,
setServiceGroupEnabled: () => setServiceGroupEnabled,
setServiceIpEnabled: () => setServiceIpEnabled,
setServiceLb: () => setServiceLb,
unlinkBindingRecord: () => unlinkBindingRecord,
unlinkGroupDnsRecord: () => unlinkGroupDnsRecord,
updateBindingDomain: () => updateBindingDomain,
updateBindingFields: () => updateBindingFields,
updateBindingLbConfig: () => updateBindingLbConfig,
updateDnsFields: () => updateDnsFields,
updateDomain: () => updateDomain,
updateDomainMonitorResult: () => updateDomainMonitorResult,
updateGroup: () => updateGroup,
updateHealthCheck: () => updateHealthCheck,
updateNode: () => updateNode,
updateService: () => updateService,
updateServiceGroup: () => updateServiceGroup,
updateSubdomain: () => updateSubdomain,
@@ -986,6 +1136,9 @@ function deleteService(db, id) {
const result = db.delete(services).where(eq3(services.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service ${id}`);
}
function normalizeHealthProvider(value) {
return value === "cloudflare" ? "cloudflare" : "local";
}
function mapServiceGroup(row) {
return {
id: row.id,
@@ -1003,6 +1156,7 @@ function mapServiceGroup(row) {
health_check_interval_sec: row.health_check_interval_sec,
health_check_timeout_ms: row.health_check_timeout_ms,
health_check_verify_tls: row.health_check_verify_tls,
health_check_provider: normalizeHealthProvider(row.health_check_provider),
created_at: row.created_at,
updated_at: row.updated_at
};
@@ -1029,7 +1183,8 @@ function createServiceGroup(db, name, groupType, icon, domain, lbPatch) {
health_check_expected_status: lbPatch?.health_check_expected_status ?? null,
health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3e3,
health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false
health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false,
health_check_provider: lbPatch?.health_check_provider ?? "local"
}).returning({ id: serviceGroups.id }).get().id;
return getServiceGroup(db, id);
}
@@ -1059,6 +1214,8 @@ function updateServiceGroup(db, id, name, groupType, icon, domain, lbPatch) {
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
if (lbPatch.health_check_verify_tls !== void 0)
update.health_check_verify_tls = lbPatch.health_check_verify_tls;
if (lbPatch.health_check_provider !== void 0)
update.health_check_provider = lbPatch.health_check_provider;
}
const result = db.update(serviceGroups).set(update).where(eq3(serviceGroups.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
@@ -1073,14 +1230,236 @@ function deleteServiceGroup(db, id) {
const result = db.delete(serviceGroups).where(eq3(serviceGroups.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
}
function insertServiceIpIfMissing(db, serviceId, ip) {
const existing = db.select({ ip: serviceIps.ip }).from(serviceIps).where(and2(eq3(serviceIps.service_id, serviceId), eq3(serviceIps.ip, ip))).get();
if (!existing) {
db.insert(serviceIps).values({ service_id: serviceId, ip, enabled: true }).run();
}
}
function listServiceIpRows(db, serviceId) {
return db.select({ ip: serviceIps.ip, enabled: serviceIps.enabled }).from(serviceIps).where(eq3(serviceIps.service_id, serviceId)).all().map((row) => ({ ip: row.ip, enabled: Boolean(row.enabled) }));
}
function listServiceIps(db, serviceId) {
return db.select({ ip: serviceIps.ip }).from(serviceIps).where(eq3(serviceIps.service_id, serviceId)).all().map((r) => r.ip);
return listServiceIpRows(db, serviceId).map((row) => row.ip);
}
function setServiceIpEnabled(db, serviceId, ip, enabled) {
const result = db.update(serviceIps).set({ enabled }).where(and2(eq3(serviceIps.service_id, serviceId), eq3(serviceIps.ip, ip))).run();
if (result.changes === 0) {
throw new NotFoundError(`service ip ${ip}`);
}
}
function replaceServiceIps(db, serviceId, ips) {
const previous = new Map(
listServiceIpRows(db, serviceId).map((row) => [row.ip, row.enabled])
);
db.delete(serviceIps).where(eq3(serviceIps.service_id, serviceId)).run();
for (const ip of ips) {
db.insert(serviceIps).values({ service_id: serviceId, ip }).run();
db.insert(serviceIps).values({
service_id: serviceId,
ip,
enabled: previous.get(ip) ?? true
}).run();
ensureNode(db, serviceId, ip);
}
const keep = new Set(ips);
for (const node of listNodes(db, serviceId)) {
if (keep.has(node.address)) continue;
const bound = db.select({ node_id: bindingNodes.node_id }).from(bindingNodes).where(eq3(bindingNodes.node_id, node.id)).get();
if (!bound) deleteNode(db, node.id);
}
}
function mapNode(row) {
return {
id: row.id,
service_id: row.service_id,
address: row.address,
protocol: row.protocol,
port: row.port,
enabled: Boolean(row.enabled),
priority: row.priority,
weight: row.weight,
health_status: row.health_status,
health_check_id: row.health_check_id,
consecutive_failures: row.consecutive_failures,
consecutive_successes: row.consecutive_successes,
last_check_at: row.last_check_at,
last_failure_reason: row.last_failure_reason,
created_at: row.created_at,
updated_at: row.updated_at
};
}
function listNodes(db, serviceId) {
return db.select().from(nodes).where(eq3(nodes.service_id, serviceId)).all().map(mapNode);
}
function getNode(db, id) {
const row = db.select().from(nodes).where(eq3(nodes.id, id)).get();
if (!row) throw new NotFoundError(`node ${id}`);
return mapNode(row);
}
function findNodeByAddress(db, serviceId, address) {
const row = db.select().from(nodes).where(and2(eq3(nodes.service_id, serviceId), eq3(nodes.address, address))).get();
return row ? mapNode(row) : null;
}
function findNodeByIp(db, address) {
const row = db.select().from(nodes).where(eq3(nodes.address, address)).get();
return row ? mapNode(row) : null;
}
function ensureNode(db, serviceId, address, meta) {
const existing = findNodeByAddress(db, serviceId, address);
if (existing) return existing;
const id = db.insert(nodes).values({
service_id: serviceId,
address,
protocol: meta?.protocol ?? "tcp",
port: meta?.port ?? null,
weight: meta?.weight ?? 1,
priority: meta?.priority ?? 1
}).returning({ id: nodes.id }).get().id;
return getNode(db, id);
}
function createNode(db, serviceId, input) {
getService(db, serviceId);
const existing = findNodeByAddress(db, serviceId, input.address);
if (existing) {
throw new ConflictError(`node ${input.address} already exists`);
}
const id = db.insert(nodes).values({
service_id: serviceId,
address: input.address,
protocol: input.protocol ?? "tcp",
port: input.port ?? null,
enabled: input.enabled ?? true,
priority: input.priority ?? 1,
weight: input.weight ?? 1,
health_check_id: input.health_check_id ?? null
}).returning({ id: nodes.id }).get().id;
insertServiceIpIfMissing(db, serviceId, input.address);
return getNode(db, id);
}
function updateNode(db, id, patch) {
const current = getNode(db, id);
const update = { updated_at: sql2`datetime('now')` };
for (const [key, value] of Object.entries(patch)) {
if (value !== void 0) update[key] = value;
}
db.update(nodes).set(update).where(eq3(nodes.id, id)).run();
if (patch.address && patch.address !== current.address) {
db.delete(serviceIps).where(
and2(
eq3(serviceIps.service_id, current.service_id),
eq3(serviceIps.ip, current.address)
)
).run();
insertServiceIpIfMissing(db, current.service_id, patch.address);
}
return getNode(db, id);
}
function deleteNode(db, id) {
const current = getNode(db, id);
db.delete(nodes).where(eq3(nodes.id, id)).run();
db.delete(serviceIps).where(
and2(
eq3(serviceIps.service_id, current.service_id),
eq3(serviceIps.ip, current.address)
)
).run();
}
function mapHealthCheck(row) {
return {
id: row.id,
provider: row.provider,
cf_healthcheck_id: row.cf_healthcheck_id,
cf_zone_id: row.cf_zone_id,
name: row.name,
protocol: row.protocol,
path: row.path,
method: row.method,
timeout: row.timeout,
interval_sec: row.interval_sec,
retries: row.retries,
expected_status: row.expected_status,
consecutive_fails: row.consecutive_fails,
consecutive_successes: row.consecutive_successes,
suspended: Boolean(row.suspended),
created_at: row.created_at,
updated_at: row.updated_at
};
}
function listHealthChecks(db) {
return db.select().from(healthChecks).all().map(mapHealthCheck);
}
function getHealthCheck(db, id) {
const row = db.select().from(healthChecks).where(eq3(healthChecks.id, id)).get();
if (!row) throw new NotFoundError(`health check ${id}`);
return mapHealthCheck(row);
}
function findHealthCheckByCfId(db, cfId) {
const row = db.select().from(healthChecks).where(eq3(healthChecks.cf_healthcheck_id, cfId)).get();
return row ? mapHealthCheck(row) : null;
}
function createHealthCheck(db, input) {
const id = db.insert(healthChecks).values({
provider: input.provider,
name: input.name,
cf_healthcheck_id: input.cf_healthcheck_id ?? null,
cf_zone_id: input.cf_zone_id ?? null,
protocol: input.protocol ?? "tcp",
path: input.path ?? null,
method: input.method ?? null,
timeout: input.timeout ?? 5,
interval_sec: input.interval_sec ?? 30,
retries: input.retries ?? 2,
expected_status: input.expected_status ?? null,
consecutive_fails: input.consecutive_fails ?? 2,
consecutive_successes: input.consecutive_successes ?? 2,
suspended: input.suspended ?? false
}).returning({ id: healthChecks.id }).get().id;
return getHealthCheck(db, id);
}
function updateHealthCheck(db, id, patch) {
getHealthCheck(db, id);
const update = { updated_at: sql2`datetime('now')` };
for (const [key, value] of Object.entries(patch)) {
if (value !== void 0) update[key] = value;
}
db.update(healthChecks).set(update).where(eq3(healthChecks.id, id)).run();
return getHealthCheck(db, id);
}
function deleteHealthCheck(db, id) {
const result = db.delete(healthChecks).where(eq3(healthChecks.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`health check ${id}`);
}
function bumpBindingVersion(db, bindingId, expected) {
const binding = getBinding(db, bindingId);
if (expected != null && binding.operation_version !== expected) {
throw new ConflictError(`binding ${bindingId} version conflict`);
}
const next = (binding.operation_version ?? 0) + 1;
db.update(serviceBindings).set({
operation_version: next,
updated_at: sql2`datetime('now')`
}).where(eq3(serviceBindings.id, bindingId)).run();
return next;
}
function setBindingRoutingStrategy(db, bindingId, strategy) {
db.update(serviceBindings).set({
routing_strategy: strategy,
lb_mode: strategy,
updated_at: sql2`datetime('now')`
}).where(eq3(serviceBindings.id, bindingId)).run();
}
function listAllNodes(db) {
return db.select().from(nodes).all().map(mapNode);
}
function updateBindingDomain(db, bindingId, domainId, hostname) {
db.update(serviceBindings).set({
domain_id: domainId,
hostname,
updated_at: sql2`datetime('now')`
}).where(eq3(serviceBindings.id, bindingId)).run();
}
function listBindingNodes(db, bindingId) {
return db.select({ node: nodes }).from(bindingNodes).innerJoin(nodes, eq3(bindingNodes.node_id, nodes.id)).where(eq3(bindingNodes.binding_id, bindingId)).all().map((row) => mapNode(row.node));
}
function listBindingIps(db, bindingId) {
return db.select({ ip: serviceBindingIps.ip }).from(serviceBindingIps).where(eq3(serviceBindingIps.binding_id, bindingId)).all().map((r) => r.ip);
@@ -1100,7 +1479,9 @@ function replaceBindingIps(db, bindingId, ips) {
);
}
function replaceBindingIpsWithMeta(db, bindingId, entries) {
const binding = getBinding(db, bindingId);
db.delete(serviceBindingIps).where(eq3(serviceBindingIps.binding_id, bindingId)).run();
db.delete(bindingNodes).where(eq3(bindingNodes.binding_id, bindingId)).run();
for (const entry of entries) {
db.insert(serviceBindingIps).values({
binding_id: bindingId,
@@ -1108,13 +1489,26 @@ function replaceBindingIpsWithMeta(db, bindingId, entries) {
weight: entry.weight,
priority: entry.priority
}).run();
const node = ensureNode(db, binding.service_id, entry.ip, {
weight: entry.weight,
priority: entry.priority
});
db.insert(bindingNodes).values({
binding_id: bindingId,
node_id: node.id,
weight: entry.weight,
priority: entry.priority
}).run();
}
}
function updateBindingLbConfig(db, bindingId, patch) {
const update = {
updated_at: sql2`datetime('now')`
};
if (patch.lb_mode !== void 0) update.lb_mode = patch.lb_mode;
if (patch.lb_mode !== void 0) {
update.lb_mode = patch.lb_mode;
update.routing_strategy = patch.lb_mode;
}
if (patch.health_check_enabled !== void 0)
update.health_check_enabled = patch.health_check_enabled;
if (patch.health_check_type !== void 0)
@@ -1131,6 +1525,8 @@ function updateBindingLbConfig(db, bindingId, patch) {
update.health_check_timeout_ms = patch.health_check_timeout_ms;
if (patch.health_check_verify_tls !== void 0)
update.health_check_verify_tls = patch.health_check_verify_tls;
if (patch.health_check_provider !== void 0)
update.health_check_provider = patch.health_check_provider;
db.update(serviceBindings).set(update).where(eq3(serviceBindings.id, bindingId)).run();
}
function setBindingCnameTarget(db, bindingId, target) {
@@ -1189,7 +1585,7 @@ function dnsRecordMatchesHostname(recordName, hostname, zoneName) {
var SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec,
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.cname_target,
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider, sb.cname_target,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
@@ -1410,7 +1806,7 @@ function finishSyncJob(db, id, status, message) {
function listIpHealthStatus(db, scope, refId) {
return db.all(sql2`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error
last_checked_at, last_error, colo, provider
FROM ip_health_status
WHERE scope = ${scope} AND ref_id = ${refId}
`);
@@ -1523,6 +1919,47 @@ function aggregateIpHealthByServiceIds(db, serviceIds) {
}
return result;
}
function listIpHealthByServiceIds(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,
ihs.ip AS ip,
${WORST_HEALTH_SQL} AS health_status,
MAX(ihs.latency_ms) AS health_latency_ms,
MAX(ihs.last_checked_at) AS last_checked_at,
MAX(ihs.last_error) AS last_error,
MAX(ihs.provider) AS provider,
MAX(ihs.colo) AS colo
FROM ip_health_status ihs
INNER JOIN service_bindings sb
ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
WHERE sb.service_id IN (${idList})
GROUP BY sb.service_id, ihs.ip
`);
for (const row of rows) {
const parsed = parseHealthAggregateRow({
health_status: row.health_status,
health_latency_ms: row.health_latency_ms
});
const list = result.get(row.service_id) ?? [];
list.push({
ip: row.ip,
status: parsed.health_status,
latency_ms: parsed.health_latency_ms,
last_checked_at: row.last_checked_at,
last_error: row.last_error,
provider: normalizeHealthProvider(row.provider),
colo: row.colo
});
result.set(row.service_id, list);
}
return result;
}
function mergeHealthAggregates(parts) {
const rank = {
unknown: 0,
@@ -1552,26 +1989,33 @@ function mergeHealthAggregates(parts) {
function getIpHealthStatusRow(db, scope, refId, ip) {
const rows = db.all(sql2`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error
consecutive_successes, last_checked_at, last_error, colo, provider
FROM ip_health_status
WHERE scope = ${scope} AND ref_id = ${refId} AND ip = ${ip}
LIMIT 1
`);
return rows[0] ?? null;
}
function upsertIpHealthStatus(db, scope, refId, ip, status, latencyMs, consecutiveFailures, lastError) {
function upsertIpHealthStatus(db, scope, refId, ip, status, latencyMs, consecutiveFailures, lastError, consecutiveSuccesses = 0, extras) {
const colo = extras?.colo ?? null;
const provider = extras?.provider ?? "local";
db.run(sql2`
INSERT INTO ip_health_status
(scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error, created_at, updated_at)
consecutive_successes, last_checked_at, last_error, colo, provider,
created_at, updated_at)
VALUES (${scope}, ${refId}, ${ip}, ${status}, ${latencyMs}, ${consecutiveFailures},
datetime('now'), ${lastError}, datetime('now'), datetime('now'))
${consecutiveSuccesses}, datetime('now'), ${lastError}, ${colo}, ${provider},
datetime('now'), datetime('now'))
ON CONFLICT(scope, ref_id, ip) DO UPDATE SET
status = excluded.status,
latency_ms = excluded.latency_ms,
consecutive_failures = excluded.consecutive_failures,
consecutive_successes = excluded.consecutive_successes,
last_checked_at = excluded.last_checked_at,
last_error = excluded.last_error,
colo = excluded.colo,
provider = excluded.provider,
updated_at = datetime('now')
`);
}
@@ -1634,7 +2078,8 @@ function listHealthCheckTargets(db) {
sb.health_check_path AS path,
sb.health_check_expected_status AS expected_status,
sb.health_check_timeout_ms AS timeout_ms,
sb.health_check_verify_tls AS verify_tls
sb.health_check_verify_tls AS verify_tls,
COALESCE(sb.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN domains d ON d.id = sb.domain_id
@@ -1648,7 +2093,8 @@ function listHealthCheckTargets(db) {
sg.health_check_path AS path,
sg.health_check_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls
sg.health_check_verify_tls AS verify_tls,
COALESCE(sg.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN services s ON s.id = sb.service_id
@@ -1668,7 +2114,8 @@ function listHealthCheckTargets(db) {
sg.health_check_path AS path,
sg.health_check_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls
sg.health_check_verify_tls AS verify_tls,
COALESCE(sg.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN domains d ON d.id = sb.domain_id
@@ -1688,7 +2135,8 @@ function listHealthCheckTargets(db) {
sb.health_check_path AS path,
sb.health_check_expected_status AS expected_status,
sb.health_check_timeout_ms AS timeout_ms,
sb.health_check_verify_tls AS verify_tls
sb.health_check_verify_tls AS verify_tls,
COALESCE(sb.health_check_provider, 'local') AS provider
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
JOIN services s ON s.id = sb.service_id
@@ -1705,7 +2153,8 @@ function listHealthCheckTargets(db) {
sg.health_check_path AS path,
sg.health_check_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls
sg.health_check_verify_tls AS verify_tls,
COALESCE(sg.health_check_provider, 'local') AS provider
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
JOIN services s ON s.id = sb.service_id
@@ -1726,7 +2175,8 @@ function listHealthCheckTargets(db) {
...groupInheritedCnameBindingTargets
].map((t) => ({
...t,
verify_tls: Boolean(t.verify_tls)
verify_tls: Boolean(t.verify_tls),
provider: normalizeHealthProvider(t.provider)
}));
}
function listDomainTags(db, domainId) {
@@ -1821,6 +2271,46 @@ function listDomainMonitorResultsForDomain(db, domainId, limit = 50) {
LIMIT ${limit}
`);
}
var HEALTH_PROBE_LOG_KEEP = 50;
function insertHealthProbeLog(db, entry) {
db.insert(healthProbeLog).values({
scope: entry.scope,
ref_id: entry.refId,
ip: entry.ip,
provider: entry.provider,
status: entry.status,
ok: entry.ok,
latency_ms: entry.latencyMs,
colo: entry.colo,
error: entry.error
}).run();
db.run(sql2`
DELETE FROM health_probe_log
WHERE id IN (
SELECT id FROM health_probe_log
WHERE scope = ${entry.scope} AND ref_id = ${entry.refId} AND ip = ${entry.ip}
ORDER BY checked_at DESC, id DESC
LIMIT -1 OFFSET ${HEALTH_PROBE_LOG_KEEP}
)
`);
}
function listHealthProbeLogForService(db, serviceId, limit = 50) {
const rows = db.all(sql2`
SELECT l.id, l.scope, l.ref_id, l.ip, l.provider, l.status, l.ok,
l.latency_ms, l.colo, l.error, l.checked_at
FROM health_probe_log l
INNER JOIN service_bindings sb
ON l.scope = 'binding' AND l.ref_id = sb.id
WHERE sb.service_id = ${serviceId}
ORDER BY l.checked_at DESC, l.id DESC
LIMIT ${limit}
`);
return rows.map((row) => ({
...row,
provider: normalizeHealthProvider(row.provider),
ok: Boolean(row.ok)
}));
}
function insertNotificationLog(db, kind, refType, refId, title, message) {
db.insert(notificationLog).values({
kind,
@@ -1844,6 +2334,7 @@ export {
appSettings,
appendAudit,
auditLog,
bindingNodes,
certificates,
createDb,
createMemoryDb,
@@ -1856,8 +2347,11 @@ export {
getAppSettingsSecrets,
groups,
healthCheck,
healthChecks,
healthProbeLog,
ipHealthStatus,
listAudit,
nodes,
notificationLog,
repos_exports as repos,
resolveDatabasePath,
@@ -0,0 +1,83 @@
CREATE TABLE IF NOT EXISTS health_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL DEFAULT 'local',
cf_healthcheck_id TEXT,
cf_zone_id TEXT,
name TEXT NOT NULL,
protocol TEXT NOT NULL DEFAULT 'tcp',
path TEXT,
method TEXT,
timeout INTEGER NOT NULL DEFAULT 5,
interval_sec INTEGER NOT NULL DEFAULT 30,
retries INTEGER NOT NULL DEFAULT 2,
expected_status INTEGER,
consecutive_fails INTEGER NOT NULL DEFAULT 2,
consecutive_successes INTEGER NOT NULL DEFAULT 2,
suspended INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE,
address TEXT NOT NULL,
protocol TEXT NOT NULL DEFAULT 'tcp',
port INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 1,
weight INTEGER NOT NULL DEFAULT 1,
health_status TEXT NOT NULL DEFAULT 'unknown',
health_check_id INTEGER REFERENCES health_checks(id) ON DELETE SET NULL,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
consecutive_successes INTEGER NOT NULL DEFAULT 0,
last_check_at TEXT,
last_failure_reason TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(service_id, address)
);
CREATE TABLE IF NOT EXISTS binding_nodes (
binding_id INTEGER NOT NULL REFERENCES service_bindings(id) ON DELETE CASCADE,
node_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
weight INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (binding_id, node_id)
);
CREATE INDEX IF NOT EXISTS idx_nodes_service ON nodes(service_id);
CREATE INDEX IF NOT EXISTS idx_binding_nodes_node ON binding_nodes(node_id);
CREATE INDEX IF NOT EXISTS idx_health_checks_cf ON health_checks(cf_healthcheck_id);
ALTER TABLE service_bindings ADD COLUMN routing_strategy TEXT NOT NULL DEFAULT 'round_robin';
ALTER TABLE service_bindings ADD COLUMN operation_version INTEGER NOT NULL DEFAULT 0;
ALTER TABLE ip_health_status ADD COLUMN consecutive_successes INTEGER NOT NULL DEFAULT 0;
INSERT INTO nodes (service_id, address, enabled, priority, weight, health_status)
SELECT service_id, ip, 1, 1, 1, 'unknown'
FROM service_ips
WHERE NOT EXISTS (
SELECT 1 FROM nodes n WHERE n.service_id = service_ips.service_id AND n.address = service_ips.ip
);
INSERT INTO nodes (service_id, address, enabled, priority, weight, health_status)
SELECT DISTINCT sb.service_id, sbi.ip, 1, sbi.priority, sbi.weight, 'unknown'
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
WHERE NOT EXISTS (
SELECT 1 FROM nodes n WHERE n.service_id = sb.service_id AND n.address = sbi.ip
);
INSERT INTO binding_nodes (binding_id, node_id, weight, priority)
SELECT sbi.binding_id, n.id, sbi.weight, sbi.priority
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN nodes n ON n.service_id = sb.service_id AND n.address = sbi.ip
WHERE NOT EXISTS (
SELECT 1 FROM binding_nodes bn
WHERE bn.binding_id = sbi.binding_id AND bn.node_id = n.id
);
UPDATE service_bindings SET routing_strategy = lb_mode WHERE routing_strategy = 'round_robin';
@@ -0,0 +1 @@
ALTER TABLE service_ips ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
@@ -0,0 +1,7 @@
-- Local health-check engine settings (cron + state-machine thresholds).
-- NULL = inherit from process env (HEALTH_CHECK_CRON / HEALTH_*).
ALTER TABLE app_settings ADD COLUMN health_check_cron TEXT;
ALTER TABLE app_settings ADD COLUMN health_degraded_failures INTEGER;
ALTER TABLE app_settings ADD COLUMN health_down_failures INTEGER;
ALTER TABLE app_settings ADD COLUMN health_latency_warn_ms INTEGER;
ALTER TABLE app_settings ADD COLUMN health_success_recoveries INTEGER;
@@ -0,0 +1,27 @@
-- XOR health-check: persist provider on bindings/groups; Worker URL/token;
-- colo + probe journal. Cloudflare = Worker edge probe, not Health Checks API.
ALTER TABLE service_bindings ADD COLUMN health_check_provider TEXT NOT NULL DEFAULT 'local';
ALTER TABLE service_groups ADD COLUMN health_check_provider TEXT NOT NULL DEFAULT 'local';
ALTER TABLE app_settings ADD COLUMN health_worker_url TEXT;
ALTER TABLE app_settings ADD COLUMN health_worker_token TEXT;
ALTER TABLE ip_health_status ADD COLUMN colo TEXT;
ALTER TABLE ip_health_status ADD COLUMN provider TEXT NOT NULL DEFAULT 'local';
CREATE TABLE IF NOT EXISTS health_probe_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL,
ref_id INTEGER NOT NULL,
ip TEXT NOT NULL,
provider TEXT NOT NULL,
status TEXT NOT NULL,
ok INTEGER NOT NULL,
latency_ms INTEGER,
colo TEXT,
error TEXT,
checked_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_health_probe_log_target
ON health_probe_log(scope, ref_id, ip, checked_at DESC);
+623 -16
View File
@@ -5,23 +5,26 @@ import type {
DomainListItem,
Group,
GroupWithStats,
HealthCheckProvider,
HealthCheckScope,
HealthCheckTarget,
HealthCheckType,
IpHealthState,
IpHealthStatus,
LbMode,
OriginHealthCheck,
Service,
ServiceBinding,
ServiceBindingView,
ServiceGroup,
ServiceNode,
Subdomain,
SyncJob,
} from "@cfdm/shared";
import { dnsRecordNamesMatch, isIpLiteral } from "@cfdm/shared";
import { and, asc, count, eq, isNull, like, notInArray, or, sql } from "drizzle-orm";
import type { Db } from "./client.js";
import { NotFoundError } from "./errors.js";
import { ConflictError, NotFoundError } from "./errors.js";
import {
certificates,
dnsRecords,
@@ -30,7 +33,11 @@ import {
domainTags,
domains,
groups,
healthChecks,
healthProbeLog,
ipHealthStatus,
nodes,
bindingNodes,
notificationLog,
serviceBindingIps,
serviceBindingRecords,
@@ -763,6 +770,10 @@ export function deleteService(db: Db, id: number): void {
// --- Service Groups ---
function normalizeHealthProvider(value: unknown): HealthCheckProvider {
return value === "cloudflare" ? "cloudflare" : "local";
}
function mapServiceGroup(row: typeof serviceGroups.$inferSelect): ServiceGroup {
return {
id: row.id,
@@ -780,6 +791,7 @@ function mapServiceGroup(row: typeof serviceGroups.$inferSelect): ServiceGroup {
health_check_interval_sec: row.health_check_interval_sec,
health_check_timeout_ms: row.health_check_timeout_ms,
health_check_verify_tls: row.health_check_verify_tls,
health_check_provider: normalizeHealthProvider(row.health_check_provider),
created_at: row.created_at,
updated_at: row.updated_at,
};
@@ -814,6 +826,7 @@ export interface ServiceGroupLbPatch {
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
health_check_verify_tls?: boolean;
health_check_provider?: HealthCheckProvider;
}
export function createServiceGroup(
@@ -840,6 +853,7 @@ export function createServiceGroup(
health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3000,
health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false,
health_check_provider: lbPatch?.health_check_provider ?? "local",
})
.returning({ id: serviceGroups.id })
.get()!.id;
@@ -880,6 +894,8 @@ export function updateServiceGroup(
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
if (lbPatch.health_check_verify_tls !== undefined)
update.health_check_verify_tls = lbPatch.health_check_verify_tls;
if (lbPatch.health_check_provider !== undefined)
update.health_check_provider = lbPatch.health_check_provider;
}
const result = db
.update(serviceGroups)
@@ -911,13 +927,49 @@ export function deleteServiceGroup(db: Db, id: number): void {
// --- Service IPs ---
export function listServiceIps(db: Db, serviceId: number): string[] {
return db
function insertServiceIpIfMissing(db: Db, serviceId: number, ip: string): void {
const existing = db
.select({ ip: serviceIps.ip })
.from(serviceIps)
.where(and(eq(serviceIps.service_id, serviceId), eq(serviceIps.ip, ip)))
.get();
if (!existing) {
db.insert(serviceIps).values({ service_id: serviceId, ip, enabled: true }).run();
}
}
export type ServiceIpRow = {
ip: string;
enabled: boolean;
};
export function listServiceIpRows(db: Db, serviceId: number): ServiceIpRow[] {
return db
.select({ ip: serviceIps.ip, enabled: serviceIps.enabled })
.from(serviceIps)
.where(eq(serviceIps.service_id, serviceId))
.all()
.map((r) => r.ip);
.map((row) => ({ ip: row.ip, enabled: Boolean(row.enabled) }));
}
export function listServiceIps(db: Db, serviceId: number): string[] {
return listServiceIpRows(db, serviceId).map((row) => row.ip);
}
export function setServiceIpEnabled(
db: Db,
serviceId: number,
ip: string,
enabled: boolean,
): void {
const result = db
.update(serviceIps)
.set({ enabled })
.where(and(eq(serviceIps.service_id, serviceId), eq(serviceIps.ip, ip)))
.run();
if (result.changes === 0) {
throw new NotFoundError(`service ip ${ip}`);
}
}
export function replaceServiceIps(
@@ -925,10 +977,375 @@ export function replaceServiceIps(
serviceId: number,
ips: string[],
): void {
const previous = new Map(
listServiceIpRows(db, serviceId).map((row) => [row.ip, row.enabled]),
);
db.delete(serviceIps).where(eq(serviceIps.service_id, serviceId)).run();
for (const ip of ips) {
db.insert(serviceIps).values({ service_id: serviceId, ip }).run();
db.insert(serviceIps)
.values({
service_id: serviceId,
ip,
enabled: previous.get(ip) ?? true,
})
.run();
ensureNode(db, serviceId, ip);
}
const keep = new Set(ips);
for (const node of listNodes(db, serviceId)) {
if (keep.has(node.address)) continue;
const bound = db
.select({ node_id: bindingNodes.node_id })
.from(bindingNodes)
.where(eq(bindingNodes.node_id, node.id))
.get();
if (!bound) deleteNode(db, node.id);
}
}
function mapNode(row: typeof nodes.$inferSelect): ServiceNode {
return {
id: row.id,
service_id: row.service_id,
address: row.address,
protocol: row.protocol,
port: row.port,
enabled: Boolean(row.enabled),
priority: row.priority,
weight: row.weight,
health_status: row.health_status as ServiceNode["health_status"],
health_check_id: row.health_check_id,
consecutive_failures: row.consecutive_failures,
consecutive_successes: row.consecutive_successes,
last_check_at: row.last_check_at,
last_failure_reason: row.last_failure_reason,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
export function listNodes(db: Db, serviceId: number): ServiceNode[] {
return db
.select()
.from(nodes)
.where(eq(nodes.service_id, serviceId))
.all()
.map(mapNode);
}
export function getNode(db: Db, id: number): ServiceNode {
const row = db.select().from(nodes).where(eq(nodes.id, id)).get();
if (!row) throw new NotFoundError(`node ${id}`);
return mapNode(row);
}
export function findNodeByAddress(
db: Db,
serviceId: number,
address: string,
): ServiceNode | null {
const row = db
.select()
.from(nodes)
.where(and(eq(nodes.service_id, serviceId), eq(nodes.address, address)))
.get();
return row ? mapNode(row) : null;
}
export function findNodeByIp(db: Db, address: string): ServiceNode | null {
const row = db.select().from(nodes).where(eq(nodes.address, address)).get();
return row ? mapNode(row) : null;
}
export function ensureNode(
db: Db,
serviceId: number,
address: string,
meta?: { weight?: number; priority?: number; protocol?: string; port?: number | null },
): ServiceNode {
const existing = findNodeByAddress(db, serviceId, address);
if (existing) return existing;
const id = db
.insert(nodes)
.values({
service_id: serviceId,
address,
protocol: meta?.protocol ?? "tcp",
port: meta?.port ?? null,
weight: meta?.weight ?? 1,
priority: meta?.priority ?? 1,
})
.returning({ id: nodes.id })
.get()!.id;
return getNode(db, id);
}
export function createNode(
db: Db,
serviceId: number,
input: {
address: string;
protocol?: string;
port?: number | null;
enabled?: boolean;
priority?: number;
weight?: number;
health_check_id?: number | null;
},
): ServiceNode {
getService(db, serviceId);
const existing = findNodeByAddress(db, serviceId, input.address);
if (existing) {
throw new ConflictError(`node ${input.address} already exists`);
}
const id = db
.insert(nodes)
.values({
service_id: serviceId,
address: input.address,
protocol: input.protocol ?? "tcp",
port: input.port ?? null,
enabled: input.enabled ?? true,
priority: input.priority ?? 1,
weight: input.weight ?? 1,
health_check_id: input.health_check_id ?? null,
})
.returning({ id: nodes.id })
.get()!.id;
insertServiceIpIfMissing(db, serviceId, input.address);
return getNode(db, id);
}
export function updateNode(
db: Db,
id: number,
patch: Partial<{
address: string;
protocol: string;
port: number | null;
enabled: boolean;
priority: number;
weight: number;
health_check_id: number | null;
health_status: string;
consecutive_failures: number;
consecutive_successes: number;
last_check_at: string | null;
last_failure_reason: string | null;
}>,
): ServiceNode {
const current = getNode(db, id);
const update: Record<string, unknown> = { updated_at: sql`datetime('now')` };
for (const [key, value] of Object.entries(patch)) {
if (value !== undefined) update[key] = value;
}
db.update(nodes).set(update).where(eq(nodes.id, id)).run();
if (patch.address && patch.address !== current.address) {
db.delete(serviceIps)
.where(
and(
eq(serviceIps.service_id, current.service_id),
eq(serviceIps.ip, current.address),
),
)
.run();
insertServiceIpIfMissing(db, current.service_id, patch.address);
}
return getNode(db, id);
}
export function deleteNode(db: Db, id: number): void {
const current = getNode(db, id);
db.delete(nodes).where(eq(nodes.id, id)).run();
db.delete(serviceIps)
.where(
and(
eq(serviceIps.service_id, current.service_id),
eq(serviceIps.ip, current.address),
),
)
.run();
}
function mapHealthCheck(row: typeof healthChecks.$inferSelect): OriginHealthCheck {
return {
id: row.id,
provider: row.provider as OriginHealthCheck["provider"],
cf_healthcheck_id: row.cf_healthcheck_id,
cf_zone_id: row.cf_zone_id,
name: row.name,
protocol: row.protocol,
path: row.path,
method: row.method,
timeout: row.timeout,
interval_sec: row.interval_sec,
retries: row.retries,
expected_status: row.expected_status,
consecutive_fails: row.consecutive_fails,
consecutive_successes: row.consecutive_successes,
suspended: Boolean(row.suspended),
created_at: row.created_at,
updated_at: row.updated_at,
};
}
export function listHealthChecks(db: Db): OriginHealthCheck[] {
return db.select().from(healthChecks).all().map(mapHealthCheck);
}
export function getHealthCheck(db: Db, id: number): OriginHealthCheck {
const row = db.select().from(healthChecks).where(eq(healthChecks.id, id)).get();
if (!row) throw new NotFoundError(`health check ${id}`);
return mapHealthCheck(row);
}
export function findHealthCheckByCfId(
db: Db,
cfId: string,
): OriginHealthCheck | null {
const row = db
.select()
.from(healthChecks)
.where(eq(healthChecks.cf_healthcheck_id, cfId))
.get();
return row ? mapHealthCheck(row) : null;
}
export function createHealthCheck(
db: Db,
input: {
provider: string;
name: string;
cf_healthcheck_id?: string | null;
cf_zone_id?: string | null;
protocol?: string;
path?: string | null;
method?: string | null;
timeout?: number;
interval_sec?: number;
retries?: number;
expected_status?: number | null;
consecutive_fails?: number;
consecutive_successes?: number;
suspended?: boolean;
},
): OriginHealthCheck {
const id = db
.insert(healthChecks)
.values({
provider: input.provider,
name: input.name,
cf_healthcheck_id: input.cf_healthcheck_id ?? null,
cf_zone_id: input.cf_zone_id ?? null,
protocol: input.protocol ?? "tcp",
path: input.path ?? null,
method: input.method ?? null,
timeout: input.timeout ?? 5,
interval_sec: input.interval_sec ?? 30,
retries: input.retries ?? 2,
expected_status: input.expected_status ?? null,
consecutive_fails: input.consecutive_fails ?? 2,
consecutive_successes: input.consecutive_successes ?? 2,
suspended: input.suspended ?? false,
})
.returning({ id: healthChecks.id })
.get()!.id;
return getHealthCheck(db, id);
}
export function updateHealthCheck(
db: Db,
id: number,
patch: Partial<{
provider: string;
name: string;
cf_healthcheck_id: string | null;
cf_zone_id: string | null;
protocol: string;
path: string | null;
method: string | null;
timeout: number;
interval_sec: number;
retries: number;
expected_status: number | null;
consecutive_fails: number;
consecutive_successes: number;
suspended: boolean;
}>,
): OriginHealthCheck {
getHealthCheck(db, id);
const update: Record<string, unknown> = { updated_at: sql`datetime('now')` };
for (const [key, value] of Object.entries(patch)) {
if (value !== undefined) update[key] = value;
}
db.update(healthChecks).set(update).where(eq(healthChecks.id, id)).run();
return getHealthCheck(db, id);
}
export function deleteHealthCheck(db: Db, id: number): void {
const result = db.delete(healthChecks).where(eq(healthChecks.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`health check ${id}`);
}
export function bumpBindingVersion(db: Db, bindingId: number, expected?: number): number {
const binding = getBinding(db, bindingId);
if (expected != null && binding.operation_version !== expected) {
throw new ConflictError(`binding ${bindingId} version conflict`);
}
const next = (binding.operation_version ?? 0) + 1;
db.update(serviceBindings)
.set({
operation_version: next,
updated_at: sql`datetime('now')`,
})
.where(eq(serviceBindings.id, bindingId))
.run();
return next;
}
export function setBindingRoutingStrategy(
db: Db,
bindingId: number,
strategy: LbMode,
): void {
db.update(serviceBindings)
.set({
routing_strategy: strategy,
lb_mode: strategy,
updated_at: sql`datetime('now')`,
})
.where(eq(serviceBindings.id, bindingId))
.run();
}
export function listAllNodes(db: Db): ServiceNode[] {
return db.select().from(nodes).all().map(mapNode);
}
export function updateBindingDomain(
db: Db,
bindingId: number,
domainId: number,
hostname: string,
): void {
db.update(serviceBindings)
.set({
domain_id: domainId,
hostname,
updated_at: sql`datetime('now')`,
})
.where(eq(serviceBindings.id, bindingId))
.run();
}
export function listBindingNodes(db: Db, bindingId: number): ServiceNode[] {
return db
.select({ node: nodes })
.from(bindingNodes)
.innerJoin(nodes, eq(bindingNodes.node_id, nodes.id))
.where(eq(bindingNodes.binding_id, bindingId))
.all()
.map((row) => mapNode(row.node));
}
// --- Service Binding IPs ---
@@ -980,9 +1397,13 @@ export function replaceBindingIpsWithMeta(
bindingId: number,
entries: BindingIpMeta[],
): void {
const binding = getBinding(db, bindingId);
db.delete(serviceBindingIps)
.where(eq(serviceBindingIps.binding_id, bindingId))
.run();
db.delete(bindingNodes)
.where(eq(bindingNodes.binding_id, bindingId))
.run();
for (const entry of entries) {
db.insert(serviceBindingIps)
.values({
@@ -992,6 +1413,18 @@ export function replaceBindingIpsWithMeta(
priority: entry.priority,
})
.run();
const node = ensureNode(db, binding.service_id, entry.ip, {
weight: entry.weight,
priority: entry.priority,
});
db.insert(bindingNodes)
.values({
binding_id: bindingId,
node_id: node.id,
weight: entry.weight,
priority: entry.priority,
})
.run();
}
}
@@ -1005,6 +1438,7 @@ export interface BindingLbPatch {
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
health_check_verify_tls?: boolean;
health_check_provider?: HealthCheckProvider;
}
export function updateBindingLbConfig(
@@ -1015,7 +1449,10 @@ export function updateBindingLbConfig(
const update: Record<string, unknown> = {
updated_at: sql`datetime('now')`,
};
if (patch.lb_mode !== undefined) update.lb_mode = patch.lb_mode;
if (patch.lb_mode !== undefined) {
update.lb_mode = patch.lb_mode;
update.routing_strategy = patch.lb_mode;
}
if (patch.health_check_enabled !== undefined)
update.health_check_enabled = patch.health_check_enabled;
if (patch.health_check_type !== undefined)
@@ -1032,6 +1469,8 @@ export function updateBindingLbConfig(
update.health_check_timeout_ms = patch.health_check_timeout_ms;
if (patch.health_check_verify_tls !== undefined)
update.health_check_verify_tls = patch.health_check_verify_tls;
if (patch.health_check_provider !== undefined)
update.health_check_provider = patch.health_check_provider;
db.update(serviceBindings)
.set(update)
.where(eq(serviceBindings.id, bindingId))
@@ -1139,7 +1578,7 @@ function dnsRecordMatchesHostname(
const SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec,
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.cname_target,
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider, sb.cname_target,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
@@ -1518,7 +1957,7 @@ export function listIpHealthStatus(
return db
.all<IpHealthStatus>(sql`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error
last_checked_at, last_error, colo, provider
FROM ip_health_status
WHERE scope = ${scope} AND ref_id = ${refId}
`);
@@ -1679,6 +2118,71 @@ export function aggregateIpHealthByServiceIds(
return result;
}
export type ServiceIpHealthRow = {
ip: string;
status: IpHealthState;
latency_ms: number | null;
last_checked_at: string | null;
last_error: string | null;
provider: HealthCheckProvider;
colo: string | null;
};
/** Per-IP binding-scope health, worst status if the same IP is on several bindings. */
export function listIpHealthByServiceIds(
db: Db,
serviceIds: number[],
): Map<number, ServiceIpHealthRow[]> {
const result = new Map<number, ServiceIpHealthRow[]>();
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;
health_status: string | null;
health_latency_ms: number | null;
last_checked_at: string | null;
last_error: string | null;
provider: string | null;
colo: string | null;
}>(sql`
SELECT sb.service_id AS service_id,
ihs.ip AS ip,
${WORST_HEALTH_SQL} AS health_status,
MAX(ihs.latency_ms) AS health_latency_ms,
MAX(ihs.last_checked_at) AS last_checked_at,
MAX(ihs.last_error) AS last_error,
MAX(ihs.provider) AS provider,
MAX(ihs.colo) AS colo
FROM ip_health_status ihs
INNER JOIN service_bindings sb
ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
WHERE sb.service_id IN (${idList})
GROUP BY sb.service_id, ihs.ip
`);
for (const row of rows) {
const parsed = parseHealthAggregateRow({
health_status: row.health_status,
health_latency_ms: row.health_latency_ms,
});
const list = result.get(row.service_id) ?? [];
list.push({
ip: row.ip,
status: parsed.health_status,
latency_ms: parsed.health_latency_ms,
last_checked_at: row.last_checked_at,
last_error: row.last_error,
provider: normalizeHealthProvider(row.provider),
colo: row.colo,
});
result.set(row.service_id, list);
}
return result;
}
export function mergeHealthAggregates(
parts: Array<HealthAggregate | undefined | null>,
): HealthAggregate {
@@ -1721,7 +2225,7 @@ export function getIpHealthStatusRow(
): IpHealthStatus | null {
const rows = db.all<IpHealthStatus>(sql`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error
consecutive_successes, last_checked_at, last_error, colo, provider
FROM ip_health_status
WHERE scope = ${scope} AND ref_id = ${refId} AND ip = ${ip}
LIMIT 1
@@ -1738,19 +2242,28 @@ export function upsertIpHealthStatus(
latencyMs: number | null,
consecutiveFailures: number,
lastError: string | null,
consecutiveSuccesses = 0,
extras?: { colo?: string | null; provider?: HealthCheckProvider },
): void {
const colo = extras?.colo ?? null;
const provider = extras?.provider ?? "local";
db.run(sql`
INSERT INTO ip_health_status
(scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error, created_at, updated_at)
consecutive_successes, last_checked_at, last_error, colo, provider,
created_at, updated_at)
VALUES (${scope}, ${refId}, ${ip}, ${status}, ${latencyMs}, ${consecutiveFailures},
datetime('now'), ${lastError}, datetime('now'), datetime('now'))
${consecutiveSuccesses}, datetime('now'), ${lastError}, ${colo}, ${provider},
datetime('now'), datetime('now'))
ON CONFLICT(scope, ref_id, ip) DO UPDATE SET
status = excluded.status,
latency_ms = excluded.latency_ms,
consecutive_failures = excluded.consecutive_failures,
consecutive_successes = excluded.consecutive_successes,
last_checked_at = excluded.last_checked_at,
last_error = excluded.last_error,
colo = excluded.colo,
provider = excluded.provider,
updated_at = datetime('now')
`);
}
@@ -1845,7 +2358,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
sb.health_check_path AS path,
sb.health_check_expected_status AS expected_status,
sb.health_check_timeout_ms AS timeout_ms,
sb.health_check_verify_tls AS verify_tls
sb.health_check_verify_tls AS verify_tls,
COALESCE(sb.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN domains d ON d.id = sb.domain_id
@@ -1863,7 +2377,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
sg.health_check_path AS path,
sg.health_check_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls
sg.health_check_verify_tls AS verify_tls,
COALESCE(sg.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN services s ON s.id = sb.service_id
@@ -1888,7 +2403,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
sg.health_check_path AS path,
sg.health_check_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls
sg.health_check_verify_tls AS verify_tls,
COALESCE(sg.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN domains d ON d.id = sb.domain_id
@@ -1910,7 +2426,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
sb.health_check_path AS path,
sb.health_check_expected_status AS expected_status,
sb.health_check_timeout_ms AS timeout_ms,
sb.health_check_verify_tls AS verify_tls
sb.health_check_verify_tls AS verify_tls,
COALESCE(sb.health_check_provider, 'local') AS provider
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
JOIN services s ON s.id = sb.service_id
@@ -1930,7 +2447,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
sg.health_check_path AS path,
sg.health_check_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls
sg.health_check_verify_tls AS verify_tls,
COALESCE(sg.health_check_provider, 'local') AS provider
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
JOIN services s ON s.id = sb.service_id
@@ -1953,6 +2471,7 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
].map((t) => ({
...t,
verify_tls: Boolean(t.verify_tls),
provider: normalizeHealthProvider(t.provider),
}));
}
@@ -2157,6 +2676,94 @@ export function listDomainMonitorResultsForDomain(
`);
}
// --- Health probe journal ---
const HEALTH_PROBE_LOG_KEEP = 50;
export function insertHealthProbeLog(
db: Db,
entry: {
scope: HealthCheckScope;
refId: number;
ip: string;
provider: HealthCheckProvider;
status: string;
ok: boolean;
latencyMs: number | null;
colo: string | null;
error: string | null;
},
): void {
db.insert(healthProbeLog)
.values({
scope: entry.scope,
ref_id: entry.refId,
ip: entry.ip,
provider: entry.provider,
status: entry.status,
ok: entry.ok,
latency_ms: entry.latencyMs,
colo: entry.colo,
error: entry.error,
})
.run();
db.run(sql`
DELETE FROM health_probe_log
WHERE id IN (
SELECT id FROM health_probe_log
WHERE scope = ${entry.scope} AND ref_id = ${entry.refId} AND ip = ${entry.ip}
ORDER BY checked_at DESC, id DESC
LIMIT -1 OFFSET ${HEALTH_PROBE_LOG_KEEP}
)
`);
}
export function listHealthProbeLogForService(
db: Db,
serviceId: number,
limit = 50,
): {
id: number;
scope: string;
ref_id: number;
ip: string;
provider: HealthCheckProvider;
status: string;
ok: boolean;
latency_ms: number | null;
colo: string | null;
error: string | null;
checked_at: string;
}[] {
const rows = db.all<{
id: number;
scope: string;
ref_id: number;
ip: string;
provider: string;
status: string;
ok: number;
latency_ms: number | null;
colo: string | null;
error: string | null;
checked_at: string;
}>(sql`
SELECT l.id, l.scope, l.ref_id, l.ip, l.provider, l.status, l.ok,
l.latency_ms, l.colo, l.error, l.checked_at
FROM health_probe_log l
INNER JOIN service_bindings sb
ON l.scope = 'binding' AND l.ref_id = sb.id
WHERE sb.service_id = ${serviceId}
ORDER BY l.checked_at DESC, l.id DESC
LIMIT ${limit}
`);
return rows.map((row) => ({
...row,
provider: normalizeHealthProvider(row.provider),
ok: Boolean(row.ok),
}));
}
// --- Notification log ---
export function insertNotificationLog(
+109
View File
@@ -64,6 +64,7 @@ export const serviceGroups = sqliteTable("service_groups", {
health_check_verify_tls: integer("health_check_verify_tls", { mode: "boolean" })
.notNull()
.default(false),
health_check_provider: text("health_check_provider").notNull().default("local"),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
@@ -165,6 +166,11 @@ export const serviceBindings = sqliteTable(
})
.notNull()
.default(false),
health_check_provider: text("health_check_provider")
.notNull()
.default("local"),
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
operation_version: integer("operation_version").notNull().default(0),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
@@ -181,12 +187,83 @@ export const serviceBindings = sqliteTable(
],
);
export const healthChecks = sqliteTable("health_checks", {
id: integer("id").primaryKey({ autoIncrement: true }),
provider: text("provider").notNull().default("local"),
cf_healthcheck_id: text("cf_healthcheck_id"),
cf_zone_id: text("cf_zone_id"),
name: text("name").notNull(),
protocol: text("protocol").notNull().default("tcp"),
path: text("path"),
method: text("method"),
timeout: integer("timeout").notNull().default(5),
interval_sec: integer("interval_sec").notNull().default(30),
retries: integer("retries").notNull().default(2),
expected_status: integer("expected_status"),
consecutive_fails: integer("consecutive_fails").notNull().default(2),
consecutive_successes: integer("consecutive_successes").notNull().default(2),
suspended: integer("suspended", { mode: "boolean" }).notNull().default(false),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
updated_at: text("updated_at")
.notNull()
.default(sql`datetime('now')`),
});
export const nodes = sqliteTable(
"nodes",
{
id: integer("id").primaryKey({ autoIncrement: true }),
service_id: integer("service_id")
.notNull()
.references(() => services.id, { onDelete: "cascade" }),
address: text("address").notNull(),
protocol: text("protocol").notNull().default("tcp"),
port: integer("port"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull().default(1),
weight: integer("weight").notNull().default(1),
health_status: text("health_status").notNull().default("unknown"),
health_check_id: integer("health_check_id").references(() => healthChecks.id, {
onDelete: "set null",
}),
consecutive_failures: integer("consecutive_failures").notNull().default(0),
consecutive_successes: integer("consecutive_successes").notNull().default(0),
last_check_at: text("last_check_at"),
last_failure_reason: text("last_failure_reason"),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
updated_at: text("updated_at")
.notNull()
.default(sql`datetime('now')`),
},
(t) => [unique("nodes_service_address").on(t.service_id, t.address)],
);
export const bindingNodes = sqliteTable(
"binding_nodes",
{
binding_id: integer("binding_id")
.notNull()
.references(() => serviceBindings.id, { onDelete: "cascade" }),
node_id: integer("node_id")
.notNull()
.references(() => nodes.id, { onDelete: "cascade" }),
weight: integer("weight").notNull().default(1),
priority: integer("priority").notNull().default(1),
},
(t) => [primaryKey({ columns: [t.binding_id, t.node_id] })],
);
export const serviceIps = sqliteTable("service_ips", {
id: integer("id").primaryKey({ autoIncrement: true }),
service_id: integer("service_id")
.notNull()
.references(() => services.id, { onDelete: "cascade" }),
ip: text("ip").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
@@ -276,8 +353,13 @@ export const ipHealthStatus = sqliteTable(
consecutive_failures: integer("consecutive_failures")
.notNull()
.default(0),
consecutive_successes: integer("consecutive_successes")
.notNull()
.default(0),
last_checked_at: text("last_checked_at"),
last_error: text("last_error"),
colo: text("colo"),
provider: text("provider").notNull().default("local"),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
@@ -304,6 +386,13 @@ export const appSettings = sqliteTable("app_settings", {
})
.notNull()
.default(true),
health_check_cron: text("health_check_cron"),
health_degraded_failures: integer("health_degraded_failures"),
health_down_failures: integer("health_down_failures"),
health_latency_warn_ms: integer("health_latency_warn_ms"),
health_success_recoveries: integer("health_success_recoveries"),
health_worker_url: text("health_worker_url"),
health_worker_token: text("health_worker_token"),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
@@ -360,6 +449,22 @@ export const domainMonitorResults = sqliteTable("domain_monitor_results", {
.default(sql`datetime('now')`),
});
export const healthProbeLog = sqliteTable("health_probe_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
scope: text("scope").notNull(),
ref_id: integer("ref_id").notNull(),
ip: text("ip").notNull(),
provider: text("provider").notNull(),
status: text("status").notNull(),
ok: integer("ok", { mode: "boolean" }).notNull(),
latency_ms: integer("latency_ms"),
colo: text("colo"),
error: text("error"),
checked_at: text("checked_at")
.notNull()
.default(sql`datetime('now')`),
});
export const notificationLog = sqliteTable("notification_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
kind: text("kind").notNull(),
@@ -399,6 +504,9 @@ export const schema = {
subdomains,
dnsRecords,
serviceBindings,
healthChecks,
nodes,
bindingNodes,
serviceIps,
serviceBindingRecords,
serviceBindingIps,
@@ -410,6 +518,7 @@ export const schema = {
domainTags,
domainMonitors,
domainMonitorResults,
healthProbeLog,
notificationLog,
auditLog,
};
+105 -6
View File
@@ -4,6 +4,14 @@ import { appSettings } from "./schema.js";
const SETTINGS_ID = "settings-main";
export type HealthEngineSettings = {
healthCheckCron: string;
healthDegradedFailures: number;
healthDownFailures: number;
healthLatencyWarnMs: number;
healthSuccessRecoveries: number;
};
export type AppSettingsDto = {
id: string;
vpsTrackerUrl: string;
@@ -11,16 +19,46 @@ export type AppSettingsDto = {
vpsTrackerSyncEnabled: boolean;
vpsTrackerLastSyncAt: string | null;
showQuickActions: boolean;
};
healthWorkerUrl: string;
healthWorkerTokenSet: boolean;
} & HealthEngineSettings;
export type AppSettingsPatch = {
vpsTrackerUrl?: string;
vpsTrackerIntegrationToken?: string;
vpsTrackerSyncEnabled?: boolean;
showQuickActions?: boolean;
healthCheckCron?: string;
healthDegradedFailures?: number;
healthDownFailures?: number;
healthLatencyWarnMs?: number;
healthSuccessRecoveries?: number;
healthWorkerUrl?: string;
healthWorkerToken?: string;
};
function toDto(row: typeof appSettings.$inferSelect): AppSettingsDto {
export type HealthEngineFallbacks = HealthEngineSettings & {
healthWorkerUrl: string;
healthWorkerTokenSet: boolean;
};
function coalesceInt(value: number | null | undefined, fallback: number): number {
return value == null || Number.isNaN(value) || value < 1 ? fallback : value;
}
function toDto(
row: typeof appSettings.$inferSelect,
fallbacks?: HealthEngineFallbacks,
): AppSettingsDto {
const env = fallbacks ?? {
healthCheckCron: "0 */2 * * * *",
healthDegradedFailures: 1,
healthDownFailures: 2,
healthLatencyWarnMs: 1000,
healthSuccessRecoveries: 2,
healthWorkerUrl: "",
healthWorkerTokenSet: false,
};
return {
id: row.id,
vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "",
@@ -31,10 +69,33 @@ function toDto(row: typeof appSettings.$inferSelect): AppSettingsDto {
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at,
showQuickActions:
row.show_quick_actions == null ? true : Boolean(row.show_quick_actions),
healthCheckCron: row.health_check_cron?.trim() || env.healthCheckCron,
healthDegradedFailures: coalesceInt(
row.health_degraded_failures,
env.healthDegradedFailures,
),
healthDownFailures: coalesceInt(
row.health_down_failures,
env.healthDownFailures,
),
healthLatencyWarnMs: coalesceInt(
row.health_latency_warn_ms,
env.healthLatencyWarnMs,
),
healthSuccessRecoveries: coalesceInt(
row.health_success_recoveries,
env.healthSuccessRecoveries,
),
healthWorkerUrl: row.health_worker_url?.trim() || env.healthWorkerUrl,
healthWorkerTokenSet:
Boolean(row.health_worker_token?.trim()) || env.healthWorkerTokenSet,
};
}
export function getAppSettings(db: Db): AppSettingsDto {
export function getAppSettings(
db: Db,
fallbacks?: HealthEngineFallbacks,
): AppSettingsDto {
const row = db
.select()
.from(appSettings)
@@ -44,15 +105,18 @@ export function getAppSettings(db: Db): AppSettingsDto {
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
return toDto(
db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get()!,
fallbacks,
);
}
return toDto(row);
return toDto(row, fallbacks);
}
export function getAppSettingsSecrets(db: Db): {
vpsTrackerUrl: string;
vpsTrackerIntegrationToken: string;
vpsTrackerSyncEnabled: boolean;
healthWorkerUrl: string;
healthWorkerToken: string;
} {
const row = db
.select()
@@ -64,10 +128,16 @@ export function getAppSettingsSecrets(db: Db): {
vpsTrackerIntegrationToken:
row?.vps_tracker_integration_token?.trim() ?? "",
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled),
healthWorkerUrl: row?.health_worker_url?.trim() ?? "",
healthWorkerToken: row?.health_worker_token?.trim() ?? "",
};
}
export function updateAppSettings(db: Db, patch: AppSettingsPatch): AppSettingsDto {
export function updateAppSettings(
db: Db,
patch: AppSettingsPatch,
fallbacks?: HealthEngineFallbacks,
): AppSettingsDto {
const existing = db
.select()
.from(appSettings)
@@ -102,12 +172,41 @@ export function updateAppSettings(db: Db, patch: AppSettingsPatch): AppSettingsD
patch.showQuickActions !== undefined
? patch.showQuickActions
: current.show_quick_actions,
health_check_cron:
patch.healthCheckCron !== undefined
? patch.healthCheckCron.trim()
: current.health_check_cron,
health_degraded_failures:
patch.healthDegradedFailures !== undefined
? patch.healthDegradedFailures
: current.health_degraded_failures,
health_down_failures:
patch.healthDownFailures !== undefined
? patch.healthDownFailures
: current.health_down_failures,
health_latency_warn_ms:
patch.healthLatencyWarnMs !== undefined
? patch.healthLatencyWarnMs
: current.health_latency_warn_ms,
health_success_recoveries:
patch.healthSuccessRecoveries !== undefined
? patch.healthSuccessRecoveries
: current.health_success_recoveries,
health_worker_url:
patch.healthWorkerUrl !== undefined
? patch.healthWorkerUrl.trim() || null
: current.health_worker_url,
health_worker_token:
patch.healthWorkerToken !== undefined &&
patch.healthWorkerToken.trim() !== ""
? patch.healthWorkerToken
: current.health_worker_token,
updated_at: new Date().toISOString(),
})
.where(eq(appSettings.id, SETTINGS_ID))
.run();
return getAppSettings(db);
return getAppSettings(db, fallbacks);
}
export function touchVpsTrackerSync(db: Db): void {
+428 -1
View File
@@ -31,6 +31,7 @@ interface ServiceGroup$1 {
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider;
created_at: string;
updated_at: string;
}
@@ -60,6 +61,9 @@ interface ServiceBinding {
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider;
routing_strategy: LbMode;
operation_version: number;
created_at: string;
updated_at: string;
}
@@ -88,6 +92,7 @@ interface ServiceBindingView {
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider;
sync_status: string | null;
created_at: string;
updated_at: string;
@@ -112,8 +117,28 @@ interface ServiceDomainBindingView {
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider;
sync_status: string | null;
}
interface ServiceView$1 {
id: number;
name: string;
slug: string;
service_group_id: number | null;
subdomain: string;
enabled: boolean;
computed_fqdn: string | null;
lb_weight: number;
lb_priority: number;
created_at: string;
updated_at: string;
ips: string[];
domains: ServiceDomainBindingView[];
health_status: IpHealthState;
health_latency_ms: number | null;
ip_health: ServiceIpHealth$1[];
ip_enabled: Record<string, boolean>;
}
interface SyncJob {
id: string;
status: string;
@@ -159,6 +184,8 @@ interface JwtClaims {
type LbMode = "round_robin" | "failover" | "weighted";
type HealthCheckType = "tcp" | "http" | "ping" | "dns";
type IpHealthState = "up" | "down" | "degraded" | "unknown";
type NodeHealthState = "unknown" | "checking" | "healthy" | "degraded" | "unhealthy" | "disabled";
type HealthCheckProvider = "local" | "cloudflare";
type HealthCheckScope = "binding" | "group";
interface IpHealthStatus {
scope: HealthCheckScope;
@@ -167,8 +194,85 @@ interface IpHealthStatus {
status: IpHealthState;
latency_ms: number | null;
consecutive_failures: number;
consecutive_successes?: number;
last_checked_at: string | null;
last_error: string | null;
colo?: string | null;
provider?: HealthCheckProvider;
}
interface ServiceIpHealth$1 {
ip: string;
status: IpHealthState;
latency_ms: number | null;
last_checked_at?: string | null;
last_error?: string | null;
provider?: HealthCheckProvider;
colo?: string | null;
}
interface ServiceNode {
id: number;
service_id: number;
address: string;
protocol: string;
port: number | null;
enabled: boolean;
priority: number;
weight: number;
health_status: NodeHealthState;
health_check_id: number | null;
consecutive_failures: number;
consecutive_successes: number;
last_check_at: string | null;
last_failure_reason: string | null;
created_at: string;
updated_at: string;
}
interface OriginHealthCheck {
id: number;
provider: HealthCheckProvider;
cf_healthcheck_id: string | null;
cf_zone_id: string | null;
name: string;
protocol: string;
path: string | null;
method: string | null;
timeout: number;
interval_sec: number;
retries: number;
expected_status: number | null;
consecutive_fails: number;
consecutive_successes: number;
suspended: boolean;
created_at: string;
updated_at: string;
}
interface ServiceOverview {
service: ServiceView$1;
nodes: ServiceNode[];
health_check: OriginHealthCheck | null;
routing_strategy: LbMode;
active_addresses: string[];
}
interface PatchDnsRecordPayload {
type?: string;
name?: string;
content?: string;
ttl?: number;
proxied?: boolean;
priority?: number;
}
interface CfHealthCheck {
id: string;
address: string;
name: string;
status?: string;
type?: string;
interval?: number;
timeout?: number;
retries?: number;
consecutive_fails?: number;
consecutive_successes?: number;
suspended?: boolean;
}
interface HealthCheckTarget {
scope: HealthCheckScope;
@@ -181,6 +285,7 @@ interface HealthCheckTarget {
expected_status: number | null;
timeout_ms: number;
verify_tls: boolean;
provider: HealthCheckProvider;
}
declare class ValidationError extends Error {
@@ -250,6 +355,18 @@ declare const ipHealthStateSchema: z.ZodEnum<{
down: "down";
degraded: "degraded";
}>;
declare const nodeHealthStateSchema: z.ZodEnum<{
unknown: "unknown";
degraded: "degraded";
checking: "checking";
healthy: "healthy";
unhealthy: "unhealthy";
disabled: "disabled";
}>;
declare const healthCheckProviderSchema: z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>;
declare const healthCheckScopeSchema: z.ZodEnum<{
binding: "binding";
group: "group";
@@ -269,9 +386,55 @@ declare const ipHealthStatusSchema: z.ZodObject<{
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
consecutive_failures: z.ZodNumber;
consecutive_successes: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
last_checked_at: z.ZodNullable<z.ZodString>;
last_error: z.ZodNullable<z.ZodString>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
}, z.core.$strip>;
declare const serviceIpHealthSchema: z.ZodObject<{
ip: z.ZodString;
status: z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
last_checked_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
last_error: z.ZodOptional<z.ZodNullable<z.ZodString>>;
provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>;
type ServiceIpHealth = z.infer<typeof serviceIpHealthSchema>;
declare const healthProbeLogSchema: z.ZodObject<{
id: z.ZodNumber;
scope: z.ZodString;
ref_id: z.ZodNumber;
ip: z.ZodString;
provider: z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>;
status: z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>;
ok: z.ZodCoercedBoolean<unknown>;
latency_ms: z.ZodNullable<z.ZodNumber>;
colo: z.ZodNullable<z.ZodString>;
error: z.ZodNullable<z.ZodString>;
checked_at: z.ZodString;
}, z.core.$strip>;
type HealthProbeLog = z.infer<typeof healthProbeLogSchema>;
declare const groupSchema: z.ZodObject<{
id: z.ZodNumber;
name: z.ZodString;
@@ -325,6 +488,10 @@ declare const serviceGroupSchema: z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
created_at: z.ZodString;
updated_at: z.ZodString;
}, z.core.$strip>;
@@ -374,6 +541,10 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
@@ -395,6 +566,7 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare";
sync_status: string | null;
target_ip?: string | null | undefined;
}, {
@@ -413,6 +585,7 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare";
sync_status: string | null;
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
@@ -466,6 +639,10 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
@@ -487,6 +664,7 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare";
sync_status: string | null;
target_ip?: string | null | undefined;
}, {
@@ -505,6 +683,7 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare";
sync_status: string | null;
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
@@ -519,6 +698,24 @@ declare const serviceViewSchema: z.ZodObject<{
degraded: "degraded";
}>>;
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
ip_health: z.ZodDefault<z.ZodArray<z.ZodObject<{
ip: z.ZodString;
status: z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
last_checked_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
last_error: z.ZodOptional<z.ZodNullable<z.ZodString>>;
provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
}, z.core.$strip>;
declare const serviceGroupViewSchema: z.ZodObject<{
id: z.ZodNumber;
@@ -551,6 +748,10 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
created_at: z.ZodString;
updated_at: z.ZodString;
services: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -599,6 +800,10 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
@@ -620,6 +825,7 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare";
sync_status: string | null;
target_ip?: string | null | undefined;
}, {
@@ -638,6 +844,7 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare";
sync_status: string | null;
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
@@ -652,6 +859,24 @@ declare const serviceGroupViewSchema: z.ZodObject<{
degraded: "degraded";
}>>;
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
ip_health: z.ZodDefault<z.ZodArray<z.ZodObject<{
ip: z.ZodString;
status: z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
last_checked_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
last_error: z.ZodOptional<z.ZodNullable<z.ZodString>>;
provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
}, z.core.$strip>>>;
health_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown";
@@ -693,6 +918,10 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
created_at: z.ZodString;
updated_at: z.ZodString;
services: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -741,6 +970,10 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
@@ -762,6 +995,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare";
sync_status: string | null;
target_ip?: string | null | undefined;
}, {
@@ -780,6 +1014,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare";
sync_status: string | null;
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
@@ -794,6 +1029,24 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
degraded: "degraded";
}>>;
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
ip_health: z.ZodDefault<z.ZodArray<z.ZodObject<{
ip: z.ZodString;
status: z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
last_checked_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
last_error: z.ZodOptional<z.ZodNullable<z.ZodString>>;
provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
}, z.core.$strip>>>;
health_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown";
@@ -849,6 +1102,10 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
@@ -870,6 +1127,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare";
sync_status: string | null;
target_ip?: string | null | undefined;
}, {
@@ -888,6 +1146,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare";
sync_status: string | null;
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
@@ -902,6 +1161,24 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
degraded: "degraded";
}>>;
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
ip_health: z.ZodDefault<z.ZodArray<z.ZodObject<{
ip: z.ZodString;
status: z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
last_checked_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
last_error: z.ZodOptional<z.ZodNullable<z.ZodString>>;
provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
}, z.core.$strip>>>;
}, z.core.$strip>;
declare const domainSchema: z.ZodObject<{
@@ -1103,6 +1380,10 @@ declare const healthCheckConfigSchema: z.ZodObject<{
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
}, z.core.$strip>;
type HealthCheckConfig = z.infer<typeof healthCheckConfigSchema>;
declare const createServiceSchema: z.ZodObject<{
@@ -1130,6 +1411,10 @@ declare const createServiceWithConfigSchema: z.ZodObject<{
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
fqdn: z.ZodString;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_cname: z.ZodOptional<z.ZodString>;
@@ -1318,6 +1603,10 @@ declare const updateServiceConfigSchema: z.ZodObject<{
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
fqdn: z.ZodString;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_cname: z.ZodOptional<z.ZodString>;
@@ -1345,6 +1634,10 @@ declare const createServiceGroupSchema: z.ZodObject<{
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
name: z.ZodString;
type: z.ZodDefault<z.ZodEnum<{
vpn: "vpn";
@@ -1375,6 +1668,10 @@ declare const updateServiceGroupSchema: z.ZodObject<{
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>>;
name: z.ZodOptional<z.ZodString>;
type: z.ZodOptional<z.ZodEnum<{
vpn: "vpn";
@@ -1395,6 +1692,11 @@ type UpdateServiceGroupInput = z.infer<typeof updateServiceGroupSchema>;
declare const toggleEnabledSchema: z.ZodObject<{
enabled: z.ZodBoolean;
}, z.core.$strip>;
declare const toggleServiceIpSchema: z.ZodObject<{
ip: z.ZodString;
enabled: z.ZodBoolean;
}, z.core.$strip>;
type ToggleServiceIpInput = z.infer<typeof toggleServiceIpSchema>;
declare const reorderServicesSchema: z.ZodObject<{
group_id: z.ZodDefault<z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>>>;
service_ids: z.ZodArray<z.ZodNumber>;
@@ -1414,6 +1716,124 @@ type CreateServiceBindingInput = z.infer<typeof createServiceBindingSchema>;
type CreateDomainInput = z.infer<typeof createDomainSchema>;
type LoginInput = z.infer<typeof loginSchema>;
type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema>;
declare const serviceNodeSchema: z.ZodObject<{
id: z.ZodNumber;
service_id: z.ZodNumber;
address: z.ZodString;
protocol: z.ZodString;
port: z.ZodNullable<z.ZodNumber>;
enabled: z.ZodCoercedBoolean<unknown>;
priority: z.ZodNumber;
weight: z.ZodNumber;
health_status: z.ZodEnum<{
unknown: "unknown";
degraded: "degraded";
checking: "checking";
healthy: "healthy";
unhealthy: "unhealthy";
disabled: "disabled";
}>;
health_check_id: z.ZodNullable<z.ZodNumber>;
consecutive_failures: z.ZodNumber;
consecutive_successes: z.ZodNumber;
last_check_at: z.ZodNullable<z.ZodString>;
last_failure_reason: z.ZodNullable<z.ZodString>;
created_at: z.ZodString;
updated_at: z.ZodString;
}, z.core.$strip>;
declare const createServiceNodeSchema: z.ZodObject<{
address: z.ZodString;
protocol: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
https: "https";
}>>>;
port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
priority: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
weight: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
health_check_id: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
}, z.core.$strip>;
declare const updateServiceNodeSchema: z.ZodObject<{
address: z.ZodOptional<z.ZodString>;
protocol: z.ZodOptional<z.ZodDefault<z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
https: "https";
}>>>>;
port: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
enabled: z.ZodOptional<z.ZodDefault<z.ZodOptional<z.ZodBoolean>>>;
priority: z.ZodOptional<z.ZodDefault<z.ZodOptional<z.ZodNumber>>>;
weight: z.ZodOptional<z.ZodDefault<z.ZodOptional<z.ZodNumber>>>;
health_check_id: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
}, z.core.$strip>;
declare const originHealthCheckSchema: z.ZodObject<{
id: z.ZodNumber;
provider: z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>;
cf_healthcheck_id: z.ZodNullable<z.ZodString>;
cf_zone_id: z.ZodNullable<z.ZodString>;
name: z.ZodString;
protocol: z.ZodString;
path: z.ZodNullable<z.ZodString>;
method: z.ZodNullable<z.ZodString>;
timeout: z.ZodNumber;
interval_sec: z.ZodNumber;
retries: z.ZodNumber;
expected_status: z.ZodNullable<z.ZodNumber>;
consecutive_fails: z.ZodNumber;
consecutive_successes: z.ZodNumber;
suspended: z.ZodCoercedBoolean<unknown>;
created_at: z.ZodString;
updated_at: z.ZodString;
}, z.core.$strip>;
declare const createOriginHealthCheckSchema: z.ZodObject<{
provider: z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
}>;
name: z.ZodString;
cf_zone_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
protocol: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
https: "https";
HTTP: "HTTP";
HTTPS: "HTTPS";
TCP: "TCP";
}>>;
path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
method: z.ZodOptional<z.ZodNullable<z.ZodString>>;
timeout: z.ZodOptional<z.ZodNumber>;
interval_sec: z.ZodOptional<z.ZodNumber>;
retries: z.ZodOptional<z.ZodNumber>;
expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
consecutive_fails: z.ZodOptional<z.ZodNumber>;
consecutive_successes: z.ZodOptional<z.ZodNumber>;
suspended: z.ZodOptional<z.ZodBoolean>;
node_id: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>;
declare const changeIpSchema: z.ZodObject<{
from_ip: z.ZodOptional<z.ZodString>;
to_ip: z.ZodOptional<z.ZodString>;
node_id: z.ZodOptional<z.ZodNumber>;
dry_run: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
}, z.core.$strip>;
declare const changeDomainSchema: z.ZodObject<{
from_domain_id: z.ZodNumber;
to_domain_id: z.ZodNumber;
hostnames: z.ZodOptional<z.ZodArray<z.ZodString>>;
dry_run: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
}, z.core.$strip>;
type ServiceNodeRecord = z.infer<typeof serviceNodeSchema>;
type CreateServiceNodeInput = z.infer<typeof createServiceNodeSchema>;
type UpdateServiceNodeInput = z.infer<typeof updateServiceNodeSchema>;
type OriginHealthCheckRecord = z.infer<typeof originHealthCheckSchema>;
type CreateOriginHealthCheckInput = z.infer<typeof createOriginHealthCheckSchema>;
type ChangeIpInput = z.infer<typeof changeIpSchema>;
type ChangeDomainInput = z.infer<typeof changeDomainSchema>;
declare const appSwitcherIconSchema: z.ZodEnum<{
server: "server";
@@ -1493,6 +1913,13 @@ declare const appSettingsPatchSchema: z.ZodObject<{
vpsTrackerIntegrationToken: z.ZodOptional<z.ZodString>;
vpsTrackerSyncEnabled: z.ZodOptional<z.ZodBoolean>;
showQuickActions: z.ZodOptional<z.ZodBoolean>;
healthCheckCron: z.ZodOptional<z.ZodString>;
healthDegradedFailures: z.ZodOptional<z.ZodNumber>;
healthDownFailures: z.ZodOptional<z.ZodNumber>;
healthLatencyWarnMs: z.ZodOptional<z.ZodNumber>;
healthSuccessRecoveries: z.ZodOptional<z.ZodNumber>;
healthWorkerUrl: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodLiteral<"">]>>;
healthWorkerToken: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
declare const vpsTrackerEventSchema: z.ZodObject<{
@@ -1617,4 +2044,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{
}, z.core.$strip>;
type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>;
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfZone, type CfdmBindingSyncItem, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NotificationLog, type ParsedFqdn, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, normalizeDnsRecordName, notificationLogSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthProbeLog, type HealthStatusQuery, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type ToggleServiceIpInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthProbeLogSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, notificationLogSchema, originHealthCheckSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, toggleServiceIpSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
+150 -4
View File
@@ -171,6 +171,15 @@ var healthCheckTypeSchema = z.enum(["tcp", "http", "ping", "dns"]);
var domainEnvironmentSchema = z.enum(["prod", "staging", "dev"]);
var domainMonitorTypeSchema = z.enum(["http", "ping", "dns"]);
var ipHealthStateSchema = z.enum(["up", "down", "degraded", "unknown"]);
var nodeHealthStateSchema = z.enum([
"unknown",
"checking",
"healthy",
"degraded",
"unhealthy",
"disabled"
]);
var healthCheckProviderSchema = z.enum(["local", "cloudflare"]);
var healthCheckScopeSchema = z.enum(["binding", "group"]);
var ipHealthStatusSchema = z.object({
scope: healthCheckScopeSchema,
@@ -179,8 +188,33 @@ var ipHealthStatusSchema = z.object({
status: ipHealthStateSchema,
latency_ms: z.number().nullable(),
consecutive_failures: z.number(),
consecutive_successes: z.number().optional().default(0),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable()
last_error: z.string().nullable(),
colo: z.string().nullable().optional(),
provider: healthCheckProviderSchema.optional()
});
var serviceIpHealthSchema = z.object({
ip: z.string(),
status: ipHealthStateSchema,
latency_ms: z.number().nullable(),
last_checked_at: z.string().nullable().optional(),
last_error: z.string().nullable().optional(),
provider: healthCheckProviderSchema.optional(),
colo: z.string().nullable().optional()
});
var healthProbeLogSchema = z.object({
id: z.number(),
scope: z.string(),
ref_id: z.number(),
ip: z.string(),
provider: healthCheckProviderSchema,
status: ipHealthStateSchema,
ok: z.coerce.boolean(),
latency_ms: z.number().nullable(),
colo: z.string().nullable(),
error: z.string().nullable(),
checked_at: z.string()
});
var groupSchema = z.object({
id: z.number(),
@@ -215,6 +249,7 @@ var serviceGroupSchema = z.object({
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3e3),
health_check_verify_tls: z.coerce.boolean().default(false),
health_check_provider: healthCheckProviderSchema.catch("local"),
created_at: z.string(),
updated_at: z.string()
});
@@ -252,6 +287,7 @@ var serviceDomainBindingSchema = z.object({
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3e3),
health_check_verify_tls: z.coerce.boolean().default(false),
health_check_provider: healthCheckProviderSchema.catch("local"),
sync_status: z.string().nullable().default(null)
}).transform((binding) => ({
...binding,
@@ -267,7 +303,9 @@ var serviceViewSchema = serviceSchema.extend({
ips: z.array(z.string()).default([]),
domains: z.array(serviceDomainBindingSchema).default([]),
health_status: ipHealthStateSchema.default("unknown"),
health_latency_ms: z.number().nullable().default(null)
health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]),
ip_enabled: z.record(z.string(), z.boolean()).default({})
});
var serviceGroupViewSchema = serviceGroupSchema.extend({
services: z.array(serviceViewSchema).default([]),
@@ -366,6 +404,7 @@ var ipv4Schema = z.string().regex(
/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/,
"\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IPv4"
);
var nodeAddressSchema = z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0438\u043B\u0438 hostname").max(255);
var healthCheckConfigFields = {
health_check_enabled: z.boolean().optional(),
health_check_type: healthCheckTypeSchema.optional(),
@@ -374,7 +413,8 @@ var healthCheckConfigFields = {
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
health_check_timeout_ms: z.number().int().min(100).max(3e4).optional(),
health_check_verify_tls: z.boolean().optional()
health_check_verify_tls: z.boolean().optional(),
health_check_provider: healthCheckProviderSchema.optional()
};
var healthCheckConfigSchema = z.object(healthCheckConfigFields);
var serviceDomainInputSchema = z.object({
@@ -541,6 +581,10 @@ var updateServiceGroupSchema = z.object({
var toggleEnabledSchema = z.object({
enabled: z.boolean()
});
var toggleServiceIpSchema = z.object({
ip: ipv4Schema,
enabled: z.boolean()
});
var reorderServicesSchema = z.object({
group_id: z.union([z.number(), z.null()]).optional().default(null),
service_ids: z.array(z.number().int().positive()).min(1)
@@ -549,6 +593,81 @@ var healthStatusQuerySchema = z.object({
scope: healthCheckScopeSchema,
ref_id: z.coerce.number().int().positive()
});
var serviceNodeSchema = z.object({
id: z.number(),
service_id: z.number(),
address: z.string(),
protocol: z.string(),
port: z.number().nullable(),
enabled: z.coerce.boolean(),
priority: z.number(),
weight: z.number(),
health_status: nodeHealthStateSchema,
health_check_id: z.number().nullable(),
consecutive_failures: z.number(),
consecutive_successes: z.number(),
last_check_at: z.string().nullable(),
last_failure_reason: z.string().nullable(),
created_at: z.string(),
updated_at: z.string()
});
var createServiceNodeSchema = z.object({
address: nodeAddressSchema,
protocol: z.enum(["tcp", "http", "https"]).optional().default("tcp"),
port: z.number().int().min(1).max(65535).nullable().optional(),
enabled: z.boolean().optional().default(true),
priority: z.number().int().min(1).max(100).optional().default(1),
weight: z.number().int().min(1).max(100).optional().default(1),
health_check_id: z.number().int().positive().nullable().optional()
});
var updateServiceNodeSchema = createServiceNodeSchema.partial();
var originHealthCheckSchema = z.object({
id: z.number(),
provider: healthCheckProviderSchema,
cf_healthcheck_id: z.string().nullable(),
cf_zone_id: z.string().nullable(),
name: z.string(),
protocol: z.string(),
path: z.string().nullable(),
method: z.string().nullable(),
timeout: z.number(),
interval_sec: z.number(),
retries: z.number(),
expected_status: z.number().nullable(),
consecutive_fails: z.number(),
consecutive_successes: z.number(),
suspended: z.coerce.boolean(),
created_at: z.string(),
updated_at: z.string()
});
var createOriginHealthCheckSchema = z.object({
provider: healthCheckProviderSchema,
name: z.string().min(1).max(64),
cf_zone_id: z.string().nullable().optional(),
protocol: z.enum(["HTTP", "HTTPS", "TCP", "tcp", "http", "https"]).optional(),
path: z.string().nullable().optional(),
method: z.string().nullable().optional(),
timeout: z.number().int().min(1).max(60).optional(),
interval_sec: z.number().int().min(5).max(3600).optional(),
retries: z.number().int().min(0).max(10).optional(),
expected_status: z.number().int().min(100).max(599).nullable().optional(),
consecutive_fails: z.number().int().min(1).max(20).optional(),
consecutive_successes: z.number().int().min(1).max(20).optional(),
suspended: z.boolean().optional(),
node_id: z.number().int().positive().optional()
});
var changeIpSchema = z.object({
from_ip: z.string().optional(),
to_ip: z.string().min(1).optional(),
node_id: z.number().int().positive().optional(),
dry_run: z.boolean().optional().default(false)
});
var changeDomainSchema = z.object({
from_domain_id: z.number().int().positive(),
to_domain_id: z.number().int().positive(),
hostnames: z.array(z.string().min(1)).optional(),
dry_run: z.boolean().optional().default(false)
});
// src/app-switcher.ts
import { z as z2 } from "zod";
@@ -601,7 +720,22 @@ var appSettingsPatchSchema = z3.object({
vpsTrackerUrl: z3.string().url().or(z3.literal("")).optional(),
vpsTrackerIntegrationToken: z3.string().optional(),
vpsTrackerSyncEnabled: z3.boolean().optional(),
showQuickActions: z3.boolean().optional()
showQuickActions: z3.boolean().optional(),
healthCheckCron: z3.string().trim().min(1).max(64).optional(),
healthDegradedFailures: z3.number().int().min(1).max(20).optional(),
healthDownFailures: z3.number().int().min(1).max(50).optional(),
healthLatencyWarnMs: z3.number().int().min(50).max(6e4).optional(),
healthSuccessRecoveries: z3.number().int().min(1).max(20).optional(),
healthWorkerUrl: z3.string().url().or(z3.literal("")).optional(),
healthWorkerToken: z3.string().optional()
}).superRefine((data, ctx) => {
if (data.healthDegradedFailures != null && data.healthDownFailures != null && data.healthDownFailures < data.healthDegradedFailures) {
ctx.addIssue({
code: "custom",
message: "\u043E\u0448\u0438\u0431\u043E\u043A \u0434\u043E down \u043D\u0435 \u043C\u0435\u043D\u044C\u0448\u0435, \u0447\u0435\u043C \u0434\u043E degraded",
path: ["healthDownFailures"]
});
}
});
var vpsTrackerEventSchema = z3.object({
event: z3.enum(["vps_down", "vps_up"]),
@@ -708,12 +842,16 @@ export {
certificateSchema,
cfdmBindingSyncItemSchema,
cfdmSyncBindingsBodySchema,
changeDomainSchema,
changeIpSchema,
createDnsRecordSchema,
createDomainMonitorSchema,
createDomainSchema,
createGroupSchema,
createOriginHealthCheckSchema,
createServiceBindingSchema,
createServiceGroupSchema,
createServiceNodeSchema,
createServiceSchema,
createServiceWithConfigSchema,
createSubdomainSchema,
@@ -730,8 +868,10 @@ export {
groupSchema,
groupWithStatsSchema,
healthCheckConfigSchema,
healthCheckProviderSchema,
healthCheckScopeSchema,
healthCheckTypeSchema,
healthProbeLogSchema,
healthStatusQuerySchema,
ingestAuditEventSchema,
ipHealthStateSchema,
@@ -740,8 +880,10 @@ export {
isValidIpv4,
lbModeSchema,
loginSchema,
nodeHealthStateSchema,
normalizeDnsRecordName,
notificationLogSchema,
originHealthCheckSchema,
parseFqdn,
reorderServicesSchema,
serviceBindingSchema,
@@ -750,16 +892,20 @@ export {
serviceGroupTypeSchema,
serviceGroupViewSchema,
serviceGroupsResponseSchema,
serviceIpHealthSchema,
serviceNodeSchema,
serviceSchema,
serviceViewSchema,
shouldMonitorService,
subdomainLabelToFqdn,
subdomainSchema,
toggleEnabledSchema,
toggleServiceIpSchema,
updateDomainGroupSchema,
updateDomainSchema,
updateServiceConfigSchema,
updateServiceGroupSchema,
updateServiceNodeSchema,
updateSubdomainSchema,
validateDnsRecord,
vpsTrackerEventSchema
+7
View File
@@ -21,7 +21,14 @@ export type {
LbMode,
HealthCheckType,
IpHealthState,
NodeHealthState,
HealthCheckProvider,
HealthCheckScope,
IpHealthStatus,
HealthCheckTarget,
ServiceNode,
OriginHealthCheck,
ServiceOverview,
PatchDnsRecordPayload,
CfHealthCheck,
} from "./types.js";
@@ -30,6 +30,25 @@ export const appSettingsPatchSchema = z.object({
vpsTrackerIntegrationToken: z.string().optional(),
vpsTrackerSyncEnabled: z.boolean().optional(),
showQuickActions: z.boolean().optional(),
healthCheckCron: z.string().trim().min(1).max(64).optional(),
healthDegradedFailures: z.number().int().min(1).max(20).optional(),
healthDownFailures: z.number().int().min(1).max(50).optional(),
healthLatencyWarnMs: z.number().int().min(50).max(60_000).optional(),
healthSuccessRecoveries: z.number().int().min(1).max(20).optional(),
healthWorkerUrl: z.string().url().or(z.literal("")).optional(),
healthWorkerToken: z.string().optional(),
}).superRefine((data, ctx) => {
if (
data.healthDegradedFailures != null &&
data.healthDownFailures != null &&
data.healthDownFailures < data.healthDegradedFailures
) {
ctx.addIssue({
code: "custom",
message: "ошибок до down не меньше, чем до degraded",
path: ["healthDownFailures"],
});
}
});
export type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;

Some files were not shown because too many files have changed in this diff Show More