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.
This commit is contained in:
Denozordec
2026-08-19 15:33:47 +07:00
parent 4224db8eb3
commit d63c86065c
35 changed files with 1538 additions and 123 deletions
+7
View File
@@ -36,3 +36,10 @@ RUST_LOG=info
# Certificate scheduler (cron) # Certificate scheduler (cron)
CERT_CHECK_CRON=0 0 */6 * * * 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
+9 -68
View File
@@ -7,7 +7,6 @@ import {
} from "@fastify/type-provider-zod"; } from "@fastify/type-provider-zod";
import type { AppConfig } from "./config.js"; import type { AppConfig } from "./config.js";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { repos } from "@cfdm/db";
import authPlugin from "./plugins/auth.js"; import authPlugin from "./plugins/auth.js";
import cfClientPlugin from "./plugins/cf-client.js"; import cfClientPlugin from "./plugins/cf-client.js";
import { requireAuth } from "./plugins/auth.js"; import { requireAuth } from "./plugins/auth.js";
@@ -34,8 +33,10 @@ import { settingsRoutes } from "./routes/settings.js";
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js"; import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
import { auditRoutes } from "./routes/audit.js"; import { auditRoutes } from "./routes/audit.js";
import * as certificateService from "./services/certificate-service.js"; import * as certificateService from "./services/certificate-service.js";
import * as healthCheckService from "./services/health-check-service.js"; import {
import * as serviceConfigService from "./services/service-config-service.js"; createHealthCheckTask,
scheduleHealthCheckJob,
} from "./services/health-check-scheduler.js";
import { AsyncTask, CronJob } from "toad-scheduler"; import { AsyncTask, CronJob } from "toad-scheduler";
export interface BuildAppOptions { export interface BuildAppOptions {
@@ -125,71 +126,11 @@ export async function buildApp(opts: BuildAppOptions = {}) {
), ),
); );
const healthTask = new AsyncTask( const healthTask = createHealthCheckTask(app, config);
"health-check", scheduleHealthCheckJob(app, config, healthTask);
async () => { app.decorate("reloadHealthCheckJob", () => {
const thresholds = { scheduleHealthCheckJob(app, config, healthTask);
degradedFailures: config.healthDegradedFailures, });
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
successRecoveries: config.healthSuccessRecoveries,
};
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 },
),
);
} }
return app; return app;
+23
View File
@@ -4,6 +4,7 @@ import {
changeDomainSchema, changeDomainSchema,
createServiceNodeSchema, createServiceNodeSchema,
reorderServicesSchema, reorderServicesSchema,
toggleServiceIpSchema,
updateServiceConfigSchema, updateServiceConfigSchema,
updateServiceNodeSchema, updateServiceNodeSchema,
} from "@cfdm/shared"; } from "@cfdm/shared";
@@ -191,4 +192,26 @@ export async function serviceRoutes(app: FastifyInstance) {
body.enabled, 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, updateAppSettings,
} from "@cfdm/db"; } from "@cfdm/db";
import { pingVpsTracker } from "../services/vps-tracker-sync.js"; 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) { export async function settingsRoutes(app: FastifyInstance) {
app.get("/settings", async (request) => { app.get("/settings", async (request) => {
return getAppSettings(request.server.db); return getAppSettings(
request.server.db,
healthEngineFallbacksFromConfig(request.server.config),
);
}); });
app.patch("/settings", async (request) => { app.patch("/settings", async (request) => {
const body = appSettingsPatchSchema.parse(request.body); const parsed = appSettingsPatchSchema.safeParse(request.body);
return updateAppSettings(request.server.db, 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) => { app.post("/settings/vps-tracker/test", async (request) => {
@@ -0,0 +1,134 @@
import type { FastifyInstance } from "fastify";
import { AsyncTask, CronJob } from "toad-scheduler";
import {
getAppSettings,
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,
};
}
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,
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 },
),
);
}
@@ -242,7 +242,11 @@ async function collectKnownZones(
async function buildView(db: Db, serviceId: number): Promise<ServiceView> { async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
const service = repos.getService(db, serviceId); 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 bindings = repos.listBindingsByService(db, serviceId);
const domainViews = bindings.map((binding) => { const domainViews = bindings.map((binding) => {
@@ -304,6 +308,7 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
created_at: service.created_at, created_at: service.created_at,
updated_at: service.updated_at, updated_at: service.updated_at,
ips, ips,
ip_enabled,
domains: domainViews, domains: domainViews,
health_status: "unknown", health_status: "unknown",
health_latency_ms: null, health_latency_ms: null,
@@ -384,7 +389,7 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
const groupViews = groupViewsRaw.map((group) => { const groupViews = groupViewsRaw.map((group) => {
const services = group.services.map( const services = group.services.map(
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null, ip_health: [] }, (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); const groupScopeHealth = groupHealthById.get(group.id);
// Only enabled services feed the group badge — a disabled service with a // Only enabled services feed the group badge — a disabled service with a
@@ -414,6 +419,7 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
health_status: "unknown" as const, health_status: "unknown" as const,
health_latency_ms: null, health_latency_ms: null,
ip_health: [], ip_health: [],
ip_enabled: {},
}, },
); );
@@ -1345,6 +1351,59 @@ export async function toggleService(
return enabledView!; 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( export async function toggleGroup(
db: Db, db: Db,
cf: CloudflareClient, cf: CloudflareClient,
+5 -4
View File
@@ -43,8 +43,9 @@ function resolveIpsLocally(
const binding = index.byFqdn.get(key); const binding = index.byFqdn.get(key);
if (!binding) return []; if (!binding) return [];
if (binding.target_ips.some(isIpLiteral)) { const ips = (binding.target_ips ?? []).filter(isIpLiteral);
return binding.target_ips.filter(isIpLiteral); if (ips.length > 0) {
return ips;
} }
const cname = binding.cname_target?.trim(); const cname = binding.cname_target?.trim();
@@ -79,7 +80,7 @@ export async function resolveBindingIpsForSync(
index: BindingIpIndex, index: BindingIpIndex,
db?: Db, db?: Db,
): Promise<string[]> { ): Promise<string[]> {
const directIps = binding.target_ips.filter(isIpLiteral); const directIps = (binding.target_ips ?? []).filter(isIpLiteral);
if (directIps.length > 0) { if (directIps.length > 0) {
return [...directIps]; return [...directIps];
} }
@@ -124,7 +125,7 @@ export async function buildServiceSyncBindingsAsync(
const serviceIps = repos.listServiceIps(db, serviceId); const serviceIps = repos.listServiceIps(db, serviceId);
const allBindings = repos.listAllBindings(db); const allBindings = repos.listAllBindings(db);
const index = buildBindingIndex(allBindings); const index = buildBindingIndex(allBindings);
const bindings = repos.listBindingsByService(db, serviceId); const bindings = allBindings.filter((row) => row.service_id === serviceId);
const items: CfdmBindingSyncItem[] = []; const items: CfdmBindingSyncItem[] = [];
for (const binding of bindings) { for (const binding of bindings) {
@@ -165,4 +165,94 @@ describe("create service then list groups", () => {
await app.close(); 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();
});
}); });
+136
View File
@@ -0,0 +1,136 @@
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();
});
});
+13
View File
@@ -134,6 +134,19 @@ describe("resolveBindingIpsForSync", () => {
expect(ips).toEqual(["203.0.113.10"]); 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 () => { it("prefers service IPs over empty CNAME resolution chain", async () => {
const cname = binding({ const cname = binding({
id: 2, id: 2,
@@ -20,6 +20,7 @@ import { Switch } from '@cfdm/ui/components/switch'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { ButtonGroup } from '@cfdm/ui/components/button-group' import { ButtonGroup } from '@cfdm/ui/components/button-group'
import { FieldGroup } from '@cfdm/ui/components/field' import { FieldGroup } from '@cfdm/ui/components/field'
import { Link } from '@tanstack/react-router'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
@@ -213,7 +214,18 @@ export function HealthCheckConfigFields({
Checks, API вернёт ошибку — останется Local. Workers не используются. Checks, API вернёт ошибку — останется Local. Workers не используются.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : (
<Alert>
<AlertTitle>Local health-check</AlertTitle>
<AlertDescription>
Интервал и таймаут пробы — ниже. Cron и пороги Slow/Down задаются в{' '}
<Link to="/settings/health" className="text-foreground underline">
Настройках → Health-check
</Link>
, как параметры Cloudflare Health Checks в этой форме.
</AlertDescription>
</Alert>
)}
<SettingRow <SettingRow
title="Health-check" title="Health-check"
@@ -3,6 +3,7 @@ import { Link, useNavigate } from '@tanstack/react-router'
import { import {
FolderTreeIcon, FolderTreeIcon,
GlobeIcon, GlobeIcon,
HeartPulseIcon,
LayoutDashboardIcon, LayoutDashboardIcon,
SearchIcon, SearchIcon,
ServerIcon, ServerIcon,
@@ -57,6 +58,12 @@ const NAV_ITEMS = [
keywords: ['certificates', 'ssl', 'tls'], keywords: ['certificates', 'ssl', 'tls'],
icon: ShieldCheckIcon, icon: ShieldCheckIcon,
}, },
{
to: '/settings/health',
label: 'Health-check',
keywords: ['health', 'health-check', 'cron', 'пороги', 'настройки'],
icon: HeartPulseIcon,
},
{ {
to: '/settings/integrations', to: '/settings/integrations',
label: 'Настройки', label: 'Настройки',
@@ -24,6 +24,7 @@ const routeTitles: Record<string, string> = {
'/services': 'Сервисы', '/services': 'Сервисы',
'/certificates': 'Сертификаты', '/certificates': 'Сертификаты',
'/settings/appearance': 'Внешний вид', '/settings/appearance': 'Внешний вид',
'/settings/health': 'Health-check',
'/settings/integrations': 'Интеграции', '/settings/integrations': 'Интеграции',
} }
@@ -64,6 +65,8 @@ function getBreadcrumbs(
{ label: 'Настройки', href: '/settings/appearance' }, { label: 'Настройки', href: '/settings/appearance' },
...(pathname === '/settings/integrations' ...(pathname === '/settings/integrations'
? [{ label: 'Интеграции', href: pathname }] ? [{ label: 'Интеграции', href: pathname }]
: pathname === '/settings/health'
? [{ label: 'Health-check', href: pathname }]
: pathname === '/settings/appearance' : pathname === '/settings/appearance'
? [{ label: 'Внешний вид', href: pathname }] ? [{ label: 'Внешний вид', href: pathname }]
: []), : []),
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router' 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 { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
@@ -21,6 +21,12 @@ const DEFAULT_TABS: SettingsTabConfig[] = [
label: 'Внешний вид', label: 'Внешний вид',
icon: <PaletteIcon className="size-4" aria-hidden="true" />, 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', id: 'integrations',
to: '/settings/integrations', to: '/settings/integrations',
@@ -37,7 +43,7 @@ interface SettingsShellProps {
export function SettingsShell({ export function SettingsShell({
title = 'Настройки', title = 'Настройки',
description = 'Внешний вид и интеграции', description = 'Внешний вид, health-check и интеграции',
tabs = DEFAULT_TABS, tabs = DEFAULT_TABS,
}: SettingsShellProps) { }: SettingsShellProps) {
const isMobile = useIsMobile() const isMobile = useIsMobile()
@@ -26,9 +26,11 @@ interface ServiceCatalogSectionProps {
group: ServiceGroupView | null group: ServiceGroupView | null
services: ServiceView[] services: ServiceView[]
togglingId: number | null togglingId: number | null
togglingIp: { serviceId: number; ip: string } | null
onEditService: (service: ServiceView) => void onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void onToggleService: (serviceId: number, enabled: boolean) => void
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup: (group: ServiceGroupView) => void onDeleteGroup: (group: ServiceGroupView) => void
onAddServiceToGroup: (groupId: number | null) => void onAddServiceToGroup: (groupId: number | null) => void
@@ -38,9 +40,11 @@ export function ServiceCatalogSection({
group, group,
services, services,
togglingId, togglingId,
togglingIp,
onEditService, onEditService,
onDeleteService, onDeleteService,
onToggleService, onToggleService,
onToggleServiceIp,
onEditGroup, onEditGroup,
onDeleteGroup, onDeleteGroup,
onAddServiceToGroup, onAddServiceToGroup,
@@ -148,9 +152,13 @@ export function ServiceCatalogSection({
key={service.id} key={service.id}
service={service} service={service}
togglingId={togglingId} togglingId={togglingId}
togglingIp={
togglingIp?.serviceId === service.id ? togglingIp.ip : null
}
onEditService={onEditService} onEditService={onEditService}
onDeleteService={onDeleteService} onDeleteService={onDeleteService}
onToggleService={onToggleService} onToggleService={onToggleService}
onToggleServiceIp={onToggleServiceIp}
/> />
))} ))}
</div> </div>
@@ -8,6 +8,7 @@ import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { serviceDisplayFqdns } from '@/lib/service-utils' import { serviceDisplayFqdns } from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas' import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { Switch } from '@cfdm/ui/components/switch'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -121,6 +122,10 @@ const VISIBLE_IP_LIMIT = 6
interface ServiceIpListProps { interface ServiceIpListProps {
ips: string[] ips: string[]
ipHealth?: ServiceView['ip_health'] ipHealth?: ServiceView['ip_health']
ipEnabled?: Record<string, boolean>
togglingIp?: string | null
ipToggleDisabled?: boolean
onToggleIp?: (ip: string, enabled: boolean) => void
className?: string className?: string
emptyLabel?: string emptyLabel?: string
copyable?: boolean copyable?: boolean
@@ -130,6 +135,10 @@ interface ServiceIpListProps {
export function ServiceIpList({ export function ServiceIpList({
ips, ips,
ipHealth = [], ipHealth = [],
ipEnabled = {},
togglingIp = null,
ipToggleDisabled = false,
onToggleIp,
className, className,
emptyLabel = 'Нет IP', emptyLabel = 'Нет IP',
copyable = false, copyable = false,
@@ -144,13 +153,14 @@ export function ServiceIpList({
} }
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row])) const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
const visible = ips.slice(0, VISIBLE_IP_LIMIT) const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
const extraCount = ips.length - visible.length const extraCount = ips.length - visible.length
return ( return (
<div className={cn('flex min-w-0 flex-col gap-1', className)}> <div className={cn('flex min-w-0 flex-col gap-1', className)}>
{visible.map((ip) => { {visible.map((ip) => {
const health = healthByIp.get(ip) const health = healthByIp.get(ip)
const enabled = ipEnabled[ip] !== false
return ( return (
<div key={ip} className="flex min-w-0 items-center gap-1.5"> <div key={ip} className="flex min-w-0 items-center gap-1.5">
<HealthCheckBadge <HealthCheckBadge
@@ -160,13 +170,29 @@ export function ServiceIpList({
/> />
<TruncatedText <TruncatedText
className={cn( className={cn(
'text-muted-foreground min-w-0 font-mono text-xs', 'min-w-0 font-mono text-xs',
enabled ? 'text-muted-foreground' : 'text-muted-foreground/60',
textClassName, textClassName,
)} )}
> >
{ip} {ip}
</TruncatedText> </TruncatedText>
{copyable ? <CopyFqdnButton value={ip} /> : null} {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> </div>
) )
})} })}
@@ -1,6 +1,7 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react' import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { import {
Frame, Frame,
FrameDescription, FrameDescription,
@@ -10,9 +11,10 @@ import {
} from '@/components/reui/frame' } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile' import { IconTile } from '@/components/reui/icon-tile'
import { import {
ServiceFqdnList, CopyFqdnButton,
ServiceIpList, ServiceIpList,
} from '@/components/services/service-fqdn-list' } from '@/components/services/service-fqdn-list'
import { serviceDisplayFqdn, serviceDisplayFqdns } from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas' import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { import {
@@ -22,22 +24,36 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu' } from '@cfdm/ui/components/dropdown-menu'
import { Switch } from '@cfdm/ui/components/switch' import { Switch } from '@cfdm/ui/components/switch'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
interface ServiceUnitCardProps { interface ServiceUnitCardProps {
service: ServiceView service: ServiceView
togglingId: number | null togglingId: number | null
togglingIp: string | null
onEditService: (service: ServiceView) => void onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void onToggleService: (serviceId: number, enabled: boolean) => void
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
} }
export function ServiceUnitCard({ export function ServiceUnitCard({
service, service,
togglingId, togglingId,
togglingIp,
onEditService, onEditService,
onDeleteService, onDeleteService,
onToggleService, onToggleService,
onToggleServiceIp,
}: ServiceUnitCardProps) { }: ServiceUnitCardProps) {
const fqdns = serviceDisplayFqdns(service)
const primaryDomain = serviceDisplayFqdn(service)
const extraCount = Math.max(0, fqdns.length - 1)
return ( return (
<Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden"> <Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden">
<FrameHeader className="flex-row items-center justify-between gap-2"> <FrameHeader className="flex-row items-center justify-between gap-2">
@@ -60,9 +76,38 @@ export function ServiceUnitCard({
{service.name} {service.name}
</Link> </Link>
</FrameTitle> </FrameTitle>
<FrameDescription className="truncate font-mono text-xs"> <div className="flex min-w-0 items-center gap-1">
{service.slug} <FrameDescription className="min-w-0 truncate font-mono text-xs">
</FrameDescription> {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> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
@@ -115,12 +160,17 @@ export function ServiceUnitCard({
</div> </div>
</FrameHeader> </FrameHeader>
<FramePanel className="flex min-w-0 flex-col gap-1.5 pt-0 shadow-none!"> <FramePanel className="flex min-w-0 flex-col gap-1 pt-0 shadow-none!">
<ServiceFqdnList copyable service={service} emptyLabel="Не задан" />
<ServiceIpList <ServiceIpList
copyable copyable
ips={service.ips ?? []} ips={service.ips ?? []}
ipHealth={service.ip_health ?? []} ipHealth={service.ip_health ?? []}
ipEnabled={service.ip_enabled ?? {}}
ipToggleDisabled={togglingId === service.id}
togglingIp={togglingIp}
onToggleIp={(ip, enabled) =>
onToggleServiceIp(service.id, ip, enabled)
}
/> />
</FramePanel> </FramePanel>
</Frame> </Frame>
@@ -160,11 +160,13 @@ interface ServicesGroupedCatalogProps {
primaryAction?: ReactNode primaryAction?: ReactNode
hideHeader?: boolean hideHeader?: boolean
togglingId: number | null togglingId: number | null
togglingIp: { serviceId: number; ip: string } | null
activeTab?: string activeTab?: string
onTabChange?: (tabId: string) => void onTabChange?: (tabId: string) => void
onEditService: (service: ServiceView) => void onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void onToggleService: (serviceId: number, enabled: boolean) => void
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup: (group: ServiceGroupView) => void onDeleteGroup: (group: ServiceGroupView) => void
onAddServiceToGroup: (groupId: number | null) => void onAddServiceToGroup: (groupId: number | null) => void
@@ -179,10 +181,12 @@ export function ServicesGroupedCatalog({
primaryAction, primaryAction,
hideHeader = false, hideHeader = false,
togglingId, togglingId,
togglingIp,
activeTab = 'all', activeTab = 'all',
onEditService, onEditService,
onDeleteService, onDeleteService,
onToggleService, onToggleService,
onToggleServiceIp,
onEditGroup, onEditGroup,
onDeleteGroup, onDeleteGroup,
onAddServiceToGroup, onAddServiceToGroup,
@@ -304,9 +308,11 @@ export function ServicesGroupedCatalog({
group={unit.group} group={unit.group}
services={unit.services} services={unit.services}
togglingId={togglingId} togglingId={togglingId}
togglingIp={togglingIp}
onEditService={onEditService} onEditService={onEditService}
onDeleteService={onDeleteService} onDeleteService={onDeleteService}
onToggleService={onToggleService} onToggleService={onToggleService}
onToggleServiceIp={onToggleServiceIp}
onEditGroup={onEditGroup} onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup} onDeleteGroup={onDeleteGroup}
onAddServiceToGroup={onAddServiceToGroup} onAddServiceToGroup={onAddServiceToGroup}
+1
View File
@@ -108,6 +108,7 @@ export const serviceViewSchema = serviceSchema.extend({
health_status: z.enum(['up', 'down', 'degraded', 'unknown']).default('unknown'), health_status: z.enum(['up', 'down', 'degraded', 'unknown']).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_health: z.array(serviceIpHealthSchema).default([]),
ip_enabled: z.record(z.string(), z.boolean()).default({}),
}) })
export const serviceGroupViewSchema = serviceGroupSchema.extend({ export const serviceGroupViewSchema = serviceGroupSchema.extend({
+21
View File
@@ -20,6 +20,7 @@ import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/i
import { Route as AuthServicesIndexRouteImport } from './routes/_auth/services/index' import { Route as AuthServicesIndexRouteImport } from './routes/_auth/services/index'
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index' import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations' 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 AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId' import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
import { Route as AuthServicesServiceIdRouteRouteImport } from './routes/_auth/services/$serviceId/route' import { Route as AuthServicesServiceIdRouteRouteImport } from './routes/_auth/services/$serviceId/route'
@@ -86,6 +87,11 @@ const AuthSettingsIntegrationsRoute =
path: '/integrations', path: '/integrations',
getParentRoute: () => AuthSettingsRouteRoute, getParentRoute: () => AuthSettingsRouteRoute,
} as any) } as any)
const AuthSettingsHealthRoute = AuthSettingsHealthRouteImport.update({
id: '/health',
path: '/health',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({ const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
id: '/appearance', id: '/appearance',
path: '/appearance', path: '/appearance',
@@ -154,6 +160,7 @@ export interface FileRoutesByFullPath {
'/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren '/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
'/groups/$groupId': typeof AuthGroupsGroupIdRoute '/groups/$groupId': typeof AuthGroupsGroupIdRoute
'/settings/appearance': typeof AuthSettingsAppearanceRoute '/settings/appearance': typeof AuthSettingsAppearanceRoute
'/settings/health': typeof AuthSettingsHealthRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute '/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/domains/': typeof AuthDomainsIndexRoute '/domains/': typeof AuthDomainsIndexRoute
'/services/': typeof AuthServicesIndexRoute '/services/': typeof AuthServicesIndexRoute
@@ -174,6 +181,7 @@ export interface FileRoutesByTo {
'/': typeof AuthIndexRoute '/': typeof AuthIndexRoute
'/groups/$groupId': typeof AuthGroupsGroupIdRoute '/groups/$groupId': typeof AuthGroupsGroupIdRoute
'/settings/appearance': typeof AuthSettingsAppearanceRoute '/settings/appearance': typeof AuthSettingsAppearanceRoute
'/settings/health': typeof AuthSettingsHealthRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute '/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/domains': typeof AuthDomainsIndexRoute '/domains': typeof AuthDomainsIndexRoute
'/services': typeof AuthServicesIndexRoute '/services': typeof AuthServicesIndexRoute
@@ -198,6 +206,7 @@ export interface FileRoutesById {
'/_auth/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren '/_auth/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute '/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute '/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
'/_auth/settings/health': typeof AuthSettingsHealthRoute
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute '/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/_auth/domains/': typeof AuthDomainsIndexRoute '/_auth/domains/': typeof AuthDomainsIndexRoute
'/_auth/services/': typeof AuthServicesIndexRoute '/_auth/services/': typeof AuthServicesIndexRoute
@@ -222,6 +231,7 @@ export interface FileRouteTypes {
| '/services/$serviceId' | '/services/$serviceId'
| '/groups/$groupId' | '/groups/$groupId'
| '/settings/appearance' | '/settings/appearance'
| '/settings/health'
| '/settings/integrations' | '/settings/integrations'
| '/domains/' | '/domains/'
| '/services/' | '/services/'
@@ -242,6 +252,7 @@ export interface FileRouteTypes {
| '/' | '/'
| '/groups/$groupId' | '/groups/$groupId'
| '/settings/appearance' | '/settings/appearance'
| '/settings/health'
| '/settings/integrations' | '/settings/integrations'
| '/domains' | '/domains'
| '/services' | '/services'
@@ -265,6 +276,7 @@ export interface FileRouteTypes {
| '/_auth/services/$serviceId' | '/_auth/services/$serviceId'
| '/_auth/groups/$groupId' | '/_auth/groups/$groupId'
| '/_auth/settings/appearance' | '/_auth/settings/appearance'
| '/_auth/settings/health'
| '/_auth/settings/integrations' | '/_auth/settings/integrations'
| '/_auth/domains/' | '/_auth/domains/'
| '/_auth/services/' | '/_auth/services/'
@@ -363,6 +375,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
parentRoute: typeof AuthSettingsRouteRoute parentRoute: typeof AuthSettingsRouteRoute
} }
'/_auth/settings/health': {
id: '/_auth/settings/health'
path: '/health'
fullPath: '/settings/health'
preLoaderRoute: typeof AuthSettingsHealthRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/settings/appearance': { '/_auth/settings/appearance': {
id: '/_auth/settings/appearance' id: '/_auth/settings/appearance'
path: '/appearance' path: '/appearance'
@@ -438,12 +457,14 @@ declare module '@tanstack/react-router' {
interface AuthSettingsRouteRouteChildren { interface AuthSettingsRouteRouteChildren {
AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute
AuthSettingsHealthRoute: typeof AuthSettingsHealthRoute
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
} }
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = { const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute, AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute,
AuthSettingsHealthRoute: AuthSettingsHealthRoute,
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute, AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
AuthSettingsIndexRoute: AuthSettingsIndexRoute, AuthSettingsIndexRoute: AuthSettingsIndexRoute,
} }
@@ -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 = [ const GROUP_DOT_COLORS = [
'bg-chart-1', 'bg-chart-1',
'bg-chart-2', 'bg-chart-2',
@@ -150,6 +173,10 @@ function ServicesPage() {
const [deletingId, setDeletingId] = useState<number | null>(null) const [deletingId, setDeletingId] = useState<number | null>(null)
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null) const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
const [togglingServiceId, setTogglingServiceId] = 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 [bulkToggling, setBulkToggling] = useState(false)
const [activeTab, setActiveTab] = useState('all') const [activeTab, setActiveTab] = useState('all')
const queryClient = useQueryClient() 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({ const deleteGroupMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`), mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`),
onSuccess: async () => { onSuccess: async () => {
@@ -398,6 +473,15 @@ function ServicesPage() {
toggleServiceMutation.mutate({ id: serviceId, enabled }) 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) { function handleOpenCreateService(groupId: number | null = null) {
setDefaultGroupId(groupId) setDefaultGroupId(groupId)
setCreateSheetOpen(true) setCreateSheetOpen(true)
@@ -602,9 +686,11 @@ function ServicesPage() {
isLoading isLoading
hideHeader hideHeader
togglingId={null} togglingId={null}
togglingIp={null}
onEditService={() => {}} onEditService={() => {}}
onDeleteService={() => {}} onDeleteService={() => {}}
onToggleService={() => {}} onToggleService={() => {}}
onToggleServiceIp={() => {}}
onEditGroup={() => {}} onEditGroup={() => {}}
onDeleteGroup={() => {}} onDeleteGroup={() => {}}
onAddServiceToGroup={() => {}} onAddServiceToGroup={() => {}}
@@ -702,12 +788,14 @@ function ServicesPage() {
domainId={domainId} domainId={domainId}
domainLabel={filteredDomain?.zone_name} domainLabel={filteredDomain?.zone_name}
togglingId={togglingServiceId} togglingId={togglingServiceId}
togglingIp={togglingIp}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={setActiveTab}
hideHeader hideHeader
onEditService={setEditingService} onEditService={setEditingService}
onDeleteService={setDeletingService} onDeleteService={setDeletingService}
onToggleService={handleServiceToggle} onToggleService={handleServiceToggle}
onToggleServiceIp={handleServiceIpToggle}
onEditGroup={setEditingGroup} onEditGroup={setEditingGroup}
onDeleteGroup={setDeletingGroup} onDeleteGroup={setDeletingGroup}
onAddServiceToGroup={handleOpenCreateService} onAddServiceToGroup={handleOpenCreateService}
@@ -0,0 +1,281 @@
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'
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),
}).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
}
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,
},
})
useEffect(() => {
if (!data) return
form.reset({
healthCheckCron: data.healthCheckCron,
healthDegradedFailures: data.healthDegradedFailures,
healthDownFailures: data.healthDownFailures,
healthLatencyWarnMs: data.healthLatencyWarnMs,
healthSuccessRecoveries: data.healthSuccessRecoveries,
})
}, [data, form])
const saveMut = useMutation({
mutationFn: (values: FormValues) =>
api.patch<SettingsResponse>('/api/v1/settings', values),
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>
Расписание и пороги движка. Параметры Cloudflare Health Checks
задаются в карточке сервиса.
</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
last
>
<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>
</FieldGroup>
<FrameFooter className="flex flex-row justify-end">
<LoadingButton
type="submit"
isLoading={saveMut.isPending}
disabled={isLoading || !form.formState.isDirty}
>
Сохранить
</LoadingButton>
</FrameFooter>
</FramePanel>
</Frame>
</form>
)
}
+8 -6
View File
@@ -20,11 +20,11 @@ Manage Cloudflare zones, DNS records, domain groups, and TLS certificate expiry
| `ADMIN_USERNAME` | Admin username | | `ADMIN_USERNAME` | Admin username |
| `ADMIN_PASSWORD_HASH` | Argon2 hash (empty = dev `admin`/`admin`) | | `ADMIN_PASSWORD_HASH` | Argon2 hash (empty = dev `admin`/`admin`) |
| `LOG_LEVEL` | Уровень логов API (`info`, `debug`) | | `LOG_LEVEL` | Уровень логов API (`info`, `debug`) |
| `HEALTH_CHECK_CRON` | Cron для health-check (default `*/30 * * * * *`) | | `HEALTH_CHECK_CRON` | Cron для health-check (default `0 */2 * * * *`). Переопределяется в **Настройки → Health-check**. |
| `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`) | | `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`). То же в UI. |
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`) | | `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`). То же в UI. |
| `HEALTH_SUCCESS_RECOVERIES` | Успехов подряд для recovery `CHECKING → HEALTHY` (default `2`) | | `HEALTH_SUCCESS_RECOVERIES` | Успехов подряд для recovery `CHECKING → HEALTHY` (default `2`). То же в UI. |
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`) | | `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`). То же в UI. |
## Load balancing & health checks ## Load balancing & health checks
@@ -46,7 +46,9 @@ health-check работают на двух уровнях:
Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted` Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted`
работает как `round_robin` (одна A на IP). `unknown` **не** считается healthy и работает как `round_robin` (одна A на IP). `unknown` **не** считается healthy и
не попадает в пул, пока нет успешных проб; восстановление — `UNHEALTHY → CHECKING → HEALTHY` не попадает в пул, пока нет успешных проб; восстановление — `UNHEALTHY → CHECKING → HEALTHY`
после `HEALTH_SUCCESS_RECOVERIES` (default 2). Reconcile DNS запускается cron-задачей после `HEALTH_SUCCESS_RECOVERIES` (default 2). Пороги и cron движка задаются в
**Настройки → Health-check** (env — fallback, пока значения не сохранены в UI).
Reconcile DNS запускается cron-задачей
`health-check`. Cloudflare Health Checks — официальный API зоны, Workers не используются. `health-check`. Cloudflare Health Checks — официальный API зоны, Workers не используются.
## Docker ## Docker
+235 -5
View File
File diff suppressed because one or more lines are too long
+67 -10
View File
@@ -187,6 +187,7 @@ var serviceIps = sqliteTable("service_ips", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }), service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
ip: text("ip").notNull(), ip: text("ip").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
created_at: text("created_at").notNull().default(sql`datetime('now')`) created_at: text("created_at").notNull().default(sql`datetime('now')`)
}); });
var serviceBindingRecords = sqliteTable( var serviceBindingRecords = sqliteTable(
@@ -269,6 +270,11 @@ var appSettings = sqliteTable("app_settings", {
show_quick_actions: integer("show_quick_actions", { show_quick_actions: integer("show_quick_actions", {
mode: "boolean" mode: "boolean"
}).notNull().default(true), }).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"),
created_at: text("created_at").notNull().default(sql`datetime('now')`), created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`) updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
}); });
@@ -493,7 +499,17 @@ function listAudit(db, opts = {}) {
// src/settings-repo.ts // src/settings-repo.ts
import { eq as eq2 } from "drizzle-orm"; import { eq as eq2 } from "drizzle-orm";
var SETTINGS_ID = "settings-main"; 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
};
return { return {
id: row.id, id: row.id,
vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "", vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "",
@@ -502,18 +518,36 @@ function toDto(row) {
), ),
vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled), vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled),
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at, 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
)
}; };
} }
function getAppSettings(db) { function getAppSettings(db, fallbacks) {
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get(); const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
if (!row) { if (!row) {
db.insert(appSettings).values({ id: SETTINGS_ID }).run(); db.insert(appSettings).values({ id: SETTINGS_ID }).run();
return toDto( 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) { function getAppSettingsSecrets(db) {
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get(); const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
@@ -523,7 +557,7 @@ function getAppSettingsSecrets(db) {
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled) vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled)
}; };
} }
function updateAppSettings(db, patch) { function updateAppSettings(db, patch, fallbacks) {
const existing = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get(); const existing = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
if (!existing) { if (!existing) {
db.insert(appSettings).values({ id: SETTINGS_ID }).run(); db.insert(appSettings).values({ id: SETTINGS_ID }).run();
@@ -535,9 +569,14 @@ function updateAppSettings(db, patch) {
vps_tracker_integration_token: patch.vpsTrackerIntegrationToken !== void 0 && patch.vpsTrackerIntegrationToken.trim() !== "" ? patch.vpsTrackerIntegrationToken : current.vps_tracker_integration_token, 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, 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, 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,
updated_at: (/* @__PURE__ */ new Date()).toISOString() updated_at: (/* @__PURE__ */ new Date()).toISOString()
}).where(eq2(appSettings.id, SETTINGS_ID)).run(); }).where(eq2(appSettings.id, SETTINGS_ID)).run();
return getAppSettings(db); return getAppSettings(db, fallbacks);
} }
function touchVpsTrackerSync(db) { function touchVpsTrackerSync(db) {
db.update(appSettings).set({ db.update(appSettings).set({
@@ -638,6 +677,7 @@ __export(repos_exports, {
listOriginIpsForFqdn: () => listOriginIpsForFqdn, listOriginIpsForFqdn: () => listOriginIpsForFqdn,
listRecordsForBinding: () => listRecordsForBinding, listRecordsForBinding: () => listRecordsForBinding,
listServiceGroups: () => listServiceGroups, listServiceGroups: () => listServiceGroups,
listServiceIpRows: () => listServiceIpRows,
listServiceIps: () => listServiceIps, listServiceIps: () => listServiceIps,
listServices: () => listServices, listServices: () => listServices,
listServicesByGroup: () => listServicesByGroup, listServicesByGroup: () => listServicesByGroup,
@@ -659,6 +699,7 @@ __export(repos_exports, {
setServiceEnabled: () => setServiceEnabled, setServiceEnabled: () => setServiceEnabled,
setServiceGroup: () => setServiceGroup, setServiceGroup: () => setServiceGroup,
setServiceGroupEnabled: () => setServiceGroupEnabled, setServiceGroupEnabled: () => setServiceGroupEnabled,
setServiceIpEnabled: () => setServiceIpEnabled,
setServiceLb: () => setServiceLb, setServiceLb: () => setServiceLb,
unlinkBindingRecord: () => unlinkBindingRecord, unlinkBindingRecord: () => unlinkBindingRecord,
unlinkGroupDnsRecord: () => unlinkGroupDnsRecord, unlinkGroupDnsRecord: () => unlinkGroupDnsRecord,
@@ -1155,16 +1196,32 @@ function deleteServiceGroup(db, id) {
function insertServiceIpIfMissing(db, serviceId, ip) { 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(); const existing = db.select({ ip: serviceIps.ip }).from(serviceIps).where(and2(eq3(serviceIps.service_id, serviceId), eq3(serviceIps.ip, ip))).get();
if (!existing) { if (!existing) {
db.insert(serviceIps).values({ service_id: serviceId, ip }).run(); 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) { 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) { 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(); db.delete(serviceIps).where(eq3(serviceIps.service_id, serviceId)).run();
for (const ip of ips) { 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); ensureNode(db, serviceId, ip);
} }
const keep = new Set(ips); const keep = new Set(ips);
@@ -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;
+39 -5
View File
@@ -923,17 +923,42 @@ function insertServiceIpIfMissing(db: Db, serviceId: number, ip: string): void {
.where(and(eq(serviceIps.service_id, serviceId), eq(serviceIps.ip, ip))) .where(and(eq(serviceIps.service_id, serviceId), eq(serviceIps.ip, ip)))
.get(); .get();
if (!existing) { if (!existing) {
db.insert(serviceIps).values({ service_id: serviceId, ip }).run(); db.insert(serviceIps).values({ service_id: serviceId, ip, enabled: true }).run();
} }
} }
export function listServiceIps(db: Db, serviceId: number): string[] { export type ServiceIpRow = {
ip: string;
enabled: boolean;
};
export function listServiceIpRows(db: Db, serviceId: number): ServiceIpRow[] {
return db return db
.select({ ip: serviceIps.ip }) .select({ ip: serviceIps.ip, enabled: serviceIps.enabled })
.from(serviceIps) .from(serviceIps)
.where(eq(serviceIps.service_id, serviceId)) .where(eq(serviceIps.service_id, serviceId))
.all() .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( export function replaceServiceIps(
@@ -941,9 +966,18 @@ export function replaceServiceIps(
serviceId: number, serviceId: number,
ips: string[], ips: string[],
): void { ): void {
const previous = new Map(
listServiceIpRows(db, serviceId).map((row) => [row.ip, row.enabled]),
);
db.delete(serviceIps).where(eq(serviceIps.service_id, serviceId)).run(); db.delete(serviceIps).where(eq(serviceIps.service_id, serviceId)).run();
for (const ip of ips) { 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); ensureNode(db, serviceId, ip);
} }
const keep = new Set(ips); const keep = new Set(ips);
+6
View File
@@ -259,6 +259,7 @@ export const serviceIps = sqliteTable("service_ips", {
.notNull() .notNull()
.references(() => services.id, { onDelete: "cascade" }), .references(() => services.id, { onDelete: "cascade" }),
ip: text("ip").notNull(), ip: text("ip").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
created_at: text("created_at") created_at: text("created_at")
.notNull() .notNull()
.default(sql`datetime('now')`), .default(sql`datetime('now')`),
@@ -379,6 +380,11 @@ export const appSettings = sqliteTable("app_settings", {
}) })
.notNull() .notNull()
.default(true), .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"),
created_at: text("created_at") created_at: text("created_at")
.notNull() .notNull()
.default(sql`datetime('now')`), .default(sql`datetime('now')`),
+80 -6
View File
@@ -4,6 +4,14 @@ import { appSettings } from "./schema.js";
const SETTINGS_ID = "settings-main"; const SETTINGS_ID = "settings-main";
export type HealthEngineSettings = {
healthCheckCron: string;
healthDegradedFailures: number;
healthDownFailures: number;
healthLatencyWarnMs: number;
healthSuccessRecoveries: number;
};
export type AppSettingsDto = { export type AppSettingsDto = {
id: string; id: string;
vpsTrackerUrl: string; vpsTrackerUrl: string;
@@ -11,16 +19,37 @@ export type AppSettingsDto = {
vpsTrackerSyncEnabled: boolean; vpsTrackerSyncEnabled: boolean;
vpsTrackerLastSyncAt: string | null; vpsTrackerLastSyncAt: string | null;
showQuickActions: boolean; showQuickActions: boolean;
}; } & HealthEngineSettings;
export type AppSettingsPatch = { export type AppSettingsPatch = {
vpsTrackerUrl?: string; vpsTrackerUrl?: string;
vpsTrackerIntegrationToken?: string; vpsTrackerIntegrationToken?: string;
vpsTrackerSyncEnabled?: boolean; vpsTrackerSyncEnabled?: boolean;
showQuickActions?: boolean; showQuickActions?: boolean;
healthCheckCron?: string;
healthDegradedFailures?: number;
healthDownFailures?: number;
healthLatencyWarnMs?: number;
healthSuccessRecoveries?: number;
}; };
function toDto(row: typeof appSettings.$inferSelect): AppSettingsDto { export type HealthEngineFallbacks = HealthEngineSettings;
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,
};
return { return {
id: row.id, id: row.id,
vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "", vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "",
@@ -31,10 +60,30 @@ function toDto(row: typeof appSettings.$inferSelect): AppSettingsDto {
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at, vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at,
showQuickActions: showQuickActions:
row.show_quick_actions == null ? true : Boolean(row.show_quick_actions), 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,
),
}; };
} }
export function getAppSettings(db: Db): AppSettingsDto { export function getAppSettings(
db: Db,
fallbacks?: HealthEngineFallbacks,
): AppSettingsDto {
const row = db const row = db
.select() .select()
.from(appSettings) .from(appSettings)
@@ -44,9 +93,10 @@ export function getAppSettings(db: Db): AppSettingsDto {
db.insert(appSettings).values({ id: SETTINGS_ID }).run(); db.insert(appSettings).values({ id: SETTINGS_ID }).run();
return toDto( return toDto(
db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get()!, db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get()!,
fallbacks,
); );
} }
return toDto(row); return toDto(row, fallbacks);
} }
export function getAppSettingsSecrets(db: Db): { export function getAppSettingsSecrets(db: Db): {
@@ -67,7 +117,11 @@ export function getAppSettingsSecrets(db: Db): {
}; };
} }
export function updateAppSettings(db: Db, patch: AppSettingsPatch): AppSettingsDto { export function updateAppSettings(
db: Db,
patch: AppSettingsPatch,
fallbacks?: HealthEngineFallbacks,
): AppSettingsDto {
const existing = db const existing = db
.select() .select()
.from(appSettings) .from(appSettings)
@@ -102,12 +156,32 @@ export function updateAppSettings(db: Db, patch: AppSettingsPatch): AppSettingsD
patch.showQuickActions !== undefined patch.showQuickActions !== undefined
? patch.showQuickActions ? patch.showQuickActions
: current.show_quick_actions, : 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,
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}) })
.where(eq(appSettings.id, SETTINGS_ID)) .where(eq(appSettings.id, SETTINGS_ID))
.run(); .run();
return getAppSettings(db); return getAppSettings(db, fallbacks);
} }
export function touchVpsTrackerSync(db: Db): void { export function touchVpsTrackerSync(db: Db): void {
+16 -1
View File
@@ -133,6 +133,7 @@ interface ServiceView$1 {
health_status: IpHealthState; health_status: IpHealthState;
health_latency_ms: number | null; health_latency_ms: number | null;
ip_health: ServiceIpHealth$1[]; ip_health: ServiceIpHealth$1[];
ip_enabled: Record<string, boolean>;
} }
interface SyncJob { interface SyncJob {
id: string; id: string;
@@ -646,6 +647,7 @@ declare const serviceViewSchema: z.ZodObject<{
}>; }>;
latency_ms: z.ZodNullable<z.ZodNumber>; latency_ms: z.ZodNullable<z.ZodNumber>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
}, z.core.$strip>; }, z.core.$strip>;
declare const serviceGroupViewSchema: z.ZodObject<{ declare const serviceGroupViewSchema: z.ZodObject<{
id: z.ZodNumber; id: z.ZodNumber;
@@ -789,6 +791,7 @@ declare const serviceGroupViewSchema: z.ZodObject<{
}>; }>;
latency_ms: z.ZodNullable<z.ZodNumber>; latency_ms: z.ZodNullable<z.ZodNumber>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
health_status: z.ZodDefault<z.ZodEnum<{ health_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown"; unknown: "unknown";
@@ -941,6 +944,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
}>; }>;
latency_ms: z.ZodNullable<z.ZodNumber>; latency_ms: z.ZodNullable<z.ZodNumber>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
health_status: z.ZodDefault<z.ZodEnum<{ health_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown"; unknown: "unknown";
@@ -1059,6 +1063,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
}>; }>;
latency_ms: z.ZodNullable<z.ZodNumber>; latency_ms: z.ZodNullable<z.ZodNumber>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
}, z.core.$strip>; }, z.core.$strip>;
declare const domainSchema: z.ZodObject<{ declare const domainSchema: z.ZodObject<{
@@ -1552,6 +1557,11 @@ type UpdateServiceGroupInput = z.infer<typeof updateServiceGroupSchema>;
declare const toggleEnabledSchema: z.ZodObject<{ declare const toggleEnabledSchema: z.ZodObject<{
enabled: z.ZodBoolean; enabled: z.ZodBoolean;
}, z.core.$strip>; }, 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<{ declare const reorderServicesSchema: z.ZodObject<{
group_id: z.ZodDefault<z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>>>; group_id: z.ZodDefault<z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>>>;
service_ids: z.ZodArray<z.ZodNumber>; service_ids: z.ZodArray<z.ZodNumber>;
@@ -1768,6 +1778,11 @@ declare const appSettingsPatchSchema: z.ZodObject<{
vpsTrackerIntegrationToken: z.ZodOptional<z.ZodString>; vpsTrackerIntegrationToken: z.ZodOptional<z.ZodString>;
vpsTrackerSyncEnabled: z.ZodOptional<z.ZodBoolean>; vpsTrackerSyncEnabled: z.ZodOptional<z.ZodBoolean>;
showQuickActions: 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>;
}, z.core.$strip>; }, z.core.$strip>;
type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>; type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
declare const vpsTrackerEventSchema: z.ZodObject<{ declare const vpsTrackerEventSchema: z.ZodObject<{
@@ -1892,4 +1907,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{
}, z.core.$strip>; }, z.core.$strip>;
type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>; 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 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 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 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, 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, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, 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 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, 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 };
+21 -2
View File
@@ -283,7 +283,8 @@ var serviceViewSchema = serviceSchema.extend({
domains: z.array(serviceDomainBindingSchema).default([]), domains: z.array(serviceDomainBindingSchema).default([]),
health_status: ipHealthStateSchema.default("unknown"), 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_health: z.array(serviceIpHealthSchema).default([]),
ip_enabled: z.record(z.string(), z.boolean()).default({})
}); });
var serviceGroupViewSchema = serviceGroupSchema.extend({ var serviceGroupViewSchema = serviceGroupSchema.extend({
services: z.array(serviceViewSchema).default([]), services: z.array(serviceViewSchema).default([]),
@@ -558,6 +559,10 @@ var updateServiceGroupSchema = z.object({
var toggleEnabledSchema = z.object({ var toggleEnabledSchema = z.object({
enabled: z.boolean() enabled: z.boolean()
}); });
var toggleServiceIpSchema = z.object({
ip: ipv4Schema,
enabled: z.boolean()
});
var reorderServicesSchema = z.object({ var reorderServicesSchema = z.object({
group_id: z.union([z.number(), z.null()]).optional().default(null), group_id: z.union([z.number(), z.null()]).optional().default(null),
service_ids: z.array(z.number().int().positive()).min(1) service_ids: z.array(z.number().int().positive()).min(1)
@@ -693,7 +698,20 @@ var appSettingsPatchSchema = z3.object({
vpsTrackerUrl: z3.string().url().or(z3.literal("")).optional(), vpsTrackerUrl: z3.string().url().or(z3.literal("")).optional(),
vpsTrackerIntegrationToken: z3.string().optional(), vpsTrackerIntegrationToken: z3.string().optional(),
vpsTrackerSyncEnabled: z3.boolean().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()
}).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({ var vpsTrackerEventSchema = z3.object({
event: z3.enum(["vps_down", "vps_up"]), event: z3.enum(["vps_down", "vps_up"]),
@@ -857,6 +875,7 @@ export {
subdomainLabelToFqdn, subdomainLabelToFqdn,
subdomainSchema, subdomainSchema,
toggleEnabledSchema, toggleEnabledSchema,
toggleServiceIpSchema,
updateDomainGroupSchema, updateDomainGroupSchema,
updateDomainSchema, updateDomainSchema,
updateServiceConfigSchema, updateServiceConfigSchema,
@@ -30,6 +30,23 @@ export const appSettingsPatchSchema = z.object({
vpsTrackerIntegrationToken: z.string().optional(), vpsTrackerIntegrationToken: z.string().optional(),
vpsTrackerSyncEnabled: z.boolean().optional(), vpsTrackerSyncEnabled: z.boolean().optional(),
showQuickActions: 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(),
}).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>; export type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
+8
View File
@@ -159,6 +159,7 @@ export const serviceViewSchema = serviceSchema.extend({
health_status: ipHealthStateSchema.default('unknown'), 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_health: z.array(serviceIpHealthSchema).default([]),
ip_enabled: z.record(z.string(), z.boolean()).default({}),
}) })
export const serviceGroupViewSchema = serviceGroupSchema.extend({ export const serviceGroupViewSchema = serviceGroupSchema.extend({
@@ -521,6 +522,13 @@ export const toggleEnabledSchema = z.object({
enabled: z.boolean(), enabled: z.boolean(),
}) })
export const toggleServiceIpSchema = z.object({
ip: ipv4Schema,
enabled: z.boolean(),
})
export type ToggleServiceIpInput = z.infer<typeof toggleServiceIpSchema>
export const reorderServicesSchema = z.object({ export const reorderServicesSchema = z.object({
group_id: z.union([z.number(), z.null()]).optional().default(null), group_id: z.union([z.number(), z.null()]).optional().default(null),
service_ids: z.array(z.number().int().positive()).min(1), service_ids: z.array(z.number().int().positive()).min(1),
+1
View File
@@ -197,6 +197,7 @@ export interface ServiceView {
health_status: IpHealthState; health_status: IpHealthState;
health_latency_ms: number | null; health_latency_ms: number | null;
ip_health: ServiceIpHealth[]; ip_health: ServiceIpHealth[];
ip_enabled: Record<string, boolean>;
} }
export interface GroupWithStats extends Group { export interface GroupWithStats extends Group {