Enhance service management by adding service groups functionality; introduce new schemas for service groups and views; update API routes and handlers for service groups; implement service group creation and update logic; refactor service queries to support grouping; add new dependencies in pnpm-lock.yaml for improved UI components.
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled

This commit is contained in:
Denozordec
2026-06-19 11:19:22 +07:00
parent 5f7b3ac388
commit 0a0a1a92f1
1321 changed files with 10817 additions and 317 deletions
+53
View File
@@ -0,0 +1,53 @@
export interface ParsedFqdn {
zoneName: string
hostname: string
fqdn: string
}
export function fqdnToDisplay(hostname: string, zoneName: string): string {
if (hostname === '@') {
return zoneName
}
return `${hostname}.${zoneName}`
}
export function parseFqdn(fqdn: string, knownZones: string[]): ParsedFqdn | null {
const normalized = fqdn.trim().toLowerCase()
if (!normalized) {
return null
}
const zones = [...knownZones].sort((a, b) => b.length - a.length)
for (const zone of zones) {
const zoneLower = zone.toLowerCase()
if (normalized === zoneLower) {
return {
zoneName: zone,
hostname: '@',
fqdn: fqdnToDisplay('@', zone),
}
}
const suffix = `.${zoneLower}`
if (normalized.endsWith(suffix)) {
const prefix = normalized.slice(0, -suffix.length)
if (prefix) {
return {
zoneName: zone,
hostname: prefix,
fqdn: fqdnToDisplay(prefix, zone),
}
}
}
}
return null
}
export function bindingToFqdn(binding: {
hostname: string
zone_name: string
fqdn?: string
}): string {
return binding.fqdn ?? fqdnToDisplay(binding.hostname, binding.zone_name)
}
+110
View File
@@ -12,14 +12,74 @@ export const groupWithStatsSchema = groupSchema.extend({
domain_count: z.number(),
})
export const serviceGroupTypeSchema = z.enum([
'vpn',
'network',
'internet',
'bgp',
'custom',
])
export const serviceGroupSchema = z.object({
id: z.number(),
name: z.string(),
type: serviceGroupTypeSchema.catch('custom'),
icon: z.string().nullable(),
domain: z.string().nullable(),
enabled: z.boolean(),
created_at: z.string(),
updated_at: z.string(),
})
export const serviceSchema = z.object({
id: z.number(),
name: z.string(),
slug: z.string(),
service_group_id: z.number().nullable().optional(),
subdomain: z.string().optional(),
enabled: z.boolean().optional(),
computed_fqdn: z.string().nullable().optional(),
created_at: z.string(),
updated_at: z.string(),
})
export const serviceDomainBindingSchema = z
.object({
binding_id: z.number(),
domain_id: z.number(),
zone_name: z.string(),
hostname: z.string(),
fqdn: z.string(),
target_ips: z.array(z.string()).optional(),
target_ip: z.string().nullable().optional(),
sync_status: z.string().nullable(),
})
.transform((binding) => ({
...binding,
target_ips:
binding.target_ips && binding.target_ips.length > 0
? binding.target_ips
: binding.target_ip
? [binding.target_ip]
: [],
}))
export const serviceViewSchema = serviceSchema.extend({
subdomain: z.string().default(''),
enabled: z.boolean().default(false),
ips: z.array(z.string()).default([]),
domains: z.array(serviceDomainBindingSchema).default([]),
})
export const serviceGroupViewSchema = serviceGroupSchema.extend({
services: z.array(serviceViewSchema).default([]),
})
export const serviceGroupsResponseSchema = z.object({
groups: z.array(serviceGroupViewSchema).default([]),
ungrouped: z.array(serviceViewSchema).default([]),
})
export const domainSchema = z.object({
id: z.number(),
group_id: z.number().nullable(),
@@ -86,6 +146,11 @@ export const certificateSchema = z.object({
export type Group = z.infer<typeof groupSchema>
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
export type Service = z.infer<typeof serviceSchema>
export type ServiceDomainBinding = z.infer<typeof serviceDomainBindingSchema>
export type ServiceView = z.infer<typeof serviceViewSchema>
export type ServiceGroup = z.infer<typeof serviceGroupSchema>
export type ServiceGroupView = z.infer<typeof serviceGroupViewSchema>
export type ServiceGroupsResponse = z.infer<typeof serviceGroupsResponseSchema>
export type Domain = z.infer<typeof domainSchema>
export type DomainListItem = z.infer<typeof domainListItemSchema>
export type ServiceBinding = z.infer<typeof serviceBindingSchema>
@@ -97,11 +162,29 @@ export const createGroupSchema = z.object({
slug: z.string().min(1, 'Укажите slug'),
})
const ipv4Schema = z
.string()
.regex(
/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/,
'Некорректный IPv4',
)
const serviceDomainInputSchema = z.object({
fqdn: z.string().min(1, 'Укажите FQDN'),
target_ips: z.array(ipv4Schema).min(1, 'Выберите хотя бы один IP'),
})
export const createServiceSchema = z.object({
name: z.string().min(1, 'Укажите название'),
slug: z.string().min(1, 'Укажите slug'),
})
export const createServiceWithConfigSchema = createServiceSchema.extend({
service_group_id: z.number().nullable().optional(),
ips: z.array(ipv4Schema).default([]),
domains: z.array(serviceDomainInputSchema).default([]),
})
export const createServiceBindingSchema = z.object({
domain_id: z.string().min(1, 'Выберите домен'),
service_id: z.string().min(1, 'Выберите сервис'),
@@ -134,6 +217,33 @@ export const createDnsRecordSchema = z.object({
export type CreateGroupInput = z.infer<typeof createGroupSchema>
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(),
service_group_id: z.number().nullable().optional(),
ips: z.array(ipv4Schema).optional(),
domains: z
.array(serviceDomainInputSchema)
.optional(),
})
export type UpdateServiceConfigInput = z.infer<typeof updateServiceConfigSchema>
export const createServiceGroupSchema = z.object({
name: z.string().min(1, 'Укажите название'),
type: serviceGroupTypeSchema.default('custom'),
icon: z.string().nullable().optional(),
domain: z.string().nullable().optional(),
})
export const toggleEnabledSchema = z.object({
enabled: z.boolean(),
})
export type CreateServiceGroupInput = z.infer<typeof createServiceGroupSchema>
export type ToggleEnabledInput = z.infer<typeof toggleEnabledSchema>
export type CreateServiceBindingInput = z.infer<typeof createServiceBindingSchema>
export type CreateDomainInput = z.infer<typeof createDomainSchema>
export type LoginInput = z.infer<typeof loginSchema>
+21
View File
@@ -0,0 +1,21 @@
import { bindingToFqdn } from '@/lib/parse-fqdn'
import type { ServiceView } from '@/lib/schemas'
export function serviceDisplayFqdn(service: ServiceView): string {
const first = service.domains?.[0]
if (first) {
return bindingToFqdn(first)
}
return '—'
}
export function aggregateServiceSyncStatus(service: ServiceView): string | null {
const statuses = (service.domains ?? [])
.map((domain) => domain.sync_status)
.filter((status): status is string => Boolean(status))
if (statuses.length === 0) return null
if (statuses.includes('error')) return 'error'
if (statuses.includes('pending_push')) return 'pending_push'
if (statuses.every((status) => status === 'synced')) return 'synced'
return statuses[0]
}