fix(services): сохранять источники health-check и агрегацию в форме сервиса
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 9s
quality / changes (push) Successful in 12s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m14s
quality / api (push) Successful in 53s
CD / quality (push) Successful in 2m23s
CD / publish (push) Successful in 2m5s

GET отдавал sqlite-типы (0/1 и JSON-строка), Zod отклонял PATCH; форма сбрасывалась на refetch.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-20 01:03:00 +07:00
co-authored by Cursor
parent 78811bc9b1
commit 5d84c7bf6c
11 changed files with 385 additions and 84 deletions
+84 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { serviceGroupsResponseSchema } from "@cfdm/shared";
import { serviceGroupsResponseSchema, updateServiceConfigSchema } from "@cfdm/shared";
import { repos } from "@cfdm/db";
import type { CloudflareClient } from "../src/lib/cf-client.js";
import { buildApp } from "../src/app.js";
@@ -51,6 +51,26 @@ async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
}
describe("create service then list groups", () => {
it("accepts sqlite-shaped health fields on service config PATCH", () => {
const parsed = updateServiceConfigSchema.parse({
domains: [
{
fqdn: "gw.example.com",
target_ips: ["1.2.3.4"],
health_check_enabled: 1,
health_check_verify_tls: 0,
health_check_providers: '["local","cloudflare"]',
health_check_aggregate: "majority",
},
],
});
expect(parsed.domains?.[0]?.health_check_enabled).toBe(true);
expect(parsed.domains?.[0]?.health_check_verify_tls).toBe(false);
expect(parsed.domains?.[0]?.health_check_providers).toEqual([
"local",
"cloudflare",
]);
});
it("create + updateConfig then listGroupViews parses with shared Zod schema", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
@@ -133,6 +153,69 @@ describe("create service then list groups", () => {
await app.close();
});
it("PATCH /services/:id persists health providers and aggregate", 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 createRes = await app.inject({
method: "POST",
url: "/api/v1/services",
headers,
payload: { name: "GW", slug: "gw" },
});
expect(createRes.statusCode).toBe(200);
const created = createRes.json() as { id: number };
await updateConfig(app.db, cf, created.id, {
ips: ["1.2.3.4"],
domains: [
{
fqdn: "gw.example.com",
target_ips: ["1.2.3.4"],
health_check_enabled: true,
health_check_type: "tcp",
health_check_interval_sec: 30,
health_check_timeout_ms: 3000,
health_check_providers: ["local", "cloudflare"],
health_check_aggregate: "majority",
},
],
});
const stored = repos.listBindingsByService(app.db, created.id)[0]!;
expect(stored.health_check_enabled).toBe(true);
expect(Array.isArray(stored.health_check_providers)).toBe(true);
expect(stored.health_check_providers).toEqual(["local", "cloudflare"]);
expect(stored.health_check_aggregate).toBe("majority");
const getRes = await app.inject({
method: "GET",
url: `/api/v1/services/${created.id}`,
headers,
});
expect(getRes.statusCode).toBe(200);
const view = getRes.json() as {
domains: Array<{
health_check_enabled: boolean;
health_check_providers: string[];
health_check_aggregate: string;
}>;
};
expect(view.domains[0]?.health_check_enabled).toBe(true);
expect(view.domains[0]?.health_check_providers).toEqual([
"local",
"cloudflare",
]);
expect(view.domains[0]?.health_check_aggregate).toBe("majority");
await app.close();
});
it("POST /services returns resolved ServiceView with numeric id", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
@@ -28,7 +28,7 @@ import {
type HealthAggregate,
type HealthProvider,
} from '@/components/reui-kit/health-source-tiles'
import { uniqueHealthProviders } from '@cfdm/shared'
import { parseHealthProviders } from '@cfdm/shared'
export type LbMode = 'round_robin' | 'failover' | 'weighted'
export type HealthCheckType = 'tcp' | 'http'
@@ -116,10 +116,10 @@ export function HealthCheckConfigFields({
onChange({ ...value, ...next })
}
const providers =
value.providers?.length > 0
? uniqueHealthProviders(value.providers)
: uniqueHealthProviders([value.provider ?? 'local'])
const providers = parseHealthProviders(
value.providers,
value.provider ?? 'local',
)
const aggregate = value.aggregate ?? 'majority'
const isHttp = value.type === 'http'
const rowClass = 'gap-3 px-0 py-3'
@@ -14,7 +14,7 @@ import {
} from '@cfdm/ui/components/item'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { cn } from '@cfdm/ui/lib/utils'
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
import { parseHealthProviders, type HealthCheckAggregate, type HealthCheckProvider } from '@cfdm/shared'
import type { HealthLogStatus } from '@/lib/health-log'
export type HealthProvider = HealthCheckProvider
@@ -189,7 +189,7 @@ export function HealthSourceTiles({
value: HealthProvider[]
onChange: (next: HealthProvider[]) => void
}) {
const selected = value.length > 0 ? value : (['local'] as HealthProvider[])
const selected = parseHealthProviders(value)
function toggle(id: HealthProvider) {
if (selected.includes(id)) {
+10 -7
View File
@@ -18,6 +18,7 @@ import type {
ServiceView,
UpdateServiceConfigInput,
} from '@/lib/schemas'
import { parseHealthProviders } from '@cfdm/shared'
import { bindingToFqdn, parseFqdn } from '@/lib/parse-fqdn'
import { Badge } from '@/components/reui/badge'
import { toast } from 'sonner'
@@ -108,19 +109,19 @@ function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
target_cname: binding.target_cname ?? '',
lb_mode: binding.lb_mode,
health: {
enabled: binding.health_check_enabled,
enabled: Boolean(binding.health_check_enabled),
type: binding.health_check_type === 'http' ? 'http' : 'tcp',
port: binding.health_check_port,
path: binding.health_check_path,
expected_status: binding.health_check_expected_status,
interval_sec: binding.health_check_interval_sec,
timeout_ms: binding.health_check_timeout_ms,
verify_tls: binding.health_check_verify_tls ?? false,
verify_tls: Boolean(binding.health_check_verify_tls),
provider: binding.health_check_provider ?? 'local',
providers:
binding.health_check_providers?.length > 0
? binding.health_check_providers
: [binding.health_check_provider ?? 'local'],
providers: parseHealthProviders(
binding.health_check_providers,
binding.health_check_provider ?? 'local',
),
aggregate: binding.health_check_aggregate ?? 'majority',
},
target_ip_weights: binding.target_ip_weights ?? {},
@@ -232,6 +233,8 @@ export function ServiceEditSheet({
[groups],
)
// Reset only when the sheet opens or the service id changes.
// Health polling replaces `service` by identity and would wipe unsaved settings.
useEffect(() => {
if (!open) return
if (mode === 'edit' && service) {
@@ -260,7 +263,7 @@ export function ServiceEditSheet({
setLbWeight(1)
setLbPriority(1)
}
}, [open, mode, service, defaultGroupId])
}, [open, mode, service?.id, defaultGroupId])
const zoneHints = useMemo(
() => knownDomains.map((domain) => domain.zone_name),
+2 -2
View File
@@ -291,14 +291,14 @@ const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted'])
const healthCheckTypeSchema = z.enum(['tcp', 'http', 'ping', 'dns'])
const healthCheckConfigFields = {
health_check_enabled: z.boolean().optional(),
health_check_enabled: z.coerce.boolean().optional(),
health_check_type: healthCheckTypeSchema.optional(),
health_check_port: z.number().int().min(1).max(65535).nullable().optional(),
health_check_path: z.string().nullable().optional(),
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
health_check_verify_tls: z.boolean().optional(),
health_check_verify_tls: z.coerce.boolean().optional(),
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).optional(),
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).optional(),
health_check_aggregate: z.enum(['any', 'all', 'majority']).optional(),