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
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:
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user