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
@@ -0,0 +1,52 @@
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
import { Button } from '@cfdm/ui/components/button'
interface ServiceBindingIpInputProps {
id?: string
value: string[]
pool: string[]
onChange: (value: string[]) => void
disabled?: boolean
}
export function ServiceBindingIpInput({
id,
value,
pool,
onChange,
disabled,
}: ServiceBindingIpInputProps) {
const available = pool.filter((ip) => !value.includes(ip))
const isPoolEmpty = pool.length === 0
return (
<div className="flex flex-col gap-2">
<TaggedInput
id={id}
value={value}
onChange={onChange}
placeholder={
isPoolEmpty ? 'Сначала добавьте IP в пул сервиса' : 'Выберите IP из пула'
}
validate={(ip) => isValidIpv4(ip) && pool.includes(ip)}
disabled={disabled || isPoolEmpty}
/>
{available.length > 0 ? (
<div className="flex flex-wrap gap-1">
{available.map((ip) => (
<Button
key={ip}
type="button"
variant="outline"
size="xs"
disabled={disabled}
onClick={() => onChange([...value, ip])}
>
+ {ip}
</Button>
))}
</div>
) : null}
</div>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { PencilIcon } from 'lucide-react'
import { StatusBadge } from '@/components/status-badge'
import { bindingToFqdn } from '@/lib/parse-fqdn'
import type { ServiceView } from '@/lib/schemas'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import {
Item,
ItemContent,
ItemGroup,
ItemTitle,
} from '@cfdm/ui/components/item'
interface ServiceCardProps {
service: ServiceView
onEdit: (service: ServiceView) => void
}
function aggregateSyncStatus(service: ServiceView) {
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]
}
export function ServiceCard({ service, onEdit }: ServiceCardProps) {
const ips = service.ips ?? []
const domains = service.domains ?? []
const syncStatus = aggregateSyncStatus(service)
return (
<Card>
<CardHeader>
<CardTitle>{service.name}</CardTitle>
<CardDescription>
<Badge variant="outline">{service.slug}</Badge>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">IP-адреса</p>
{ips.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="flex flex-wrap gap-1.5">
{ips.map((ip) => (
<Badge key={ip} variant="secondary">
{ip}
</Badge>
))}
</div>
)}
</div>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Домены</p>
{domains.length === 0 ? (
<p className="text-sm text-muted-foreground">Не привязаны</p>
) : (
<ItemGroup>
{domains.map((binding) => (
<Item key={binding.binding_id} variant="outline" size="sm">
<ItemContent>
<ItemTitle className="flex flex-wrap items-center gap-2">
<span>{bindingToFqdn(binding)}</span>
{binding.target_ips.map((ip) => (
<Badge key={ip} variant="secondary">
{ip}
</Badge>
))}
{binding.sync_status ? (
<StatusBadge status={binding.sync_status} />
) : null}
</ItemTitle>
</ItemContent>
</Item>
))}
</ItemGroup>
)}
</div>
</CardContent>
<CardFooter className="flex flex-wrap items-center justify-between gap-2">
<div>{syncStatus ? <StatusBadge status={syncStatus} /> : null}</div>
<Button type="button" variant="outline" size="sm" onClick={() => onEdit(service)}>
<PencilIcon data-icon="inline-start" />
Редактировать
</Button>
</CardFooter>
</Card>
)
}
@@ -0,0 +1,332 @@
import { useEffect, useMemo, useState } from 'react'
import { PlusIcon, Trash2Icon } from 'lucide-react'
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
import type {
CreateServiceWithConfigInput,
DomainListItem,
ServiceGroupView,
ServiceView,
UpdateServiceConfigInput,
} from '@/lib/schemas'
import { bindingToFqdn } from '@/lib/parse-fqdn'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Field,
FieldGroup,
FieldLabel,
} from '@cfdm/ui/components/field'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
} from '@cfdm/ui/components/item'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { Spinner } from '@cfdm/ui/components/spinner'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
export interface ServiceBindingDraft {
fqdn: string
target_ips: string[]
}
interface ServiceEditSheetProps {
mode: 'create' | 'edit'
service: ServiceView | null
groups: ServiceGroupView[]
open: boolean
knownDomains: DomainListItem[]
isSaving: boolean
isDeleting?: boolean
onOpenChange: (open: boolean) => void
onCreate?: (body: CreateServiceWithConfigInput) => void
onSave?: (id: number, body: UpdateServiceConfigInput) => void
onDelete?: (id: number) => void
}
function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
return (service.domains ?? []).map((binding) => ({
fqdn: bindingToFqdn(binding),
target_ips: binding.target_ips ?? [],
}))
}
function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
return bindings
.filter((binding) => binding.fqdn.trim() && binding.target_ips.length > 0)
.map((binding) => ({
fqdn: binding.fqdn.trim(),
target_ips: binding.target_ips,
}))
}
export function ServiceEditSheet({
mode,
service,
groups,
open,
knownDomains,
isSaving,
isDeleting = false,
onOpenChange,
onCreate,
onSave,
onDelete,
}: ServiceEditSheetProps) {
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [serviceGroupId, setServiceGroupId] = useState('none')
const [ips, setIps] = useState<string[]>([])
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
const groupItems = useMemo(
() => [
{ label: 'Без группы', value: 'none' },
...groups.map((group) => ({ label: group.name, value: String(group.id) })),
],
[groups],
)
useEffect(() => {
if (!open) return
if (mode === 'edit' && service) {
setName(service.name)
setSlug(service.slug)
setServiceGroupId(
service.service_group_id != null ? String(service.service_group_id) : 'none',
)
setIps(service.ips ?? [])
setBindings(toBindingDrafts(service))
return
}
if (mode === 'create') {
setName('')
setSlug('')
setServiceGroupId('none')
setIps([])
setBindings([])
}
}, [open, mode, service])
const zoneHints = useMemo(
() => knownDomains.map((domain) => domain.zone_name),
[knownDomains],
)
function handleAddBinding() {
setBindings((current) => [...current, { fqdn: '', target_ips: [] }])
}
function handleRemoveBinding(index: number) {
setBindings((current) => current.filter((_, i) => i !== index))
}
function handleFqdnChange(index: number, tags: string[]) {
const fqdn = tags[0] ?? ''
setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
)
}
function handleIpsChange(index: number, targetIps: string[]) {
setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, target_ips: targetIps } : item)),
)
}
function resolveServiceGroupId(): number | null {
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
}
function handleSubmit() {
const domains = buildDomainsPayload(bindings)
const groupId = resolveServiceGroupId()
const configPayload = {
ips,
...(domains.length > 0 ? { domains } : {}),
}
if (mode === 'create') {
onCreate?.({
name: name.trim(),
slug: slug.trim(),
service_group_id: groupId,
...configPayload,
})
return
}
if (!service) return
onSave?.(service.id, {
name,
slug,
service_group_id: groupId,
...configPayload,
})
}
function handleDelete() {
if (!service) return
onDelete?.(service.id)
}
const isCreate = mode === 'create'
const canSubmit = isCreate
? name.trim().length > 0 && slug.trim().length > 0
: Boolean(service)
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="overflow-y-auto sm:max-w-lg">
<SheetHeader>
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
<SheetDescription>
Настройте IP-пул и привязки FQDN IP. Зона определяется из FQDN автоматически.
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 px-4">
<FieldGroup className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
<Input
id="edit-service-name"
value={name}
placeholder={isCreate ? 'VPN Panel' : undefined}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-slug">Slug</FieldLabel>
<Input
id="edit-service-slug"
value={slug}
placeholder={isCreate ? 'vpn-panel' : undefined}
onChange={(e) => setSlug(e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
<Select
items={groupItems}
value={serviceGroupId}
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
>
<SelectTrigger id="edit-service-group" className="w-full">
<SelectValue placeholder="Без группы" />
</SelectTrigger>
<SelectContent>
{groupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
<TaggedInput
id="edit-service-ips"
value={ips}
onChange={setIps}
placeholder="192.168.1.1"
validate={isValidIpv4}
/>
</Field>
</FieldGroup>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<FieldLabel>Привязки доменов</FieldLabel>
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" />
Добавить
</Button>
</div>
{bindings.length === 0 ? (
<p className="text-sm text-muted-foreground">
Необязательно. Введите FQDN, например newdom.ivx.su зона ivx.su определится
автоматически.
</p>
) : (
<ItemGroup>
{bindings.map((binding, index) => (
<Item key={`binding-${index}`} variant="outline">
<ItemContent className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
<TaggedInput
id={`binding-fqdn-${index}`}
value={binding.fqdn ? [binding.fqdn] : []}
onChange={(tags) => handleFqdnChange(index, tags)}
placeholder={zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'}
maxItems={1}
/>
</Field>
<Field>
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
<ServiceBindingIpInput
id={`binding-ip-${index}`}
value={binding.target_ips}
pool={ips}
onChange={(targetIps) => handleIpsChange(index, targetIps)}
/>
</Field>
</ItemContent>
<ItemActions>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Удалить привязку"
onClick={() => handleRemoveBinding(index)}
>
<Trash2Icon />
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
)}
</div>
</div>
<SheetFooter className="flex flex-row flex-wrap gap-2">
{!isCreate ? (
<Button
type="button"
variant="destructive"
disabled={!service || isDeleting || isSaving}
onClick={handleDelete}
>
{isDeleting && <Spinner data-icon="inline-start" />}
<Trash2Icon data-icon="inline-start" />
Удалить
</Button>
) : null}
<Button
type="button"
className={isCreate ? 'ml-auto' : 'ml-auto'}
disabled={!canSubmit || isSaving || isDeleting}
onClick={handleSubmit}
>
{isSaving && <Spinner data-icon="inline-start" />}
{isCreate ? 'Создать' : 'Сохранить'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,116 @@
import { ChevronDownIcon, PencilIcon } from 'lucide-react'
import { useState } from 'react'
import { ServiceGroupIcon } from '@/components/service-group-icon'
import { ServiceRow } from '@/components/service-row'
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@cfdm/ui/components/collapsible'
import { ItemGroup } from '@cfdm/ui/components/item'
import { Spinner } from '@cfdm/ui/components/spinner'
import { Switch } from '@cfdm/ui/components/switch'
import { cn } from '@cfdm/ui/lib/utils'
interface ServiceGroupCardProps {
group: ServiceGroupView
onGroupToggle: (groupId: number, enabled: boolean) => void
onServiceToggle: (serviceId: number, enabled: boolean) => void
onEditService: (service: ServiceView) => void
onEditGroup: (group: ServiceGroupView) => void
togglingGroupId?: number | null
togglingServiceId?: number | null
}
export function ServiceGroupCard({
group,
onGroupToggle,
onServiceToggle,
onEditService,
onEditGroup,
togglingGroupId = null,
togglingServiceId = null,
}: ServiceGroupCardProps) {
const [open, setOpen] = useState(true)
const isGroupToggling = togglingGroupId === group.id
return (
<Card>
<Collapsible open={open} onOpenChange={setOpen}>
<CardHeader>
<div className="flex items-center gap-2">
<CollapsibleTrigger
className={cn(
'flex flex-1 items-center gap-2 text-left',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
<ChevronDownIcon
className={cn(
'size-4 shrink-0 transition-transform',
open && 'rotate-180',
)}
/>
<ServiceGroupIcon type={group.type} />
<CardTitle className="flex-1">{group.name}</CardTitle>
</CollapsibleTrigger>
{group.domain ? (
<Badge variant="outline">{group.domain}</Badge>
) : null}
</div>
<CardAction className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={() => onEditGroup(group)}
aria-label={`Редактировать группу ${group.name}`}
>
<PencilIcon />
</Button>
{isGroupToggling ? (
<Spinner className="size-4" />
) : (
<Switch
checked={group.enabled}
disabled={isGroupToggling}
onCheckedChange={(checked) => onGroupToggle(group.id, checked)}
aria-label={`${group.enabled ? 'Выключить' : 'Включить'} группу ${group.name}`}
/>
)}
</CardAction>
</CardHeader>
<CollapsibleContent>
<CardContent>
{group.services.length === 0 ? (
<p className="text-sm text-muted-foreground">Нет сервисов в группе</p>
) : (
<ItemGroup>
{group.services.map((service) => (
<ServiceRow
key={service.id}
service={service}
onToggle={onServiceToggle}
onEdit={onEditService}
isToggling={togglingServiceId === service.id}
disabled={!group.enabled}
/>
))}
</ItemGroup>
)}
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
)
}
@@ -0,0 +1,149 @@
import { useEffect, useState } from 'react'
import type { CreateServiceGroupInput, ServiceGroup } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
Field,
FieldGroup,
FieldLabel,
} from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { Spinner } from '@cfdm/ui/components/spinner'
const groupTypes = [
{ value: 'vpn', label: 'VPN' },
{ value: 'network', label: 'Сеть' },
{ value: 'internet', label: 'Интернет' },
{ value: 'bgp', label: 'BGP' },
{ value: 'custom', label: 'Другое' },
] as const
interface ServiceGroupEditSheetProps {
mode: 'create' | 'edit'
group: ServiceGroup | null
open: boolean
isSaving: boolean
onOpenChange: (open: boolean) => void
onCreate?: (body: CreateServiceGroupInput) => void
onSave?: (id: number, body: CreateServiceGroupInput) => void
}
export function ServiceGroupEditSheet({
mode,
group,
open,
isSaving,
onOpenChange,
onCreate,
onSave,
}: ServiceGroupEditSheetProps) {
const [name, setName] = useState('')
const [type, setType] = useState<CreateServiceGroupInput['type']>('custom')
const [domain, setDomain] = useState('')
useEffect(() => {
if (!open) return
if (mode === 'edit' && group) {
setName(group.name)
setType(group.type)
setDomain(group.domain ?? '')
} else {
setName('')
setType('custom')
setDomain('')
}
}, [open, mode, group])
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const trimmedName = name.trim()
if (!trimmedName) return
const body: CreateServiceGroupInput = {
name: trimmedName,
type,
domain: domain.trim() || null,
}
if (mode === 'create') {
onCreate?.(body)
} else if (group) {
onSave?.(group.id, body)
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>
{mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
</SheetTitle>
<SheetDescription>
Домен группы любой FQDN (gr.ivx.su, domain.new.ivx.su). Публикуется в Cloudflare отдельно от привязок сервисов.
</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-6 px-4">
<FieldGroup>
<Field>
<FieldLabel htmlFor="group-name">Название</FieldLabel>
<Input
id="group-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="VPN"
/>
</Field>
<Field>
<FieldLabel htmlFor="group-type">Тип</FieldLabel>
<Select
value={type}
onValueChange={(value) =>
setType(value as CreateServiceGroupInput['type'])
}
>
<SelectTrigger id="group-type">
<SelectValue placeholder="Выберите тип" />
</SelectTrigger>
<SelectContent>
{groupTypes.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="group-domain">Домен группы (FQDN)</FieldLabel>
<Input
id="group-domain"
value={domain}
onChange={(event) => setDomain(event.target.value)}
placeholder="domain.new.ivx.su"
/>
</Field>
</FieldGroup>
<SheetFooter>
<Button type="submit" disabled={isSaving} className="w-full">
{isSaving && <Spinner data-icon="inline-start" />}
{isSaving ? 'Сохранение…' : mode === 'create' ? 'Создать' : 'Сохранить'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,28 @@
import {
GlobeIcon,
NetworkIcon,
RouterIcon,
ServerIcon,
ShieldIcon,
type LucideIcon,
} from 'lucide-react'
import type { ServiceGroup } from '@/lib/schemas'
import { cn } from '@cfdm/ui/lib/utils'
const typeIcons: Record<ServiceGroup['type'], LucideIcon> = {
vpn: ShieldIcon,
network: NetworkIcon,
internet: GlobeIcon,
bgp: RouterIcon,
custom: ServerIcon,
}
interface ServiceGroupIconProps {
type: ServiceGroup['type']
className?: string
}
export function ServiceGroupIcon({ type, className }: ServiceGroupIconProps) {
const Icon = typeIcons[type] ?? ServerIcon
return <Icon className={cn('size-4 shrink-0', className)} />
}
@@ -0,0 +1,55 @@
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@cfdm/ui/components/combobox'
interface ServiceIpComboboxProps {
id?: string
value: string
items: string[]
onChange: (value: string) => void
disabled?: boolean
placeholder?: string
}
export function ServiceIpCombobox({
id,
value,
items,
onChange,
disabled,
placeholder = 'Выберите IP',
}: ServiceIpComboboxProps) {
const isDisabled = disabled || items.length === 0
return (
<Combobox
items={items}
value={value}
onValueChange={(next) => onChange(next ?? '')}
disabled={isDisabled}
>
<ComboboxInput
id={id}
className="w-full"
placeholder={items.length === 0 ? 'Сначала добавьте IP' : placeholder}
showClear={Boolean(value)}
disabled={isDisabled}
/>
<ComboboxContent>
<ComboboxEmpty>Нет совпадений</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { PencilIcon } from 'lucide-react'
import { StatusBadge } from '@/components/status-badge'
import {
aggregateServiceSyncStatus,
serviceDisplayFqdn,
} from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from '@cfdm/ui/components/item'
import { Spinner } from '@cfdm/ui/components/spinner'
import { Switch } from '@cfdm/ui/components/switch'
interface ServiceRowProps {
service: ServiceView
onToggle: (serviceId: number, enabled: boolean) => void
onEdit: (service: ServiceView) => void
isToggling?: boolean
disabled?: boolean
}
export function ServiceRow({
service,
onToggle,
onEdit,
isToggling = false,
disabled = false,
}: ServiceRowProps) {
const syncStatus = aggregateServiceSyncStatus(service)
const fqdn = serviceDisplayFqdn(service)
return (
<Item variant="outline" size="sm">
<ItemContent>
<ItemTitle>{service.name}</ItemTitle>
<ItemDescription>{fqdn}</ItemDescription>
</ItemContent>
<ItemActions className="flex flex-wrap items-center gap-2">
{syncStatus ? <StatusBadge status={syncStatus} /> : null}
{isToggling ? (
<Spinner className="size-4" />
) : (
<Switch
checked={service.enabled}
disabled={disabled || isToggling}
onCheckedChange={(checked) => onToggle(service.id, checked)}
aria-label={`${service.enabled ? 'Выключить' : 'Включить'} ${service.name}`}
/>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onEdit(service)}
>
<PencilIcon data-icon="inline-start" />
Редактировать
</Button>
</ItemActions>
</Item>
)
}
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useState } from 'react'
import { XIcon } from 'lucide-react'
import { Badge } from '@cfdm/ui/components/badge'
import {
InputGroup,
InputGroupButton,
InputGroupInput,
} from '@cfdm/ui/components/input-group'
import { cn } from '@cfdm/ui/lib/utils'
interface TaggedInputProps {
id?: string
value: string[]
onChange: (value: string[]) => void
placeholder?: string
validate?: (value: string) => boolean
maxItems?: number
disabled?: boolean
className?: string
'aria-invalid'?: boolean
}
function normalizeTag(raw: string) {
return raw.trim()
}
export function TaggedInput({
id,
value,
onChange,
placeholder,
validate,
maxItems,
disabled,
className,
'aria-invalid': ariaInvalid,
}: TaggedInputProps) {
const [pending, setPending] = useState('')
useEffect(() => {
if (!pending.includes(',')) return
const chunks = pending
.split(',')
.map(normalizeTag)
.filter(Boolean)
.filter((chunk) => !validate || validate(chunk))
if (chunks.length === 0) {
setPending('')
return
}
const next = new Set(maxItems === 1 ? chunks.slice(-1) : [...value, ...chunks])
onChange(Array.from(next))
setPending('')
}, [pending, onChange, validate, value, maxItems])
function addPending() {
const tag = normalizeTag(pending)
if (!tag) return
if (validate && !validate(tag)) return
if (value.includes(tag)) {
setPending('')
return
}
const next = maxItems === 1 ? [tag] : [...value, tag]
onChange(next)
setPending('')
}
function removeTag(tag: string) {
onChange(value.filter((item) => item !== tag))
}
return (
<InputGroup
className={cn(
'h-auto min-h-8 flex-wrap items-center gap-1.5 py-1.5',
className,
)}
>
{value.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1 pr-1">
{tag}
<InputGroupButton
type="button"
size="icon-xs"
variant="ghost"
disabled={disabled}
aria-label={`Удалить ${tag}`}
onClick={() => removeTag(tag)}
>
<XIcon />
</InputGroupButton>
</Badge>
))}
<InputGroupInput
id={id}
value={pending}
disabled={disabled}
placeholder={value.length === 0 ? placeholder : undefined}
aria-invalid={ariaInvalid}
className="min-w-24 flex-1"
onChange={(e) => setPending(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addPending()
} else if (
e.key === 'Backspace' &&
pending.length === 0 &&
value.length > 0
) {
e.preventDefault()
onChange(value.slice(0, -1))
}
}}
onBlur={addPending}
/>
</InputGroup>
)
}
export const IPV4_REGEX =
/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/
export function isValidIpv4(value: string) {
return IPV4_REGEX.test(value)
}
+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]
}
+16 -2
View File
@@ -8,7 +8,8 @@ import {
groupSchema,
groupWithStatsSchema,
serviceBindingSchema,
serviceSchema,
serviceGroupsResponseSchema,
serviceViewSchema,
} from '@/lib/schemas'
import { subdomainSchema } from '@/lib/schemas-ext'
import { z } from 'zod'
@@ -40,12 +41,25 @@ export const serviceKeys = {
all: ['services'] as const,
}
export const serviceGroupKeys = {
all: ['service-groups'] as const,
}
export const serviceGroupsQueryOptions = () =>
queryOptions({
queryKey: serviceGroupKeys.all,
queryFn: async () => {
const data = await api.get<unknown>('/api/v1/service-groups')
return serviceGroupsResponseSchema.parse(data)
},
})
export const servicesQueryOptions = () =>
queryOptions({
queryKey: serviceKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/services')
return z.array(serviceSchema).parse(data)
return z.array(serviceViewSchema).parse(data)
},
})
+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>
)
}