diff --git a/.cursor/settings.json b/.cursor/settings.json
index 5a97eaf..aa9f40d 100644
--- a/.cursor/settings.json
+++ b/.cursor/settings.json
@@ -2,6 +2,9 @@
"plugins": {
"cloudflare": {
"enabled": true
+ },
+ "claude-plugins-official/typescript-lsp": {
+ "enabled": true
}
}
}
diff --git a/apps/web/package.json b/apps/web/package.json
index 20a9d16..a515187 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -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",
diff --git a/apps/web/src/components/service-binding-ip-input.tsx b/apps/web/src/components/service-binding-ip-input.tsx
new file mode 100644
index 0000000..e600ef3
--- /dev/null
+++ b/apps/web/src/components/service-binding-ip-input.tsx
@@ -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 (
+
+
isValidIpv4(ip) && pool.includes(ip)}
+ disabled={disabled || isPoolEmpty}
+ />
+ {available.length > 0 ? (
+
+ {available.map((ip) => (
+
+ ))}
+
+ ) : null}
+
+ )
+}
diff --git a/apps/web/src/components/service-card.tsx b/apps/web/src/components/service-card.tsx
new file mode 100644
index 0000000..7efeab6
--- /dev/null
+++ b/apps/web/src/components/service-card.tsx
@@ -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 (
+
+
+ {service.name}
+
+ {service.slug}
+
+
+
+
+
IP-адреса
+ {ips.length === 0 ? (
+
—
+ ) : (
+
+ {ips.map((ip) => (
+
+ {ip}
+
+ ))}
+
+ )}
+
+
+
+
Домены
+ {domains.length === 0 ? (
+
Не привязаны
+ ) : (
+
+ {domains.map((binding) => (
+ -
+
+
+ {bindingToFqdn(binding)}
+ {binding.target_ips.map((ip) => (
+
+ {ip}
+
+ ))}
+ {binding.sync_status ? (
+
+ ) : null}
+
+
+
+ ))}
+
+ )}
+
+
+
+ {syncStatus ? : null}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/service-edit-sheet.tsx b/apps/web/src/components/service-edit-sheet.tsx
new file mode 100644
index 0000000..10e2a11
--- /dev/null
+++ b/apps/web/src/components/service-edit-sheet.tsx
@@ -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([])
+ const [bindings, setBindings] = useState([])
+
+ 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 (
+
+
+
+ {isCreate ? 'Новый сервис' : 'Редактирование сервиса'}
+
+ Настройте IP-пул и привязки FQDN → IP. Зона определяется из FQDN автоматически.
+
+
+
+
+
+ Название
+ setName(e.target.value)}
+ />
+
+
+ Slug
+ setSlug(e.target.value)}
+ />
+
+
+ Группа сервисов
+
+
+
+ IP-адреса сервиса
+
+
+
+
+
+
+
Привязки доменов
+
+
+ {bindings.length === 0 ? (
+
+ Необязательно. Введите FQDN, например newdom.ivx.su — зона ivx.su определится
+ автоматически.
+
+ ) : (
+
+ {bindings.map((binding, index) => (
+ -
+
+
+ FQDN
+ handleFqdnChange(index, tags)}
+ placeholder={zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'}
+ maxItems={1}
+ />
+
+
+ IP
+ handleIpsChange(index, targetIps)}
+ />
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {!isCreate ? (
+
+ ) : null}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/service-group-card.tsx b/apps/web/src/components/service-group-card.tsx
new file mode 100644
index 0000000..292a5d9
--- /dev/null
+++ b/apps/web/src/components/service-group-card.tsx
@@ -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 (
+
+
+
+
+
+
+
+ {group.name}
+
+ {group.domain ? (
+ {group.domain}
+ ) : null}
+
+
+
+ {isGroupToggling ? (
+
+ ) : (
+ onGroupToggle(group.id, checked)}
+ aria-label={`${group.enabled ? 'Выключить' : 'Включить'} группу ${group.name}`}
+ />
+ )}
+
+
+
+
+ {group.services.length === 0 ? (
+ Нет сервисов в группе
+ ) : (
+
+ {group.services.map((service) => (
+
+ ))}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/service-group-edit-sheet.tsx b/apps/web/src/components/service-group-edit-sheet.tsx
new file mode 100644
index 0000000..4bb7ea4
--- /dev/null
+++ b/apps/web/src/components/service-group-edit-sheet.tsx
@@ -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('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 (
+
+
+
+
+ {mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
+
+
+ Домен группы — любой FQDN (gr.ivx.su, domain.new.ivx.su). Публикуется в Cloudflare отдельно от привязок сервисов.
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/service-group-icon.tsx b/apps/web/src/components/service-group-icon.tsx
new file mode 100644
index 0000000..8ae0a30
--- /dev/null
+++ b/apps/web/src/components/service-group-icon.tsx
@@ -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 = {
+ 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
+}
diff --git a/apps/web/src/components/service-ip-combobox.tsx b/apps/web/src/components/service-ip-combobox.tsx
new file mode 100644
index 0000000..f61ddf8
--- /dev/null
+++ b/apps/web/src/components/service-ip-combobox.tsx
@@ -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 (
+ onChange(next ?? '')}
+ disabled={isDisabled}
+ >
+
+
+ Нет совпадений
+
+ {(item: string) => (
+
+ {item}
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/service-row.tsx b/apps/web/src/components/service-row.tsx
new file mode 100644
index 0000000..657466b
--- /dev/null
+++ b/apps/web/src/components/service-row.tsx
@@ -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 (
+ -
+
+ {service.name}
+ {fqdn}
+
+
+ {syncStatus ? : null}
+ {isToggling ? (
+
+ ) : (
+ onToggle(service.id, checked)}
+ aria-label={`${service.enabled ? 'Выключить' : 'Включить'} ${service.name}`}
+ />
+ )}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/tagged-input.tsx b/apps/web/src/components/tagged-input.tsx
new file mode 100644
index 0000000..dfe9deb
--- /dev/null
+++ b/apps/web/src/components/tagged-input.tsx
@@ -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 (
+
+ {value.map((tag) => (
+
+ {tag}
+ removeTag(tag)}
+ >
+
+
+
+ ))}
+ 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}
+ />
+
+ )
+}
+
+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)
+}
diff --git a/apps/web/src/lib/parse-fqdn.ts b/apps/web/src/lib/parse-fqdn.ts
new file mode 100644
index 0000000..7edee73
--- /dev/null
+++ b/apps/web/src/lib/parse-fqdn.ts
@@ -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)
+}
diff --git a/apps/web/src/lib/schemas.ts b/apps/web/src/lib/schemas.ts
index c2ed73a..e1dfbdc 100644
--- a/apps/web/src/lib/schemas.ts
+++ b/apps/web/src/lib/schemas.ts
@@ -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
export type GroupWithStats = z.infer
export type Service = z.infer
+export type ServiceDomainBinding = z.infer
+export type ServiceView = z.infer
+export type ServiceGroup = z.infer
+export type ServiceGroupView = z.infer
+export type ServiceGroupsResponse = z.infer
export type Domain = z.infer
export type DomainListItem = z.infer
export type ServiceBinding = z.infer
@@ -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
export type CreateServiceInput = z.infer
+export type CreateServiceWithConfigInput = z.infer
+
+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
+
+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
+export type ToggleEnabledInput = z.infer
export type CreateServiceBindingInput = z.infer
export type CreateDomainInput = z.infer
export type LoginInput = z.infer
diff --git a/apps/web/src/lib/service-utils.ts b/apps/web/src/lib/service-utils.ts
new file mode 100644
index 0000000..89c646d
--- /dev/null
+++ b/apps/web/src/lib/service-utils.ts
@@ -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]
+}
diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts
index d869986..5882c92 100644
--- a/apps/web/src/queries/index.ts
+++ b/apps/web/src/queries/index.ts
@@ -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('/api/v1/service-groups')
+ return serviceGroupsResponseSchema.parse(data)
+ },
+ })
+
export const servicesQueryOptions = () =>
queryOptions({
queryKey: serviceKeys.all,
queryFn: async () => {
const data = await api.get('/api/v1/services')
- return z.array(serviceSchema).parse(data)
+ return z.array(serviceViewSchema).parse(data)
},
})
diff --git a/apps/web/src/routes/_auth/services.tsx b/apps/web/src/routes/_auth/services.tsx
index def6a65..e13807f 100644
--- a/apps/web/src/routes/_auth/services.tsx
+++ b/apps/web/src/routes/_auth/services.tsx
@@ -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(null)
+ const [editingService, setEditingService] = useState(null)
+ const [savingId, setSavingId] = useState(null)
+ const [deletingId, setDeletingId] = useState(null)
+ const [togglingServiceId, setTogglingServiceId] = useState(null)
+ const [togglingGroupId, setTogglingGroupId] = useState(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({
- 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({
- 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('/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(`/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(`/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) => (
-
- )
-
- const handleCatalogSubmit = catalogForm.handleSubmit((values) => {
- createServiceMutation.mutate(values)
+ const toggleServiceMutation = useMutation({
+ mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
+ api.patch(`/api/v1/services/${id}/toggle`, { enabled }),
+ onMutate: async ({ id, enabled }) => {
+ await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
+ const previous = queryClient.getQueryData(
+ 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(`/api/v1/service-groups/${id}/toggle`, {
+ enabled,
+ }),
+ onMutate: async ({ id, enabled }) => {
+ await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
+ const previous = queryClient.getQueryData(
+ 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 (
setSheetOpen(true)}>Добавить привязку
+
+
+
+
}
/>
-
-
- Канбан
- Справочник
-
-
-
-
- Доска сервисов
-
- Перетащите привязку между колонками или отредактируйте IP прямо на карточке
-
-
-
- String(binding.id)}
- renderCard={renderBindingCard}
- renderOverlay={renderBindingCard}
- onMove={handleMove}
- />
-
-
-
-
-
-
- Создать сервис
- Добавить новый тип сервиса в справочник
-
-
-
-
-
-
-
-
-
- Название
- Slug
-
-
-
- {services?.map((service) => (
-
- {service.name}
- {service.slug}
-
- ))}
-
-
-
-
-
-
-
-
- Новая привязка
-
- Свяжите домен с сервисом и укажите IP для A-записи
-
-
-
)
}
diff --git a/backend/migrations/003_service_ips.sql b/backend/migrations/003_service_ips.sql
new file mode 100644
index 0000000..15ee4ea
--- /dev/null
+++ b/backend/migrations/003_service_ips.sql
@@ -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;
diff --git a/backend/migrations/004_service_groups.sql b/backend/migrations/004_service_groups.sql
new file mode 100644
index 0000000..99d6632
--- /dev/null
+++ b/backend/migrations/004_service_groups.sql
@@ -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;
diff --git a/backend/migrations/005_service_binding_ips.sql b/backend/migrations/005_service_binding_ips.sql
new file mode 100644
index 0000000..80b677f
--- /dev/null
+++ b/backend/migrations/005_service_binding_ips.sql
@@ -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;
diff --git a/backend/migrations/006_service_group_dns.sql b/backend/migrations/006_service_group_dns.sql
new file mode 100644
index 0000000..d9e95af
--- /dev/null
+++ b/backend/migrations/006_service_group_dns.sql
@@ -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);
diff --git a/backend/src/api/handlers/mod.rs b/backend/src/api/handlers/mod.rs
index a9b9cd8..445c230 100644
--- a/backend/src/api/handlers/mod.rs
+++ b/backend/src/api/handlers/mod.rs
@@ -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;
diff --git a/backend/src/api/handlers/service_groups.rs b/backend/src/api/handlers/service_groups.rs
new file mode 100644
index 0000000..eecfe60
--- /dev/null
+++ b/backend/src/api/handlers/service_groups.rs
@@ -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,
+) -> AppResult> {
+ Ok(axum::Json(
+ service_config_service::list_group_views(&state.pool).await?,
+ ))
+}
+
+pub async fn create(
+ State(state): State,
+ axum::Json(body): axum::Json,
+) -> AppResult> {
+ Ok(axum::Json(
+ service_config_service::create_group(&state.pool, &state.cf, &body).await?,
+ ))
+}
+
+pub async fn update(
+ State(state): State,
+ Path(id): Path,
+ axum::Json(body): axum::Json,
+) -> AppResult> {
+ Ok(axum::Json(
+ service_config_service::update_group(&state.pool, &state.cf, id, &body).await?,
+ ))
+}
+
+pub async fn delete(
+ State(state): State,
+ Path(id): Path,
+) -> AppResult> {
+ service_config_service::delete_group(&state.pool, id).await?;
+ Ok(axum::Json(serde_json::json!({ "deleted": true })))
+}
+
+pub async fn toggle(
+ State(state): State,
+ Path(id): Path,
+ axum::Json(body): axum::Json,
+) -> AppResult> {
+ Ok(axum::Json(
+ service_config_service::toggle_group(&state.pool, &state.cf, id, body.enabled).await?,
+ ))
+}
diff --git a/backend/src/api/handlers/services.rs b/backend/src/api/handlers/services.rs
index 9f1ed61..d226676 100644
--- a/backend/src/api/handlers/services.rs
+++ b/backend/src/api/handlers/services.rs
@@ -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,
}
-pub async fn list(State(state): State) -> AppResult>> {
- Ok(axum::Json(service_repo::list(&state.pool).await?))
+pub async fn list(State(state): State) -> AppResult>> {
+ Ok(axum::Json(service_config_service::list_views(&state.pool).await?))
}
pub async fn get_one(
State(state): State,
Path(id): Path,
-) -> AppResult> {
- Ok(axum::Json(service_repo::get(&state.pool, id).await?))
+) -> AppResult> {
+ Ok(axum::Json(service_config_service::get_view(&state.pool, id).await?))
}
pub async fn create(
State(state): State,
axum::Json(body): axum::Json,
-) -> AppResult> {
- Ok(axum::Json(service_repo::create(&state.pool, &body.name, &body.slug).await?))
+) -> AppResult> {
+ 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,
Path(id): Path,
- axum::Json(body): axum::Json,
-) -> AppResult> {
- Ok(axum::Json(service_repo::update(&state.pool, id, &body.name, &body.slug).await?))
+ axum::Json(body): axum::Json,
+) -> AppResult> {
+ Ok(axum::Json(
+ service_config_service::update_config(&state.pool, &state.cf, id, body).await?,
+ ))
}
pub async fn delete(
State(state): State,
Path(id): Path,
) -> AppResult> {
- 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,
+ Path(id): Path,
+ axum::Json(body): axum::Json,
+) -> AppResult> {
+ Ok(axum::Json(
+ service_config_service::toggle_service(&state.pool, &state.cf, id, body.enabled).await?,
+ ))
+}
diff --git a/backend/src/api/router.rs b/backend/src/api/router.rs
index 1e3594c..634698d 100644
--- a/backend/src/api/router.rs
+++ b/backend/src/api/router.rs
@@ -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),
diff --git a/backend/src/domain/entities.rs b/backend/src/domain/entities.rs
index 6d931a0..5e9c3d4 100644
--- a/backend/src/domain/entities.rs
+++ b/backend/src/domain/entities.rs
@@ -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,
+ pub domain: Option,
+ 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,
+ pub domain: Option,
+ pub enabled: bool,
+ pub created_at: String,
+ pub updated_at: String,
+ pub services: Vec,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ServiceGroupsResponse {
+ pub groups: Vec,
+ pub ungrouped: Vec,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Service {
pub id: i64,
pub name: String,
pub slug: String,
+ pub service_group_id: Option,
+ 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,
+ pub sync_status: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ServiceView {
+ pub id: i64,
+ pub name: String,
+ pub slug: String,
+ pub service_group_id: Option,
+ pub subdomain: String,
+ pub enabled: bool,
+ pub computed_fqdn: Option,
+ pub created_at: String,
+ pub updated_at: String,
+ pub ips: Vec,
+ pub domains: Vec,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct GroupWithStats {
pub id: i64,
diff --git a/backend/src/main.rs b/backend/src/main.rs
index a570437..e9497b8 100644
--- a/backend/src/main.rs
+++ b/backend/src/main.rs
@@ -17,6 +17,10 @@ use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<(), Box> {
+ 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))?;
diff --git a/backend/src/repositories/domains.rs b/backend/src/repositories/domains.rs
index 764d7f3..d9ff9ad 100644
--- a/backend/src/repositories/domains.rs
+++ b/backend/src/repositories/domains.rs
@@ -48,6 +48,15 @@ pub async fn list_enriched(pool: &SqlitePool, group_id: Option) -> AppResul
}
}
+pub async fn find_by_zone_name(pool: &SqlitePool, zone_name: &str) -> AppResult