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