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
+3
View File
@@ -2,6 +2,9 @@
"plugins": {
"cloudflare": {
"enabled": true
},
"claude-plugins-official/typescript-lsp": {
"enabled": true
}
}
}
+2
View File
@@ -11,6 +11,7 @@
"preview": "vite preview"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@cfdm/ui": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -22,6 +23,7 @@
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-vite-plugin": "^1.167.18",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"lucide-react": "^1.18.0",
"next-themes": "^0.4.6",
"react": "^19.2.6",
@@ -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>
)
}
+40
View File
@@ -0,0 +1,40 @@
PRAGMA foreign_keys = OFF;
CREATE TABLE service_ips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE,
ip TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(service_id, ip)
);
CREATE INDEX idx_service_ips_service_id ON service_ips(service_id);
CREATE TABLE service_binding_records (
binding_id INTEGER NOT NULL REFERENCES service_bindings(id) ON DELETE CASCADE,
dns_record_id INTEGER NOT NULL REFERENCES dns_records(id) ON DELETE CASCADE,
PRIMARY KEY (binding_id, dns_record_id)
);
INSERT INTO service_ips (service_id, ip, created_at)
SELECT DISTINCT sb.service_id, dr.content, datetime('now')
FROM service_bindings sb
JOIN dns_records dr ON dr.id = sb.dns_record_id
WHERE dr.record_type = 'A'
AND dr.content IS NOT NULL
AND TRIM(dr.content) != ''
ON CONFLICT(service_id, ip) DO NOTHING;
INSERT INTO service_binding_records (binding_id, dns_record_id)
SELECT sb.id, sb.dns_record_id
FROM service_bindings sb
WHERE sb.dns_record_id IS NOT NULL
ON CONFLICT(binding_id, dns_record_id) DO NOTHING;
INSERT INTO services (name, slug) VALUES
('VPN Panel', 'vpn-panel'),
('VPN Node', 'vpn-node'),
('Home Assistant', 'home-assistant')
ON CONFLICT(slug) DO NOTHING;
PRAGMA foreign_keys = ON;
+29
View File
@@ -0,0 +1,29 @@
PRAGMA foreign_keys = OFF;
CREATE TABLE service_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'custom',
icon TEXT,
domain TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
ALTER TABLE services ADD COLUMN service_group_id INTEGER REFERENCES service_groups(id) ON DELETE SET NULL;
ALTER TABLE services ADD COLUMN subdomain TEXT;
ALTER TABLE services ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;
UPDATE services SET subdomain = slug WHERE subdomain IS NULL;
INSERT INTO service_groups (name, type, enabled) VALUES
('VPN', 'vpn', 1),
('Сеть', 'network', 1),
('Интернет', 'internet', 1);
UPDATE services
SET service_group_id = (SELECT id FROM service_groups WHERE type = 'vpn' LIMIT 1)
WHERE slug IN ('vpn-panel', 'vpn-node');
PRAGMA foreign_keys = ON;
@@ -0,0 +1,25 @@
CREATE TABLE service_binding_ips (
binding_id INTEGER NOT NULL REFERENCES service_bindings(id) ON DELETE CASCADE,
ip TEXT NOT NULL,
PRIMARY KEY (binding_id, ip)
);
CREATE INDEX idx_service_binding_ips_binding_id ON service_binding_ips(binding_id);
INSERT INTO service_binding_ips (binding_id, ip)
SELECT sbr.binding_id, dr.content
FROM service_binding_records sbr
JOIN dns_records dr ON dr.id = sbr.dns_record_id
WHERE dr.record_type = 'A'
AND dr.content IS NOT NULL
AND TRIM(dr.content) != ''
ON CONFLICT(binding_id, ip) DO NOTHING;
INSERT INTO service_binding_ips (binding_id, ip)
SELECT sb.id, dr.content
FROM service_bindings sb
JOIN dns_records dr ON dr.id = sb.dns_record_id
WHERE dr.record_type = 'A'
AND dr.content IS NOT NULL
AND TRIM(dr.content) != ''
ON CONFLICT(binding_id, ip) DO NOTHING;
@@ -0,0 +1,7 @@
CREATE TABLE service_group_dns_records (
group_id INTEGER NOT NULL REFERENCES service_groups(id) ON DELETE CASCADE,
dns_record_id INTEGER NOT NULL REFERENCES dns_records(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, dns_record_id)
);
CREATE INDEX idx_service_group_dns_records_group_id ON service_group_dns_records(group_id);
+1
View File
@@ -5,6 +5,7 @@ pub mod domains;
pub mod groups;
pub mod health;
pub mod service_bindings;
pub mod service_groups;
pub mod services;
pub mod subdomains;
pub mod sync;
@@ -0,0 +1,49 @@
use crate::error::AppResult;
use crate::services::service_config_service::{self, ServiceGroupBody, ToggleRequest};
use crate::state::AppState;
use axum::extract::{Path, State};
pub async fn list(
State(state): State<AppState>,
) -> AppResult<axum::Json<crate::domain::ServiceGroupsResponse>> {
Ok(axum::Json(
service_config_service::list_group_views(&state.pool).await?,
))
}
pub async fn create(
State(state): State<AppState>,
axum::Json(body): axum::Json<ServiceGroupBody>,
) -> AppResult<axum::Json<crate::domain::ServiceGroup>> {
Ok(axum::Json(
service_config_service::create_group(&state.pool, &state.cf, &body).await?,
))
}
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<ServiceGroupBody>,
) -> AppResult<axum::Json<crate::domain::ServiceGroup>> {
Ok(axum::Json(
service_config_service::update_group(&state.pool, &state.cf, id, &body).await?,
))
}
pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<serde_json::Value>> {
service_config_service::delete_group(&state.pool, id).await?;
Ok(axum::Json(serde_json::json!({ "deleted": true })))
}
pub async fn toggle(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<ToggleRequest>,
) -> AppResult<axum::Json<crate::domain::ServiceGroupsResponse>> {
Ok(axum::Json(
service_config_service::toggle_group(&state.pool, &state.cf, id, body.enabled).await?,
))
}
+28 -11
View File
@@ -1,5 +1,5 @@
use crate::error::AppResult;
use crate::repositories::services as service_repo;
use crate::services::service_config_service::{self, ToggleRequest, UpdateServiceConfigRequest};
use crate::state::AppState;
use axum::extract::{Path, State};
use serde::Deserialize;
@@ -8,38 +8,55 @@ use serde::Deserialize;
pub struct ServiceBody {
pub name: String,
pub slug: String,
pub service_group_id: Option<i64>,
}
pub async fn list(State(state): State<AppState>) -> AppResult<axum::Json<Vec<crate::domain::Service>>> {
Ok(axum::Json(service_repo::list(&state.pool).await?))
pub async fn list(State(state): State<AppState>) -> AppResult<axum::Json<Vec<crate::domain::ServiceView>>> {
Ok(axum::Json(service_config_service::list_views(&state.pool).await?))
}
pub async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<crate::domain::Service>> {
Ok(axum::Json(service_repo::get(&state.pool, id).await?))
) -> AppResult<axum::Json<crate::domain::ServiceView>> {
Ok(axum::Json(service_config_service::get_view(&state.pool, id).await?))
}
pub async fn create(
State(state): State<AppState>,
axum::Json(body): axum::Json<ServiceBody>,
) -> AppResult<axum::Json<crate::domain::Service>> {
Ok(axum::Json(service_repo::create(&state.pool, &body.name, &body.slug).await?))
) -> AppResult<axum::Json<crate::domain::ServiceView>> {
let service = crate::repositories::services::create(&state.pool, &body.name, &body.slug).await?;
if let Some(group_id) = body.service_group_id {
crate::repositories::services::set_group(&state.pool, service.id, Some(group_id)).await?;
}
Ok(axum::Json(service_config_service::get_view(&state.pool, service.id).await?))
}
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<ServiceBody>,
) -> AppResult<axum::Json<crate::domain::Service>> {
Ok(axum::Json(service_repo::update(&state.pool, id, &body.name, &body.slug).await?))
axum::Json(body): axum::Json<UpdateServiceConfigRequest>,
) -> AppResult<axum::Json<crate::domain::ServiceView>> {
Ok(axum::Json(
service_config_service::update_config(&state.pool, &state.cf, id, body).await?,
))
}
pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<serde_json::Value>> {
service_repo::delete(&state.pool, id).await?;
crate::repositories::services::delete(&state.pool, id).await?;
Ok(axum::Json(serde_json::json!({ "deleted": true })))
}
pub async fn toggle(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<ToggleRequest>,
) -> AppResult<axum::Json<crate::domain::ServiceView>> {
Ok(axum::Json(
service_config_service::toggle_service(&state.pool, &state.cf, id, body.enabled).await?,
))
}
+14 -1
View File
@@ -1,4 +1,4 @@
use super::handlers::{auth, certificates, dns, domains, groups, health, service_bindings, services, subdomains, sync};
use super::handlers::{auth, certificates, dns, domains, groups, health, service_bindings, service_groups, services, subdomains, sync};
use crate::state::AppState;
use axum::{
middleware,
@@ -33,6 +33,19 @@ pub fn create_router(state: AppState) -> Router {
.patch(services::update)
.delete(services::delete),
)
.route("/services/{id}/toggle", axum::routing::patch(services::toggle))
.route(
"/service-groups",
get(service_groups::list).post(service_groups::create),
)
.route(
"/service-groups/{id}",
axum::routing::patch(service_groups::update).delete(service_groups::delete),
)
.route(
"/service-groups/{id}/toggle",
axum::routing::patch(service_groups::toggle),
)
.route(
"/service-bindings",
get(service_bindings::list).post(service_bindings::create),
+62
View File
@@ -9,11 +9,47 @@ pub struct Group {
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ServiceGroup {
pub id: i64,
pub name: String,
#[serde(rename = "type")]
pub group_type: String,
pub icon: Option<String>,
pub domain: Option<String>,
pub enabled: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceGroupView {
pub id: i64,
pub name: String,
#[serde(rename = "type")]
pub group_type: String,
pub icon: Option<String>,
pub domain: Option<String>,
pub enabled: bool,
pub created_at: String,
pub updated_at: String,
pub services: Vec<ServiceView>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceGroupsResponse {
pub groups: Vec<ServiceGroupView>,
pub ungrouped: Vec<ServiceView>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Service {
pub id: i64,
pub name: String,
pub slug: String,
pub service_group_id: Option<i64>,
pub subdomain: String,
pub enabled: bool,
pub created_at: String,
pub updated_at: String,
}
@@ -101,6 +137,32 @@ pub struct ServiceBindingView {
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDomainBindingView {
pub binding_id: i64,
pub domain_id: i64,
pub zone_name: String,
pub hostname: String,
pub fqdn: String,
pub target_ips: Vec<String>,
pub sync_status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceView {
pub id: i64,
pub name: String,
pub slug: String,
pub service_group_id: Option<i64>,
pub subdomain: String,
pub enabled: bool,
pub computed_fqdn: Option<String>,
pub created_at: String,
pub updated_at: String,
pub ips: Vec<String>,
pub domains: Vec<ServiceDomainBindingView>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct GroupWithStats {
pub id: i64,
+4
View File
@@ -17,6 +17,10 @@ use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
rustls::crypto::ring::default_provider()
.install_default()
.map_err(|_| "failed to install rustls ring crypto provider")?;
load_dotenv();
let config = Config::from_env().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
+9
View File
@@ -48,6 +48,15 @@ pub async fn list_enriched(pool: &SqlitePool, group_id: Option<i64>) -> AppResul
}
}
pub async fn find_by_zone_name(pool: &SqlitePool, zone_name: &str) -> AppResult<Option<Domain>> {
Ok(sqlx::query_as::<_, Domain>(
"SELECT * FROM domains WHERE LOWER(zone_name) = LOWER(?) LIMIT 1",
)
.bind(zone_name.trim())
.fetch_optional(pool)
.await?)
}
pub async fn get(pool: &SqlitePool, id: i64) -> AppResult<Domain> {
sqlx::query_as::<_, Domain>("SELECT * FROM domains WHERE id = ?")
.bind(id)
+5
View File
@@ -2,7 +2,12 @@ pub mod certificates;
pub mod dns_records;
pub mod domains;
pub mod groups;
pub mod service_binding_ips;
pub mod service_binding_records;
pub mod service_bindings;
pub mod service_group_dns_records;
pub mod service_groups;
pub mod service_ips;
pub mod services;
pub mod subdomains;
pub mod sync_jobs;
@@ -0,0 +1,32 @@
use crate::error::AppResult;
use sqlx::SqlitePool;
pub async fn list_for_binding(pool: &SqlitePool, binding_id: i64) -> AppResult<Vec<String>> {
Ok(sqlx::query_scalar::<_, String>(
"SELECT ip FROM service_binding_ips WHERE binding_id = ? ORDER BY ip",
)
.bind(binding_id)
.fetch_all(pool)
.await?)
}
pub async fn replace_for_binding(
pool: &SqlitePool,
binding_id: i64,
ips: &[String],
) -> AppResult<()> {
sqlx::query("DELETE FROM service_binding_ips WHERE binding_id = ?")
.bind(binding_id)
.execute(pool)
.await?;
for ip in ips {
sqlx::query("INSERT INTO service_binding_ips (binding_id, ip) VALUES (?, ?)")
.bind(binding_id)
.bind(ip)
.execute(pool)
.await?;
}
Ok(())
}
@@ -0,0 +1,59 @@
use crate::domain::DnsRecord;
use crate::error::AppResult;
use sqlx::SqlitePool;
pub async fn list_records_for_binding(
pool: &SqlitePool,
binding_id: i64,
) -> AppResult<Vec<DnsRecord>> {
Ok(sqlx::query_as::<_, DnsRecord>(
r#"
SELECT dr.*
FROM service_binding_records sbr
JOIN dns_records dr ON dr.id = sbr.dns_record_id
WHERE sbr.binding_id = ?
ORDER BY dr.content
"#,
)
.bind(binding_id)
.fetch_all(pool)
.await?)
}
pub async fn link(pool: &SqlitePool, binding_id: i64, dns_record_id: i64) -> AppResult<()> {
sqlx::query(
"INSERT INTO service_binding_records (binding_id, dns_record_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
)
.bind(binding_id)
.bind(dns_record_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn unlink(pool: &SqlitePool, binding_id: i64, dns_record_id: i64) -> AppResult<()> {
sqlx::query(
"DELETE FROM service_binding_records WHERE binding_id = ? AND dns_record_id = ?",
)
.bind(binding_id)
.bind(dns_record_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn unlink_all_for_binding(pool: &SqlitePool, binding_id: i64) -> AppResult<Vec<i64>> {
let ids = sqlx::query_scalar::<_, i64>(
"SELECT dns_record_id FROM service_binding_records WHERE binding_id = ?",
)
.bind(binding_id)
.fetch_all(pool)
.await?;
sqlx::query("DELETE FROM service_binding_records WHERE binding_id = ?")
.bind(binding_id)
.execute(pool)
.await?;
Ok(ids)
}
@@ -32,6 +32,33 @@ pub async fn list_all(pool: &SqlitePool) -> AppResult<Vec<ServiceBindingView>> {
.await?)
}
pub async fn list_by_service(pool: &SqlitePool, service_id: i64) -> AppResult<Vec<ServiceBindingView>> {
let sql = format!("{VIEW_SELECT} WHERE sb.service_id = ? ORDER BY d.zone_name, sb.hostname");
Ok(sqlx::query_as::<_, ServiceBindingView>(&sql)
.bind(service_id)
.fetch_all(pool)
.await?)
}
pub async fn find_for_service_domain_hostname(
pool: &SqlitePool,
service_id: i64,
domain_id: i64,
hostname: &str,
) -> AppResult<Option<ServiceBinding>> {
Ok(sqlx::query_as::<_, ServiceBinding>(
r#"
SELECT * FROM service_bindings
WHERE service_id = ? AND domain_id = ? AND hostname = ?
"#,
)
.bind(service_id)
.bind(domain_id)
.bind(hostname)
.fetch_optional(pool)
.await?)
}
pub async fn list_by_domain(pool: &SqlitePool, domain_id: i64) -> AppResult<Vec<ServiceBindingView>> {
let sql = format!("{VIEW_SELECT} WHERE sb.domain_id = ? ORDER BY s.name");
Ok(sqlx::query_as::<_, ServiceBindingView>(&sql)
@@ -122,6 +149,36 @@ pub async fn set_dns_record_id(pool: &SqlitePool, id: i64, dns_record_id: Option
Ok(())
}
pub async fn bindings_to_remove(
pool: &SqlitePool,
service_id: i64,
keep_ids: &[i64],
) -> AppResult<Vec<ServiceBinding>> {
let bindings = sqlx::query_as::<_, ServiceBinding>(
"SELECT * FROM service_bindings WHERE service_id = ?",
)
.bind(service_id)
.fetch_all(pool)
.await?;
Ok(bindings
.into_iter()
.filter(|binding| !keep_ids.contains(&binding.id))
.collect())
}
pub async fn delete_for_service_except(
pool: &SqlitePool,
service_id: i64,
keep_ids: &[i64],
) -> AppResult<()> {
let to_remove = bindings_to_remove(pool, service_id, keep_ids).await?;
for binding in to_remove {
delete(pool, binding.id).await?;
}
Ok(())
}
pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> {
let affected = sqlx::query("DELETE FROM service_bindings WHERE id = ?")
.bind(id)
@@ -0,0 +1,43 @@
use crate::domain::DnsRecord;
use crate::error::AppResult;
use sqlx::SqlitePool;
pub async fn list_records_for_group(
pool: &SqlitePool,
group_id: i64,
) -> AppResult<Vec<DnsRecord>> {
Ok(sqlx::query_as::<_, DnsRecord>(
r#"
SELECT dr.*
FROM service_group_dns_records sgdr
JOIN dns_records dr ON dr.id = sgdr.dns_record_id
WHERE sgdr.group_id = ?
ORDER BY dr.content
"#,
)
.bind(group_id)
.fetch_all(pool)
.await?)
}
pub async fn link(pool: &SqlitePool, group_id: i64, dns_record_id: i64) -> AppResult<()> {
sqlx::query(
"INSERT INTO service_group_dns_records (group_id, dns_record_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
)
.bind(group_id)
.bind(dns_record_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn unlink(pool: &SqlitePool, group_id: i64, dns_record_id: i64) -> AppResult<()> {
sqlx::query(
"DELETE FROM service_group_dns_records WHERE group_id = ? AND dns_record_id = ?",
)
.bind(group_id)
.bind(dns_record_id)
.execute(pool)
.await?;
Ok(())
}
@@ -0,0 +1,92 @@
use crate::domain::ServiceGroup;
use crate::error::{AppError, AppResult};
use sqlx::SqlitePool;
pub async fn list(pool: &SqlitePool) -> AppResult<Vec<ServiceGroup>> {
Ok(sqlx::query_as::<_, ServiceGroup>(
"SELECT id, name, type AS group_type, icon, domain, enabled, created_at, updated_at FROM service_groups ORDER BY name",
)
.fetch_all(pool)
.await?)
}
pub async fn get(pool: &SqlitePool, id: i64) -> AppResult<ServiceGroup> {
sqlx::query_as::<_, ServiceGroup>(
"SELECT id, name, type AS group_type, icon, domain, enabled, created_at, updated_at FROM service_groups WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("service group {id}")))
}
pub async fn create(
pool: &SqlitePool,
name: &str,
group_type: &str,
icon: Option<&str>,
domain: Option<&str>,
) -> AppResult<ServiceGroup> {
let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO service_groups (name, type, icon, domain) VALUES (?, ?, ?, ?) RETURNING id",
)
.bind(name)
.bind(group_type)
.bind(icon)
.bind(domain)
.fetch_one(pool)
.await?;
get(pool, id).await
}
pub async fn update(
pool: &SqlitePool,
id: i64,
name: &str,
group_type: &str,
icon: Option<&str>,
domain: Option<&str>,
) -> AppResult<ServiceGroup> {
let affected = sqlx::query(
"UPDATE service_groups SET name = ?, type = ?, icon = ?, domain = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(name)
.bind(group_type)
.bind(icon)
.bind(domain)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service group {id}")));
}
get(pool, id).await
}
pub async fn set_enabled(pool: &SqlitePool, id: i64, enabled: bool) -> AppResult<ServiceGroup> {
let affected = sqlx::query(
"UPDATE service_groups SET enabled = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(enabled)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service group {id}")));
}
get(pool, id).await
}
pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> {
let affected = sqlx::query("DELETE FROM service_groups WHERE id = ?")
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service group {id}")));
}
Ok(())
}
+28
View File
@@ -0,0 +1,28 @@
use crate::error::AppResult;
use sqlx::SqlitePool;
pub async fn list_by_service(pool: &SqlitePool, service_id: i64) -> AppResult<Vec<String>> {
let rows = sqlx::query_scalar::<_, String>(
"SELECT ip FROM service_ips WHERE service_id = ? ORDER BY ip",
)
.bind(service_id)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn replace_for_service(pool: &SqlitePool, service_id: i64, ips: &[String]) -> AppResult<()> {
sqlx::query("DELETE FROM service_ips WHERE service_id = ?")
.bind(service_id)
.execute(pool)
.await?;
for ip in ips {
sqlx::query("INSERT INTO service_ips (service_id, ip) VALUES (?, ?)")
.bind(service_id)
.bind(ip)
.execute(pool)
.await?;
}
Ok(())
}
+51 -2
View File
@@ -8,6 +8,23 @@ pub async fn list(pool: &SqlitePool) -> AppResult<Vec<Service>> {
.await?)
}
pub async fn list_by_group(pool: &SqlitePool, group_id: i64) -> AppResult<Vec<Service>> {
Ok(sqlx::query_as::<_, Service>(
"SELECT * FROM services WHERE service_group_id = ? ORDER BY name",
)
.bind(group_id)
.fetch_all(pool)
.await?)
}
pub async fn list_ungrouped(pool: &SqlitePool) -> AppResult<Vec<Service>> {
Ok(sqlx::query_as::<_, Service>(
"SELECT * FROM services WHERE service_group_id IS NULL ORDER BY name",
)
.fetch_all(pool)
.await?)
}
pub async fn get(pool: &SqlitePool, id: i64) -> AppResult<Service> {
sqlx::query_as::<_, Service>("SELECT * FROM services WHERE id = ?")
.bind(id)
@@ -18,10 +35,11 @@ pub async fn get(pool: &SqlitePool, id: i64) -> AppResult<Service> {
pub async fn create(pool: &SqlitePool, name: &str, slug: &str) -> AppResult<Service> {
let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO services (name, slug) VALUES (?, ?) RETURNING id",
"INSERT INTO services (name, slug, subdomain) VALUES (?, ?, ?) RETURNING id",
)
.bind(name)
.bind(slug)
.bind(slug)
.fetch_one(pool)
.await?;
get(pool, id).await
@@ -29,10 +47,41 @@ pub async fn create(pool: &SqlitePool, name: &str, slug: &str) -> AppResult<Serv
pub async fn update(pool: &SqlitePool, id: i64, name: &str, slug: &str) -> AppResult<Service> {
let affected = sqlx::query(
"UPDATE services SET name = ?, slug = ?, updated_at = datetime('now') WHERE id = ?",
"UPDATE services SET name = ?, slug = ?, subdomain = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(name)
.bind(slug)
.bind(slug)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service {id}")));
}
get(pool, id).await
}
pub async fn set_enabled(pool: &SqlitePool, id: i64, enabled: bool) -> AppResult<Service> {
let affected = sqlx::query(
"UPDATE services SET enabled = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(enabled)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service {id}")));
}
get(pool, id).await
}
pub async fn set_group(pool: &SqlitePool, id: i64, group_id: Option<i64>) -> AppResult<Service> {
let affected = sqlx::query(
"UPDATE services SET service_group_id = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(group_id)
.bind(id)
.execute(pool)
.await?
+1
View File
@@ -4,4 +4,5 @@ pub mod certificate_service;
pub mod dns_service;
pub mod domain_service;
pub mod group_service;
pub mod service_config_service;
pub mod sync_service;
@@ -0,0 +1,867 @@
use crate::cloudflare::CloudflareClient;
use crate::domain::{
ServiceDomainBindingView, ServiceGroupView, ServiceGroupsResponse, ServiceView, SYNC_ERROR,
SYNC_PENDING_PUSH, SYNC_SYNCED,
};
use crate::error::{AppError, AppResult};
use crate::repositories::{
domains, service_binding_ips, service_binding_records, service_bindings, service_group_dns_records,
service_groups, service_ips, services as service_repo,
};
use crate::services::dns_service::{self, CreateDnsRequest, UpdateDnsRequest};
use crate::services::domain_service;
use serde::Deserialize;
use sqlx::SqlitePool;
#[derive(Debug, Deserialize)]
pub struct ServiceDomainInput {
pub fqdn: String,
pub target_ips: Option<Vec<String>>,
pub target_ip: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ToggleRequest {
pub enabled: bool,
}
#[derive(Debug, Deserialize)]
pub struct ServiceGroupBody {
pub name: String,
#[serde(rename = "type")]
pub group_type: Option<String>,
pub icon: Option<String>,
pub domain: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateServiceConfigRequest {
pub name: Option<String>,
pub slug: Option<String>,
pub service_group_id: Option<Option<i64>>,
pub ips: Option<Vec<String>>,
pub domains: Option<Vec<ServiceDomainInput>>,
}
pub fn fqdn_to_display(hostname: &str, zone_name: &str) -> String {
if hostname == "@" {
zone_name.to_string()
} else {
format!("{hostname}.{zone_name}")
}
}
pub fn parse_fqdn(fqdn: &str, known_zones: &[String]) -> AppResult<(String, String)> {
let fqdn = fqdn.trim().to_lowercase();
if fqdn.is_empty() {
return Err(AppError::Validation("укажите FQDN".into()));
}
let mut zones: Vec<String> = known_zones.to_vec();
zones.sort_by_key(|z| std::cmp::Reverse(z.len()));
for zone in zones {
let zone_lower = zone.to_lowercase();
if fqdn == zone_lower {
return Ok((zone, "@".to_string()));
}
let suffix = format!(".{zone_lower}");
if let Some(prefix) = fqdn.strip_suffix(&suffix) {
if !prefix.is_empty() {
return Ok((zone, prefix.to_string()));
}
}
}
Err(AppError::Validation(format!(
"не удалось определить зону для «{fqdn}» — зона должна существовать в Cloudflare"
)))
}
fn normalize_ips(ips: &[String]) -> Vec<String> {
let mut out = Vec::new();
for ip in ips {
let trimmed = ip.trim();
if trimmed.is_empty() {
continue;
}
if !is_valid_ipv4(trimmed) {
continue;
}
if !out.iter().any(|v: &String| v == trimmed) {
out.push(trimmed.to_string());
}
}
out.sort();
out
}
fn is_valid_ipv4(ip: &str) -> bool {
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() != 4 {
return false;
}
parts.iter().all(|p| p.parse::<u16>().ok().is_some_and(|n| n <= 255))
}
fn aggregate_sync_status(statuses: &[String]) -> Option<String> {
if statuses.is_empty() {
return None;
}
if statuses.iter().any(|s| s == SYNC_ERROR) {
return Some(SYNC_ERROR.into());
}
if statuses.iter().any(|s| s == SYNC_PENDING_PUSH) {
return Some(SYNC_PENDING_PUSH.into());
}
if statuses.iter().all(|s| s == SYNC_SYNCED) {
return Some(SYNC_SYNCED.into());
}
statuses.first().cloned()
}
async fn collect_known_zones(pool: &SqlitePool, cf: &CloudflareClient) -> AppResult<Vec<String>> {
let db_domains = domains::list(pool, None).await?;
let mut zones: Vec<String> = db_domains.into_iter().map(|d| d.zone_name).collect();
let cf_zones = cf.list_zones().await?;
for zone in cf_zones {
if !zones
.iter()
.any(|name| name.eq_ignore_ascii_case(&zone.name))
{
zones.push(zone.name);
}
}
Ok(zones)
}
pub async fn list_views(pool: &SqlitePool) -> AppResult<Vec<ServiceView>> {
let services = service_repo::list(pool).await?;
let mut views = Vec::with_capacity(services.len());
for service in services {
views.push(build_view_for_service(pool, service.id).await?);
}
Ok(views)
}
pub async fn get_view(pool: &SqlitePool, id: i64) -> AppResult<ServiceView> {
service_repo::get(pool, id).await?;
build_view_for_service(pool, id).await
}
pub async fn list_group_views(pool: &SqlitePool) -> AppResult<ServiceGroupsResponse> {
let groups = service_groups::list(pool).await?;
let mut group_views = Vec::with_capacity(groups.len());
for group in groups {
let services = service_repo::list_by_group(pool, group.id).await?;
let mut service_views = Vec::with_capacity(services.len());
for service in services {
service_views.push(build_view(pool, service.id).await?);
}
group_views.push(ServiceGroupView {
id: group.id,
name: group.name,
group_type: group.group_type,
icon: group.icon,
domain: group.domain,
enabled: group.enabled,
created_at: group.created_at,
updated_at: group.updated_at,
services: service_views,
});
}
let ungrouped_services = service_repo::list_ungrouped(pool).await?;
let mut ungrouped = Vec::with_capacity(ungrouped_services.len());
for service in ungrouped_services {
ungrouped.push(build_view(pool, service.id).await?);
}
Ok(ServiceGroupsResponse {
groups: group_views,
ungrouped,
})
}
async fn build_view(pool: &SqlitePool, service_id: i64) -> AppResult<ServiceView> {
let service = service_repo::get(pool, service_id).await?;
let ips = service_ips::list_by_service(pool, service_id).await?;
let bindings = service_bindings::list_by_service(pool, service_id).await?;
let mut domain_views = Vec::with_capacity(bindings.len());
for binding in bindings {
let records = service_binding_records::list_records_for_binding(pool, binding.id).await?;
let statuses: Vec<String> = records.iter().map(|r| r.sync_status.clone()).collect();
let target_ips = service_binding_ips::list_for_binding(pool, binding.id).await?;
domain_views.push(ServiceDomainBindingView {
binding_id: binding.id,
domain_id: binding.domain_id,
zone_name: binding.zone_name.clone(),
hostname: binding.hostname.clone(),
fqdn: fqdn_to_display(&binding.hostname, &binding.zone_name),
target_ips,
sync_status: aggregate_sync_status(&statuses),
});
}
Ok(ServiceView {
id: service.id,
name: service.name,
slug: service.slug,
service_group_id: service.service_group_id,
subdomain: service.subdomain.clone(),
enabled: service.enabled,
computed_fqdn: None,
created_at: service.created_at,
updated_at: service.updated_at,
ips,
domains: domain_views,
})
}
async fn build_view_for_service(pool: &SqlitePool, service_id: i64) -> AppResult<ServiceView> {
build_view(pool, service_id).await
}
async fn cleanup_service_dns_only(
pool: &SqlitePool,
cf: &CloudflareClient,
service_id: i64,
) -> AppResult<()> {
let bindings = service_bindings::list_by_service(pool, service_id).await?;
for binding in bindings {
cleanup_binding_dns(pool, cf, binding.id, binding.domain_id, &binding.hostname).await?;
}
Ok(())
}
/// DNS в Cloudflare публикуется только для включённого сервиса (и группы, если сервис в группе).
async fn should_push_dns_to_cloudflare(
pool: &SqlitePool,
service: &crate::domain::Service,
) -> AppResult<bool> {
if !service.enabled {
return Ok(false);
}
let Some(group_id) = service.service_group_id else {
return Ok(true);
};
let group = service_groups::get(pool, group_id).await?;
Ok(group.enabled)
}
/// Синхронизирует DNS по явным привязкам FQDN → target IP сервиса.
async fn sync_service_bindings_to_dns(
pool: &SqlitePool,
cf: &CloudflareClient,
service_id: i64,
) -> AppResult<()> {
let ips = service_ips::list_by_service(pool, service_id).await?;
if ips.is_empty() {
return Err(AppError::Validation(
"добавьте IP-адреса в пул сервиса".into(),
));
}
let bindings = service_bindings::list_by_service(pool, service_id).await?;
if bindings.is_empty() {
return Err(AppError::Validation(
"настройте FQDN в редакторе сервиса".into(),
));
}
for binding in bindings {
let target_ips = service_binding_ips::list_for_binding(pool, binding.id).await?;
if target_ips.is_empty() {
return Err(AppError::Validation(format!(
"укажите IP для {}",
fqdn_to_display(&binding.hostname, &binding.zone_name)
)));
}
validate_target_ips_in_pool(&target_ips, &ips)?;
sync_binding_dns(
pool,
cf,
binding.id,
binding.domain_id,
&binding.hostname,
&target_ips,
)
.await?;
}
Ok(())
}
async fn collect_group_dns_ips(pool: &SqlitePool, group_id: i64) -> AppResult<Vec<String>> {
let services = service_repo::list_by_group(pool, group_id).await?;
let mut ips = Vec::new();
for service in services {
if !service.enabled {
continue;
}
let bindings = service_bindings::list_by_service(pool, service.id).await?;
for binding in bindings {
for ip in service_binding_ips::list_for_binding(pool, binding.id).await? {
if !ips.iter().any(|v| v == &ip) {
ips.push(ip);
}
}
}
}
ips.sort();
Ok(ips)
}
async fn sync_group_domain_dns_records(
pool: &SqlitePool,
cf: &CloudflareClient,
group_id: i64,
domain_id: i64,
hostname: &str,
desired_ips: &[String],
) -> AppResult<()> {
let existing_records = service_group_dns_records::list_records_for_group(pool, group_id).await?;
for record in &existing_records {
if !desired_ips.contains(&record.content) {
service_group_dns_records::unlink(pool, group_id, record.id).await?;
dns_service::delete_record(pool, cf, domain_id, record.id).await?;
}
}
if desired_ips.is_empty() {
return Ok(());
}
let refreshed = service_group_dns_records::list_records_for_group(pool, group_id).await?;
for ip in desired_ips {
if let Some(record) = refreshed.iter().find(|r| r.content == *ip) {
if record.name != hostname {
dns_service::update(
pool,
cf,
domain_id,
record.id,
UpdateDnsRequest {
record_type: Some("A".into()),
name: Some(hostname.to_string()),
content: Some(ip.clone()),
ttl: None,
proxied: Some(false),
priority: None,
},
)
.await?;
}
continue;
}
let record = dns_service::create(
pool,
cf,
domain_id,
CreateDnsRequest {
record_type: "A".into(),
name: hostname.to_string(),
content: ip.clone(),
ttl: Some(1),
proxied: Some(false),
priority: None,
},
)
.await?;
service_group_dns_records::link(pool, group_id, record.id).await?;
}
Ok(())
}
async fn cleanup_group_domain_dns(pool: &SqlitePool, cf: &CloudflareClient, group_id: i64) -> AppResult<()> {
let group = service_groups::get(pool, group_id).await?;
let Some(domain_value) = group
.domain
.as_deref()
.map(str::trim)
.filter(|d| !d.is_empty())
else {
return Ok(());
};
let known_zones = collect_known_zones(pool, cf).await?;
let (zone_name, hostname) = parse_fqdn(domain_value, &known_zones)?;
let domain_id = resolve_domain_id(pool, cf, &zone_name).await?;
sync_group_domain_dns_records(pool, cf, group_id, domain_id, &hostname, &[]).await
}
async fn sync_group_domain_dns(pool: &SqlitePool, cf: &CloudflareClient, group_id: i64) -> AppResult<()> {
let group = service_groups::get(pool, group_id).await?;
if !group.enabled {
return cleanup_group_domain_dns(pool, cf, group_id).await;
}
let Some(domain_value) = group
.domain
.as_deref()
.map(str::trim)
.filter(|d| !d.is_empty())
else {
return Ok(());
};
let known_zones = collect_known_zones(pool, cf).await?;
let (zone_name, hostname) = parse_fqdn(domain_value, &known_zones)?;
let domain_id = resolve_domain_id(pool, cf, &zone_name).await?;
let desired_ips = collect_group_dns_ips(pool, group_id).await?;
sync_group_domain_dns_records(
pool,
cf,
group_id,
domain_id,
&hostname,
&desired_ips,
)
.await
}
async fn sync_group_domain_for_service(pool: &SqlitePool, cf: &CloudflareClient, service_id: i64) -> AppResult<()> {
let service = service_repo::get(pool, service_id).await?;
let Some(group_id) = service.service_group_id else {
return Ok(());
};
sync_group_domain_dns(pool, cf, group_id).await
}
async fn sync_enabled_services_in_group(
pool: &SqlitePool,
cf: &CloudflareClient,
group_id: i64,
) -> AppResult<()> {
let group = service_groups::get(pool, group_id).await?;
if !group.enabled {
return Ok(());
}
if group
.domain
.as_deref()
.map(str::trim)
.filter(|d| !d.is_empty())
.is_none()
{
return Ok(());
}
let services = service_repo::list_by_group(pool, group_id).await?;
for service in services {
if service.enabled {
sync_service_bindings_to_dns(pool, cf, service.id).await?;
}
}
sync_group_domain_dns(pool, cf, group_id).await?;
Ok(())
}
async fn resolve_domain_id(
pool: &SqlitePool,
cf: &CloudflareClient,
zone_name: &str,
) -> AppResult<i64> {
let zone_name = zone_name.trim();
if zone_name.is_empty() {
return Err(AppError::Validation("укажите имя зоны".into()));
}
if let Some(domain) = domains::find_by_zone_name(pool, zone_name).await? {
return Ok(domain.id);
}
let created = domain_service::create_domain(pool, cf, None, zone_name).await?;
Ok(created.id)
}
async fn sync_binding_dns(
pool: &SqlitePool,
cf: &CloudflareClient,
binding_id: i64,
domain_id: i64,
hostname: &str,
desired_ips: &[String],
) -> AppResult<()> {
let existing_records =
service_binding_records::list_records_for_binding(pool, binding_id).await?;
for record in &existing_records {
if !desired_ips.contains(&record.content) {
service_binding_records::unlink(pool, binding_id, record.id).await?;
dns_service::delete_record(pool, cf, domain_id, record.id).await?;
}
}
if desired_ips.is_empty() {
service_bindings::set_dns_record_id(pool, binding_id, None).await?;
return Ok(());
}
let refreshed = service_binding_records::list_records_for_binding(pool, binding_id).await?;
let mut primary_id: Option<i64> = None;
for ip in desired_ips {
let record_id = if let Some(record) = refreshed.iter().find(|r| r.content == *ip) {
if record.name != hostname {
dns_service::update(
pool,
cf,
domain_id,
record.id,
UpdateDnsRequest {
record_type: Some("A".into()),
name: Some(hostname.to_string()),
content: Some(ip.clone()),
ttl: None,
proxied: Some(false),
priority: None,
},
)
.await?;
}
record.id
} else {
let record = dns_service::create(
pool,
cf,
domain_id,
CreateDnsRequest {
record_type: "A".into(),
name: hostname.to_string(),
content: ip.clone(),
ttl: Some(1),
proxied: Some(false),
priority: None,
},
)
.await?;
service_binding_records::link(pool, binding_id, record.id).await?;
record.id
};
if primary_id.is_none() {
primary_id = Some(record_id);
}
}
service_bindings::set_dns_record_id(pool, binding_id, primary_id).await?;
Ok(())
}
async fn cleanup_binding_dns(
pool: &SqlitePool,
cf: &CloudflareClient,
binding_id: i64,
domain_id: i64,
hostname: &str,
) -> AppResult<()> {
sync_binding_dns(pool, cf, binding_id, domain_id, hostname, &[]).await
}
fn binding_target_ips(input: &ServiceDomainInput) -> AppResult<Vec<String>> {
let raw = if let Some(ips) = &input.target_ips {
ips.clone()
} else if let Some(ip) = input.target_ip.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
vec![ip.to_string()]
} else {
Vec::new()
};
let normalized = normalize_ips(&raw);
if !raw.is_empty() && normalized.is_empty() {
return Err(AppError::Validation(
"некорректные IP в привязке домена".into(),
));
}
Ok(normalized)
}
fn validate_target_ips_in_pool(target_ips: &[String], ips: &[String]) -> AppResult<()> {
for ip in target_ips {
if !is_valid_ipv4(ip) {
return Err(AppError::Validation(format!("некорректный IPv4: {ip}")));
}
if !ips.iter().any(|pool_ip| pool_ip == ip) {
return Err(AppError::Validation(format!(
"IP {ip} не входит в пул адресов сервиса"
)));
}
}
Ok(())
}
pub async fn update_config(
pool: &SqlitePool,
cf: &CloudflareClient,
id: i64,
req: UpdateServiceConfigRequest,
) -> AppResult<ServiceView> {
if let (Some(name), Some(slug)) = (&req.name, &req.slug) {
service_repo::update(pool, id, name, slug).await?;
} else if let Some(name) = &req.name {
let existing = service_repo::get(pool, id).await?;
service_repo::update(pool, id, name, &existing.slug).await?;
} else if let Some(slug) = &req.slug {
let existing = service_repo::get(pool, id).await?;
service_repo::update(pool, id, &existing.name, slug).await?;
}
if let Some(group_id) = req.service_group_id {
service_repo::set_group(pool, id, group_id).await?;
}
let ips_updated = req.ips.is_some();
let known_zones = collect_known_zones(pool, cf).await?;
let ips = if let Some(ref raw_ips) = req.ips {
normalize_ips(raw_ips)
} else {
service_ips::list_by_service(pool, id).await?
};
if ips_updated {
service_ips::replace_for_service(pool, id, &ips).await?;
}
let mut kept_binding_ids = Vec::new();
let service = service_repo::get(pool, id).await?;
let push_dns = should_push_dns_to_cloudflare(pool, &service).await?;
if let Some(domain_inputs) = req.domains {
if !domain_inputs.is_empty() {
for input in domain_inputs {
let fqdn = input.fqdn.trim();
if fqdn.is_empty() {
continue;
}
let target_ips = binding_target_ips(&input)?;
validate_target_ips_in_pool(&target_ips, &ips)?;
let (zone_name, hostname) = parse_fqdn(fqdn, &known_zones)?;
let domain_id = resolve_domain_id(pool, cf, &zone_name).await?;
let binding = if let Some(existing) =
service_bindings::find_for_service_domain_hostname(pool, id, domain_id, &hostname)
.await?
{
existing
} else {
service_bindings::insert(pool, domain_id, id, &hostname, None).await?
};
kept_binding_ids.push(binding.id);
service_binding_ips::replace_for_binding(pool, binding.id, &target_ips).await?;
if push_dns {
sync_binding_dns(
pool,
cf,
binding.id,
domain_id,
&hostname,
&target_ips,
)
.await?;
}
}
let removed = service_bindings::bindings_to_remove(pool, id, &kept_binding_ids).await?;
for binding in &removed {
cleanup_binding_dns(pool, cf, binding.id, binding.domain_id, &binding.hostname)
.await?;
}
service_bindings::delete_for_service_except(pool, id, &kept_binding_ids).await?;
}
} else if ips_updated {
let bindings = service_bindings::list_by_service(pool, id).await?;
for binding in bindings {
let target_ips = service_binding_ips::list_for_binding(pool, binding.id).await?;
for ip in &target_ips {
if !ips.contains(ip) {
return Err(AppError::Validation(format!(
"IP {} привязан к {}, но отсутствует в новом пуле адресов",
ip,
fqdn_to_display(&binding.hostname, &binding.zone_name)
)));
}
}
}
}
let service = service_repo::get(pool, id).await?;
if should_push_dns_to_cloudflare(pool, &service).await? {
sync_service_bindings_to_dns(pool, cf, id).await?;
sync_group_domain_for_service(pool, cf, id).await?;
}
get_view(pool, id).await
}
async fn normalize_group_domain(
pool: &SqlitePool,
cf: &CloudflareClient,
domain: Option<&str>,
) -> AppResult<Option<String>> {
let Some(raw) = domain.map(str::trim).filter(|d| !d.is_empty()) else {
return Ok(None);
};
let known_zones = collect_known_zones(pool, cf).await?;
let (zone_name, hostname) = parse_fqdn(raw, &known_zones)?;
Ok(Some(fqdn_to_display(&hostname, &zone_name)))
}
async fn cleanup_stale_group_fqdn_bindings(
pool: &SqlitePool,
cf: &CloudflareClient,
group_id: i64,
fqdn: &str,
) -> AppResult<()> {
let known_zones = collect_known_zones(pool, cf).await?;
let (zone_name, hostname) = parse_fqdn(fqdn, &known_zones)?;
if hostname == "@" {
return Ok(());
}
let Some(domain) = domains::find_by_zone_name(pool, &zone_name).await? else {
return Ok(());
};
let services = service_repo::list_by_group(pool, group_id).await?;
for service in services {
let Some(binding) = service_bindings::find_for_service_domain_hostname(
pool,
service.id,
domain.id,
&hostname,
)
.await?
else {
continue;
};
cleanup_binding_dns(pool, cf, binding.id, binding.domain_id, &binding.hostname).await?;
service_bindings::delete(pool, binding.id).await?;
}
Ok(())
}
pub async fn create_group(
pool: &SqlitePool,
cf: &CloudflareClient,
body: &ServiceGroupBody,
) -> AppResult<crate::domain::ServiceGroup> {
let group_type = body
.group_type
.as_deref()
.filter(|t| !t.is_empty())
.unwrap_or("custom");
let domain = normalize_group_domain(pool, cf, body.domain.as_deref()).await?;
service_groups::create(
pool,
&body.name,
group_type,
body.icon.as_deref(),
domain.as_deref(),
)
.await
}
pub async fn update_group(
pool: &SqlitePool,
cf: &CloudflareClient,
id: i64,
body: &ServiceGroupBody,
) -> AppResult<crate::domain::ServiceGroup> {
let group_type = body
.group_type
.as_deref()
.filter(|t| !t.is_empty())
.unwrap_or("custom");
let previous = service_groups::get(pool, id).await?;
if let Some(old_domain) = previous.domain.as_deref().map(str::trim).filter(|d| !d.is_empty()) {
cleanup_stale_group_fqdn_bindings(pool, cf, id, old_domain).await?;
cleanup_group_domain_dns(pool, cf, id).await?;
}
let domain = normalize_group_domain(pool, cf, body.domain.as_deref()).await?;
let group = service_groups::update(
pool,
id,
&body.name,
group_type,
body.icon.as_deref(),
domain.as_deref(),
)
.await?;
sync_enabled_services_in_group(pool, cf, id).await?;
Ok(group)
}
pub async fn delete_group(pool: &SqlitePool, id: i64) -> AppResult<()> {
service_groups::delete(pool, id).await
}
pub async fn toggle_service(
pool: &SqlitePool,
cf: &CloudflareClient,
service_id: i64,
enabled: bool,
) -> AppResult<ServiceView> {
let service = service_repo::get(pool, service_id).await?;
if enabled {
if let Some(group_id) = service.service_group_id {
let group = service_groups::get(pool, group_id).await?;
if !group.enabled {
return Err(AppError::Validation(
"сначала включите группу сервисов".into(),
));
}
if group
.domain
.as_deref()
.map(str::trim)
.filter(|d| !d.is_empty())
.is_none()
{
return Err(AppError::Validation(
"укажите домен у группы сервисов".into(),
));
}
}
}
service_repo::set_enabled(pool, service_id, enabled).await?;
if !enabled {
cleanup_service_dns_only(pool, cf, service_id).await?;
sync_group_domain_for_service(pool, cf, service_id).await?;
return get_view(pool, service_id).await;
}
sync_service_bindings_to_dns(pool, cf, service_id).await?;
sync_group_domain_for_service(pool, cf, service_id).await?;
get_view(pool, service_id).await
}
pub async fn toggle_group(
pool: &SqlitePool,
cf: &CloudflareClient,
group_id: i64,
enabled: bool,
) -> AppResult<ServiceGroupsResponse> {
service_groups::set_enabled(pool, group_id, enabled).await?;
if !enabled {
let services = service_repo::list_by_group(pool, group_id).await?;
for service in services {
if service.enabled {
service_repo::set_enabled(pool, service.id, false).await?;
cleanup_service_dns_only(pool, cf, service.id).await?;
}
}
cleanup_group_domain_dns(pool, cf, group_id).await?;
} else {
sync_enabled_services_in_group(pool, cf, group_id).await?;
}
list_group_views(pool).await
}
+1
View File
@@ -0,0 +1 @@
{"rustc_fingerprint":10405975024632683256,"outputs":{"5414958869613609987":{"success":true,"status":"","code":0,"stdout":"rustc 1.96.0 (ac68faa20 2026-05-25)\nbinary: rustc\ncommit-hash: ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96\ncommit-date: 2026-05-25\nhost: x86_64-pc-windows-msvc\nrelease: 1.96.0\nLLVM version: 22.1.2\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\shats\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""}},"successes":{}}
+3
View File
@@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by cargo.
# For information about cache directory tags see https://bford.info/cachedir/
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"alloc\"]","declared_features":"[\"alloc\", \"default\", \"fresh-rust\", \"nightly\", \"serde\", \"std\"]","target":5388200169723499962,"profile":12994027242049262075,"path":858156221311629571,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\allocator-api2-2a8f64255b7efe9c\\dep-lib-allocator_api2","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
e831c6c5ed209b7e
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[]","declared_features":"[\"portable-atomic\"]","target":14411119108718288063,"profile":15657897354478470176,"path":15437344850296186914,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\atomic-waker-a63bfe5478bbd105\\dep-lib-atomic_waker","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
b9f33158d267c092
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[]","declared_features":"[]","target":6962977057026645649,"profile":2225463790103693989,"path":556384679085276519,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\autocfg-8c965f4063c10cfd\\dep-lib-autocfg","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"aws-lc-sys\", \"prebuilt-nasm\"]","declared_features":"[\"alloc\", \"asan\", \"aws-lc-sys\", \"bindgen\", \"default\", \"dev-tests-only\", \"fips\", \"legacy-des\", \"non-fips\", \"prebuilt-nasm\", \"ring-io\", \"ring-sig-verify\", \"test_logging\", \"unstable\"]","target":5408242616063297496,"profile":2225463790103693989,"path":17378911581759362327,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\aws-lc-rs-314c0c119bd5fad7\\dep-build-script-build-script-build","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"prebuilt-nasm\"]","declared_features":"[\"all-bindings\", \"asan\", \"bindgen\", \"default\", \"disable-prebuilt-nasm\", \"fips\", \"prebuilt-nasm\", \"ssl\"]","target":10419965325687163515,"profile":2225463790103693989,"path":6516759104092648623,"deps":[[6778462791484060249,"cmake",false,8499549698829582152],[10941422031512991391,"cc",false,1822230614944177140],[11989259058781683633,"dunce",false,14718673328393535644],[13866570822711233627,"fs_extra",false,8310801538502535119]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\aws-lc-sys-241b387d6b60cf96\\dep-build-script-build-script-main","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
33c1dd5a2011c388
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"alloc\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":13060062996227388079,"profile":2225463790103693989,"path":5400602453988895566,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\base64-3192b3561e34161f\\dep-lib-base64","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
ac5b72562c5fd97f
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":13060062996227388079,"profile":15657897354478470176,"path":5400602453988895566,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\base64-ca72789b075579c1\\dep-lib-base64","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
34b884917b38f88d
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[]","declared_features":"[]","target":4098124618827574291,"profile":15657897354478470176,"path":6693771042739296001,"deps":[[10520923840501062997,"generic_array",false,1146696233455483665]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\block-buffer-79c3cbccb2d3574f\\dep-lib-block_buffer","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
6d04ddceccb2cde3
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"extra-platforms\", \"serde\", \"std\"]","target":11402411492164584411,"profile":5585765287293540646,"path":6474649128878754451,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\bytes-2374475d4ec174da\\dep-lib-bytes","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
f4db96f616dd4919
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"parallel\"]","declared_features":"[\"jobserver\", \"parallel\"]","target":11042037588551934598,"profile":4333757155065362140,"path":9146646945818721565,"deps":[[9159843920629750842,"find_msvc_tools",false,1898077288473079768],[12678166843757613889,"shlex",false,12920360296390691516],[16589527331085190088,"jobserver",false,14657419342798237281]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\cc-768aef08dd0f9d30\\dep-lib-cc","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
2cd3fb7b0f6980b5
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[]","declared_features":"[\"core\", \"rustc-dep-of-std\"]","target":13840298032947503755,"profile":15657897354478470176,"path":9313841782024363090,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\cfg-if-50da2642d7d10359\\dep-lib-cfg_if","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
6fd7a02770ff0d21
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"alloc\", \"clock\", \"iana-time-zone\", \"now\", \"std\", \"winapi\", \"windows-link\"]","declared_features":"[\"__internal_bench\", \"alloc\", \"arbitrary\", \"clock\", \"core-error\", \"default\", \"defmt\", \"iana-time-zone\", \"js-sys\", \"libc\", \"now\", \"oldtime\", \"pure-rust-locales\", \"rkyv\", \"rkyv-16\", \"rkyv-32\", \"rkyv-64\", \"rkyv-validation\", \"serde\", \"std\", \"unstable-locales\", \"wasm-bindgen\", \"wasmbind\", \"winapi\", \"windows-link\"]","target":15315924755136109342,"profile":2225463790103693989,"path":603728486674853559,"deps":[[5157631553186200874,"num_traits",false,11933587593128332154],[6959378045035346538,"windows_link",false,15251307201754467492]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\chrono-d67f72f8eeb6b168\\dep-lib-chrono","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
48ff83256b77f475
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[]","declared_features":"[]","target":7530650721721229426,"profile":2225463790103693989,"path":6606306489662641573,"deps":[[10941422031512991391,"cc",false,1822230614944177140]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\cmake-7ca4466e63a0ceeb\\dep-lib-cmake","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"std\"]","declared_features":"[\"default\", \"loom\", \"portable-atomic\", \"std\"]","target":13225166943538818286,"profile":15657897354478470176,"path":5091892759674352740,"deps":[[4468123440088164316,"crossbeam_utils",false,10899212479415549947]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\concurrent-queue-c441731d28dd1257\\dep-lib-concurrent_queue","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.

Some files were not shown because too many files have changed in this diff Show More