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 committed 2026-08-19 15:33:47 +07:00
1 parent 4224db8eb3
commit d63c86065c
35 files changed
+1538 -123

No files matched your search

+9 -68
View File
@@ -7,7 +7,6 @@ import {
} from "@fastify/type-provider-zod";
import type { AppConfig } from "./config.js";
import { loadConfig } from "./config.js";
import { repos } from "@cfdm/db";
import authPlugin from "./plugins/auth.js";
import cfClientPlugin from "./plugins/cf-client.js";
import { requireAuth } from "./plugins/auth.js";
@@ -34,8 +33,10 @@ import { settingsRoutes } from "./routes/settings.js";
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
import { auditRoutes } from "./routes/audit.js";
import * as certificateService from "./services/certificate-service.js";
import * as healthCheckService from "./services/health-check-service.js";
import * as serviceConfigService from "./services/service-config-service.js";
import {
createHealthCheckTask,
scheduleHealthCheckJob,
} from "./services/health-check-scheduler.js";
import { AsyncTask, CronJob } from "toad-scheduler";
export interface BuildAppOptions {
@@ -125,71 +126,11 @@ export async function buildApp(opts: BuildAppOptions = {}) {
),
);
const healthTask = new AsyncTask(
"health-check",
async () => {
const thresholds = {
degradedFailures: config.healthDegradedFailures,
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
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 },
),
);
const healthTask = createHealthCheckTask(app, config);
scheduleHealthCheckJob(app, config, healthTask);
app.decorate("reloadHealthCheckJob", () => {
scheduleHealthCheckJob(app, config, healthTask);
});
}
return app;
+23
View File
@@ -4,6 +4,7 @@ import {
changeDomainSchema,
createServiceNodeSchema,
reorderServicesSchema,
toggleServiceIpSchema,
updateServiceConfigSchema,
updateServiceNodeSchema,
} from "@cfdm/shared";
@@ -191,4 +192,26 @@ export async function serviceRoutes(app: FastifyInstance) {
body.enabled,
);
});
app.patch("/services/:id/ips/toggle", async (request) => {
const { id } = request.params as { id: string };
const body = toggleServiceIpSchema.parse(request.body);
const view = await serviceConfig.toggleServiceIp(
request.server.db,
request.server.cf,
Number(id),
body.ip,
body.enabled,
);
recordAudit(request.server, request, {
action: "service.ip.toggle",
targetType: "app_resource",
targetId: String(id),
summary: body.enabled
? `Включён IP ${body.ip} сервиса «${view.name}»`
: `Выключен IP ${body.ip} сервиса «${view.name}»`,
details: { ip: body.ip, enabled: body.enabled },
});
return view;
});
}
+34 -3
View File
@@ -5,15 +5,46 @@ import {
updateAppSettings,
} from "@cfdm/db";
import { pingVpsTracker } from "../services/vps-tracker-sync.js";
import { AppError } from "../errors.js";
import {
assertValidHealthCron,
healthEngineFallbacksFromConfig,
} from "../services/health-check-scheduler.js";
export async function settingsRoutes(app: FastifyInstance) {
app.get("/settings", async (request) => {
return getAppSettings(request.server.db);
return getAppSettings(
request.server.db,
healthEngineFallbacksFromConfig(request.server.config),
);
});
app.patch("/settings", async (request) => {
const body = appSettingsPatchSchema.parse(request.body);
return updateAppSettings(request.server.db, body);
const parsed = appSettingsPatchSchema.safeParse(request.body);
if (!parsed.success) {
throw AppError.validation(
parsed.error.issues[0]?.message ?? "некорректные настройки",
);
}
const body = parsed.data;
if (body.healthCheckCron) {
assertValidHealthCron(body.healthCheckCron);
}
const fallbacks = healthEngineFallbacksFromConfig(request.server.config);
const current = getAppSettings(request.server.db, fallbacks);
const nextDegraded =
body.healthDegradedFailures ?? current.healthDegradedFailures;
const nextDown = body.healthDownFailures ?? current.healthDownFailures;
if (nextDown < nextDegraded) {
throw AppError.validation(
"ошибок до down не меньше, чем до degraded",
);
}
const next = updateAppSettings(request.server.db, body, fallbacks);
if (body.healthCheckCron !== undefined) {
request.server.reloadHealthCheckJob?.();
}
return next;
});
app.post("/settings/vps-tracker/test", async (request) => {
@@ -0,0 +1,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> {
const service = repos.getService(db, serviceId);
const ips = repos.listServiceIps(db, serviceId);
const ipRows = repos.listServiceIpRows(db, serviceId);
const ips = ipRows.map((row) => row.ip);
const ip_enabled = Object.fromEntries(
ipRows.map((row) => [row.ip, row.enabled]),
);
const bindings = repos.listBindingsByService(db, serviceId);
const domainViews = bindings.map((binding) => {
@@ -304,6 +308,7 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
created_at: service.created_at,
updated_at: service.updated_at,
ips,
ip_enabled,
domains: domainViews,
health_status: "unknown",
health_latency_ms: null,
@@ -384,7 +389,7 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
const groupViews = groupViewsRaw.map((group) => {
const services = group.services.map(
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null, 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);
// 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_latency_ms: null,
ip_health: [],
ip_enabled: {},
},
);
@@ -1345,6 +1351,59 @@ export async function toggleService(
return enabledView!;
}
export async function toggleServiceIp(
db: Db,
cf: CloudflareClient,
serviceId: number,
ip: string,
enabled: boolean,
): Promise<ServiceView> {
repos.getService(db, serviceId);
const pool = repos.listServiceIps(db, serviceId);
if (!pool.includes(ip)) {
throw AppError.validation(`IP ${ip} не входит в пул адресов сервиса`);
}
repos.setServiceIpEnabled(db, serviceId, ip, enabled);
const node = repos
.listNodes(db, serviceId)
.find((entry) => entry.address === ip);
if (node) {
repos.updateNode(db, node.id, { enabled });
}
const bindings = repos.listBindingsByService(db, serviceId);
for (const binding of bindings) {
if (binding.cname_target?.trim()) continue;
const current = repos.listBindingIpsWithMeta(db, binding.id);
const hasIp = current.some((entry) => entry.ip === ip);
if (enabled && !hasIp) {
repos.replaceBindingIpsWithMeta(db, binding.id, [
...current,
{ ip, weight: 1, priority: 1 },
]);
continue;
}
if (!enabled && hasIp) {
repos.replaceBindingIpsWithMeta(
db,
binding.id,
current.filter((entry) => entry.ip !== ip),
);
}
}
const service = repos.getService(db, serviceId);
if (shouldPushDns(db, service)) {
await syncServiceBindingsToDns(db, cf, serviceId);
await syncGroupDomainForService(db, cf, serviceId);
}
void syncServiceToVpsTracker(db, serviceId);
const [view] = attachServiceHealth(db, [await buildView(db, serviceId)]);
return view!;
}
export async function toggleGroup(
db: Db,
cf: CloudflareClient,
+5 -4
View File
@@ -43,8 +43,9 @@ function resolveIpsLocally(
const binding = index.byFqdn.get(key);
if (!binding) return [];
if (binding.target_ips.some(isIpLiteral)) {
return binding.target_ips.filter(isIpLiteral);
const ips = (binding.target_ips ?? []).filter(isIpLiteral);
if (ips.length > 0) {
return ips;
}
const cname = binding.cname_target?.trim();
@@ -79,7 +80,7 @@ export async function resolveBindingIpsForSync(
index: BindingIpIndex,
db?: Db,
): Promise<string[]> {
const directIps = binding.target_ips.filter(isIpLiteral);
const directIps = (binding.target_ips ?? []).filter(isIpLiteral);
if (directIps.length > 0) {
return [...directIps];
}
@@ -124,7 +125,7 @@ export async function buildServiceSyncBindingsAsync(
const serviceIps = repos.listServiceIps(db, serviceId);
const allBindings = repos.listAllBindings(db);
const index = buildBindingIndex(allBindings);
const bindings = repos.listBindingsByService(db, serviceId);
const bindings = allBindings.filter((row) => row.service_id === serviceId);
const items: CfdmBindingSyncItem[] = [];
for (const binding of bindings) {
@@ -165,4 +165,94 @@ describe("create service then list groups", () => {
await app.close();
});
it("PATCH /services/:id/ips/toggle keeps IP in pool and removes it from A-binding", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const cf = mockCf();
repos.createDomain(app.db, null, "example.com", "zone-1");
const group = repos.createServiceGroup(
app.db,
"VPN",
"vpn",
null,
"vpn.example.com",
);
const createRes = await app.inject({
method: "POST",
url: "/api/v1/services",
headers,
payload: {
name: "Panel",
slug: "panel-ip-toggle",
service_group_id: group.id,
},
});
expect(createRes.statusCode).toBe(200);
const created = createRes.json() as { id: number };
await updateConfig(app.db, cf, created.id, {
ips: ["1.2.3.4", "5.6.7.8"],
service_group_id: group.id,
domains: [
{
fqdn: "panel.example.com",
target_ips: ["1.2.3.4", "5.6.7.8"],
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1 },
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1 },
lb_mode: "round_robin",
health_check_enabled: false,
health_check_type: "tcp",
health_check_port: 443,
health_check_path: null,
health_check_expected_status: null,
health_check_interval_sec: 30,
health_check_timeout_ms: 3000,
health_check_verify_tls: false,
},
],
});
// HTTP toggle uses request.server.cf; disable DNS push so the test
// does not call the real Cloudflare client.
repos.setServiceEnabled(app.db, created.id, false);
const offRes = await app.inject({
method: "PATCH",
url: `/api/v1/services/${created.id}/ips/toggle`,
headers,
payload: { ip: "1.2.3.4", enabled: false },
});
expect(offRes.statusCode, JSON.stringify(offRes.json())).toBe(200);
const offView = offRes.json() as {
ips: string[];
ip_enabled: Record<string, boolean>;
};
expect(offView.ips).toEqual(expect.arrayContaining(["1.2.3.4", "5.6.7.8"]));
expect(offView.ip_enabled["1.2.3.4"]).toBe(false);
expect(offView.ip_enabled["5.6.7.8"]).toBe(true);
const binding = repos.listBindingsByService(app.db, created.id)[0]!;
expect(repos.listBindingIps(app.db, binding.id)).toEqual(["5.6.7.8"]);
const onRes = await app.inject({
method: "PATCH",
url: `/api/v1/services/${created.id}/ips/toggle`,
headers,
payload: { ip: "1.2.3.4", enabled: true },
});
expect(onRes.statusCode).toBe(200);
const onView = onRes.json() as { ip_enabled: Record<string, boolean> };
expect(onView.ip_enabled["1.2.3.4"]).toBe(true);
expect(repos.listBindingIps(app.db, binding.id)).toEqual(
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
);
await app.close();
});
});
+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"]);
});
it("treats missing target_ips as empty instead of throwing", async () => {
const cname = binding({
id: 2,
hostname: "imsk",
zone_name: "rkns.top",
cname_target: "ihome.rkns.top",
});
delete (cname as { target_ips?: string[] }).target_ips;
const index = { byFqdn: new Map([["imsk.rkns.top", cname]]) };
const ips = await resolveBindingIpsForSync(cname, ["198.51.100.9"], index);
expect(ips).toEqual(["198.51.100.9"]);
});
it("prefers service IPs over empty CNAME resolution chain", async () => {
const cname = binding({
id: 2,
@@ -20,6 +20,7 @@ import { Switch } from '@cfdm/ui/components/switch'
import { Button } from '@cfdm/ui/components/button'
import { ButtonGroup } from '@cfdm/ui/components/button-group'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Link } from '@tanstack/react-router'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { cn } from '@cfdm/ui/lib/utils'
@@ -213,7 +214,18 @@ export function HealthCheckConfigFields({
Checks, API вернёт ошибку — останется Local. Workers не используются.
</AlertDescription>
</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
title="Health-check"
@@ -3,6 +3,7 @@ import { Link, useNavigate } from '@tanstack/react-router'
import {
FolderTreeIcon,
GlobeIcon,
HeartPulseIcon,
LayoutDashboardIcon,
SearchIcon,
ServerIcon,
@@ -57,6 +58,12 @@ const NAV_ITEMS = [
keywords: ['certificates', 'ssl', 'tls'],
icon: ShieldCheckIcon,
},
{
to: '/settings/health',
label: 'Health-check',
keywords: ['health', 'health-check', 'cron', 'пороги', 'настройки'],
icon: HeartPulseIcon,
},
{
to: '/settings/integrations',
label: 'Настройки',
@@ -24,6 +24,7 @@ const routeTitles: Record<string, string> = {
'/services': 'Сервисы',
'/certificates': 'Сертификаты',
'/settings/appearance': 'Внешний вид',
'/settings/health': 'Health-check',
'/settings/integrations': 'Интеграции',
}
@@ -64,6 +65,8 @@ function getBreadcrumbs(
{ label: 'Настройки', href: '/settings/appearance' },
...(pathname === '/settings/integrations'
? [{ label: 'Интеграции', href: pathname }]
: pathname === '/settings/health'
? [{ label: 'Health-check', href: pathname }]
: pathname === '/settings/appearance'
? [{ label: 'Внешний вид', href: pathname }]
: []),
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { PaletteIcon, SettingsIcon } from 'lucide-react'
import { HeartPulseIcon, PaletteIcon, SettingsIcon } from 'lucide-react'
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
import { cn } from '@cfdm/ui/lib/utils'
@@ -21,6 +21,12 @@ const DEFAULT_TABS: SettingsTabConfig[] = [
label: 'Внешний вид',
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
},
{
id: 'health',
to: '/settings/health',
label: 'Health-check',
icon: <HeartPulseIcon className="size-4" aria-hidden="true" />,
},
{
id: 'integrations',
to: '/settings/integrations',
@@ -37,7 +43,7 @@ interface SettingsShellProps {
export function SettingsShell({
title = 'Настройки',
description = 'Внешний вид и интеграции',
description = 'Внешний вид, health-check и интеграции',
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
@@ -26,9 +26,11 @@ interface ServiceCatalogSectionProps {
group: ServiceGroupView | null
services: ServiceView[]
togglingId: number | null
togglingIp: { serviceId: number; ip: string } | null
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup: (group: ServiceGroupView) => void
onAddServiceToGroup: (groupId: number | null) => void
@@ -38,9 +40,11 @@ export function ServiceCatalogSection({
group,
services,
togglingId,
togglingIp,
onEditService,
onDeleteService,
onToggleService,
onToggleServiceIp,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
@@ -148,9 +152,13 @@ export function ServiceCatalogSection({
key={service.id}
service={service}
togglingId={togglingId}
togglingIp={
togglingIp?.serviceId === service.id ? togglingIp.ip : null
}
onEditService={onEditService}
onDeleteService={onDeleteService}
onToggleService={onToggleService}
onToggleServiceIp={onToggleServiceIp}
/>
))}
</div>
@@ -8,6 +8,7 @@ import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { serviceDisplayFqdns } from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import { Switch } from '@cfdm/ui/components/switch'
import {
Tooltip,
TooltipContent,
@@ -121,6 +122,10 @@ const VISIBLE_IP_LIMIT = 6
interface ServiceIpListProps {
ips: string[]
ipHealth?: ServiceView['ip_health']
ipEnabled?: Record<string, boolean>
togglingIp?: string | null
ipToggleDisabled?: boolean
onToggleIp?: (ip: string, enabled: boolean) => void
className?: string
emptyLabel?: string
copyable?: boolean
@@ -130,6 +135,10 @@ interface ServiceIpListProps {
export function ServiceIpList({
ips,
ipHealth = [],
ipEnabled = {},
togglingIp = null,
ipToggleDisabled = false,
onToggleIp,
className,
emptyLabel = 'Нет IP',
copyable = false,
@@ -144,13 +153,14 @@ export function ServiceIpList({
}
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
return (
<div className={cn('flex min-w-0 flex-col gap-1', className)}>
{visible.map((ip) => {
const health = healthByIp.get(ip)
const enabled = ipEnabled[ip] !== false
return (
<div key={ip} className="flex min-w-0 items-center gap-1.5">
<HealthCheckBadge
@@ -160,13 +170,29 @@ export function ServiceIpList({
/>
<TruncatedText
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,
)}
>
{ip}
</TruncatedText>
{copyable ? <CopyFqdnButton value={ip} /> : null}
{onToggleIp ? (
<Switch
size="sm"
className="ml-auto shrink-0"
checked={enabled}
disabled={ipToggleDisabled || togglingIp === ip}
onClick={(event) => {
event.stopPropagation()
}}
onCheckedChange={(checked) => onToggleIp(ip, Boolean(checked))}
aria-label={
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
}
/>
) : null}
</div>
)
})}
@@ -1,6 +1,7 @@
import { Link } from '@tanstack/react-router'
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
@@ -10,9 +11,10 @@ import {
} from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import {
ServiceFqdnList,
CopyFqdnButton,
ServiceIpList,
} from '@/components/services/service-fqdn-list'
import { serviceDisplayFqdn, serviceDisplayFqdns } from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
@@ -22,22 +24,36 @@ import {
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { Switch } from '@cfdm/ui/components/switch'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
interface ServiceUnitCardProps {
service: ServiceView
togglingId: number | null
togglingIp: string | null
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
}
export function ServiceUnitCard({
service,
togglingId,
togglingIp,
onEditService,
onDeleteService,
onToggleService,
onToggleServiceIp,
}: ServiceUnitCardProps) {
const fqdns = serviceDisplayFqdns(service)
const primaryDomain = serviceDisplayFqdn(service)
const extraCount = Math.max(0, fqdns.length - 1)
return (
<Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden">
<FrameHeader className="flex-row items-center justify-between gap-2">
@@ -60,9 +76,38 @@ export function ServiceUnitCard({
{service.name}
</Link>
</FrameTitle>
<FrameDescription className="truncate font-mono text-xs">
{service.slug}
</FrameDescription>
<div className="flex min-w-0 items-center gap-1">
<FrameDescription className="min-w-0 truncate font-mono text-xs">
{primaryDomain}
</FrameDescription>
{extraCount > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Badge
variant="outline"
size="xs"
className="shrink-0 tabular-nums"
/>
}
>
+{extraCount}
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<ul className="flex flex-col gap-0.5 font-mono text-xs">
{fqdns.map((fqdn) => (
<li key={fqdn}>{fqdn}</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
{primaryDomain !== '—' ? (
<CopyFqdnButton value={fqdns.join('\n')} />
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
@@ -115,12 +160,17 @@ export function ServiceUnitCard({
</div>
</FrameHeader>
<FramePanel className="flex min-w-0 flex-col gap-1.5 pt-0 shadow-none!">
<ServiceFqdnList copyable service={service} emptyLabel="Не задан" />
<FramePanel className="flex min-w-0 flex-col gap-1 pt-0 shadow-none!">
<ServiceIpList
copyable
ips={service.ips ?? []}
ipHealth={service.ip_health ?? []}
ipEnabled={service.ip_enabled ?? {}}
ipToggleDisabled={togglingId === service.id}
togglingIp={togglingIp}
onToggleIp={(ip, enabled) =>
onToggleServiceIp(service.id, ip, enabled)
}
/>
</FramePanel>
</Frame>
@@ -160,11 +160,13 @@ interface ServicesGroupedCatalogProps {
primaryAction?: ReactNode
hideHeader?: boolean
togglingId: number | null
togglingIp: { serviceId: number; ip: string } | null
activeTab?: string
onTabChange?: (tabId: string) => void
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup: (group: ServiceGroupView) => void
onAddServiceToGroup: (groupId: number | null) => void
@@ -179,10 +181,12 @@ export function ServicesGroupedCatalog({
primaryAction,
hideHeader = false,
togglingId,
togglingIp,
activeTab = 'all',
onEditService,
onDeleteService,
onToggleService,
onToggleServiceIp,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
@@ -304,9 +308,11 @@ export function ServicesGroupedCatalog({
group={unit.group}
services={unit.services}
togglingId={togglingId}
togglingIp={togglingIp}
onEditService={onEditService}
onDeleteService={onDeleteService}
onToggleService={onToggleService}
onToggleServiceIp={onToggleServiceIp}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
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_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]),
ip_enabled: z.record(z.string(), z.boolean()).default({}),
})
export const serviceGroupViewSchema = serviceGroupSchema.extend({
+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 AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
import { Route as AuthSettingsHealthRouteImport } from './routes/_auth/settings/health'
import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
import { Route as AuthServicesServiceIdRouteRouteImport } from './routes/_auth/services/$serviceId/route'
@@ -86,6 +87,11 @@ const AuthSettingsIntegrationsRoute =
path: '/integrations',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthSettingsHealthRoute = AuthSettingsHealthRouteImport.update({
id: '/health',
path: '/health',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
id: '/appearance',
path: '/appearance',
@@ -154,6 +160,7 @@ export interface FileRoutesByFullPath {
'/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
'/settings/appearance': typeof AuthSettingsAppearanceRoute
'/settings/health': typeof AuthSettingsHealthRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/domains/': typeof AuthDomainsIndexRoute
'/services/': typeof AuthServicesIndexRoute
@@ -174,6 +181,7 @@ export interface FileRoutesByTo {
'/': typeof AuthIndexRoute
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
'/settings/appearance': typeof AuthSettingsAppearanceRoute
'/settings/health': typeof AuthSettingsHealthRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/domains': typeof AuthDomainsIndexRoute
'/services': typeof AuthServicesIndexRoute
@@ -198,6 +206,7 @@ export interface FileRoutesById {
'/_auth/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
'/_auth/settings/health': typeof AuthSettingsHealthRoute
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/_auth/domains/': typeof AuthDomainsIndexRoute
'/_auth/services/': typeof AuthServicesIndexRoute
@@ -222,6 +231,7 @@ export interface FileRouteTypes {
| '/services/$serviceId'
| '/groups/$groupId'
| '/settings/appearance'
| '/settings/health'
| '/settings/integrations'
| '/domains/'
| '/services/'
@@ -242,6 +252,7 @@ export interface FileRouteTypes {
| '/'
| '/groups/$groupId'
| '/settings/appearance'
| '/settings/health'
| '/settings/integrations'
| '/domains'
| '/services'
@@ -265,6 +276,7 @@ export interface FileRouteTypes {
| '/_auth/services/$serviceId'
| '/_auth/groups/$groupId'
| '/_auth/settings/appearance'
| '/_auth/settings/health'
| '/_auth/settings/integrations'
| '/_auth/domains/'
| '/_auth/services/'
@@ -363,6 +375,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/settings/health': {
id: '/_auth/settings/health'
path: '/health'
fullPath: '/settings/health'
preLoaderRoute: typeof AuthSettingsHealthRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/settings/appearance': {
id: '/_auth/settings/appearance'
path: '/appearance'
@@ -438,12 +457,14 @@ declare module '@tanstack/react-router' {
interface AuthSettingsRouteRouteChildren {
AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute
AuthSettingsHealthRoute: typeof AuthSettingsHealthRoute
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
}
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute,
AuthSettingsHealthRoute: AuthSettingsHealthRoute,
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
}
@@ -81,6 +81,29 @@ function setServiceEnabled(
}
}
function setCatalogServiceIpEnabled(
data: ServiceGroupsResponse,
serviceId: number,
ip: string,
enabled: boolean,
): ServiceGroupsResponse {
const patch = (service: ServiceView): ServiceView =>
service.id === serviceId
? {
...service,
ip_enabled: { ...(service.ip_enabled ?? {}), [ip]: enabled },
}
: service
return {
groups: data.groups.map((group) => ({
...group,
services: group.services.map(patch),
})),
ungrouped: data.ungrouped.map(patch),
}
}
const GROUP_DOT_COLORS = [
'bg-chart-1',
'bg-chart-2',
@@ -150,6 +173,10 @@ function ServicesPage() {
const [deletingId, setDeletingId] = useState<number | null>(null)
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
const [togglingIp, setTogglingIp] = useState<{
serviceId: number
ip: string
} | null>(null)
const [bulkToggling, setBulkToggling] = useState(false)
const [activeTab, setActiveTab] = useState('all')
const queryClient = useQueryClient()
@@ -364,6 +391,54 @@ function ServicesPage() {
},
})
const toggleServiceIpMutation = useMutation({
mutationFn: ({
id,
ip,
enabled,
}: {
id: number
ip: string
enabled: boolean
}) =>
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, {
ip,
enabled,
}),
onMutate: async ({ id, ip, enabled }) => {
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
serviceGroupKeys.all,
)
if (previous) {
queryClient.setQueryData(
serviceGroupKeys.all,
setCatalogServiceIpEnabled(previous, id, ip, enabled),
)
}
return { previous }
},
onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(serviceGroupKeys.all, context.previous)
}
toast.error(
err instanceof Error ? err.message : 'Не удалось переключить IP',
)
},
onSuccess: (_data, { enabled }) => {
toast.success(
enabled
? 'IP включён и добавлен в DNS-привязки'
: 'IP выключен и снят с DNS-привязок',
)
},
onSettled: async () => {
setTogglingIp(null)
await invalidateAll()
},
})
const deleteGroupMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`),
onSuccess: async () => {
@@ -398,6 +473,15 @@ function ServicesPage() {
toggleServiceMutation.mutate({ id: serviceId, enabled })
}
function handleServiceIpToggle(
serviceId: number,
ip: string,
enabled: boolean,
) {
setTogglingIp({ serviceId, ip })
toggleServiceIpMutation.mutate({ id: serviceId, ip, enabled })
}
function handleOpenCreateService(groupId: number | null = null) {
setDefaultGroupId(groupId)
setCreateSheetOpen(true)
@@ -602,9 +686,11 @@ function ServicesPage() {
isLoading
hideHeader
togglingId={null}
togglingIp={null}
onEditService={() => {}}
onDeleteService={() => {}}
onToggleService={() => {}}
onToggleServiceIp={() => {}}
onEditGroup={() => {}}
onDeleteGroup={() => {}}
onAddServiceToGroup={() => {}}
@@ -702,12 +788,14 @@ function ServicesPage() {
domainId={domainId}
domainLabel={filteredDomain?.zone_name}
togglingId={togglingServiceId}
togglingIp={togglingIp}
activeTab={activeTab}
onTabChange={setActiveTab}
hideHeader
onEditService={setEditingService}
onDeleteService={setDeletingService}
onToggleService={handleServiceToggle}
onToggleServiceIp={handleServiceIpToggle}
onEditGroup={setEditingGroup}
onDeleteGroup={setDeletingGroup}
onAddServiceToGroup={handleOpenCreateService}
@@ -0,0 +1,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>
)
}