Enhance service management by adding service groups functionality; introduce new schemas for service groups and views; update API routes and handlers for service groups; implement service group creation and update logic; refactor service queries to support grouping; add new dependencies in pnpm-lock.yaml for improved UI components.
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
"plugins": {
|
||||
"cloudflare": {
|
||||
"enabled": true
|
||||
},
|
||||
"claude-plugins-official/typescript-lsp": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { PencilIcon } from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { bindingToFqdn } from '@/lib/parse-fqdn'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
|
||||
interface ServiceCardProps {
|
||||
service: ServiceView
|
||||
onEdit: (service: ServiceView) => void
|
||||
}
|
||||
|
||||
function aggregateSyncStatus(service: ServiceView) {
|
||||
const statuses = (service.domains ?? [])
|
||||
.map((domain) => domain.sync_status)
|
||||
.filter((status): status is string => Boolean(status))
|
||||
if (statuses.length === 0) return null
|
||||
if (statuses.includes('error')) return 'error'
|
||||
if (statuses.includes('pending_push')) return 'pending_push'
|
||||
if (statuses.every((status) => status === 'synced')) return 'synced'
|
||||
return statuses[0]
|
||||
}
|
||||
|
||||
export function ServiceCard({ service, onEdit }: ServiceCardProps) {
|
||||
const ips = service.ips ?? []
|
||||
const domains = service.domains ?? []
|
||||
const syncStatus = aggregateSyncStatus(service)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{service.name}</CardTitle>
|
||||
<CardDescription>
|
||||
<Badge variant="outline">{service.slug}</Badge>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">IP-адреса</p>
|
||||
{ips.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">—</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ips.map((ip) => (
|
||||
<Badge key={ip} variant="secondary">
|
||||
{ip}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">Домены</p>
|
||||
{domains.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Не привязаны</p>
|
||||
) : (
|
||||
<ItemGroup>
|
||||
{domains.map((binding) => (
|
||||
<Item key={binding.binding_id} variant="outline" size="sm">
|
||||
<ItemContent>
|
||||
<ItemTitle className="flex flex-wrap items-center gap-2">
|
||||
<span>{bindingToFqdn(binding)}</span>
|
||||
{binding.target_ips.map((ip) => (
|
||||
<Badge key={ip} variant="secondary">
|
||||
{ip}
|
||||
</Badge>
|
||||
))}
|
||||
{binding.sync_status ? (
|
||||
<StatusBadge status={binding.sync_status} />
|
||||
) : null}
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>{syncStatus ? <StatusBadge status={syncStatus} /> : null}</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onEdit(service)}>
|
||||
<PencilIcon data-icon="inline-start" />
|
||||
Редактировать
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
||||
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
|
||||
import type {
|
||||
CreateServiceWithConfigInput,
|
||||
DomainListItem,
|
||||
ServiceGroupView,
|
||||
ServiceView,
|
||||
UpdateServiceConfigInput,
|
||||
} from '@/lib/schemas'
|
||||
import { bindingToFqdn } from '@/lib/parse-fqdn'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
|
||||
export interface ServiceBindingDraft {
|
||||
fqdn: string
|
||||
target_ips: string[]
|
||||
}
|
||||
|
||||
interface ServiceEditSheetProps {
|
||||
mode: 'create' | 'edit'
|
||||
service: ServiceView | null
|
||||
groups: ServiceGroupView[]
|
||||
open: boolean
|
||||
knownDomains: DomainListItem[]
|
||||
isSaving: boolean
|
||||
isDeleting?: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreate?: (body: CreateServiceWithConfigInput) => void
|
||||
onSave?: (id: number, body: UpdateServiceConfigInput) => void
|
||||
onDelete?: (id: number) => void
|
||||
}
|
||||
|
||||
function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||
return (service.domains ?? []).map((binding) => ({
|
||||
fqdn: bindingToFqdn(binding),
|
||||
target_ips: binding.target_ips ?? [],
|
||||
}))
|
||||
}
|
||||
|
||||
function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||
return bindings
|
||||
.filter((binding) => binding.fqdn.trim() && binding.target_ips.length > 0)
|
||||
.map((binding) => ({
|
||||
fqdn: binding.fqdn.trim(),
|
||||
target_ips: binding.target_ips,
|
||||
}))
|
||||
}
|
||||
|
||||
export function ServiceEditSheet({
|
||||
mode,
|
||||
service,
|
||||
groups,
|
||||
open,
|
||||
knownDomains,
|
||||
isSaving,
|
||||
isDeleting = false,
|
||||
onOpenChange,
|
||||
onCreate,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: ServiceEditSheetProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [serviceGroupId, setServiceGroupId] = useState('none')
|
||||
const [ips, setIps] = useState<string[]>([])
|
||||
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
|
||||
|
||||
const groupItems = useMemo(
|
||||
() => [
|
||||
{ label: 'Без группы', value: 'none' },
|
||||
...groups.map((group) => ({ label: group.name, value: String(group.id) })),
|
||||
],
|
||||
[groups],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (mode === 'edit' && service) {
|
||||
setName(service.name)
|
||||
setSlug(service.slug)
|
||||
setServiceGroupId(
|
||||
service.service_group_id != null ? String(service.service_group_id) : 'none',
|
||||
)
|
||||
setIps(service.ips ?? [])
|
||||
setBindings(toBindingDrafts(service))
|
||||
return
|
||||
}
|
||||
if (mode === 'create') {
|
||||
setName('')
|
||||
setSlug('')
|
||||
setServiceGroupId('none')
|
||||
setIps([])
|
||||
setBindings([])
|
||||
}
|
||||
}, [open, mode, service])
|
||||
|
||||
const zoneHints = useMemo(
|
||||
() => knownDomains.map((domain) => domain.zone_name),
|
||||
[knownDomains],
|
||||
)
|
||||
|
||||
function handleAddBinding() {
|
||||
setBindings((current) => [...current, { fqdn: '', target_ips: [] }])
|
||||
}
|
||||
|
||||
function handleRemoveBinding(index: number) {
|
||||
setBindings((current) => current.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function handleFqdnChange(index: number, tags: string[]) {
|
||||
const fqdn = tags[0] ?? ''
|
||||
setBindings((current) =>
|
||||
current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
|
||||
)
|
||||
}
|
||||
|
||||
function handleIpsChange(index: number, targetIps: string[]) {
|
||||
setBindings((current) =>
|
||||
current.map((item, i) => (i === index ? { ...item, target_ips: targetIps } : item)),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveServiceGroupId(): number | null {
|
||||
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const domains = buildDomainsPayload(bindings)
|
||||
const groupId = resolveServiceGroupId()
|
||||
const configPayload = {
|
||||
ips,
|
||||
...(domains.length > 0 ? { domains } : {}),
|
||||
}
|
||||
if (mode === 'create') {
|
||||
onCreate?.({
|
||||
name: name.trim(),
|
||||
slug: slug.trim(),
|
||||
service_group_id: groupId,
|
||||
...configPayload,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!service) return
|
||||
onSave?.(service.id, {
|
||||
name,
|
||||
slug,
|
||||
service_group_id: groupId,
|
||||
...configPayload,
|
||||
})
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!service) return
|
||||
onDelete?.(service.id)
|
||||
}
|
||||
|
||||
const isCreate = mode === 'create'
|
||||
const canSubmit = isCreate
|
||||
? name.trim().length > 0 && slug.trim().length > 0
|
||||
: Boolean(service)
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="overflow-y-auto sm:max-w-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Настройте IP-пул и привязки FQDN → IP. Зона определяется из FQDN автоматически.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-4 px-4">
|
||||
<FieldGroup className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="edit-service-name"
|
||||
value={name}
|
||||
placeholder={isCreate ? 'VPN Panel' : undefined}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-slug">Slug</FieldLabel>
|
||||
<Input
|
||||
id="edit-service-slug"
|
||||
value={slug}
|
||||
placeholder={isCreate ? 'vpn-panel' : undefined}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
|
||||
<Select
|
||||
items={groupItems}
|
||||
value={serviceGroupId}
|
||||
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
|
||||
>
|
||||
<SelectTrigger id="edit-service-group" className="w-full">
|
||||
<SelectValue placeholder="Без группы" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
||||
<TaggedInput
|
||||
id="edit-service-ips"
|
||||
value={ips}
|
||||
onChange={setIps}
|
||||
placeholder="192.168.1.1"
|
||||
validate={isValidIpv4}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FieldLabel>Привязки доменов</FieldLabel>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
{bindings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Необязательно. Введите FQDN, например newdom.ivx.su — зона ivx.su определится
|
||||
автоматически.
|
||||
</p>
|
||||
) : (
|
||||
<ItemGroup>
|
||||
{bindings.map((binding, index) => (
|
||||
<Item key={`binding-${index}`} variant="outline">
|
||||
<ItemContent className="flex flex-col gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
|
||||
<TaggedInput
|
||||
id={`binding-fqdn-${index}`}
|
||||
value={binding.fqdn ? [binding.fqdn] : []}
|
||||
onChange={(tags) => handleFqdnChange(index, tags)}
|
||||
placeholder={zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'}
|
||||
maxItems={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
|
||||
<ServiceBindingIpInput
|
||||
id={`binding-ip-${index}`}
|
||||
value={binding.target_ips}
|
||||
pool={ips}
|
||||
onChange={(targetIps) => handleIpsChange(index, targetIps)}
|
||||
/>
|
||||
</Field>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Удалить привязку"
|
||||
onClick={() => handleRemoveBinding(index)}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SheetFooter className="flex flex-row flex-wrap gap-2">
|
||||
{!isCreate ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={!service || isDeleting || isSaving}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{isDeleting && <Spinner data-icon="inline-start" />}
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
Удалить
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
className={isCreate ? 'ml-auto' : 'ml-auto'}
|
||||
disabled={!canSubmit || isSaving || isDeleting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSaving && <Spinner data-icon="inline-start" />}
|
||||
{isCreate ? 'Создать' : 'Сохранить'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ChevronDownIcon, PencilIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { ServiceGroupIcon } from '@/components/service-group-icon'
|
||||
import { ServiceRow } from '@/components/service-row'
|
||||
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@cfdm/ui/components/collapsible'
|
||||
import { ItemGroup } from '@cfdm/ui/components/item'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface ServiceGroupCardProps {
|
||||
group: ServiceGroupView
|
||||
onGroupToggle: (groupId: number, enabled: boolean) => void
|
||||
onServiceToggle: (serviceId: number, enabled: boolean) => void
|
||||
onEditService: (service: ServiceView) => void
|
||||
onEditGroup: (group: ServiceGroupView) => void
|
||||
togglingGroupId?: number | null
|
||||
togglingServiceId?: number | null
|
||||
}
|
||||
|
||||
export function ServiceGroupCard({
|
||||
group,
|
||||
onGroupToggle,
|
||||
onServiceToggle,
|
||||
onEditService,
|
||||
onEditGroup,
|
||||
togglingGroupId = null,
|
||||
togglingServiceId = null,
|
||||
}: ServiceGroupCardProps) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const isGroupToggling = togglingGroupId === group.id
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'flex flex-1 items-center gap-2 text-left',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'size-4 shrink-0 transition-transform',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
<ServiceGroupIcon type={group.type} />
|
||||
<CardTitle className="flex-1">{group.name}</CardTitle>
|
||||
</CollapsibleTrigger>
|
||||
{group.domain ? (
|
||||
<Badge variant="outline">{group.domain}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<CardAction className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => onEditGroup(group)}
|
||||
aria-label={`Редактировать группу ${group.name}`}
|
||||
>
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
{isGroupToggling ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={group.enabled}
|
||||
disabled={isGroupToggling}
|
||||
onCheckedChange={(checked) => onGroupToggle(group.id, checked)}
|
||||
aria-label={`${group.enabled ? 'Выключить' : 'Включить'} группу ${group.name}`}
|
||||
/>
|
||||
)}
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CollapsibleContent>
|
||||
<CardContent>
|
||||
{group.services.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Нет сервисов в группе</p>
|
||||
) : (
|
||||
<ItemGroup>
|
||||
{group.services.map((service) => (
|
||||
<ServiceRow
|
||||
key={service.id}
|
||||
service={service}
|
||||
onToggle={onServiceToggle}
|
||||
onEdit={onEditService}
|
||||
isToggling={togglingServiceId === service.id}
|
||||
disabled={!group.enabled}
|
||||
/>
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { CreateServiceGroupInput, ServiceGroup } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
const groupTypes = [
|
||||
{ value: 'vpn', label: 'VPN' },
|
||||
{ value: 'network', label: 'Сеть' },
|
||||
{ value: 'internet', label: 'Интернет' },
|
||||
{ value: 'bgp', label: 'BGP' },
|
||||
{ value: 'custom', label: 'Другое' },
|
||||
] as const
|
||||
|
||||
interface ServiceGroupEditSheetProps {
|
||||
mode: 'create' | 'edit'
|
||||
group: ServiceGroup | null
|
||||
open: boolean
|
||||
isSaving: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreate?: (body: CreateServiceGroupInput) => void
|
||||
onSave?: (id: number, body: CreateServiceGroupInput) => void
|
||||
}
|
||||
|
||||
export function ServiceGroupEditSheet({
|
||||
mode,
|
||||
group,
|
||||
open,
|
||||
isSaving,
|
||||
onOpenChange,
|
||||
onCreate,
|
||||
onSave,
|
||||
}: ServiceGroupEditSheetProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [type, setType] = useState<CreateServiceGroupInput['type']>('custom')
|
||||
const [domain, setDomain] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (mode === 'edit' && group) {
|
||||
setName(group.name)
|
||||
setType(group.type)
|
||||
setDomain(group.domain ?? '')
|
||||
} else {
|
||||
setName('')
|
||||
setType('custom')
|
||||
setDomain('')
|
||||
}
|
||||
}, [open, mode, group])
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) return
|
||||
const body: CreateServiceGroupInput = {
|
||||
name: trimmedName,
|
||||
type,
|
||||
domain: domain.trim() || null,
|
||||
}
|
||||
if (mode === 'create') {
|
||||
onCreate?.(body)
|
||||
} else if (group) {
|
||||
onSave?.(group.id, body)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Домен группы — любой FQDN (gr.ivx.su, domain.new.ivx.su). Публикуется в Cloudflare отдельно от привязок сервисов.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6 px-4">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="group-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="group-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="VPN"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="group-type">Тип</FieldLabel>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(value) =>
|
||||
setType(value as CreateServiceGroupInput['type'])
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="group-type">
|
||||
<SelectValue placeholder="Выберите тип" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupTypes.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="group-domain">Домен группы (FQDN)</FieldLabel>
|
||||
<Input
|
||||
id="group-domain"
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
placeholder="domain.new.ivx.su"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<SheetFooter>
|
||||
<Button type="submit" disabled={isSaving} className="w-full">
|
||||
{isSaving && <Spinner data-icon="inline-start" />}
|
||||
{isSaving ? 'Сохранение…' : mode === 'create' ? 'Создать' : 'Сохранить'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
GlobeIcon,
|
||||
NetworkIcon,
|
||||
RouterIcon,
|
||||
ServerIcon,
|
||||
ShieldIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type { ServiceGroup } from '@/lib/schemas'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
const typeIcons: Record<ServiceGroup['type'], LucideIcon> = {
|
||||
vpn: ShieldIcon,
|
||||
network: NetworkIcon,
|
||||
internet: GlobeIcon,
|
||||
bgp: RouterIcon,
|
||||
custom: ServerIcon,
|
||||
}
|
||||
|
||||
interface ServiceGroupIconProps {
|
||||
type: ServiceGroup['type']
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ServiceGroupIcon({ type, className }: ServiceGroupIconProps) {
|
||||
const Icon = typeIcons[type] ?? ServerIcon
|
||||
return <Icon className={cn('size-4 shrink-0', className)} />
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from '@cfdm/ui/components/combobox'
|
||||
|
||||
interface ServiceIpComboboxProps {
|
||||
id?: string
|
||||
value: string
|
||||
items: string[]
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function ServiceIpCombobox({
|
||||
id,
|
||||
value,
|
||||
items,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = 'Выберите IP',
|
||||
}: ServiceIpComboboxProps) {
|
||||
const isDisabled = disabled || items.length === 0
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
items={items}
|
||||
value={value}
|
||||
onValueChange={(next) => onChange(next ?? '')}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<ComboboxInput
|
||||
id={id}
|
||||
className="w-full"
|
||||
placeholder={items.length === 0 ? 'Сначала добавьте IP' : placeholder}
|
||||
showClear={Boolean(value)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>Нет совпадений</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item: string) => (
|
||||
<ComboboxItem key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { PencilIcon } from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
aggregateServiceSyncStatus,
|
||||
serviceDisplayFqdn,
|
||||
} from '@/lib/service-utils'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
|
||||
interface ServiceRowProps {
|
||||
service: ServiceView
|
||||
onToggle: (serviceId: number, enabled: boolean) => void
|
||||
onEdit: (service: ServiceView) => void
|
||||
isToggling?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function ServiceRow({
|
||||
service,
|
||||
onToggle,
|
||||
onEdit,
|
||||
isToggling = false,
|
||||
disabled = false,
|
||||
}: ServiceRowProps) {
|
||||
const syncStatus = aggregateServiceSyncStatus(service)
|
||||
const fqdn = serviceDisplayFqdn(service)
|
||||
|
||||
return (
|
||||
<Item variant="outline" size="sm">
|
||||
<ItemContent>
|
||||
<ItemTitle>{service.name}</ItemTitle>
|
||||
<ItemDescription>{fqdn}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions className="flex flex-wrap items-center gap-2">
|
||||
{syncStatus ? <StatusBadge status={syncStatus} /> : null}
|
||||
{isToggling ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={service.enabled}
|
||||
disabled={disabled || isToggling}
|
||||
onCheckedChange={(checked) => onToggle(service.id, checked)}
|
||||
aria-label={`${service.enabled ? 'Выключить' : 'Включить'} ${service.name}`}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(service)}
|
||||
>
|
||||
<PencilIcon data-icon="inline-start" />
|
||||
Редактировать
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@cfdm/ui/components/input-group'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface TaggedInputProps {
|
||||
id?: string
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
placeholder?: string
|
||||
validate?: (value: string) => boolean
|
||||
maxItems?: number
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
'aria-invalid'?: boolean
|
||||
}
|
||||
|
||||
function normalizeTag(raw: string) {
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
export function TaggedInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
validate,
|
||||
maxItems,
|
||||
disabled,
|
||||
className,
|
||||
'aria-invalid': ariaInvalid,
|
||||
}: TaggedInputProps) {
|
||||
const [pending, setPending] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending.includes(',')) return
|
||||
const chunks = pending
|
||||
.split(',')
|
||||
.map(normalizeTag)
|
||||
.filter(Boolean)
|
||||
.filter((chunk) => !validate || validate(chunk))
|
||||
if (chunks.length === 0) {
|
||||
setPending('')
|
||||
return
|
||||
}
|
||||
const next = new Set(maxItems === 1 ? chunks.slice(-1) : [...value, ...chunks])
|
||||
onChange(Array.from(next))
|
||||
setPending('')
|
||||
}, [pending, onChange, validate, value, maxItems])
|
||||
|
||||
function addPending() {
|
||||
const tag = normalizeTag(pending)
|
||||
if (!tag) return
|
||||
if (validate && !validate(tag)) return
|
||||
if (value.includes(tag)) {
|
||||
setPending('')
|
||||
return
|
||||
}
|
||||
const next = maxItems === 1 ? [tag] : [...value, tag]
|
||||
onChange(next)
|
||||
setPending('')
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
onChange(value.filter((item) => item !== tag))
|
||||
}
|
||||
|
||||
return (
|
||||
<InputGroup
|
||||
className={cn(
|
||||
'h-auto min-h-8 flex-wrap items-center gap-1.5 py-1.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{value.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="gap-1 pr-1">
|
||||
{tag}
|
||||
<InputGroupButton
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
aria-label={`Удалить ${tag}`}
|
||||
onClick={() => removeTag(tag)}
|
||||
>
|
||||
<XIcon />
|
||||
</InputGroupButton>
|
||||
</Badge>
|
||||
))}
|
||||
<InputGroupInput
|
||||
id={id}
|
||||
value={pending}
|
||||
disabled={disabled}
|
||||
placeholder={value.length === 0 ? placeholder : undefined}
|
||||
aria-invalid={ariaInvalid}
|
||||
className="min-w-24 flex-1"
|
||||
onChange={(e) => setPending(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
addPending()
|
||||
} else if (
|
||||
e.key === 'Backspace' &&
|
||||
pending.length === 0 &&
|
||||
value.length > 0
|
||||
) {
|
||||
e.preventDefault()
|
||||
onChange(value.slice(0, -1))
|
||||
}
|
||||
}}
|
||||
onBlur={addPending}
|
||||
/>
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export const IPV4_REGEX =
|
||||
/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/
|
||||
|
||||
export function isValidIpv4(value: string) {
|
||||
return IPV4_REGEX.test(value)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface ParsedFqdn {
|
||||
zoneName: string
|
||||
hostname: string
|
||||
fqdn: string
|
||||
}
|
||||
|
||||
export function fqdnToDisplay(hostname: string, zoneName: string): string {
|
||||
if (hostname === '@') {
|
||||
return zoneName
|
||||
}
|
||||
return `${hostname}.${zoneName}`
|
||||
}
|
||||
|
||||
export function parseFqdn(fqdn: string, knownZones: string[]): ParsedFqdn | null {
|
||||
const normalized = fqdn.trim().toLowerCase()
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
const zones = [...knownZones].sort((a, b) => b.length - a.length)
|
||||
|
||||
for (const zone of zones) {
|
||||
const zoneLower = zone.toLowerCase()
|
||||
if (normalized === zoneLower) {
|
||||
return {
|
||||
zoneName: zone,
|
||||
hostname: '@',
|
||||
fqdn: fqdnToDisplay('@', zone),
|
||||
}
|
||||
}
|
||||
const suffix = `.${zoneLower}`
|
||||
if (normalized.endsWith(suffix)) {
|
||||
const prefix = normalized.slice(0, -suffix.length)
|
||||
if (prefix) {
|
||||
return {
|
||||
zoneName: zone,
|
||||
hostname: prefix,
|
||||
fqdn: fqdnToDisplay(prefix, zone),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function bindingToFqdn(binding: {
|
||||
hostname: string
|
||||
zone_name: string
|
||||
fqdn?: string
|
||||
}): string {
|
||||
return binding.fqdn ?? fqdnToDisplay(binding.hostname, binding.zone_name)
|
||||
}
|
||||
@@ -12,14 +12,74 @@ export const groupWithStatsSchema = groupSchema.extend({
|
||||
domain_count: z.number(),
|
||||
})
|
||||
|
||||
export const serviceGroupTypeSchema = z.enum([
|
||||
'vpn',
|
||||
'network',
|
||||
'internet',
|
||||
'bgp',
|
||||
'custom',
|
||||
])
|
||||
|
||||
export const serviceGroupSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
type: serviceGroupTypeSchema.catch('custom'),
|
||||
icon: z.string().nullable(),
|
||||
domain: z.string().nullable(),
|
||||
enabled: z.boolean(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const serviceSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
service_group_id: z.number().nullable().optional(),
|
||||
subdomain: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
computed_fqdn: z.string().nullable().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const serviceDomainBindingSchema = z
|
||||
.object({
|
||||
binding_id: z.number(),
|
||||
domain_id: z.number(),
|
||||
zone_name: z.string(),
|
||||
hostname: z.string(),
|
||||
fqdn: z.string(),
|
||||
target_ips: z.array(z.string()).optional(),
|
||||
target_ip: z.string().nullable().optional(),
|
||||
sync_status: z.string().nullable(),
|
||||
})
|
||||
.transform((binding) => ({
|
||||
...binding,
|
||||
target_ips:
|
||||
binding.target_ips && binding.target_ips.length > 0
|
||||
? binding.target_ips
|
||||
: binding.target_ip
|
||||
? [binding.target_ip]
|
||||
: [],
|
||||
}))
|
||||
|
||||
export const serviceViewSchema = serviceSchema.extend({
|
||||
subdomain: z.string().default(''),
|
||||
enabled: z.boolean().default(false),
|
||||
ips: z.array(z.string()).default([]),
|
||||
domains: z.array(serviceDomainBindingSchema).default([]),
|
||||
})
|
||||
|
||||
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||
services: z.array(serviceViewSchema).default([]),
|
||||
})
|
||||
|
||||
export const serviceGroupsResponseSchema = z.object({
|
||||
groups: z.array(serviceGroupViewSchema).default([]),
|
||||
ungrouped: z.array(serviceViewSchema).default([]),
|
||||
})
|
||||
|
||||
export const domainSchema = z.object({
|
||||
id: z.number(),
|
||||
group_id: z.number().nullable(),
|
||||
@@ -86,6 +146,11 @@ export const certificateSchema = z.object({
|
||||
export type Group = z.infer<typeof groupSchema>
|
||||
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
|
||||
export type Service = z.infer<typeof serviceSchema>
|
||||
export type ServiceDomainBinding = z.infer<typeof serviceDomainBindingSchema>
|
||||
export type ServiceView = z.infer<typeof serviceViewSchema>
|
||||
export type ServiceGroup = z.infer<typeof serviceGroupSchema>
|
||||
export type ServiceGroupView = z.infer<typeof serviceGroupViewSchema>
|
||||
export type ServiceGroupsResponse = z.infer<typeof serviceGroupsResponseSchema>
|
||||
export type Domain = z.infer<typeof domainSchema>
|
||||
export type DomainListItem = z.infer<typeof domainListItemSchema>
|
||||
export type ServiceBinding = z.infer<typeof serviceBindingSchema>
|
||||
@@ -97,11 +162,29 @@ export const createGroupSchema = z.object({
|
||||
slug: z.string().min(1, 'Укажите slug'),
|
||||
})
|
||||
|
||||
const ipv4Schema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/,
|
||||
'Некорректный IPv4',
|
||||
)
|
||||
|
||||
const serviceDomainInputSchema = z.object({
|
||||
fqdn: z.string().min(1, 'Укажите FQDN'),
|
||||
target_ips: z.array(ipv4Schema).min(1, 'Выберите хотя бы один IP'),
|
||||
})
|
||||
|
||||
export const createServiceSchema = z.object({
|
||||
name: z.string().min(1, 'Укажите название'),
|
||||
slug: z.string().min(1, 'Укажите slug'),
|
||||
})
|
||||
|
||||
export const createServiceWithConfigSchema = createServiceSchema.extend({
|
||||
service_group_id: z.number().nullable().optional(),
|
||||
ips: z.array(ipv4Schema).default([]),
|
||||
domains: z.array(serviceDomainInputSchema).default([]),
|
||||
})
|
||||
|
||||
export const createServiceBindingSchema = z.object({
|
||||
domain_id: z.string().min(1, 'Выберите домен'),
|
||||
service_id: z.string().min(1, 'Выберите сервис'),
|
||||
@@ -134,6 +217,33 @@ export const createDnsRecordSchema = z.object({
|
||||
|
||||
export type CreateGroupInput = z.infer<typeof createGroupSchema>
|
||||
export type CreateServiceInput = z.infer<typeof createServiceSchema>
|
||||
export type CreateServiceWithConfigInput = z.infer<typeof createServiceWithConfigSchema>
|
||||
|
||||
export const updateServiceConfigSchema = z.object({
|
||||
name: z.string().min(1, 'Укажите название').optional(),
|
||||
slug: z.string().min(1, 'Укажите slug').optional(),
|
||||
service_group_id: z.number().nullable().optional(),
|
||||
ips: z.array(ipv4Schema).optional(),
|
||||
domains: z
|
||||
.array(serviceDomainInputSchema)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export type UpdateServiceConfigInput = z.infer<typeof updateServiceConfigSchema>
|
||||
|
||||
export const createServiceGroupSchema = z.object({
|
||||
name: z.string().min(1, 'Укажите название'),
|
||||
type: serviceGroupTypeSchema.default('custom'),
|
||||
icon: z.string().nullable().optional(),
|
||||
domain: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export const toggleEnabledSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
|
||||
export type CreateServiceGroupInput = z.infer<typeof createServiceGroupSchema>
|
||||
export type ToggleEnabledInput = z.infer<typeof toggleEnabledSchema>
|
||||
export type CreateServiceBindingInput = z.infer<typeof createServiceBindingSchema>
|
||||
export type CreateDomainInput = z.infer<typeof createDomainSchema>
|
||||
export type LoginInput = z.infer<typeof loginSchema>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { bindingToFqdn } from '@/lib/parse-fqdn'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
|
||||
export function serviceDisplayFqdn(service: ServiceView): string {
|
||||
const first = service.domains?.[0]
|
||||
if (first) {
|
||||
return bindingToFqdn(first)
|
||||
}
|
||||
return '—'
|
||||
}
|
||||
|
||||
export function aggregateServiceSyncStatus(service: ServiceView): string | null {
|
||||
const statuses = (service.domains ?? [])
|
||||
.map((domain) => domain.sync_status)
|
||||
.filter((status): status is string => Boolean(status))
|
||||
if (statuses.length === 0) return null
|
||||
if (statuses.includes('error')) return 'error'
|
||||
if (statuses.includes('pending_push')) return 'pending_push'
|
||||
if (statuses.every((status) => status === 'synced')) return 'synced'
|
||||
return statuses[0]
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
groupSchema,
|
||||
groupWithStatsSchema,
|
||||
serviceBindingSchema,
|
||||
serviceSchema,
|
||||
serviceGroupsResponseSchema,
|
||||
serviceViewSchema,
|
||||
} from '@/lib/schemas'
|
||||
import { subdomainSchema } from '@/lib/schemas-ext'
|
||||
import { z } from 'zod'
|
||||
@@ -40,12 +41,25 @@ export const serviceKeys = {
|
||||
all: ['services'] as const,
|
||||
}
|
||||
|
||||
export const serviceGroupKeys = {
|
||||
all: ['service-groups'] as const,
|
||||
}
|
||||
|
||||
export const serviceGroupsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: serviceGroupKeys.all,
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown>('/api/v1/service-groups')
|
||||
return serviceGroupsResponseSchema.parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
export const servicesQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: serviceKeys.all,
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown[]>('/api/v1/services')
|
||||
return z.array(serviceSchema).parse(data)
|
||||
return z.array(serviceViewSchema).parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,131 +1,157 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
domainKeys,
|
||||
domainsListQueryOptions,
|
||||
serviceBindingKeys,
|
||||
serviceBindingsQueryOptions,
|
||||
serviceGroupKeys,
|
||||
serviceGroupsQueryOptions,
|
||||
serviceKeys,
|
||||
servicesQueryOptions,
|
||||
} from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import {
|
||||
createServiceBindingSchema,
|
||||
createServiceSchema,
|
||||
type CreateServiceBindingInput,
|
||||
type CreateServiceInput,
|
||||
type ServiceBinding,
|
||||
import type {
|
||||
CreateServiceGroupInput,
|
||||
CreateServiceWithConfigInput,
|
||||
ServiceGroupView,
|
||||
ServiceGroupsResponse,
|
||||
ServiceView,
|
||||
UpdateServiceConfigInput,
|
||||
} from '@/lib/schemas'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { KanbanBoard } from '@/components/kanban-board'
|
||||
import { ServiceBindingCard } from '@/components/service-binding-card'
|
||||
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
||||
import { ServiceGroupCard } from '@/components/service-group-card'
|
||||
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
|
||||
import { ServiceRow } from '@/components/service-row'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@cfdm/ui/components/tabs'
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@cfdm/ui/components/empty'
|
||||
import { ItemGroup } from '@cfdm/ui/components/item'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
Promise.all([
|
||||
queryClient.ensureQueryData(servicesQueryOptions()),
|
||||
queryClient.ensureQueryData(serviceBindingsQueryOptions()),
|
||||
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
|
||||
queryClient.ensureQueryData(domainsListQueryOptions()),
|
||||
]),
|
||||
component: ServicesPage,
|
||||
})
|
||||
|
||||
function serviceColumnId(serviceId: number) {
|
||||
return `service-${serviceId}`
|
||||
function setServiceEnabled(
|
||||
data: ServiceGroupsResponse,
|
||||
serviceId: number,
|
||||
enabled: boolean,
|
||||
): ServiceGroupsResponse {
|
||||
return {
|
||||
groups: data.groups.map((group) => ({
|
||||
...group,
|
||||
services: group.services.map((service) =>
|
||||
service.id === serviceId ? { ...service, enabled } : service,
|
||||
),
|
||||
})),
|
||||
ungrouped: data.ungrouped.map((service) =>
|
||||
service.id === serviceId ? { ...service, enabled } : service,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function parseServiceColumnId(columnId: string): number | null {
|
||||
const match = columnId.match(/^service-(\d+)$/)
|
||||
return match ? Number(match[1]) : null
|
||||
function setGroupEnabled(
|
||||
data: ServiceGroupsResponse,
|
||||
groupId: number,
|
||||
enabled: boolean,
|
||||
): ServiceGroupsResponse {
|
||||
return {
|
||||
...data,
|
||||
groups: data.groups.map((group) => {
|
||||
if (group.id !== groupId) return group
|
||||
return {
|
||||
...group,
|
||||
enabled,
|
||||
services: enabled
|
||||
? group.services
|
||||
: group.services.map((service) => ({ ...service, enabled: false })),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function ServicesPage() {
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [createSheetOpen, setCreateSheetOpen] = useState(false)
|
||||
const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false)
|
||||
const [editingGroup, setEditingGroup] = useState<ServiceGroupView | null>(null)
|
||||
const [editingService, setEditingService] = useState<ServiceView | null>(null)
|
||||
const [savingId, setSavingId] = useState<number | null>(null)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
||||
const [togglingGroupId, setTogglingGroupId] = useState<number | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
const { data: services } = useQuery(servicesQueryOptions())
|
||||
const { data: bindings } = useQuery(serviceBindingsQueryOptions())
|
||||
const { data, isLoading } = useQuery(serviceGroupsQueryOptions())
|
||||
const { data: domains } = useQuery(domainsListQueryOptions())
|
||||
|
||||
const catalogForm = useForm<CreateServiceInput>({
|
||||
resolver: zodResolver(createServiceSchema),
|
||||
defaultValues: { name: '', slug: '' },
|
||||
})
|
||||
function invalidateAll() {
|
||||
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
||||
}
|
||||
|
||||
const bindingForm = useForm<CreateServiceBindingInput>({
|
||||
resolver: zodResolver(createServiceBindingSchema),
|
||||
defaultValues: {
|
||||
domain_id: '',
|
||||
service_id: '',
|
||||
hostname: '@',
|
||||
target_ip: '',
|
||||
const createGroupMutation = useMutation({
|
||||
mutationFn: (body: CreateServiceGroupInput) =>
|
||||
api.post('/api/v1/service-groups', body),
|
||||
onSuccess: () => {
|
||||
invalidateAll()
|
||||
setCreateGroupSheetOpen(false)
|
||||
toast.success('Группа создана')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось создать группу')
|
||||
},
|
||||
})
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return (
|
||||
services?.map((service) => ({
|
||||
id: serviceColumnId(service.id),
|
||||
title: service.name,
|
||||
description: service.slug,
|
||||
items: bindings?.filter((b) => b.service_id === service.id) ?? [],
|
||||
})) ?? []
|
||||
)
|
||||
}, [services, bindings])
|
||||
const updateGroupMutation = useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: CreateServiceGroupInput }) =>
|
||||
api.patch(`/api/v1/service-groups/${id}`, body),
|
||||
onSuccess: () => {
|
||||
invalidateAll()
|
||||
setEditingGroup(null)
|
||||
toast.success('Группа сохранена, DNS синхронизируется')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить группу')
|
||||
},
|
||||
})
|
||||
|
||||
const createServiceMutation = useMutation({
|
||||
mutationFn: (body: CreateServiceInput) => api.post('/api/v1/services', body),
|
||||
mutationFn: async (body: CreateServiceWithConfigInput) => {
|
||||
const created = await api.post<ServiceView>('/api/v1/services', {
|
||||
name: body.name,
|
||||
slug: body.slug,
|
||||
service_group_id: body.service_group_id ?? null,
|
||||
})
|
||||
const hasConfig = body.ips.length > 0 || body.domains.length > 0
|
||||
if (!hasConfig) return created
|
||||
return api.patch<ServiceView>(`/api/v1/services/${created.id}`, {
|
||||
ips: body.ips,
|
||||
...(body.domains.length > 0 ? { domains: body.domains } : {}),
|
||||
service_group_id: body.service_group_id ?? null,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
|
||||
catalogForm.reset()
|
||||
invalidateAll()
|
||||
setCreateSheetOpen(false)
|
||||
toast.success('Сервис создан')
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -133,249 +159,256 @@ function ServicesPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const createBindingMutation = useMutation({
|
||||
mutationFn: (body: {
|
||||
domain_id: number
|
||||
service_id: number
|
||||
hostname?: string
|
||||
target_ip?: string
|
||||
}) => api.post('/api/v1/service-bindings', body),
|
||||
const updateServiceMutation = useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: UpdateServiceConfigInput }) =>
|
||||
api.patch<ServiceView>(`/api/v1/services/${id}`, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
||||
bindingForm.reset({ domain_id: '', service_id: '', hostname: '@', target_ip: '' })
|
||||
setSheetOpen(false)
|
||||
toast.success('Привязка создана')
|
||||
invalidateAll()
|
||||
setEditingService(null)
|
||||
toast.success('Сервис сохранён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось создать привязку')
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис')
|
||||
},
|
||||
onSettled: () => {
|
||||
setSavingId(null)
|
||||
},
|
||||
})
|
||||
|
||||
const updateBindingMutation = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
}: {
|
||||
id: number
|
||||
body: { service_id?: number; hostname?: string; target_ip?: string }
|
||||
}) => api.patch(`/api/v1/service-bindings/${id}`, body),
|
||||
const deleteServiceMutation = useMutation({
|
||||
mutationFn: (id: number) => api.delete(`/api/v1/services/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
||||
invalidateAll()
|
||||
setEditingService(null)
|
||||
toast.success('Сервис удалён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось обновить привязку')
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис')
|
||||
},
|
||||
onSettled: () => {
|
||||
setDeletingId(null)
|
||||
},
|
||||
})
|
||||
|
||||
function handleMove(itemId: string, _fromColumnId: string, toColumnId: string) {
|
||||
const serviceId = parseServiceColumnId(toColumnId)
|
||||
if (serviceId === null) return
|
||||
updateBindingMutation.mutate({
|
||||
id: Number(itemId),
|
||||
body: { service_id: serviceId },
|
||||
})
|
||||
}
|
||||
|
||||
function handleIpChange(id: number, targetIp: string) {
|
||||
updateBindingMutation.mutate({ id, body: { target_ip: targetIp } })
|
||||
}
|
||||
|
||||
function handleHostnameChange(id: number, hostname: string) {
|
||||
updateBindingMutation.mutate({ id, body: { hostname } })
|
||||
}
|
||||
|
||||
const renderBindingCard = (binding: ServiceBinding) => (
|
||||
<ServiceBindingCard
|
||||
binding={binding}
|
||||
onIpChange={handleIpChange}
|
||||
onHostnameChange={handleHostnameChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const handleCatalogSubmit = catalogForm.handleSubmit((values) => {
|
||||
createServiceMutation.mutate(values)
|
||||
const toggleServiceMutation = useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
|
||||
api.patch<ServiceView>(`/api/v1/services/${id}/toggle`, { enabled }),
|
||||
onMutate: async ({ id, enabled }) => {
|
||||
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
|
||||
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
|
||||
serviceGroupKeys.all,
|
||||
)
|
||||
if (previous) {
|
||||
queryClient.setQueryData(
|
||||
serviceGroupKeys.all,
|
||||
setServiceEnabled(previous, id, enabled),
|
||||
)
|
||||
}
|
||||
return { previous }
|
||||
},
|
||||
onError: (err, _vars, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(serviceGroupKeys.all, context.previous)
|
||||
}
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось переключить сервис')
|
||||
},
|
||||
onSuccess: (_data, { enabled }) => {
|
||||
toast.success(
|
||||
enabled
|
||||
? 'Сервис включён, DNS синхронизируется с Cloudflare'
|
||||
: 'Сервис выключен, DNS-записи удалены из Cloudflare',
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
setTogglingServiceId(null)
|
||||
invalidateAll()
|
||||
},
|
||||
})
|
||||
|
||||
const handleBindingSubmit = bindingForm.handleSubmit((values) => {
|
||||
createBindingMutation.mutate({
|
||||
domain_id: Number(values.domain_id),
|
||||
service_id: Number(values.service_id),
|
||||
hostname: values.hostname || '@',
|
||||
target_ip: values.target_ip || undefined,
|
||||
})
|
||||
const toggleGroupMutation = useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
|
||||
api.patch<ServiceGroupsResponse>(`/api/v1/service-groups/${id}/toggle`, {
|
||||
enabled,
|
||||
}),
|
||||
onMutate: async ({ id, enabled }) => {
|
||||
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
|
||||
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
|
||||
serviceGroupKeys.all,
|
||||
)
|
||||
if (previous) {
|
||||
queryClient.setQueryData(
|
||||
serviceGroupKeys.all,
|
||||
setGroupEnabled(previous, id, enabled),
|
||||
)
|
||||
}
|
||||
return { previous }
|
||||
},
|
||||
onError: (err, _vars, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(serviceGroupKeys.all, context.previous)
|
||||
}
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось переключить группу')
|
||||
},
|
||||
onSuccess: (_data, { enabled }) => {
|
||||
toast.success(
|
||||
enabled
|
||||
? 'Группа включена, DNS включённых сервисов синхронизируется'
|
||||
: 'Группа выключена, DNS-записи сервисов удалены',
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
setTogglingGroupId(null)
|
||||
invalidateAll()
|
||||
},
|
||||
})
|
||||
|
||||
function handleSave(id: number, body: UpdateServiceConfigInput) {
|
||||
setSavingId(id)
|
||||
updateServiceMutation.mutate({ id, body })
|
||||
}
|
||||
|
||||
function handleDelete(id: number) {
|
||||
setDeletingId(id)
|
||||
deleteServiceMutation.mutate(id)
|
||||
}
|
||||
|
||||
function handleCreate(body: CreateServiceWithConfigInput) {
|
||||
createServiceMutation.mutate(body)
|
||||
}
|
||||
|
||||
function handleServiceToggle(serviceId: number, enabled: boolean) {
|
||||
setTogglingServiceId(serviceId)
|
||||
toggleServiceMutation.mutate({ id: serviceId, enabled })
|
||||
}
|
||||
|
||||
function handleGroupToggle(groupId: number, enabled: boolean) {
|
||||
setTogglingGroupId(groupId)
|
||||
toggleGroupMutation.mutate({ id: groupId, enabled })
|
||||
}
|
||||
|
||||
const groups = data?.groups ?? []
|
||||
const ungrouped = data?.ungrouped ?? []
|
||||
const isEmpty = groups.length === 0 && ungrouped.length === 0
|
||||
|
||||
return (
|
||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageHeader
|
||||
title="Сервисы"
|
||||
description="Канбан привязок доменов к сервисам с настройкой IP через DNS"
|
||||
description="Домен группы (FQDN) и FQDN сервисов синхронизируются в Cloudflare при включении"
|
||||
actions={
|
||||
<Button onClick={() => setSheetOpen(true)}>Добавить привязку</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
||||
Добавить группу
|
||||
</Button>
|
||||
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Tabs defaultValue="kanban" orientation="horizontal" className="flex w-full flex-col gap-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="kanban">Канбан</TabsTrigger>
|
||||
<TabsTrigger value="catalog">Справочник</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="kanban">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Доска сервисов</CardTitle>
|
||||
<CardDescription>
|
||||
Перетащите привязку между колонками или отредактируйте IP прямо на карточке
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<KanbanBoard
|
||||
columns={columns}
|
||||
getItemId={(binding) => String(binding.id)}
|
||||
renderCard={renderBindingCard}
|
||||
renderOverlay={renderBindingCard}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="catalog" className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Создать сервис</CardTitle>
|
||||
<CardDescription>Добавить новый тип сервиса в справочник</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleCatalogSubmit}>
|
||||
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
|
||||
<Field className="flex-1">
|
||||
<FieldLabel htmlFor="svc-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="svc-name"
|
||||
placeholder="Название"
|
||||
{...catalogForm.register('name')}
|
||||
aria-invalid={!!catalogForm.formState.errors.name}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="flex-1">
|
||||
<FieldLabel htmlFor="svc-slug">Slug</FieldLabel>
|
||||
<Input
|
||||
id="svc-slug"
|
||||
placeholder="slug"
|
||||
{...catalogForm.register('slug')}
|
||||
aria-invalid={!!catalogForm.formState.errors.slug}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" disabled={createServiceMutation.isPending}>
|
||||
{createServiceMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{createServiceMutation.isPending ? 'Создание…' : 'Создать'}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataTableCard
|
||||
title="Справочник сервисов"
|
||||
description="Типы сервисов для привязки к доменам"
|
||||
isEmpty={!services?.length}
|
||||
emptyTitle="Сервисы не найдены"
|
||||
emptyDescription="Создайте первый сервис в форме выше"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services?.map((service) => (
|
||||
<TableRow key={service.id}>
|
||||
<TableCell className="font-medium">{service.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{service.slug}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DataTableCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Новая привязка</SheetTitle>
|
||||
<SheetDescription>
|
||||
Свяжите домен с сервисом и укажите IP для A-записи
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleBindingSubmit} className="flex flex-col gap-4 px-4">
|
||||
<Field>
|
||||
<FieldLabel>Домен</FieldLabel>
|
||||
<Select
|
||||
value={bindingForm.watch('domain_id')}
|
||||
onValueChange={(value) => bindingForm.setValue('domain_id', value ?? '')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите домен" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{domains?.map((domain) => (
|
||||
<SelectItem key={domain.id} value={String(domain.id)}>
|
||||
{domain.zone_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Сервис</FieldLabel>
|
||||
<Select
|
||||
value={bindingForm.watch('service_id')}
|
||||
onValueChange={(value) => bindingForm.setValue('service_id', value ?? '')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите сервис" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{services?.map((service) => (
|
||||
<SelectItem key={service.id} value={String(service.id)}>
|
||||
{service.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="binding-hostname">Hostname</FieldLabel>
|
||||
<Input
|
||||
id="binding-hostname"
|
||||
placeholder="@"
|
||||
{...bindingForm.register('hostname')}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="binding-ip">IPv4</FieldLabel>
|
||||
<Input
|
||||
id="binding-ip"
|
||||
placeholder="192.168.1.1"
|
||||
{...bindingForm.register('target_ip')}
|
||||
/>
|
||||
</Field>
|
||||
<SheetFooter>
|
||||
<Button type="submit" disabled={createBindingMutation.isPending}>
|
||||
{createBindingMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
Создать
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner className="size-8" />
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Сервисы не найдены</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Создайте группу и сервис — например VPN Panel или Home Assistant.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
||||
Добавить группу
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
|
||||
</div>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{groups.map((group) => (
|
||||
<ServiceGroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
onGroupToggle={handleGroupToggle}
|
||||
onServiceToggle={handleServiceToggle}
|
||||
onEditService={setEditingService}
|
||||
onEditGroup={setEditingGroup}
|
||||
togglingGroupId={togglingGroupId}
|
||||
togglingServiceId={togglingServiceId}
|
||||
/>
|
||||
))}
|
||||
|
||||
{ungrouped.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Без группы</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ItemGroup>
|
||||
{ungrouped.map((service) => (
|
||||
<ServiceRow
|
||||
key={service.id}
|
||||
service={service}
|
||||
onToggle={handleServiceToggle}
|
||||
onEdit={setEditingService}
|
||||
isToggling={togglingServiceId === service.id}
|
||||
/>
|
||||
))}
|
||||
</ItemGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ServiceEditSheet
|
||||
mode="edit"
|
||||
service={editingService}
|
||||
groups={groups}
|
||||
open={editingService !== null}
|
||||
knownDomains={domains ?? []}
|
||||
isSaving={editingService !== null && savingId === editingService.id}
|
||||
isDeleting={editingService !== null && deletingId === editingService.id}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingService(null)
|
||||
}}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<ServiceEditSheet
|
||||
mode="create"
|
||||
service={null}
|
||||
groups={groups}
|
||||
open={createSheetOpen}
|
||||
knownDomains={domains ?? []}
|
||||
isSaving={createServiceMutation.isPending}
|
||||
onOpenChange={setCreateSheetOpen}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
|
||||
<ServiceGroupEditSheet
|
||||
mode="create"
|
||||
group={null}
|
||||
open={createGroupSheetOpen}
|
||||
isSaving={createGroupMutation.isPending}
|
||||
onOpenChange={setCreateGroupSheetOpen}
|
||||
onCreate={(body) => createGroupMutation.mutate(body)}
|
||||
/>
|
||||
|
||||
<ServiceGroupEditSheet
|
||||
mode="edit"
|
||||
group={editingGroup}
|
||||
open={editingGroup !== null}
|
||||
isSaving={updateGroupMutation.isPending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingGroup(null)
|
||||
}}
|
||||
onSave={(id, body) => updateGroupMutation.mutate({ id, body })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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?,
|
||||
))
|
||||
}
|
||||
@@ -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?,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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":{}}
|
||||
@@ -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/
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
+1
@@ -0,0 +1 @@
|
||||
dff2c57ea9ce14ed
|
||||
+1
@@ -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}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
e831c6c5ed209b7e
|
||||
+1
@@ -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}
|
||||
Binary file not shown.
@@ -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}
|
||||
+1
@@ -0,0 +1 @@
|
||||
b324936e04e29ad8
|
||||
+1
@@ -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}
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
+1
@@ -0,0 +1 @@
|
||||
f0c4738d492d35b7
|
||||
+1
@@ -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}
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
Binary file not shown.
@@ -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}
|
||||
Binary file not shown.
@@ -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}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
34b884917b38f88d
|
||||
+1
@@ -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}
|
||||
Binary file not shown.
@@ -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}
|
||||
Binary file not shown.
@@ -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}
|
||||
Binary file not shown.
@@ -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}
|
||||
Binary file not shown.
@@ -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}
|
||||
Binary file not shown.
@@ -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}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
+1
@@ -0,0 +1 @@
|
||||
7ecd79b325f6c06e
|
||||
+1
@@ -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}
|
||||
BIN
Binary file not shown.
@@ -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
Reference in New Issue
Block a user