feat: Implement load balancing and health check features for service groups, including DNS-based load balancing modes and health check configurations, enhancing service reliability and performance monitoring
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m0s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m13s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Failing after 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

This commit is contained in:
Denozordec
2026-06-25 18:24:00 +07:00
parent e5dc483d43
commit 6a1498bf80
35 changed files with 5312 additions and 281 deletions
+84 -3
View File
@@ -27,6 +27,14 @@ export const serviceGroupSchema = z.object({
icon: z.string().nullable(),
domain: z.string().nullable(),
enabled: z.boolean(),
lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
health_check_enabled: z.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_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
created_at: z.string(),
updated_at: z.string(),
})
@@ -39,6 +47,8 @@ export const serviceSchema = z.object({
subdomain: z.string().optional(),
enabled: z.boolean().optional(),
computed_fqdn: z.string().nullable().optional(),
lb_weight: z.number().default(1),
lb_priority: z.number().default(1),
created_at: z.string(),
updated_at: z.string(),
})
@@ -53,7 +63,17 @@ 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_cname: z.string().nullable().optional(),
lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
health_check_enabled: z.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_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
sync_status: z.string().nullable(),
})
.transform((binding) => ({
@@ -64,6 +84,8 @@ export const serviceDomainBindingSchema = z
: binding.target_ip
? [binding.target_ip]
: [],
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
target_cname: binding.target_cname?.trim() || null,
record_type: binding.target_cname?.trim()
? ('CNAME' as const)
@@ -117,6 +139,16 @@ 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(),
lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
health_check_enabled: z.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_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
sync_status: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
@@ -129,6 +161,8 @@ export const serviceBindingSchema = z
: binding.target_ip
? [binding.target_ip]
: [],
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
}))
export const dnsRecordSchema = z.object({
@@ -187,11 +221,28 @@ const ipv4Schema = z
'╨Э╨╡╨║╨╛╤А╤А╨╡╨║╤В╨╜╤Л╨╣ IPv4',
)
const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted'])
const healthCheckTypeSchema = z.enum(['tcp', 'http'])
const healthCheckConfigFields = {
health_check_enabled: z.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(),
}
const serviceDomainInputSchema = z
.object({
fqdn: z.string().min(1, 'Укажите FQDN'),
target_ips: z.array(ipv4Schema).optional(),
target_cname: z.string().min(1, 'Укажите CNAME-цель').optional(),
target_ip_weights: z.record(ipv4Schema, z.number().int().min(1).max(100)).optional(),
target_ip_priorities: z.record(ipv4Schema, z.number().int().min(1).max(100)).optional(),
lb_mode: lbModeSchema.optional(),
...healthCheckConfigFields,
})
.superRefine((data, ctx) => {
const hasIps = (data.target_ips?.length ?? 0) > 0
@@ -220,6 +271,8 @@ export const createServiceSchema = z.object({
export const createServiceWithConfigSchema = createServiceSchema.extend({
service_group_id: z.number().nullable().optional(),
ips: z.array(ipv4Schema).default([]),
lb_weight: z.number().int().min(1).max(100).optional(),
lb_priority: z.number().int().min(1).max(100).optional(),
domains: z.array(serviceDomainInputSchema).default([]),
})
@@ -258,10 +311,12 @@ export type CreateServiceInput = z.infer<typeof createServiceSchema>
export type CreateServiceWithConfigInput = z.infer<typeof createServiceWithConfigSchema>
export const updateServiceConfigSchema = z.object({
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡').optional(),
slug: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ slug').optional(),
name: z.string().min(1, 'name').optional(),
slug: z.string().min(1, 'slug').optional(),
service_group_id: z.number().nullable().optional(),
ips: z.array(ipv4Schema).optional(),
lb_weight: z.number().int().min(1).max(100).optional(),
lb_priority: z.number().int().min(1).max(100).optional(),
domains: z
.array(serviceDomainInputSchema)
.optional(),
@@ -269,13 +324,39 @@ export const updateServiceConfigSchema = z.object({
export type UpdateServiceConfigInput = z.infer<typeof updateServiceConfigSchema>
export const updateServiceGroupSchema = z.object({
name: z.string().min(1, 'name').optional(),
type: serviceGroupTypeSchema.optional(),
icon: z.string().nullable().optional(),
domain: z.string().nullable().optional(),
lb_mode: lbModeSchema.optional(),
...healthCheckConfigFields,
})
export type UpdateServiceGroupInput = z.infer<typeof updateServiceGroupSchema>
export const createServiceGroupSchema = z.object({
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
name: z.string().min(1, 'name'),
type: serviceGroupTypeSchema.default('custom'),
icon: z.string().nullable().optional(),
domain: z.string().nullable().optional(),
lb_mode: lbModeSchema.optional(),
...healthCheckConfigFields,
})
export const ipHealthStatusSchema = z.object({
scope: z.enum(['binding', 'group']),
ref_id: z.number(),
ip: z.string(),
status: z.enum(['up', 'down', 'degraded', 'unknown']),
latency_ms: z.number().nullable(),
consecutive_failures: z.number(),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable(),
})
export type IpHealthStatus = z.infer<typeof ipHealthStatusSchema>
export const toggleEnabledSchema = z.object({
enabled: z.boolean(),
})
+106
View File
@@ -0,0 +1,106 @@
import { useQuery } from '@tanstack/react-query'
import { healthStatusQueryOptions } from '@/queries'
import type { IpHealthStatus } from '@/lib/schemas'
export type HealthScope = 'binding' | 'group'
export type AggregatedHealthStatus =
| 'up'
| 'degraded'
| 'down'
| 'unknown'
const statusRank: Record<AggregatedHealthStatus, number> = {
up: 0,
unknown: 1,
degraded: 2,
down: 3,
}
export interface AggregatedHealth {
status: AggregatedHealthStatus
upCount: number
downCount: number
degradedCount: number
unknownCount: number
total: number
worstLatencyMs: number | null
lastCheckedAt: string | null
lastError: string | null
}
function emptyAggregated(): AggregatedHealth {
return {
status: 'unknown',
upCount: 0,
downCount: 0,
degradedCount: 0,
unknownCount: 0,
total: 0,
worstLatencyMs: null,
lastCheckedAt: null,
lastError: null,
}
}
export function aggregateHealth(rows: IpHealthStatus[]): AggregatedHealth {
if (rows.length === 0) return emptyAggregated()
const counts = { up: 0, degraded: 0, down: 0, unknown: 0 }
let worstLatencyMs: number | null = null
let lastCheckedAt: string | null = null
let lastError: string | null = null
let worstStatus: AggregatedHealthStatus = 'up'
for (const row of rows) {
const status = row.status as AggregatedHealthStatus
counts[status] = (counts[status] ?? 0) + 1
if (statusRank[status] > statusRank[worstStatus]) worstStatus = status
if (row.latency_ms != null) {
if (worstLatencyMs == null || row.latency_ms > worstLatencyMs) {
worstLatencyMs = row.latency_ms
}
}
if (row.last_checked_at) {
if (!lastCheckedAt || row.last_checked_at > lastCheckedAt) {
lastCheckedAt = row.last_checked_at
}
}
if (row.last_error && !lastError) lastError = row.last_error
}
return {
status: worstStatus,
upCount: counts.up,
degradedCount: counts.degraded,
downCount: counts.down,
unknownCount: counts.unknown,
total: rows.length,
worstLatencyMs,
lastCheckedAt,
lastError,
}
}
export function useAggregatedHealth(
scope: HealthScope,
refId: number | null | undefined,
enabled: boolean,
) {
return useQuery({
...healthStatusQueryOptions(
scope,
refId ?? 0,
),
enabled: enabled && refId != null,
select: aggregateHealth,
})
}
export function useHealthRows(
scope: HealthScope,
refId: number | null | undefined,
enabled: boolean,
) {
return useQuery({
...healthStatusQueryOptions(scope, refId ?? 0),
enabled: enabled && refId != null,
})
}