refactor(services): update service creation logic and enhance service group schema
- Changed service creation to default `enabled` to true in the database schema. - Updated service group schema to set default values for `icon`, `domain`, and health check parameters. - Refactored service routes to use `await` for fetching service views, ensuring proper asynchronous handling. - Improved error handling in service groups query to provide clearer feedback on response validation. This commit enhances the overall service management experience by ensuring services are enabled by default and improving the robustness of the service group schema.
This commit is contained in:
+21
-21
@@ -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(),
|
||||
|
||||
@@ -20,7 +20,19 @@ export const serviceGroupsQueryOptions = () =>
|
||||
queryKey: serviceGroupKeys.all,
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown>('/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
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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<ServiceView>(`/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<ServiceView>(`/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 (
|
||||
<PageShell>
|
||||
<PageHeader title="Сервисы" description={pageDescription} />
|
||||
|
||||
Reference in New Issue
Block a user