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
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:
@@ -36,3 +36,10 @@ RUST_LOG=info
|
||||
|
||||
# Certificate scheduler (cron)
|
||||
CERT_CHECK_CRON=0 0 */6 * * *
|
||||
|
||||
# Local health-check engine (override in UI: Настройки → Health-check)
|
||||
# HEALTH_CHECK_CRON=0 */2 * * * *
|
||||
# HEALTH_DEGRADED_FAILURES=1
|
||||
# HEALTH_DOWN_FAILURES=2
|
||||
# HEALTH_SUCCESS_RECOVERIES=2
|
||||
# HEALTH_LATENCY_WARN_MS=1000
|
||||
|
||||
+9
-68
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+8
-6
@@ -20,11 +20,11 @@ Manage Cloudflare zones, DNS records, domain groups, and TLS certificate expiry
|
||||
| `ADMIN_USERNAME` | Admin username |
|
||||
| `ADMIN_PASSWORD_HASH` | Argon2 hash (empty = dev `admin`/`admin`) |
|
||||
| `LOG_LEVEL` | Уровень логов API (`info`, `debug`) |
|
||||
| `HEALTH_CHECK_CRON` | Cron для health-check (default `*/30 * * * * *`) |
|
||||
| `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`) |
|
||||
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`) |
|
||||
| `HEALTH_SUCCESS_RECOVERIES` | Успехов подряд для recovery `CHECKING → HEALTHY` (default `2`) |
|
||||
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`) |
|
||||
| `HEALTH_CHECK_CRON` | Cron для health-check (default `0 */2 * * * *`). Переопределяется в **Настройки → Health-check**. |
|
||||
| `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`). То же в UI. |
|
||||
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`). То же в UI. |
|
||||
| `HEALTH_SUCCESS_RECOVERIES` | Успехов подряд для recovery `CHECKING → HEALTHY` (default `2`). То же в UI. |
|
||||
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`). То же в UI. |
|
||||
|
||||
## Load balancing & health checks
|
||||
|
||||
@@ -46,7 +46,9 @@ health-check работают на двух уровнях:
|
||||
Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted`
|
||||
работает как `round_robin` (одна A на IP). `unknown` **не** считается 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 не используются.
|
||||
|
||||
## Docker
|
||||
|
||||
Vendored
+235
-5
File diff suppressed because one or more lines are too long
Vendored
+67
-10
@@ -187,6 +187,7 @@ var serviceIps = sqliteTable("service_ips", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
|
||||
ip: text("ip").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`)
|
||||
});
|
||||
var serviceBindingRecords = sqliteTable(
|
||||
@@ -269,6 +270,11 @@ var appSettings = sqliteTable("app_settings", {
|
||||
show_quick_actions: integer("show_quick_actions", {
|
||||
mode: "boolean"
|
||||
}).notNull().default(true),
|
||||
health_check_cron: text("health_check_cron"),
|
||||
health_degraded_failures: integer("health_degraded_failures"),
|
||||
health_down_failures: integer("health_down_failures"),
|
||||
health_latency_warn_ms: integer("health_latency_warn_ms"),
|
||||
health_success_recoveries: integer("health_success_recoveries"),
|
||||
created_at: text("created_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
|
||||
import { eq as eq2 } from "drizzle-orm";
|
||||
var SETTINGS_ID = "settings-main";
|
||||
function toDto(row) {
|
||||
function coalesceInt(value, fallback) {
|
||||
return value == null || Number.isNaN(value) || value < 1 ? fallback : value;
|
||||
}
|
||||
function toDto(row, fallbacks) {
|
||||
const env = fallbacks ?? {
|
||||
healthCheckCron: "0 */2 * * * *",
|
||||
healthDegradedFailures: 1,
|
||||
healthDownFailures: 2,
|
||||
healthLatencyWarnMs: 1e3,
|
||||
healthSuccessRecoveries: 2
|
||||
};
|
||||
return {
|
||||
id: row.id,
|
||||
vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "",
|
||||
@@ -502,18 +518,36 @@ function toDto(row) {
|
||||
),
|
||||
vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled),
|
||||
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at,
|
||||
showQuickActions: row.show_quick_actions == null ? true : Boolean(row.show_quick_actions)
|
||||
showQuickActions: row.show_quick_actions == null ? true : Boolean(row.show_quick_actions),
|
||||
healthCheckCron: row.health_check_cron?.trim() || env.healthCheckCron,
|
||||
healthDegradedFailures: coalesceInt(
|
||||
row.health_degraded_failures,
|
||||
env.healthDegradedFailures
|
||||
),
|
||||
healthDownFailures: coalesceInt(
|
||||
row.health_down_failures,
|
||||
env.healthDownFailures
|
||||
),
|
||||
healthLatencyWarnMs: coalesceInt(
|
||||
row.health_latency_warn_ms,
|
||||
env.healthLatencyWarnMs
|
||||
),
|
||||
healthSuccessRecoveries: coalesceInt(
|
||||
row.health_success_recoveries,
|
||||
env.healthSuccessRecoveries
|
||||
)
|
||||
};
|
||||
}
|
||||
function getAppSettings(db) {
|
||||
function getAppSettings(db, fallbacks) {
|
||||
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
||||
if (!row) {
|
||||
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
|
||||
return toDto(
|
||||
db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get()
|
||||
db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get(),
|
||||
fallbacks
|
||||
);
|
||||
}
|
||||
return toDto(row);
|
||||
return toDto(row, fallbacks);
|
||||
}
|
||||
function getAppSettingsSecrets(db) {
|
||||
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
||||
@@ -523,7 +557,7 @@ function getAppSettingsSecrets(db) {
|
||||
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();
|
||||
if (!existing) {
|
||||
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_sync_enabled: patch.vpsTrackerSyncEnabled !== void 0 ? patch.vpsTrackerSyncEnabled : current.vps_tracker_sync_enabled,
|
||||
show_quick_actions: patch.showQuickActions !== void 0 ? patch.showQuickActions : current.show_quick_actions,
|
||||
health_check_cron: patch.healthCheckCron !== void 0 ? patch.healthCheckCron.trim() : current.health_check_cron,
|
||||
health_degraded_failures: patch.healthDegradedFailures !== void 0 ? patch.healthDegradedFailures : current.health_degraded_failures,
|
||||
health_down_failures: patch.healthDownFailures !== void 0 ? patch.healthDownFailures : current.health_down_failures,
|
||||
health_latency_warn_ms: patch.healthLatencyWarnMs !== void 0 ? patch.healthLatencyWarnMs : current.health_latency_warn_ms,
|
||||
health_success_recoveries: patch.healthSuccessRecoveries !== void 0 ? patch.healthSuccessRecoveries : current.health_success_recoveries,
|
||||
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
||||
}).where(eq2(appSettings.id, SETTINGS_ID)).run();
|
||||
return getAppSettings(db);
|
||||
return getAppSettings(db, fallbacks);
|
||||
}
|
||||
function touchVpsTrackerSync(db) {
|
||||
db.update(appSettings).set({
|
||||
@@ -638,6 +677,7 @@ __export(repos_exports, {
|
||||
listOriginIpsForFqdn: () => listOriginIpsForFqdn,
|
||||
listRecordsForBinding: () => listRecordsForBinding,
|
||||
listServiceGroups: () => listServiceGroups,
|
||||
listServiceIpRows: () => listServiceIpRows,
|
||||
listServiceIps: () => listServiceIps,
|
||||
listServices: () => listServices,
|
||||
listServicesByGroup: () => listServicesByGroup,
|
||||
@@ -659,6 +699,7 @@ __export(repos_exports, {
|
||||
setServiceEnabled: () => setServiceEnabled,
|
||||
setServiceGroup: () => setServiceGroup,
|
||||
setServiceGroupEnabled: () => setServiceGroupEnabled,
|
||||
setServiceIpEnabled: () => setServiceIpEnabled,
|
||||
setServiceLb: () => setServiceLb,
|
||||
unlinkBindingRecord: () => unlinkBindingRecord,
|
||||
unlinkGroupDnsRecord: () => unlinkGroupDnsRecord,
|
||||
@@ -1155,16 +1196,32 @@ function deleteServiceGroup(db, id) {
|
||||
function insertServiceIpIfMissing(db, serviceId, ip) {
|
||||
const existing = db.select({ ip: serviceIps.ip }).from(serviceIps).where(and2(eq3(serviceIps.service_id, serviceId), eq3(serviceIps.ip, ip))).get();
|
||||
if (!existing) {
|
||||
db.insert(serviceIps).values({ service_id: serviceId, ip }).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) {
|
||||
return db.select({ ip: serviceIps.ip }).from(serviceIps).where(eq3(serviceIps.service_id, serviceId)).all().map((r) => r.ip);
|
||||
return listServiceIpRows(db, serviceId).map((row) => row.ip);
|
||||
}
|
||||
function setServiceIpEnabled(db, serviceId, ip, enabled) {
|
||||
const result = db.update(serviceIps).set({ enabled }).where(and2(eq3(serviceIps.service_id, serviceId), eq3(serviceIps.ip, ip))).run();
|
||||
if (result.changes === 0) {
|
||||
throw new NotFoundError(`service ip ${ip}`);
|
||||
}
|
||||
}
|
||||
function replaceServiceIps(db, serviceId, ips) {
|
||||
const previous = new Map(
|
||||
listServiceIpRows(db, serviceId).map((row) => [row.ip, row.enabled])
|
||||
);
|
||||
db.delete(serviceIps).where(eq3(serviceIps.service_id, serviceId)).run();
|
||||
for (const ip of ips) {
|
||||
db.insert(serviceIps).values({ service_id: serviceId, ip }).run();
|
||||
db.insert(serviceIps).values({
|
||||
service_id: serviceId,
|
||||
ip,
|
||||
enabled: previous.get(ip) ?? true
|
||||
}).run();
|
||||
ensureNode(db, serviceId, ip);
|
||||
}
|
||||
const keep = new Set(ips);
|
||||
|
||||
@@ -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;
|
||||
@@ -923,17 +923,42 @@ function insertServiceIpIfMissing(db: Db, serviceId: number, ip: string): void {
|
||||
.where(and(eq(serviceIps.service_id, serviceId), eq(serviceIps.ip, ip)))
|
||||
.get();
|
||||
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
|
||||
.select({ ip: serviceIps.ip })
|
||||
.select({ ip: serviceIps.ip, enabled: serviceIps.enabled })
|
||||
.from(serviceIps)
|
||||
.where(eq(serviceIps.service_id, serviceId))
|
||||
.all()
|
||||
.map((r) => r.ip);
|
||||
.map((row) => ({ ip: row.ip, enabled: Boolean(row.enabled) }));
|
||||
}
|
||||
|
||||
export function listServiceIps(db: Db, serviceId: number): string[] {
|
||||
return listServiceIpRows(db, serviceId).map((row) => row.ip);
|
||||
}
|
||||
|
||||
export function setServiceIpEnabled(
|
||||
db: Db,
|
||||
serviceId: number,
|
||||
ip: string,
|
||||
enabled: boolean,
|
||||
): void {
|
||||
const result = db
|
||||
.update(serviceIps)
|
||||
.set({ enabled })
|
||||
.where(and(eq(serviceIps.service_id, serviceId), eq(serviceIps.ip, ip)))
|
||||
.run();
|
||||
if (result.changes === 0) {
|
||||
throw new NotFoundError(`service ip ${ip}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function replaceServiceIps(
|
||||
@@ -941,9 +966,18 @@ export function replaceServiceIps(
|
||||
serviceId: number,
|
||||
ips: string[],
|
||||
): void {
|
||||
const previous = new Map(
|
||||
listServiceIpRows(db, serviceId).map((row) => [row.ip, row.enabled]),
|
||||
);
|
||||
db.delete(serviceIps).where(eq(serviceIps.service_id, serviceId)).run();
|
||||
for (const ip of ips) {
|
||||
db.insert(serviceIps).values({ service_id: serviceId, ip }).run();
|
||||
db.insert(serviceIps)
|
||||
.values({
|
||||
service_id: serviceId,
|
||||
ip,
|
||||
enabled: previous.get(ip) ?? true,
|
||||
})
|
||||
.run();
|
||||
ensureNode(db, serviceId, ip);
|
||||
}
|
||||
const keep = new Set(ips);
|
||||
|
||||
@@ -259,6 +259,7 @@ export const serviceIps = sqliteTable("service_ips", {
|
||||
.notNull()
|
||||
.references(() => services.id, { onDelete: "cascade" }),
|
||||
ip: text("ip").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
@@ -379,6 +380,11 @@ export const appSettings = sqliteTable("app_settings", {
|
||||
})
|
||||
.notNull()
|
||||
.default(true),
|
||||
health_check_cron: text("health_check_cron"),
|
||||
health_degraded_failures: integer("health_degraded_failures"),
|
||||
health_down_failures: integer("health_down_failures"),
|
||||
health_latency_warn_ms: integer("health_latency_warn_ms"),
|
||||
health_success_recoveries: integer("health_success_recoveries"),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
|
||||
@@ -4,6 +4,14 @@ import { appSettings } from "./schema.js";
|
||||
|
||||
const SETTINGS_ID = "settings-main";
|
||||
|
||||
export type HealthEngineSettings = {
|
||||
healthCheckCron: string;
|
||||
healthDegradedFailures: number;
|
||||
healthDownFailures: number;
|
||||
healthLatencyWarnMs: number;
|
||||
healthSuccessRecoveries: number;
|
||||
};
|
||||
|
||||
export type AppSettingsDto = {
|
||||
id: string;
|
||||
vpsTrackerUrl: string;
|
||||
@@ -11,16 +19,37 @@ export type AppSettingsDto = {
|
||||
vpsTrackerSyncEnabled: boolean;
|
||||
vpsTrackerLastSyncAt: string | null;
|
||||
showQuickActions: boolean;
|
||||
};
|
||||
} & HealthEngineSettings;
|
||||
|
||||
export type AppSettingsPatch = {
|
||||
vpsTrackerUrl?: string;
|
||||
vpsTrackerIntegrationToken?: string;
|
||||
vpsTrackerSyncEnabled?: 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 {
|
||||
id: row.id,
|
||||
vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "",
|
||||
@@ -31,10 +60,30 @@ function toDto(row: typeof appSettings.$inferSelect): AppSettingsDto {
|
||||
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at,
|
||||
showQuickActions:
|
||||
row.show_quick_actions == null ? true : Boolean(row.show_quick_actions),
|
||||
healthCheckCron: row.health_check_cron?.trim() || env.healthCheckCron,
|
||||
healthDegradedFailures: coalesceInt(
|
||||
row.health_degraded_failures,
|
||||
env.healthDegradedFailures,
|
||||
),
|
||||
healthDownFailures: coalesceInt(
|
||||
row.health_down_failures,
|
||||
env.healthDownFailures,
|
||||
),
|
||||
healthLatencyWarnMs: coalesceInt(
|
||||
row.health_latency_warn_ms,
|
||||
env.healthLatencyWarnMs,
|
||||
),
|
||||
healthSuccessRecoveries: coalesceInt(
|
||||
row.health_success_recoveries,
|
||||
env.healthSuccessRecoveries,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAppSettings(db: Db): AppSettingsDto {
|
||||
export function getAppSettings(
|
||||
db: Db,
|
||||
fallbacks?: HealthEngineFallbacks,
|
||||
): AppSettingsDto {
|
||||
const row = db
|
||||
.select()
|
||||
.from(appSettings)
|
||||
@@ -44,9 +93,10 @@ export function getAppSettings(db: Db): AppSettingsDto {
|
||||
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
|
||||
return toDto(
|
||||
db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get()!,
|
||||
fallbacks,
|
||||
);
|
||||
}
|
||||
return toDto(row);
|
||||
return toDto(row, fallbacks);
|
||||
}
|
||||
|
||||
export function getAppSettingsSecrets(db: Db): {
|
||||
@@ -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
|
||||
.select()
|
||||
.from(appSettings)
|
||||
@@ -102,12 +156,32 @@ export function updateAppSettings(db: Db, patch: AppSettingsPatch): AppSettingsD
|
||||
patch.showQuickActions !== undefined
|
||||
? patch.showQuickActions
|
||||
: current.show_quick_actions,
|
||||
health_check_cron:
|
||||
patch.healthCheckCron !== undefined
|
||||
? patch.healthCheckCron.trim()
|
||||
: current.health_check_cron,
|
||||
health_degraded_failures:
|
||||
patch.healthDegradedFailures !== undefined
|
||||
? patch.healthDegradedFailures
|
||||
: current.health_degraded_failures,
|
||||
health_down_failures:
|
||||
patch.healthDownFailures !== undefined
|
||||
? patch.healthDownFailures
|
||||
: current.health_down_failures,
|
||||
health_latency_warn_ms:
|
||||
patch.healthLatencyWarnMs !== undefined
|
||||
? patch.healthLatencyWarnMs
|
||||
: current.health_latency_warn_ms,
|
||||
health_success_recoveries:
|
||||
patch.healthSuccessRecoveries !== undefined
|
||||
? patch.healthSuccessRecoveries
|
||||
: current.health_success_recoveries,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(appSettings.id, SETTINGS_ID))
|
||||
.run();
|
||||
|
||||
return getAppSettings(db);
|
||||
return getAppSettings(db, fallbacks);
|
||||
}
|
||||
|
||||
export function touchVpsTrackerSync(db: Db): void {
|
||||
|
||||
Vendored
+16
-1
@@ -133,6 +133,7 @@ interface ServiceView$1 {
|
||||
health_status: IpHealthState;
|
||||
health_latency_ms: number | null;
|
||||
ip_health: ServiceIpHealth$1[];
|
||||
ip_enabled: Record<string, boolean>;
|
||||
}
|
||||
interface SyncJob {
|
||||
id: string;
|
||||
@@ -646,6 +647,7 @@ declare const serviceViewSchema: z.ZodObject<{
|
||||
}>;
|
||||
latency_ms: z.ZodNullable<z.ZodNumber>;
|
||||
}, z.core.$strip>>>;
|
||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||
}, z.core.$strip>;
|
||||
declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
id: z.ZodNumber;
|
||||
@@ -789,6 +791,7 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
}>;
|
||||
latency_ms: z.ZodNullable<z.ZodNumber>;
|
||||
}, z.core.$strip>>>;
|
||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||
}, z.core.$strip>>>;
|
||||
health_status: z.ZodDefault<z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
@@ -941,6 +944,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
}>;
|
||||
latency_ms: z.ZodNullable<z.ZodNumber>;
|
||||
}, z.core.$strip>>>;
|
||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||
}, z.core.$strip>>>;
|
||||
health_status: z.ZodDefault<z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
@@ -1059,6 +1063,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
}>;
|
||||
latency_ms: z.ZodNullable<z.ZodNumber>;
|
||||
}, z.core.$strip>>>;
|
||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
declare const domainSchema: z.ZodObject<{
|
||||
@@ -1552,6 +1557,11 @@ type UpdateServiceGroupInput = z.infer<typeof updateServiceGroupSchema>;
|
||||
declare const toggleEnabledSchema: z.ZodObject<{
|
||||
enabled: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
declare const toggleServiceIpSchema: z.ZodObject<{
|
||||
ip: z.ZodString;
|
||||
enabled: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
type ToggleServiceIpInput = z.infer<typeof toggleServiceIpSchema>;
|
||||
declare const reorderServicesSchema: z.ZodObject<{
|
||||
group_id: z.ZodDefault<z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>>>;
|
||||
service_ids: z.ZodArray<z.ZodNumber>;
|
||||
@@ -1768,6 +1778,11 @@ declare const appSettingsPatchSchema: z.ZodObject<{
|
||||
vpsTrackerIntegrationToken: z.ZodOptional<z.ZodString>;
|
||||
vpsTrackerSyncEnabled: z.ZodOptional<z.ZodBoolean>;
|
||||
showQuickActions: z.ZodOptional<z.ZodBoolean>;
|
||||
healthCheckCron: z.ZodOptional<z.ZodString>;
|
||||
healthDegradedFailures: z.ZodOptional<z.ZodNumber>;
|
||||
healthDownFailures: z.ZodOptional<z.ZodNumber>;
|
||||
healthLatencyWarnMs: z.ZodOptional<z.ZodNumber>;
|
||||
healthSuccessRecoveries: z.ZodOptional<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
|
||||
declare const vpsTrackerEventSchema: z.ZodObject<{
|
||||
@@ -1892,4 +1907,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{
|
||||
}, z.core.$strip>;
|
||||
type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>;
|
||||
|
||||
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type 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 };
|
||||
|
||||
Vendored
+21
-2
@@ -283,7 +283,8 @@ var serviceViewSchema = serviceSchema.extend({
|
||||
domains: z.array(serviceDomainBindingSchema).default([]),
|
||||
health_status: ipHealthStateSchema.default("unknown"),
|
||||
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({
|
||||
services: z.array(serviceViewSchema).default([]),
|
||||
@@ -558,6 +559,10 @@ var updateServiceGroupSchema = z.object({
|
||||
var toggleEnabledSchema = z.object({
|
||||
enabled: z.boolean()
|
||||
});
|
||||
var toggleServiceIpSchema = z.object({
|
||||
ip: ipv4Schema,
|
||||
enabled: z.boolean()
|
||||
});
|
||||
var reorderServicesSchema = z.object({
|
||||
group_id: z.union([z.number(), z.null()]).optional().default(null),
|
||||
service_ids: z.array(z.number().int().positive()).min(1)
|
||||
@@ -693,7 +698,20 @@ var appSettingsPatchSchema = z3.object({
|
||||
vpsTrackerUrl: z3.string().url().or(z3.literal("")).optional(),
|
||||
vpsTrackerIntegrationToken: z3.string().optional(),
|
||||
vpsTrackerSyncEnabled: z3.boolean().optional(),
|
||||
showQuickActions: z3.boolean().optional()
|
||||
showQuickActions: z3.boolean().optional(),
|
||||
healthCheckCron: z3.string().trim().min(1).max(64).optional(),
|
||||
healthDegradedFailures: z3.number().int().min(1).max(20).optional(),
|
||||
healthDownFailures: z3.number().int().min(1).max(50).optional(),
|
||||
healthLatencyWarnMs: z3.number().int().min(50).max(6e4).optional(),
|
||||
healthSuccessRecoveries: z3.number().int().min(1).max(20).optional()
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.healthDegradedFailures != null && data.healthDownFailures != null && data.healthDownFailures < data.healthDegradedFailures) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "\u043E\u0448\u0438\u0431\u043E\u043A \u0434\u043E down \u043D\u0435 \u043C\u0435\u043D\u044C\u0448\u0435, \u0447\u0435\u043C \u0434\u043E degraded",
|
||||
path: ["healthDownFailures"]
|
||||
});
|
||||
}
|
||||
});
|
||||
var vpsTrackerEventSchema = z3.object({
|
||||
event: z3.enum(["vps_down", "vps_up"]),
|
||||
@@ -857,6 +875,7 @@ export {
|
||||
subdomainLabelToFqdn,
|
||||
subdomainSchema,
|
||||
toggleEnabledSchema,
|
||||
toggleServiceIpSchema,
|
||||
updateDomainGroupSchema,
|
||||
updateDomainSchema,
|
||||
updateServiceConfigSchema,
|
||||
|
||||
@@ -30,6 +30,23 @@ export const appSettingsPatchSchema = z.object({
|
||||
vpsTrackerIntegrationToken: z.string().optional(),
|
||||
vpsTrackerSyncEnabled: z.boolean().optional(),
|
||||
showQuickActions: z.boolean().optional(),
|
||||
healthCheckCron: z.string().trim().min(1).max(64).optional(),
|
||||
healthDegradedFailures: z.number().int().min(1).max(20).optional(),
|
||||
healthDownFailures: z.number().int().min(1).max(50).optional(),
|
||||
healthLatencyWarnMs: z.number().int().min(50).max(60_000).optional(),
|
||||
healthSuccessRecoveries: z.number().int().min(1).max(20).optional(),
|
||||
}).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>;
|
||||
|
||||
@@ -159,6 +159,7 @@ export const serviceViewSchema = serviceSchema.extend({
|
||||
health_status: ipHealthStateSchema.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({
|
||||
@@ -521,6 +522,13 @@ export const toggleEnabledSchema = z.object({
|
||||
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({
|
||||
group_id: z.union([z.number(), z.null()]).optional().default(null),
|
||||
service_ids: z.array(z.number().int().positive()).min(1),
|
||||
|
||||
@@ -197,6 +197,7 @@ export interface ServiceView {
|
||||
health_status: IpHealthState;
|
||||
health_latency_ms: number | null;
|
||||
ip_health: ServiceIpHealth[];
|
||||
ip_enabled: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface GroupWithStats extends Group {
|
||||
|
||||
Reference in New Issue
Block a user