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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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