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
+334 -301
View File
@@ -1,131 +1,157 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useState } from 'react'
import { toast } from 'sonner'
import {
domainKeys,
domainsListQueryOptions,
serviceBindingKeys,
serviceBindingsQueryOptions,
serviceGroupKeys,
serviceGroupsQueryOptions,
serviceKeys,
servicesQueryOptions,
} from '@/queries'
import { api } from '@/lib/api-client'
import {
createServiceBindingSchema,
createServiceSchema,
type CreateServiceBindingInput,
type CreateServiceInput,
type ServiceBinding,
import type {
CreateServiceGroupInput,
CreateServiceWithConfigInput,
ServiceGroupView,
ServiceGroupsResponse,
ServiceView,
UpdateServiceConfigInput,
} from '@/lib/schemas'
import { PageHeader } from '@/components/page-header'
import { DataTableCard } from '@/components/data-table-card'
import { KanbanBoard } from '@/components/kanban-board'
import { ServiceBindingCard } from '@/components/service-binding-card'
import { ServiceEditSheet } from '@/components/service-edit-sheet'
import { ServiceGroupCard } from '@/components/service-group-card'
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
import { ServiceRow } from '@/components/service-row'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import {
Field,
FieldGroup,
FieldLabel,
} from '@cfdm/ui/components/field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@cfdm/ui/components/tabs'
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@cfdm/ui/components/empty'
import { ItemGroup } from '@cfdm/ui/components/item'
import { Spinner } from '@cfdm/ui/components/spinner'
export const Route = createFileRoute('/_auth/services')({
loader: ({ context: { queryClient } }) =>
Promise.all([
queryClient.ensureQueryData(servicesQueryOptions()),
queryClient.ensureQueryData(serviceBindingsQueryOptions()),
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
queryClient.ensureQueryData(domainsListQueryOptions()),
]),
component: ServicesPage,
})
function serviceColumnId(serviceId: number) {
return `service-${serviceId}`
function setServiceEnabled(
data: ServiceGroupsResponse,
serviceId: number,
enabled: boolean,
): ServiceGroupsResponse {
return {
groups: data.groups.map((group) => ({
...group,
services: group.services.map((service) =>
service.id === serviceId ? { ...service, enabled } : service,
),
})),
ungrouped: data.ungrouped.map((service) =>
service.id === serviceId ? { ...service, enabled } : service,
),
}
}
function parseServiceColumnId(columnId: string): number | null {
const match = columnId.match(/^service-(\d+)$/)
return match ? Number(match[1]) : null
function setGroupEnabled(
data: ServiceGroupsResponse,
groupId: number,
enabled: boolean,
): ServiceGroupsResponse {
return {
...data,
groups: data.groups.map((group) => {
if (group.id !== groupId) return group
return {
...group,
enabled,
services: enabled
? group.services
: group.services.map((service) => ({ ...service, enabled: false })),
}
}),
}
}
function ServicesPage() {
const [sheetOpen, setSheetOpen] = useState(false)
const [createSheetOpen, setCreateSheetOpen] = useState(false)
const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false)
const [editingGroup, setEditingGroup] = useState<ServiceGroupView | null>(null)
const [editingService, setEditingService] = useState<ServiceView | null>(null)
const [savingId, setSavingId] = useState<number | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
const [togglingGroupId, setTogglingGroupId] = useState<number | null>(null)
const queryClient = useQueryClient()
const { data: services } = useQuery(servicesQueryOptions())
const { data: bindings } = useQuery(serviceBindingsQueryOptions())
const { data, isLoading } = useQuery(serviceGroupsQueryOptions())
const { data: domains } = useQuery(domainsListQueryOptions())
const catalogForm = useForm<CreateServiceInput>({
resolver: zodResolver(createServiceSchema),
defaultValues: { name: '', slug: '' },
})
function invalidateAll() {
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all })
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
}
const bindingForm = useForm<CreateServiceBindingInput>({
resolver: zodResolver(createServiceBindingSchema),
defaultValues: {
domain_id: '',
service_id: '',
hostname: '@',
target_ip: '',
const createGroupMutation = useMutation({
mutationFn: (body: CreateServiceGroupInput) =>
api.post('/api/v1/service-groups', body),
onSuccess: () => {
invalidateAll()
setCreateGroupSheetOpen(false)
toast.success('Группа создана')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось создать группу')
},
})
const columns = useMemo(() => {
return (
services?.map((service) => ({
id: serviceColumnId(service.id),
title: service.name,
description: service.slug,
items: bindings?.filter((b) => b.service_id === service.id) ?? [],
})) ?? []
)
}, [services, bindings])
const updateGroupMutation = useMutation({
mutationFn: ({ id, body }: { id: number; body: CreateServiceGroupInput }) =>
api.patch(`/api/v1/service-groups/${id}`, body),
onSuccess: () => {
invalidateAll()
setEditingGroup(null)
toast.success('Группа сохранена, DNS синхронизируется')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить группу')
},
})
const createServiceMutation = useMutation({
mutationFn: (body: CreateServiceInput) => api.post('/api/v1/services', body),
mutationFn: async (body: CreateServiceWithConfigInput) => {
const created = await api.post<ServiceView>('/api/v1/services', {
name: body.name,
slug: body.slug,
service_group_id: body.service_group_id ?? null,
})
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,
...(body.domains.length > 0 ? { domains: body.domains } : {}),
service_group_id: body.service_group_id ?? null,
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
catalogForm.reset()
invalidateAll()
setCreateSheetOpen(false)
toast.success('Сервис создан')
},
onError: (err) => {
@@ -133,249 +159,256 @@ function ServicesPage() {
},
})
const createBindingMutation = useMutation({
mutationFn: (body: {
domain_id: number
service_id: number
hostname?: string
target_ip?: string
}) => api.post('/api/v1/service-bindings', body),
const updateServiceMutation = useMutation({
mutationFn: ({ id, body }: { id: number; body: UpdateServiceConfigInput }) =>
api.patch<ServiceView>(`/api/v1/services/${id}`, body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
bindingForm.reset({ domain_id: '', service_id: '', hostname: '@', target_ip: '' })
setSheetOpen(false)
toast.success('Привязка создана')
invalidateAll()
setEditingService(null)
toast.success('Сервис сохранён')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось создать привязку')
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис')
},
onSettled: () => {
setSavingId(null)
},
})
const updateBindingMutation = useMutation({
mutationFn: ({
id,
body,
}: {
id: number
body: { service_id?: number; hostname?: string; target_ip?: string }
}) => api.patch(`/api/v1/service-bindings/${id}`, body),
const deleteServiceMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/services/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
invalidateAll()
setEditingService(null)
toast.success('Сервис удалён')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось обновить привязку')
toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис')
},
onSettled: () => {
setDeletingId(null)
},
})
function handleMove(itemId: string, _fromColumnId: string, toColumnId: string) {
const serviceId = parseServiceColumnId(toColumnId)
if (serviceId === null) return
updateBindingMutation.mutate({
id: Number(itemId),
body: { service_id: serviceId },
})
}
function handleIpChange(id: number, targetIp: string) {
updateBindingMutation.mutate({ id, body: { target_ip: targetIp } })
}
function handleHostnameChange(id: number, hostname: string) {
updateBindingMutation.mutate({ id, body: { hostname } })
}
const renderBindingCard = (binding: ServiceBinding) => (
<ServiceBindingCard
binding={binding}
onIpChange={handleIpChange}
onHostnameChange={handleHostnameChange}
/>
)
const handleCatalogSubmit = catalogForm.handleSubmit((values) => {
createServiceMutation.mutate(values)
const toggleServiceMutation = useMutation({
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
api.patch<ServiceView>(`/api/v1/services/${id}/toggle`, { enabled }),
onMutate: async ({ id, enabled }) => {
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
serviceGroupKeys.all,
)
if (previous) {
queryClient.setQueryData(
serviceGroupKeys.all,
setServiceEnabled(previous, id, enabled),
)
}
return { previous }
},
onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(serviceGroupKeys.all, context.previous)
}
toast.error(err instanceof Error ? err.message : 'Не удалось переключить сервис')
},
onSuccess: (_data, { enabled }) => {
toast.success(
enabled
? 'Сервис включён, DNS синхронизируется с Cloudflare'
: 'Сервис выключен, DNS-записи удалены из Cloudflare',
)
},
onSettled: () => {
setTogglingServiceId(null)
invalidateAll()
},
})
const handleBindingSubmit = bindingForm.handleSubmit((values) => {
createBindingMutation.mutate({
domain_id: Number(values.domain_id),
service_id: Number(values.service_id),
hostname: values.hostname || '@',
target_ip: values.target_ip || undefined,
})
const toggleGroupMutation = useMutation({
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
api.patch<ServiceGroupsResponse>(`/api/v1/service-groups/${id}/toggle`, {
enabled,
}),
onMutate: async ({ id, enabled }) => {
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
serviceGroupKeys.all,
)
if (previous) {
queryClient.setQueryData(
serviceGroupKeys.all,
setGroupEnabled(previous, id, enabled),
)
}
return { previous }
},
onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(serviceGroupKeys.all, context.previous)
}
toast.error(err instanceof Error ? err.message : 'Не удалось переключить группу')
},
onSuccess: (_data, { enabled }) => {
toast.success(
enabled
? 'Группа включена, DNS включённых сервисов синхронизируется'
: 'Группа выключена, DNS-записи сервисов удалены',
)
},
onSettled: () => {
setTogglingGroupId(null)
invalidateAll()
},
})
function handleSave(id: number, body: UpdateServiceConfigInput) {
setSavingId(id)
updateServiceMutation.mutate({ id, body })
}
function handleDelete(id: number) {
setDeletingId(id)
deleteServiceMutation.mutate(id)
}
function handleCreate(body: CreateServiceWithConfigInput) {
createServiceMutation.mutate(body)
}
function handleServiceToggle(serviceId: number, enabled: boolean) {
setTogglingServiceId(serviceId)
toggleServiceMutation.mutate({ id: serviceId, enabled })
}
function handleGroupToggle(groupId: number, enabled: boolean) {
setTogglingGroupId(groupId)
toggleGroupMutation.mutate({ id: groupId, enabled })
}
const groups = data?.groups ?? []
const ungrouped = data?.ungrouped ?? []
const isEmpty = groups.length === 0 && ungrouped.length === 0
return (
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<PageHeader
title="Сервисы"
description="Канбан привязок доменов к сервисам с настройкой IP через DNS"
description="Домен группы (FQDN) и FQDN сервисов синхронизируются в Cloudflare при включении"
actions={
<Button onClick={() => setSheetOpen(true)}>Добавить привязку</Button>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу
</Button>
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
</div>
}
/>
<Tabs defaultValue="kanban" orientation="horizontal" className="flex w-full flex-col gap-4">
<TabsList>
<TabsTrigger value="kanban">Канбан</TabsTrigger>
<TabsTrigger value="catalog">Справочник</TabsTrigger>
</TabsList>
<TabsContent value="kanban">
<Card>
<CardHeader>
<CardTitle>Доска сервисов</CardTitle>
<CardDescription>
Перетащите привязку между колонками или отредактируйте IP прямо на карточке
</CardDescription>
</CardHeader>
<CardContent>
<KanbanBoard
columns={columns}
getItemId={(binding) => String(binding.id)}
renderCard={renderBindingCard}
renderOverlay={renderBindingCard}
onMove={handleMove}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="catalog" className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>Создать сервис</CardTitle>
<CardDescription>Добавить новый тип сервиса в справочник</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCatalogSubmit}>
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
<Field className="flex-1">
<FieldLabel htmlFor="svc-name">Название</FieldLabel>
<Input
id="svc-name"
placeholder="Название"
{...catalogForm.register('name')}
aria-invalid={!!catalogForm.formState.errors.name}
/>
</Field>
<Field className="flex-1">
<FieldLabel htmlFor="svc-slug">Slug</FieldLabel>
<Input
id="svc-slug"
placeholder="slug"
{...catalogForm.register('slug')}
aria-invalid={!!catalogForm.formState.errors.slug}
/>
</Field>
<Button type="submit" disabled={createServiceMutation.isPending}>
{createServiceMutation.isPending && (
<Spinner data-icon="inline-start" />
)}
{createServiceMutation.isPending ? 'Создание…' : 'Создать'}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
<DataTableCard
title="Справочник сервисов"
description="Типы сервисов для привязки к доменам"
isEmpty={!services?.length}
emptyTitle="Сервисы не найдены"
emptyDescription="Создайте первый сервис в форме выше"
>
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Slug</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services?.map((service) => (
<TableRow key={service.id}>
<TableCell className="font-medium">{service.name}</TableCell>
<TableCell className="text-muted-foreground">{service.slug}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DataTableCard>
</TabsContent>
</Tabs>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Новая привязка</SheetTitle>
<SheetDescription>
Свяжите домен с сервисом и укажите IP для A-записи
</SheetDescription>
</SheetHeader>
<form onSubmit={handleBindingSubmit} className="flex flex-col gap-4 px-4">
<Field>
<FieldLabel>Домен</FieldLabel>
<Select
value={bindingForm.watch('domain_id')}
onValueChange={(value) => bindingForm.setValue('domain_id', value ?? '')}
>
<SelectTrigger>
<SelectValue placeholder="Выберите домен" />
</SelectTrigger>
<SelectContent>
{domains?.map((domain) => (
<SelectItem key={domain.id} value={String(domain.id)}>
{domain.zone_name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Сервис</FieldLabel>
<Select
value={bindingForm.watch('service_id')}
onValueChange={(value) => bindingForm.setValue('service_id', value ?? '')}
>
<SelectTrigger>
<SelectValue placeholder="Выберите сервис" />
</SelectTrigger>
<SelectContent>
{services?.map((service) => (
<SelectItem key={service.id} value={String(service.id)}>
{service.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="binding-hostname">Hostname</FieldLabel>
<Input
id="binding-hostname"
placeholder="@"
{...bindingForm.register('hostname')}
/>
</Field>
<Field>
<FieldLabel htmlFor="binding-ip">IPv4</FieldLabel>
<Input
id="binding-ip"
placeholder="192.168.1.1"
{...bindingForm.register('target_ip')}
/>
</Field>
<SheetFooter>
<Button type="submit" disabled={createBindingMutation.isPending}>
{createBindingMutation.isPending && (
<Spinner data-icon="inline-start" />
)}
Создать
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner className="size-8" />
</div>
) : isEmpty ? (
<Empty>
<EmptyHeader>
<EmptyTitle>Сервисы не найдены</EmptyTitle>
<EmptyDescription>
Создайте группу и сервис например VPN Panel или Home Assistant.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<div className="flex flex-wrap justify-center gap-2">
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
</div>
</EmptyContent>
</Empty>
) : (
<div className="flex flex-col gap-4">
{groups.map((group) => (
<ServiceGroupCard
key={group.id}
group={group}
onGroupToggle={handleGroupToggle}
onServiceToggle={handleServiceToggle}
onEditService={setEditingService}
onEditGroup={setEditingGroup}
togglingGroupId={togglingGroupId}
togglingServiceId={togglingServiceId}
/>
))}
{ungrouped.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Без группы</CardTitle>
</CardHeader>
<CardContent>
<ItemGroup>
{ungrouped.map((service) => (
<ServiceRow
key={service.id}
service={service}
onToggle={handleServiceToggle}
onEdit={setEditingService}
isToggling={togglingServiceId === service.id}
/>
))}
</ItemGroup>
</CardContent>
</Card>
) : null}
</div>
)}
<ServiceEditSheet
mode="edit"
service={editingService}
groups={groups}
open={editingService !== null}
knownDomains={domains ?? []}
isSaving={editingService !== null && savingId === editingService.id}
isDeleting={editingService !== null && deletingId === editingService.id}
onOpenChange={(open) => {
if (!open) setEditingService(null)
}}
onSave={handleSave}
onDelete={handleDelete}
/>
<ServiceEditSheet
mode="create"
service={null}
groups={groups}
open={createSheetOpen}
knownDomains={domains ?? []}
isSaving={createServiceMutation.isPending}
onOpenChange={setCreateSheetOpen}
onCreate={handleCreate}
/>
<ServiceGroupEditSheet
mode="create"
group={null}
open={createGroupSheetOpen}
isSaving={createGroupMutation.isPending}
onOpenChange={setCreateGroupSheetOpen}
onCreate={(body) => createGroupMutation.mutate(body)}
/>
<ServiceGroupEditSheet
mode="edit"
group={editingGroup}
open={editingGroup !== null}
isSaving={updateGroupMutation.isPending}
onOpenChange={(open) => {
if (!open) setEditingGroup(null)
}}
onSave={(id, body) => updateGroupMutation.mutate({ id, body })}
/>
</div>
)
}