Update pnpm-lock.yaml to include new dependencies for @dnd-kit packages; enhance frontend documentation with MCP patterns and shadcn guidelines; refactor route handling for groups and services; implement new DataTableCard and KanbanBoard components for better domain and service management; add service binding functionality and improve DNS record management with new schemas and queries.
Build, Test, and Push CFDM Docker Image / test (push) Failing after 16h45m4s
Build, Test, and Push CFDM Docker Image / create-release (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 / update-wiki (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / test (push) Failing after 16h45m4s
Build, Test, and Push CFDM Docker Image / create-release (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 / update-wiki (push) Has been cancelled
This commit is contained in:
@@ -1,13 +1,29 @@
|
||||
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 { toast } from 'sonner'
|
||||
import { servicesQueryOptions, serviceKeys } from '@/queries'
|
||||
import {
|
||||
domainKeys,
|
||||
domainsListQueryOptions,
|
||||
serviceBindingKeys,
|
||||
serviceBindingsQueryOptions,
|
||||
serviceKeys,
|
||||
servicesQueryOptions,
|
||||
} from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { createServiceSchema, type CreateServiceInput } from '@/lib/schemas'
|
||||
import {
|
||||
createServiceBindingSchema,
|
||||
createServiceSchema,
|
||||
type CreateServiceBindingInput,
|
||||
type CreateServiceInput,
|
||||
type ServiceBinding,
|
||||
} from '@/lib/schemas'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { ResourceList } from '@/components/resource-list'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { KanbanBoard } from '@/components/kanban-board'
|
||||
import { ServiceBindingCard } from '@/components/service-binding-card'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
@@ -17,32 +33,99 @@ import {
|
||||
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'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services')({
|
||||
loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(servicesQueryOptions()),
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
Promise.all([
|
||||
queryClient.ensureQueryData(servicesQueryOptions()),
|
||||
queryClient.ensureQueryData(serviceBindingsQueryOptions()),
|
||||
queryClient.ensureQueryData(domainsListQueryOptions()),
|
||||
]),
|
||||
component: ServicesPage,
|
||||
})
|
||||
|
||||
function serviceColumnId(serviceId: number) {
|
||||
return `service-${serviceId}`
|
||||
}
|
||||
|
||||
function parseServiceColumnId(columnId: string): number | null {
|
||||
const match = columnId.match(/^service-(\d+)$/)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
function ServicesPage() {
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { data: services } = useQuery(servicesQueryOptions())
|
||||
const { data: bindings } = useQuery(serviceBindingsQueryOptions())
|
||||
const { data: domains } = useQuery(domainsListQueryOptions())
|
||||
|
||||
const form = useForm<CreateServiceInput>({
|
||||
const catalogForm = useForm<CreateServiceInput>({
|
||||
resolver: zodResolver(createServiceSchema),
|
||||
defaultValues: { name: '', slug: '' },
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
const bindingForm = useForm<CreateServiceBindingInput>({
|
||||
resolver: zodResolver(createServiceBindingSchema),
|
||||
defaultValues: {
|
||||
domain_id: '',
|
||||
service_id: '',
|
||||
hostname: '@',
|
||||
target_ip: '',
|
||||
},
|
||||
})
|
||||
|
||||
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 createServiceMutation = useMutation({
|
||||
mutationFn: (body: CreateServiceInput) => api.post('/api/v1/services', body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
|
||||
form.reset()
|
||||
catalogForm.reset()
|
||||
toast.success('Сервис создан')
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -50,63 +133,249 @@ function ServicesPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
createMutation.mutate(values)
|
||||
const createBindingMutation = useMutation({
|
||||
mutationFn: (body: {
|
||||
domain_id: number
|
||||
service_id: number
|
||||
hostname?: string
|
||||
target_ip?: string
|
||||
}) => api.post('/api/v1/service-bindings', 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('Привязка создана')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось создать привязку')
|
||||
},
|
||||
})
|
||||
|
||||
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),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось обновить привязку')
|
||||
},
|
||||
})
|
||||
|
||||
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 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,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageHeader
|
||||
title="Сервисы"
|
||||
description="Справочник сервисов для привязки к доменам"
|
||||
description="Канбан привязок доменов к сервисам с настройкой IP через DNS"
|
||||
actions={
|
||||
<Button onClick={() => setSheetOpen(true)}>Добавить привязку</Button>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Создать сервис</CardTitle>
|
||||
<CardDescription>Добавить новый сервис в справочник</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
|
||||
<Field className="flex-1">
|
||||
<FieldLabel htmlFor="name">Название</FieldLabel>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Название"
|
||||
{...form.register('name')}
|
||||
aria-invalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="flex-1">
|
||||
<FieldLabel htmlFor="slug">Slug</FieldLabel>
|
||||
<Input
|
||||
id="slug"
|
||||
placeholder="slug"
|
||||
{...form.register('slug')}
|
||||
aria-invalid={!!form.formState.errors.slug}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && (
|
||||
<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" />
|
||||
)}
|
||||
{createMutation.isPending ? 'Создание…' : 'Создать'}
|
||||
Создать
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ResourceList
|
||||
items={
|
||||
services?.map((s) => ({
|
||||
id: s.id,
|
||||
primary: s.name,
|
||||
secondary: `(${s.slug})`,
|
||||
})) ?? []
|
||||
}
|
||||
emptyTitle="Сервисы не найдены"
|
||||
emptyDescription="Создайте первый сервис в форме выше"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user