diff --git a/apps/api/src/routes/services.ts b/apps/api/src/routes/services.ts index e8e5698..2f0bda7 100644 --- a/apps/api/src/routes/services.ts +++ b/apps/api/src/routes/services.ts @@ -40,7 +40,7 @@ export async function serviceRoutes(app: FastifyInstance) { body.service_group_id, ); } - const view = serviceConfig.getView(request.server.db, service.id); + const view = await serviceConfig.getView(request.server.db, service.id); recordAudit(request.server, request, { action: "service.create", targetType: "app_resource", @@ -56,6 +56,7 @@ export async function serviceRoutes(app: FastifyInstance) { return serviceConfig.getView(request.server.db, Number(id)); }); + app.patch("/services/:id", async (request) => { const { id } = request.params as { id: string }; const body = updateServiceConfigSchema.parse(request.body); @@ -77,7 +78,7 @@ export async function serviceRoutes(app: FastifyInstance) { app.delete("/services/:id", async (request) => { const { id } = request.params as { id: string }; - const view = serviceConfig.getView(request.server.db, Number(id)); + const view = await serviceConfig.getView(request.server.db, Number(id)); repos.deleteService(request.server.db, Number(id)); recordAudit(request.server, request, { action: "service.delete", diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index bb65f1e..1522428 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -823,49 +823,6 @@ async function findOrImportDnsARecord( ); } -async function serviceBindingsExistInDns( - db: Db, - cf: CloudflareClient, - serviceId: number, -): Promise { - const bindings = repos.listBindingsByService(db, serviceId); - if (bindings.length === 0) return false; - - for (const binding of bindings) { - const cnameTarget = binding.cname_target?.trim() || null; - if (cnameTarget) { - const record = await findOrImportDnsRecord( - db, - cf, - binding.domain_id, - binding.zone_name, - binding.hostname, - "CNAME", - cnameTarget, - ); - if (!record) return false; - continue; - } - - const targetIps = repos.listBindingIps(db, binding.id); - if (targetIps.length === 0) return false; - - for (const ip of targetIps) { - const record = await findOrImportDnsARecord( - db, - cf, - binding.domain_id, - binding.zone_name, - binding.hostname, - ip, - ); - if (!record) return false; - } - } - - return true; -} - async function syncServiceBindingsToDns( db: Db, cf: CloudflareClient, @@ -1281,20 +1238,24 @@ export async function updateConfig( service = repos.getService(db, id); const remainingBindings = repos.listBindingsByService(db, id); + + // Creating/editing with FQDN bindings should publish DNS. Previously a + // disabled service stayed off unless records already existed in Cloudflare + // (chicken-and-egg for brand-new services). + if ( + req.domains && + req.domains.length > 0 && + !service.enabled + ) { + repos.setServiceEnabled(db, id, true); + service = repos.getService(db, id); + } + if (shouldPushDns(db, service)) { if (remainingBindings.length > 0) { await syncServiceBindingsToDns(db, cf, id); } await syncGroupDomainForService(db, cf, id); - } else if ( - req.domains && - req.domains.length > 0 && - !service.enabled && - (await serviceBindingsExistInDns(db, cf, id)) - ) { - repos.setServiceEnabled(db, id, true); - await syncServiceBindingsToDns(db, cf, id); - await syncGroupDomainForService(db, cf, id); } void syncServiceToVpsTracker(db, id, removedBindingIds); diff --git a/apps/api/test/services-create-list.test.ts b/apps/api/test/services-create-list.test.ts new file mode 100644 index 0000000..b94eec7 --- /dev/null +++ b/apps/api/test/services-create-list.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { serviceGroupsResponseSchema } from "@cfdm/shared"; +import { repos } from "@cfdm/db"; +import type { CloudflareClient } from "../src/lib/cf-client.js"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; +import { + listGroupViews, + updateConfig, +} from "../src/services/service-config-service.js"; + +function mockCf(): CloudflareClient { + return { + listDnsRecords: async () => [], + createDnsRecord: async (_zoneId: string, payload: { type: string; name: string; content: string }) => ({ + id: `cf-${payload.name}`, + type: payload.type, + name: payload.name, + content: payload.content, + ttl: 1, + proxied: false, + }), + updateDnsRecord: async ( + _zoneId: string, + id: string, + payload: { type: string; name: string; content: string }, + ) => ({ + id, + type: payload.type, + name: payload.name, + content: payload.content, + ttl: 1, + proxied: false, + }), + deleteDnsRecord: async () => undefined, + verifyToken: async () => true, + listZones: async () => [{ id: "zone-1", name: "example.com", status: "active" }], + } as unknown as CloudflareClient; +} + +async function authHeaders(app: Awaited>) { + const config = loadConfig(); + const res = await app.inject({ + method: "POST", + url: "/api/v1/auth/login", + payload: { username: config.adminUsername, password: "admin" }, + }); + expect(res.statusCode).toBe(200); + const { token } = res.json() as { token: string }; + return { authorization: `Bearer ${token}` }; +} + +describe("create service then list groups", () => { + it("create + updateConfig then listGroupViews parses with shared Zod schema", 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", + service_group_id: group.id, + }, + }); + expect(createRes.statusCode).toBe(200); + const created = createRes.json() as { + id: number; + name: string; + enabled: boolean; + }; + expect(created.id).toBeTypeOf("number"); + expect(created.enabled).toBe(true); + + await updateConfig(app.db, cf, created.id, { + ips: ["1.2.3.4"], + service_group_id: group.id, + domains: [ + { + fqdn: "panel.example.com", + target_ips: ["1.2.3.4"], + target_ip_weights: { "1.2.3.4": 1 }, + target_ip_priorities: { "1.2.3.4": 1 }, + lb_mode: "round_robin", + health_check_enabled: true, + 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, + }, + ], + }); + + const body = await listGroupViews(app.db); + const parsed = serviceGroupsResponseSchema.safeParse(body); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + + const listRes = await app.inject({ + method: "GET", + url: "/api/v1/service-groups", + headers, + }); + expect(listRes.statusCode).toBe(200); + const httpParsed = serviceGroupsResponseSchema.safeParse(listRes.json()); + expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe( + true, + ); + expect( + httpParsed.data!.groups + .find((g) => g.id === group.id) + ?.services.some((s) => s.id === created.id), + ).toBe(true); + + await app.close(); + }); + + it("POST /services returns resolved ServiceView with numeric id", async () => { + const app = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(app); + + const createRes = await app.inject({ + method: "POST", + url: "/api/v1/services", + headers, + payload: { name: "Bare", slug: "bare" }, + }); + expect(createRes.statusCode).toBe(200); + const created = createRes.json() as Record; + expect(typeof created.id).toBe("number"); + expect(created.name).toBe("Bare"); + expect(created.enabled).toBe(true); + expect(Array.isArray(created.ips)).toBe(true); + expect(Array.isArray(created.domains)).toBe(true); + + const listRes = await app.inject({ + method: "GET", + url: "/api/v1/service-groups", + headers, + }); + expect(listRes.statusCode).toBe(200); + const parsed = serviceGroupsResponseSchema.safeParse(listRes.json()); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + expect(parsed.data!.ungrouped.some((s) => s.slug === "bare")).toBe(true); + + await app.close(); + }); +}); diff --git a/apps/web/src/lib/schemas.ts b/apps/web/src/lib/schemas.ts index 130662d..53a7150 100644 --- a/apps/web/src/lib/schemas.ts +++ b/apps/web/src/lib/schemas.ts @@ -24,15 +24,15 @@ export const serviceGroupSchema = z.object({ id: z.number(), name: z.string(), type: serviceGroupTypeSchema.catch('custom'), - icon: z.string().nullable(), - domain: z.string().nullable(), + icon: z.string().nullable().default(null), + domain: z.string().nullable().default(null), enabled: z.coerce.boolean(), lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'), health_check_enabled: z.coerce.boolean().default(false), - health_check_type: z.enum(['tcp', 'http']).catch('tcp'), - health_check_port: z.number().nullable(), - health_check_path: z.string().nullable(), - health_check_expected_status: z.number().nullable(), + health_check_type: z.enum(['tcp', 'http', 'ping', 'dns']).catch('tcp'), + health_check_port: z.number().nullable().default(null), + health_check_path: z.string().nullable().default(null), + health_check_expected_status: z.number().nullable().default(null), health_check_interval_sec: z.number().default(30), health_check_timeout_ms: z.number().default(3000), health_check_verify_tls: z.coerce.boolean().default(false), @@ -64,19 +64,19 @@ export const serviceDomainBindingSchema = z record_type: z.enum(['A', 'CNAME']).default('A'), target_ips: z.array(z.string()).optional(), target_ip: z.string().nullable().optional(), - target_ip_weights: z.record(z.string(), z.number()).optional(), - target_ip_priorities: z.record(z.string(), z.number()).optional(), + target_ip_weights: z.record(z.string(), z.coerce.number()).optional(), + target_ip_priorities: z.record(z.string(), z.coerce.number()).optional(), target_cname: z.string().nullable().optional(), lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'), health_check_enabled: z.coerce.boolean().default(false), - health_check_type: z.enum(['tcp', 'http']).catch('tcp'), - health_check_port: z.number().nullable(), - health_check_path: z.string().nullable(), - health_check_expected_status: z.number().nullable(), + health_check_type: z.enum(['tcp', 'http', 'ping', 'dns']).catch('tcp'), + health_check_port: z.number().nullable().default(null), + health_check_path: z.string().nullable().default(null), + health_check_expected_status: z.number().nullable().default(null), health_check_interval_sec: z.number().default(30), health_check_timeout_ms: z.number().default(3000), health_check_verify_tls: z.coerce.boolean().default(false), - sync_status: z.string().nullable(), + sync_status: z.string().nullable().default(null), }) .transform((binding) => ({ ...binding, @@ -149,18 +149,18 @@ export const serviceBindingSchema = z service_slug: z.string(), target_ip: z.string().nullable(), target_ips: z.array(z.string()).optional(), - target_ip_weights: z.record(z.string(), z.number()).optional(), - target_ip_priorities: z.record(z.string(), z.number()).optional(), + target_ip_weights: z.record(z.string(), z.coerce.number()).optional(), + target_ip_priorities: z.record(z.string(), z.coerce.number()).optional(), lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'), health_check_enabled: z.coerce.boolean().default(false), - health_check_type: z.enum(['tcp', 'http']).catch('tcp'), - health_check_port: z.number().nullable(), - health_check_path: z.string().nullable(), - health_check_expected_status: z.number().nullable(), + health_check_type: z.enum(['tcp', 'http', 'ping', 'dns']).catch('tcp'), + health_check_port: z.number().nullable().default(null), + health_check_path: z.string().nullable().default(null), + health_check_expected_status: z.number().nullable().default(null), health_check_interval_sec: z.number().default(30), health_check_timeout_ms: z.number().default(3000), health_check_verify_tls: z.coerce.boolean().default(false), - sync_status: z.string().nullable(), + sync_status: z.string().nullable().default(null), created_at: z.string(), updated_at: z.string(), }) @@ -233,7 +233,7 @@ const ipv4Schema = z ) const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted']) -const healthCheckTypeSchema = z.enum(['tcp', 'http']) +const healthCheckTypeSchema = z.enum(['tcp', 'http', 'ping', 'dns']) const healthCheckConfigFields = { health_check_enabled: z.boolean().optional(), diff --git a/apps/web/src/queries/services.ts b/apps/web/src/queries/services.ts index 953b3be..26f42f6 100644 --- a/apps/web/src/queries/services.ts +++ b/apps/web/src/queries/services.ts @@ -20,7 +20,19 @@ export const serviceGroupsQueryOptions = () => queryKey: serviceGroupKeys.all, queryFn: async () => { const data = await api.get('/api/v1/service-groups') - return serviceGroupsResponseSchema.parse(data) + const parsed = serviceGroupsResponseSchema.safeParse(data) + if (!parsed.success) { + const detail = parsed.error.issues + .slice(0, 3) + .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`) + .join('; ') + throw new Error( + detail + ? `Некорректный ответ /service-groups: ${detail}` + : 'Некорректный ответ /service-groups', + ) + } + return parsed.data }, }) diff --git a/apps/web/src/routes/_auth/services.tsx b/apps/web/src/routes/_auth/services.tsx index 66fb0f3..3cbb4fc 100644 --- a/apps/web/src/routes/_auth/services.tsx +++ b/apps/web/src/routes/_auth/services.tsx @@ -159,11 +159,22 @@ function ServicesPage() { data, isLoading, isError, + isRefetchError, error, refetch, } = useQuery(serviceGroupsQueryOptions()) const { data: domains } = useQuery(domainsListQueryOptions()) + // Refetch after create/update must not wipe the list when cached data remains. + useEffect(() => { + if (!isRefetchError || !data) return + toast.error( + error instanceof Error && error.message + ? error.message + : 'Не удалось обновить список сервисов', + ) + }, [isRefetchError, data, error]) + const dragDisabled = domainId != null const { @@ -219,18 +230,20 @@ function ServicesPage() { }) } - function invalidateAll() { - queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }) - queryClient.invalidateQueries({ queryKey: serviceKeys.all }) - queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }) - queryClient.invalidateQueries({ queryKey: domainKeys.all }) + async function invalidateAll() { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }), + queryClient.invalidateQueries({ queryKey: serviceKeys.all }), + queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }), + queryClient.invalidateQueries({ queryKey: domainKeys.all }), + ]) } const createGroupMutation = useMutation({ mutationFn: (body: CreateServiceGroupInput) => api.post('/api/v1/service-groups', body), - onSuccess: () => { - invalidateAll() + onSuccess: async () => { + await invalidateAll() setCreateGroupSheetOpen(false) toast.success('Группа создана') }, @@ -242,8 +255,8 @@ function ServicesPage() { const updateGroupMutation = useMutation({ mutationFn: ({ id, body }: { id: number; body: CreateServiceGroupInput }) => api.patch(`/api/v1/service-groups/${id}`, body), - onSuccess: () => { - invalidateAll() + onSuccess: async () => { + await invalidateAll() setEditingGroup(null) toast.success('Группа сохранена, DNS синхронизируется') }, @@ -259,16 +272,21 @@ function ServicesPage() { slug: body.slug, service_group_id: body.service_group_id ?? null, }) + if (created?.id == null) { + throw new Error('Сервер не вернул id созданного сервиса') + } const hasConfig = body.ips.length > 0 || body.domains.length > 0 if (!hasConfig) return created return api.patch(`/api/v1/services/${created.id}`, { ips: body.ips, domains: body.domains, service_group_id: body.service_group_id ?? null, + ...(body.lb_weight != null ? { lb_weight: body.lb_weight } : {}), + ...(body.lb_priority != null ? { lb_priority: body.lb_priority } : {}), }) }, - onSuccess: () => { - invalidateAll() + onSuccess: async () => { + await invalidateAll() setCreateSheetOpen(false) toast.success('Сервис создан') }, @@ -280,8 +298,8 @@ function ServicesPage() { const updateServiceMutation = useMutation({ mutationFn: ({ id, body }: { id: number; body: UpdateServiceConfigInput }) => api.patch(`/api/v1/services/${id}`, body), - onSuccess: () => { - invalidateAll() + onSuccess: async () => { + await invalidateAll() setEditingService(null) clearServiceSearch() toast.success('Сервис сохранён') @@ -296,8 +314,8 @@ function ServicesPage() { const deleteServiceMutation = useMutation({ mutationFn: (id: number) => api.delete(`/api/v1/services/${id}`), - onSuccess: () => { - invalidateAll() + onSuccess: async () => { + await invalidateAll() setEditingService(null) setDeletingService(null) clearServiceSearch() @@ -340,16 +358,16 @@ function ServicesPage() { : 'Сервис выключен, DNS-записи удалены из Cloudflare', ) }, - onSettled: () => { + onSettled: async () => { setTogglingServiceId(null) - invalidateAll() + await invalidateAll() }, }) const deleteGroupMutation = useMutation({ mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`), - onSuccess: () => { - invalidateAll() + onSuccess: async () => { + await invalidateAll() setDeletingGroup(null) toast.success('Группа удалена') }, @@ -430,7 +448,7 @@ function ServicesPage() { } setBulkToggling(false) - invalidateAll() + await invalidateAll() } const columnConfigs = useMemo( @@ -598,7 +616,7 @@ function ServicesPage() { ) } - if (isError) { + if (isError && !data) { return ( diff --git a/packages/db/dist/index.js b/packages/db/dist/index.js index f2a1a43..62df0e5 100644 --- a/packages/db/dist/index.js +++ b/packages/db/dist/index.js @@ -29,7 +29,7 @@ var services = sqliteTable("services", { { onDelete: "set null" } ), subdomain: text("subdomain"), - enabled: integer("enabled", { mode: "boolean" }).notNull().default(false), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), sort_order: integer("sort_order").notNull().default(0), lb_weight: integer("lb_weight").notNull().default(1), lb_priority: integer("lb_priority").notNull().default(1), @@ -932,7 +932,7 @@ function getService(db, id) { } function createService(db, name, slug) { const sortOrder = maxSortOrderInGroup(db, null) + 1; - const id = db.insert(services).values({ name, slug, subdomain: slug, sort_order: sortOrder }).returning({ id: services.id }).get().id; + const id = db.insert(services).values({ name, slug, subdomain: slug, sort_order: sortOrder, enabled: true }).returning({ id: services.id }).get().id; return getService(db, id); } function updateService(db, id, name, slug) { diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index d2233e5..9f8212b 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -649,7 +649,7 @@ export function createService(db: Db, name: string, slug: string): Service { const sortOrder = maxSortOrderInGroup(db, null) + 1; const id = db .insert(services) - .values({ name, slug, subdomain: slug, sort_order: sortOrder }) + .values({ name, slug, subdomain: slug, sort_order: sortOrder, enabled: true }) .returning({ id: services.id }) .get()!.id; return getService(db, id); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 5419fdb..db89901 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -28,7 +28,7 @@ export const services = sqliteTable("services", { { onDelete: "set null" }, ), subdomain: text("subdomain"), - enabled: integer("enabled", { mode: "boolean" }).notNull().default(false), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), sort_order: integer("sort_order").notNull().default(0), lb_weight: integer("lb_weight").notNull().default(1), lb_priority: integer("lb_priority").notNull().default(1), diff --git a/packages/shared/dist/index.d.ts b/packages/shared/dist/index.d.ts index 7049f79..2ef968c 100644 --- a/packages/shared/dist/index.d.ts +++ b/packages/shared/dist/index.d.ts @@ -304,8 +304,8 @@ declare const serviceGroupSchema: z.ZodObject<{ bgp: "bgp"; custom: "custom"; }>>; - icon: z.ZodNullable; - domain: z.ZodNullable; + icon: z.ZodDefault>; + domain: z.ZodDefault>; enabled: z.ZodCoercedBoolean; lb_mode: z.ZodCatch>; - health_check_port: z.ZodNullable; - health_check_path: z.ZodNullable; - health_check_expected_status: z.ZodNullable; + health_check_port: z.ZodDefault>; + health_check_path: z.ZodDefault>; + health_check_expected_status: z.ZodDefault>; health_check_interval_sec: z.ZodDefault; health_check_timeout_ms: z.ZodDefault; health_check_verify_tls: z.ZodDefault>; @@ -353,8 +353,8 @@ declare const serviceDomainBindingSchema: z.ZodPipe>; target_ips: z.ZodOptional>; target_ip: z.ZodOptional>; - target_ip_weights: z.ZodOptional>; - target_ip_priorities: z.ZodOptional>; + target_ip_weights: z.ZodOptional>>; + target_ip_priorities: z.ZodOptional>>; target_cname: z.ZodOptional>; lb_mode: z.ZodCatch>; - health_check_port: z.ZodNullable; - health_check_path: z.ZodNullable; - health_check_expected_status: z.ZodNullable; + health_check_port: z.ZodDefault>; + health_check_path: z.ZodDefault>; + health_check_expected_status: z.ZodDefault>; health_check_interval_sec: z.ZodDefault; health_check_timeout_ms: z.ZodDefault; health_check_verify_tls: z.ZodDefault>; - sync_status: z.ZodNullable; + sync_status: z.ZodDefault>; }, z.core.$strip>, z.ZodTransform<{ target_ips: string[]; target_ip_weights: Record; @@ -445,8 +445,8 @@ declare const serviceViewSchema: z.ZodObject<{ }>>; target_ips: z.ZodOptional>; target_ip: z.ZodOptional>; - target_ip_weights: z.ZodOptional>; - target_ip_priorities: z.ZodOptional>; + target_ip_weights: z.ZodOptional>>; + target_ip_priorities: z.ZodOptional>>; target_cname: z.ZodOptional>; lb_mode: z.ZodCatch>; - health_check_port: z.ZodNullable; - health_check_path: z.ZodNullable; - health_check_expected_status: z.ZodNullable; + health_check_port: z.ZodDefault>; + health_check_path: z.ZodDefault>; + health_check_expected_status: z.ZodDefault>; health_check_interval_sec: z.ZodDefault; health_check_timeout_ms: z.ZodDefault; health_check_verify_tls: z.ZodDefault>; - sync_status: z.ZodNullable; + sync_status: z.ZodDefault>; }, z.core.$strip>, z.ZodTransform<{ target_ips: string[]; target_ip_weights: Record; @@ -530,8 +530,8 @@ declare const serviceGroupViewSchema: z.ZodObject<{ bgp: "bgp"; custom: "custom"; }>>; - icon: z.ZodNullable; - domain: z.ZodNullable; + icon: z.ZodDefault>; + domain: z.ZodDefault>; enabled: z.ZodCoercedBoolean; lb_mode: z.ZodCatch>; - health_check_port: z.ZodNullable; - health_check_path: z.ZodNullable; - health_check_expected_status: z.ZodNullable; + health_check_port: z.ZodDefault>; + health_check_path: z.ZodDefault>; + health_check_expected_status: z.ZodDefault>; health_check_interval_sec: z.ZodDefault; health_check_timeout_ms: z.ZodDefault; health_check_verify_tls: z.ZodDefault>; @@ -578,8 +578,8 @@ declare const serviceGroupViewSchema: z.ZodObject<{ }>>; target_ips: z.ZodOptional>; target_ip: z.ZodOptional>; - target_ip_weights: z.ZodOptional>; - target_ip_priorities: z.ZodOptional>; + target_ip_weights: z.ZodOptional>>; + target_ip_priorities: z.ZodOptional>>; target_cname: z.ZodOptional>; lb_mode: z.ZodCatch>; - health_check_port: z.ZodNullable; - health_check_path: z.ZodNullable; - health_check_expected_status: z.ZodNullable; + health_check_port: z.ZodDefault>; + health_check_path: z.ZodDefault>; + health_check_expected_status: z.ZodDefault>; health_check_interval_sec: z.ZodDefault; health_check_timeout_ms: z.ZodDefault; health_check_verify_tls: z.ZodDefault>; - sync_status: z.ZodNullable; + sync_status: z.ZodDefault>; }, z.core.$strip>, z.ZodTransform<{ target_ips: string[]; target_ip_weights: Record; @@ -672,8 +672,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ bgp: "bgp"; custom: "custom"; }>>; - icon: z.ZodNullable; - domain: z.ZodNullable; + icon: z.ZodDefault>; + domain: z.ZodDefault>; enabled: z.ZodCoercedBoolean; lb_mode: z.ZodCatch>; - health_check_port: z.ZodNullable; - health_check_path: z.ZodNullable; - health_check_expected_status: z.ZodNullable; + health_check_port: z.ZodDefault>; + health_check_path: z.ZodDefault>; + health_check_expected_status: z.ZodDefault>; health_check_interval_sec: z.ZodDefault; health_check_timeout_ms: z.ZodDefault; health_check_verify_tls: z.ZodDefault>; @@ -720,8 +720,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ }>>; target_ips: z.ZodOptional>; target_ip: z.ZodOptional>; - target_ip_weights: z.ZodOptional>; - target_ip_priorities: z.ZodOptional>; + target_ip_weights: z.ZodOptional>>; + target_ip_priorities: z.ZodOptional>>; target_cname: z.ZodOptional>; lb_mode: z.ZodCatch>; - health_check_port: z.ZodNullable; - health_check_path: z.ZodNullable; - health_check_expected_status: z.ZodNullable; + health_check_port: z.ZodDefault>; + health_check_path: z.ZodDefault>; + health_check_expected_status: z.ZodDefault>; health_check_interval_sec: z.ZodDefault; health_check_timeout_ms: z.ZodDefault; health_check_verify_tls: z.ZodDefault>; - sync_status: z.ZodNullable; + sync_status: z.ZodDefault>; }, z.core.$strip>, z.ZodTransform<{ target_ips: string[]; target_ip_weights: Record; @@ -828,8 +828,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ }>>; target_ips: z.ZodOptional>; target_ip: z.ZodOptional>; - target_ip_weights: z.ZodOptional>; - target_ip_priorities: z.ZodOptional>; + target_ip_weights: z.ZodOptional>>; + target_ip_priorities: z.ZodOptional>>; target_cname: z.ZodOptional>; lb_mode: z.ZodCatch>; - health_check_port: z.ZodNullable; - health_check_path: z.ZodNullable; - health_check_expected_status: z.ZodNullable; + health_check_port: z.ZodDefault>; + health_check_path: z.ZodDefault>; + health_check_expected_status: z.ZodDefault>; health_check_interval_sec: z.ZodDefault; health_check_timeout_ms: z.ZodDefault; health_check_verify_tls: z.ZodDefault>; - sync_status: z.ZodNullable; + sync_status: z.ZodDefault>; }, z.core.$strip>, z.ZodTransform<{ target_ips: string[]; target_ip_weights: Record; @@ -967,8 +967,8 @@ declare const serviceBindingSchema: z.ZodPipe; target_ips: z.ZodOptional>; - target_ip_weights: z.ZodOptional>; - target_ip_priorities: z.ZodOptional>; + target_ip_weights: z.ZodOptional>>; + target_ip_priorities: z.ZodOptional>>; lb_mode: z.ZodCatch>; - health_check_port: z.ZodNullable; - health_check_path: z.ZodNullable; - health_check_expected_status: z.ZodNullable; + health_check_port: z.ZodDefault>; + health_check_path: z.ZodDefault>; + health_check_expected_status: z.ZodDefault>; health_check_interval_sec: z.ZodDefault; health_check_timeout_ms: z.ZodDefault; health_check_verify_tls: z.ZodDefault>; - sync_status: z.ZodNullable; + sync_status: z.ZodDefault>; created_at: z.ZodString; updated_at: z.ZodString; }, z.core.$strip>, z.ZodTransform<{ diff --git a/packages/shared/dist/index.js b/packages/shared/dist/index.js index 6608452..1c8275c 100644 --- a/packages/shared/dist/index.js +++ b/packages/shared/dist/index.js @@ -203,15 +203,15 @@ var serviceGroupSchema = z.object({ id: z.number(), name: z.string(), type: serviceGroupTypeSchema.catch("custom"), - icon: z.string().nullable(), - domain: z.string().nullable(), + icon: z.string().nullable().default(null), + domain: z.string().nullable().default(null), enabled: z.coerce.boolean(), lb_mode: lbModeSchema.catch("round_robin"), health_check_enabled: z.coerce.boolean().default(false), health_check_type: healthCheckTypeSchema.catch("tcp"), - health_check_port: z.number().nullable(), - health_check_path: z.string().nullable(), - health_check_expected_status: z.number().nullable(), + health_check_port: z.number().nullable().default(null), + health_check_path: z.string().nullable().default(null), + health_check_expected_status: z.number().nullable().default(null), health_check_interval_sec: z.number().default(30), health_check_timeout_ms: z.number().default(3e3), health_check_verify_tls: z.coerce.boolean().default(false), @@ -240,19 +240,19 @@ var serviceDomainBindingSchema = z.object({ record_type: z.enum(["A", "CNAME"]).default("A"), target_ips: z.array(z.string()).optional(), target_ip: z.string().nullable().optional(), - target_ip_weights: z.record(z.string(), z.number()).optional(), - target_ip_priorities: z.record(z.string(), z.number()).optional(), + target_ip_weights: z.record(z.string(), z.coerce.number()).optional(), + target_ip_priorities: z.record(z.string(), z.coerce.number()).optional(), target_cname: z.string().nullable().optional(), lb_mode: lbModeSchema.catch("round_robin"), health_check_enabled: z.coerce.boolean().default(false), health_check_type: healthCheckTypeSchema.catch("tcp"), - health_check_port: z.number().nullable(), - health_check_path: z.string().nullable(), - health_check_expected_status: z.number().nullable(), + health_check_port: z.number().nullable().default(null), + health_check_path: z.string().nullable().default(null), + health_check_expected_status: z.number().nullable().default(null), health_check_interval_sec: z.number().default(30), health_check_timeout_ms: z.number().default(3e3), health_check_verify_tls: z.coerce.boolean().default(false), - sync_status: z.string().nullable() + sync_status: z.string().nullable().default(null) }).transform((binding) => ({ ...binding, target_ips: binding.target_ips && binding.target_ips.length > 0 ? binding.target_ips : binding.target_ip ? [binding.target_ip] : [], @@ -310,18 +310,18 @@ var serviceBindingSchema = z.object({ service_slug: z.string(), target_ip: z.string().nullable(), target_ips: z.array(z.string()).optional(), - target_ip_weights: z.record(z.string(), z.number()).optional(), - target_ip_priorities: z.record(z.string(), z.number()).optional(), + target_ip_weights: z.record(z.string(), z.coerce.number()).optional(), + target_ip_priorities: z.record(z.string(), z.coerce.number()).optional(), lb_mode: lbModeSchema.catch("round_robin"), health_check_enabled: z.coerce.boolean().default(false), health_check_type: healthCheckTypeSchema.catch("tcp"), - health_check_port: z.number().nullable(), - health_check_path: z.string().nullable(), - health_check_expected_status: z.number().nullable(), + health_check_port: z.number().nullable().default(null), + health_check_path: z.string().nullable().default(null), + health_check_expected_status: z.number().nullable().default(null), health_check_interval_sec: z.number().default(30), health_check_timeout_ms: z.number().default(3e3), health_check_verify_tls: z.coerce.boolean().default(false), - sync_status: z.string().nullable(), + sync_status: z.string().nullable().default(null), created_at: z.string(), updated_at: z.string() }).transform((binding) => ({ diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 9e5a664..d1dfed1 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -59,15 +59,15 @@ export const serviceGroupSchema = z.object({ id: z.number(), name: z.string(), type: serviceGroupTypeSchema.catch('custom'), - icon: z.string().nullable(), - domain: z.string().nullable(), + icon: z.string().nullable().default(null), + domain: z.string().nullable().default(null), enabled: z.coerce.boolean(), lb_mode: lbModeSchema.catch('round_robin'), health_check_enabled: z.coerce.boolean().default(false), health_check_type: healthCheckTypeSchema.catch('tcp'), - health_check_port: z.number().nullable(), - health_check_path: z.string().nullable(), - health_check_expected_status: z.number().nullable(), + health_check_port: z.number().nullable().default(null), + health_check_path: z.string().nullable().default(null), + health_check_expected_status: z.number().nullable().default(null), health_check_interval_sec: z.number().default(30), health_check_timeout_ms: z.number().default(3000), health_check_verify_tls: z.coerce.boolean().default(false), @@ -99,19 +99,19 @@ export const serviceDomainBindingSchema = z record_type: z.enum(['A', 'CNAME']).default('A'), target_ips: z.array(z.string()).optional(), target_ip: z.string().nullable().optional(), - target_ip_weights: z.record(z.string(), z.number()).optional(), - target_ip_priorities: z.record(z.string(), z.number()).optional(), + target_ip_weights: z.record(z.string(), z.coerce.number()).optional(), + target_ip_priorities: z.record(z.string(), z.coerce.number()).optional(), target_cname: z.string().nullable().optional(), lb_mode: lbModeSchema.catch('round_robin'), health_check_enabled: z.coerce.boolean().default(false), health_check_type: healthCheckTypeSchema.catch('tcp'), - health_check_port: z.number().nullable(), - health_check_path: z.string().nullable(), - health_check_expected_status: z.number().nullable(), + health_check_port: z.number().nullable().default(null), + health_check_path: z.string().nullable().default(null), + health_check_expected_status: z.number().nullable().default(null), health_check_interval_sec: z.number().default(30), health_check_timeout_ms: z.number().default(3000), health_check_verify_tls: z.coerce.boolean().default(false), - sync_status: z.string().nullable(), + sync_status: z.string().nullable().default(null), }) .transform((binding) => ({ ...binding, @@ -184,18 +184,18 @@ export const serviceBindingSchema = z service_slug: z.string(), target_ip: z.string().nullable(), target_ips: z.array(z.string()).optional(), - target_ip_weights: z.record(z.string(), z.number()).optional(), - target_ip_priorities: z.record(z.string(), z.number()).optional(), + target_ip_weights: z.record(z.string(), z.coerce.number()).optional(), + target_ip_priorities: z.record(z.string(), z.coerce.number()).optional(), lb_mode: lbModeSchema.catch('round_robin'), health_check_enabled: z.coerce.boolean().default(false), health_check_type: healthCheckTypeSchema.catch('tcp'), - health_check_port: z.number().nullable(), - health_check_path: z.string().nullable(), - health_check_expected_status: z.number().nullable(), + health_check_port: z.number().nullable().default(null), + health_check_path: z.string().nullable().default(null), + health_check_expected_status: z.number().nullable().default(null), health_check_interval_sec: z.number().default(30), health_check_timeout_ms: z.number().default(3000), health_check_verify_tls: z.coerce.boolean().default(false), - sync_status: z.string().nullable(), + sync_status: z.string().nullable().default(null), created_at: z.string(), updated_at: z.string(), })